diff --git a/codex-rs/tui/src/analytics/account_layout_tests.rs b/codex-rs/tui/src/analytics/account_layout_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..d6c7da06dc04817a8e8fb3b449bf0694c90592ee --- /dev/null +++ b/codex-rs/tui/src/analytics/account_layout_tests.rs @@ -0,0 +1,491 @@ +//! Account layouts, authenticated report loading, independent ranges, and model filters. +use super::*; +use codex_config::types::KeybindingSpec; +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; +use pretty_assertions::assert_eq; + +use crate::analytics::sections::Section; +#[test] +fn account_layouts_show_reports_and_wrap_navigation() { + let mut screens = Vec::new(); + for (kind, expected) in [ + ( + models::AccountKind::Consumer, + vec![ + Section::Summary, + Section::Usage, + Section::Activity, + Section::Plugins, + Section::Skills, + Section::Chats, + ], + ), + ( + models::AccountKind::Enterprise, + vec![ + Section::Summary, + Section::Credits, + Section::Usage, + Section::Plugins, + Section::Skills, + Section::Chats, + ], + ), + ] { + let mut view = fixture::view(kind); + for (index, section) in expected.iter().enumerate() { + press( + &mut view, + KeyCode::Char(char::from_digit(index as u32 + 1, /*radix*/ 10).unwrap()), + ); + assert_eq!(view.section, *section); + screens.push(screen(&mut view, /*width*/ 100, /*height*/ 30)); + } + press(&mut view, KeyCode::Tab); + assert_eq!(view.section, expected[0]); + press(&mut view, KeyCode::BackTab); + assert_eq!(view.section, *expected.last().unwrap()); + view.zoomed = false; + screens.push(screen(&mut view, /*width*/ 140, /*height*/ 64)); + } + insta::assert_snapshot!(screens.join("\n")); +} + +#[tokio::test] +async fn changing_ranges_and_grouping_preserves_other_reports_and_focus() { + let server = test_support::server().await; + let (_home, _app_server, mut view) = client::tests::connected_view(&server, "plus").await; + test_support::settle(&mut view).await; + view.section = Section::Usage; + press(&mut view, KeyCode::Left); + press(&mut view, KeyCode::Enter); + press(&mut view, KeyCode::Char('r')); + test_support::settle(&mut view).await; + press(&mut view, KeyCode::Char('g')); + test_support::settle(&mut view).await; + assert_eq!( + ( + view.ranges, + view.sections.0.each_ref().map(|state| state.cursor), + view.sections[Section::Usage].detail, + view.sections[Section::Usage].group + ), + ([1, 0, 0], [28, 6, 6, 0, 6, 6, 6, 6], Some(28), 2) + ); + let context = ( + view.ranges, + view.sections.0.each_ref().map(|state| state.cursor), + view.sections.0.each_ref().map(|state| state.detail), + view.sections.0.each_ref().map(|state| state.group), + ); + press(&mut view, KeyCode::Char('z')); + screen(&mut view, /*width*/ 58, /*height*/ 25); + press(&mut view, KeyCode::Char('z')); + assert_eq!( + ( + view.ranges, + view.sections.0.each_ref().map(|state| state.cursor), + view.sections.0.each_ref().map(|state| state.detail), + view.sections.0.each_ref().map(|state| state.group) + ), + context + ); + press(&mut view, KeyCode::Char('3')); + press(&mut view, KeyCode::Char('r')); + test_support::settle(&mut view).await; + assert_eq!(view.ranges, [1, 1, 0]); + assert_eq!( + view.sections + .0 + .each_ref() + .map(|state| state.history.ready().map(|history| history.data.len())), + [Some(30), Some(7), None, None, Some(30), Some(7), None, None] + ); + let server = test_support::server().await; + let (_business_home, _business_server, mut view) = + client::tests::connected_view(&server, "business").await; + test_support::settle(&mut view).await; + view.ranges = [1, 1, 0]; + view.load_report(Section::Usage); + press(&mut view, KeyCode::Char('2')); + press(&mut view, KeyCode::Char('r')); + test_support::settle(&mut view).await; + assert_eq!(view.ranges, [0, 1, 0]); + assert_eq!( + view.sections + .0 + .each_ref() + .map(|state| state.history.ready().map(|history| history.data.len())), + [Some(30), Some(7), Some(7), None, None, Some(7), None, None] + ); +} + +#[tokio::test] +async fn business_tokens_filter_models() { + let server = test_support::server().await; + let (_home, _app_server, mut view) = client::tests::connected_view(&server, "business").await; + test_support::settle(&mut view).await; + view.end_date = fixture::END_DATE; + fixture::seed_reports(&mut view); + press(&mut view, KeyCode::Char('3')); + let all = screen(&mut view, /*width*/ 100, /*height*/ 30); + assert!(all.contains("All models") && all.contains("Cached input")); + press(&mut view, KeyCode::Char('m')); + test_support::settle(&mut view).await; + assert_eq!( + view.sections[Section::Usage] + .history + .ready() + .unwrap() + .data + .iter() + .map(|day| day.total) + .sum::(), + 420.0 + ); + fixture::seed_reports(&mut view); + assert_eq!(view.token_model.as_deref(), Some("GPT-5.5")); + let filtered = screen(&mut view, /*width*/ 100, /*height*/ 30); + press(&mut view, KeyCode::Char('m')); + test_support::settle(&mut view).await; + fixture::seed_reports(&mut view); + assert_eq!(view.token_model, None); + insta::assert_snapshot!(format!("{all}\n{filtered}")); +} + +#[tokio::test] +async fn account_pending_and_error_do_not_start_reports() { + let mut view = AnalyticsView::new(RuntimeKeymap::defaults().list); + let (send, receive) = tokio::sync::oneshot::channel(); + view.account = Load::start( + async move { receive.await.unwrap() }, + FrameRequester::test_dummy(), + ); + screen(&mut view, /*width*/ 80, /*height*/ 24); + assert!( + view.sections + .0 + .iter() + .all(|state| matches!(state.history, Load::Unavailable)) + ); + send.send(Err("Sign in again to view Analytics.".to_string())) + .unwrap(); + tokio::task::yield_now().await; + let error = screen(&mut view, /*width*/ 80, /*height*/ 24); + assert!(error.contains("Sign in again")); + assert!( + view.sections + .0 + .iter() + .all(|state| matches!(state.history, Load::Unavailable)) + ); + insta::assert_snapshot!(error); +} + +#[tokio::test] +async fn account_reports_request_only_eligible_endpoints_and_refresh() { + for (plan, usage, activity) in [ + ( + "plus", + "usage/daily-token-usage-breakdown", + "analytics/daily-workspace-usage-counts", + ), + ( + "business", + "usage/daily-workspace-user-token-usage-breakdown", + "usage/daily-workspace-user-credit-usage", + ), + ] { + let server = test_support::server().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/backend-api/wham/profiles/me")) + .respond_with( + wiremock::ResponseTemplate::new(/*s*/ 200) + .set_body_json(serde_json::json!({"stats":{}})), + ) + .mount(&server) + .await; + let (_home, _app_server, mut view) = client::tests::connected_view(&server, plan).await; + test_support::settle(&mut view).await; + let mut expected = [ + "accounts/check", + usage, + activity, + "analytics/daily-plugin-usage-metrics", + "analytics/daily-skill-usage-metrics", + "profiles/me", + ] + .map(|endpoint| format!("/backend-api/wham/{endpoint}")); + expected.sort(); + let mut paths = server + .received_requests() + .await + .unwrap() + .into_iter() + .filter(|request| request.url.path().starts_with("/backend-api/wham/")) + .map(|request| request.url.path().to_string()) + .collect::>(); + paths.sort(); + assert_eq!(paths, expected); + let ready = view + .sections + .0 + .each_ref() + .map(|state| state.history.ready().is_some()); + assert_eq!( + ready, + if plan == "plus" { + [true, true, false, false, true, true, false, false] + } else { + [true, true, true, false, false, true, false, false] + } + ); + press(&mut view, KeyCode::Char('R')); + test_support::settle(&mut view).await; + assert_eq!( + view.sections + .0 + .each_ref() + .map(|state| state.history.ready().is_some()), + ready + ); + assert_eq!( + server + .received_requests() + .await + .unwrap() + .iter() + .filter(|request| request.url.path().starts_with("/backend-api/wham/")) + .count(), + 12 + ); + view.cancel_loads(); + } +} + +#[tokio::test] +async fn grouping_cycle_uses_current_server_capabilities() { + let server = test_support::server().await; + let (_home, _app_server, mut view) = client::tests::connected_view(&server, "plus").await; + view.section = Section::Usage; + view.account = Load::Ready(codex_protocol::account::PlanType::Plus); + view.sections[Section::Usage].group = 3; + view.start_reports(); + test_support::settle(&mut view).await; + assert_eq!( + (view.group_options(), view.sections[Section::Usage].group), + (&[0, 2][..], 0) + ); + press(&mut view, KeyCode::Char('g')); + assert_eq!(view.sections[Section::Usage].group, 2); + press(&mut view, KeyCode::Char('g')); + assert_eq!(view.sections[Section::Usage].group, 0); +} + +#[test] +fn ungrouped_sections_preserve_configured_g_jump_top() { + let mut config = TuiKeymap::default(); + config.list.jump_top = Some(KeybindingsSpec::One(KeybindingSpec("g".into()))); + let keymap = RuntimeKeymap::from_config(&config).expect("valid list keymap"); + let mut view = fixture::view(models::AccountKind::Enterprise); + view.keymap = keymap.list; + for section in [ + Section::Chats, + Section::Plugins, + Section::Skills, + Section::Credits, + ] { + view.section = section; + press(&mut view, KeyCode::End); + assert!(view.sections[section].cursor > 0, "{section:?}"); + press(&mut view, KeyCode::Char('g')); + assert_eq!(view.sections[section].cursor, 0, "{section:?}"); + } +} + +#[test] +fn business_chats_display_only_supplied_dollars() { + let mut view = fixture::view(models::AccountKind::Enterprise); + let chats = match &mut view.chats { + Load::Ready(chats) => chats, + _ => panic!("fixture chats"), + }; + for chat in &mut chats.rows { + if let Some(usage) = &mut chat.usage { + usage.estimated_usage_usd_micros = None; + } + } + press(&mut view, KeyCode::Char('6')); + assert!(!screen(&mut view, /*width*/ 100, /*height*/ 30).contains("Est. $ spent")); + if let Load::Ready(chats) = &mut view.chats { + chats.rows[0] + .usage + .as_mut() + .unwrap() + .estimated_usage_usd_micros = Some(123_450_000); + } + let dollars = screen(&mut view, /*width*/ 100, /*height*/ 30); + assert!(dollars.contains("$123.45") && dollars.contains("Est. $ spent")); + let narrow = screen(&mut view, /*width*/ 42, /*height*/ 30); + insta::assert_snapshot!(format!("{dollars}\n{narrow}")); +} + +#[tokio::test] +async fn unsupported_workspace_plans_hide_top_chats_and_skip_loading() { + for plan in [ + "team", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "enterprise", + "ent26", + "edu", + "edu_plus", + "edu_pro", + ] { + let server = test_support::server().await; + let (_home, _app_server, mut view) = client::tests::connected_view(&server, plan).await; + test_support::settle(&mut view).await; + assert_eq!( + view.visible_sections(), + &[ + Section::Summary, + Section::Credits, + Section::Usage, + Section::Plugins, + Section::Skills + ], + "{plan}" + ); + assert!(matches!(view.chats, Load::Unavailable), "{plan}"); + let selected = view.section; + press(&mut view, KeyCode::Char('6')); + assert_eq!(view.section, selected); + } + for plan in [ + "business", + "enterprise_cbp_usage_based", + "enterprise_cbp_automation", + ] { + let server = test_support::server().await; + let (_home, _app_server, mut view) = client::tests::connected_view(&server, plan).await; + test_support::settle(&mut view).await; + assert!(view.visible_sections().contains(&Section::Chats), "{plan}"); + press(&mut view, KeyCode::Char('6')); + assert_eq!(view.section, Section::Chats); + } + let mut view = fixture::view(models::AccountKind::Enterprise); + view.account = Load::Ready(codex_protocol::account::PlanType::Enterprise); + insta::assert_snapshot!(screen(&mut view, /*width*/ 110, /*height*/ 24)); +} + +#[tokio::test] +async fn unknown_plan_keeps_summary_available_without_billing_reports() { + let server = test_support::server().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/backend-api/wham/profiles/me")) + .respond_with( + wiremock::ResponseTemplate::new(/*s*/ 200) + .set_body_json(serde_json::json!({"stats":{"lifetime_tokens":12345}})), + ) + .expect(/*r*/ 1) + .mount(&server) + .await; + let (_home, _app_server, mut view) = + client::tests::connected_view(&server, "future-plan").await; + test_support::settle(&mut view).await; + assert_eq!(view.visible_sections(), &[Section::Summary]); + assert_eq!( + view.profile.ready().unwrap().stats.tokens.lifetime_tokens, + Some(12345) + ); + assert!( + view.sections + .0 + .iter() + .all(|section| matches!(section.history, Load::Unavailable)) + ); + let paths = server + .received_requests() + .await + .unwrap() + .into_iter() + .filter(|request| request.url.path().starts_with("/backend-api/wham/")) + .map(|request| request.url.path().to_owned()) + .collect::>(); + assert_eq!( + paths, + [ + "/backend-api/wham/accounts/check", + "/backend-api/wham/profiles/me" + ] + ); + view.end_date = "2026-09-09".parse().unwrap(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + /*width*/ 100, /*height*/ 40, + )) + .unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + insta::assert_snapshot!(terminal.backend().to_string()); +} + +#[test] +fn navigation_keeps_its_row_when_sections_change() { + for width in [64, 120] { + let mut view = fixture::view(models::AccountKind::Consumer); + let mut positions = Vec::new(); + for section in [ + Section::Summary, + Section::Usage, + Section::Chats, + Section::Summary, + ] { + view.section = section; + let rendered = screen(&mut view, width, /*height*/ 40); + positions.push( + rendered + .lines() + .position(|line| line.contains("Summary") && line.contains("usage history")) + .unwrap(), + ); + } + assert_eq!(positions, vec![positions[0]; 4]); + } +} + +#[test] +fn taller_report_reveals_more_legend_rows_and_keeps_timestamp_in_footer() { + let mut view = fixture::view(models::AccountKind::Consumer); + view.section = Section::Usage; + let mut history = fixture::history(/*report*/ 0, /*range*/ 0, /*group*/ 1); + history.updated_at = Some(1_788_364_800); + for day in &mut history.data { + day.values = (0..10) + .map(|index| models::AccountAnalyticsValue { + key: format!("feature-{index}"), + label: format!("Feature {}", index + 1), + value: day.total / 10.0, + }) + .collect(); + } + view.sections[Section::Usage].history = data::Load::Ready(history); + let short = screen(&mut view, /*width*/ 120, /*height*/ 30); + let tall = screen(&mut view, /*width*/ 120, /*height*/ 60); + assert!( + tall.lines().filter(|line| line.contains('●')).count() + > short.lines().filter(|line| line.contains('●')).count() + ); + assert!( + tall.lines() + .rev() + .take(3) + .any(|line| line.contains("Updated")) + ); + insta::assert_snapshot!("legend_tall_report", tall); + for width in [24, 40, 64] { + let narrow = screen(&mut view, width, /*height*/ 60); + assert!(narrow.lines().last().unwrap().contains("UTC")); + } +} diff --git a/codex-rs/tui/src/analytics/account_plan_tests.rs b/codex-rs/tui/src/analytics/account_plan_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..8795e2e9662c4c93847a009167eaf57f1f0239c4 --- /dev/null +++ b/codex-rs/tui/src/analytics/account_plan_tests.rs @@ -0,0 +1,167 @@ +//! Server plan discovery survives stale token claims and remains bound to the local identity. + +use super::tests::live; +use super::tests::sign_in; +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test] +async fn refresh_uses_server_plan_and_routes_with_unchanged_local_credentials() { + let server = MockServer::start().await; + let (_home, client) = live(&server, "plus").await; + let reads = Arc::new(AtomicUsize::new(/*v*/ 0)); + let plan_reads = Arc::clone(&reads); + Mock::given(method("GET")) + .and(path("/backend-api/wham/accounts/check")) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(move |_: &wiremock::Request| { + let plan = if plan_reads.fetch_add(/*val*/ 1, Ordering::SeqCst) == 0 { + "team" + } else { + "enterprise" + }; + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "accounts": [ + {"id": "other-account", "plan_type": "free"}, + {"id": "account-a", "plan_type": plan} + ], + "account_ordering": ["other-account", "account-a"] + })) + }) + .with_priority(/*p*/ 1) + .expect(/*r*/ 2) + .mount(&server) + .await; + for endpoint in [ + "/backend-api/wham/usage/daily-workspace-user-token-usage-breakdown", + "/backend-api/wham/usage/daily-workspace-user-credit-usage", + ] { + Mock::given(method("GET")) + .and(path(endpoint)) + .and(header("chatgpt-account-id", "account-a")) + .respond_with( + ResponseTemplate::new(/*s*/ 200) + .set_body_json(json!({"breakdown": "product", "series": [], "data": []})), + ) + .expect(/*r*/ 1) + .mount(&server) + .await; + } + let first = client + .history(Report::Credits, /*days*/ 7, Grouping::Surface) + .await + .unwrap(); + assert_eq!(client.session().await.unwrap().kind, AccountKind::Business); + assert_eq!( + client + .history(Report::Credits, /*days*/ 7, Grouping::Surface) + .await + .unwrap(), + first + ); + assert_eq!(reads.load(Ordering::SeqCst), 1); + let refreshed = Live::new(Arc::clone(&client.config), client.end_date); + refreshed + .history(Report::Credits, /*days*/ 7, Grouping::Surface) + .await + .unwrap(); + assert_eq!( + refreshed.session().await.unwrap().kind, + AccountKind::Enterprise + ); + server.verify().await; +} + +#[tokio::test] +async fn account_plan_lookup_recovers_from_unauthorized() { + let server = MockServer::start().await; + let (_home, client) = live(&server, "plus").await; + let reads = AtomicUsize::new(/*v*/ 0); + Mock::given(method("GET")) + .and(path("/backend-api/wham/accounts/check")) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(move |_: &wiremock::Request| { + if reads.fetch_add(/*val*/ 1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(/*s*/ 401) + } else { + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "accounts": {"account-a": {"account": { + "account_id": "account-a", "plan_type": "enterprise" + }}}, + "account_ordering": ["account-a"] + })) + } + }) + .with_priority(/*p*/ 1) + .expect(/*r*/ 2) + .mount(&server) + .await; + assert_eq!( + client.session().await.unwrap().kind, + AccountKind::Enterprise + ); + server.verify().await; +} + +#[tokio::test] +async fn failed_plan_discovery_can_retry_without_using_token_plan() { + for response in [ + ResponseTemplate::new(/*s*/ 503), + ResponseTemplate::new(/*s*/ 200).set_body_string("invalid account response"), + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"accounts": [{"id": "account-a"}]})), + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "accounts": [{"id": "other-account", "plan_type": "enterprise"}] + })), + ] { + let server = MockServer::start().await; + let (_home, client) = live(&server, "plus").await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/accounts/check")) + .respond_with(response) + .with_priority(/*p*/ 1) + .expect(/*r*/ 1) + .up_to_n_times(/*n*/ 1) + .mount(&server) + .await; + assert_eq!( + client.session().await.err().unwrap(), + "Couldn't load account plan. Press R to retry Analytics." + ); + assert_eq!(client.session().await.unwrap().kind, AccountKind::Consumer); + server.verify().await; + } +} + +#[tokio::test] +async fn account_plan_lookup_rejects_account_and_user_switches() { + for (account, user) in [("account-b", "user-a"), ("account-a", "user-b")] { + let server = MockServer::start().await; + let (home, client) = live(&server, "plus").await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/accounts/check")) + .respond_with(move |_: &wiremock::Request| { + sign_in(home.path(), account, user, "plus"); + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "accounts": [{"id": "account-a", "plan_type": "enterprise"}] + })) + }) + .with_priority(/*p*/ 1) + .expect(/*r*/ 1) + .mount(&server) + .await; + assert_eq!( + client.session().await.err().unwrap(), + "Account changed. Press R to refresh Analytics." + ); + server.verify().await; + } +} diff --git a/codex-rs/tui/src/analytics/activity_chart.rs b/codex-rs/tui/src/analytics/activity_chart.rs new file mode 100644 index 0000000000000000000000000000000000000000..8bfb704b733906f166ef7fb9193f559b7f55e650 --- /dev/null +++ b/codex-rs/tui/src/analytics/activity_chart.rs @@ -0,0 +1,319 @@ +//! Calendar and weekly token charts for the account Summary. Bucketing is independent of view state. + +mod palette; + +use std::collections::BTreeMap; + +use chrono::Datelike; +use chrono::Duration; +use chrono::NaiveDate; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; + +use crate::render::highlight::foreground_style_for_scopes; +use palette::TokenActivityPalette; + +const WEEK_COUNT: usize = 52; +const DAY_COUNT: usize = 7; +const CELL_COUNT: usize = WEEK_COUNT * DAY_COUNT; +const CHART_LEFT_WIDTH: usize = 4; + +/// Selects the aggregation represented by the token activity chart. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TokenActivityView { + Daily, + Weekly, + Cumulative, +} + +impl TokenActivityView { + /// Parses the optional `/usage` argument into a supported chart view. + /// + /// An empty argument selects Daily when a mode is requested. The bare `/usage` + /// command opens its menu. Returning `None` lets the slash-command dispatcher + /// report unsupported arguments instead of silently choosing a view. + pub(crate) fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "" | "day" | "daily" => Some(Self::Daily), + "week" | "weekly" => Some(Self::Weekly), + "cumulative" => Some(Self::Cumulative), + _ => None, + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::Daily => "Daily", + Self::Weekly => "Weekly", + Self::Cumulative => "Cumulative", + } + } +} + +pub(super) fn chart_lines( + view: TokenActivityView, + buckets: &[codex_backend_client::TokenUsageProfileDailyBucket], + today: NaiveDate, + width: u16, +) -> Vec> { + let mut lines = Vec::new(); + let values = daily_values(buckets, today); + let shown_columns = shown_columns(width); + if shown_columns == 0 { + lines.push(" Widen terminal to show activity graph".dim().into()); + return lines; + } + + let palette = TokenActivityPalette::current(); + let levels = levels_for_view(&values, view); + let first_column = WEEK_COUNT - shown_columns; + lines.push(month_labels(today, first_column, shown_columns)); + for row in 0..DAY_COUNT { + let mut spans = vec![weekday_label(view, row)]; + for column in first_column..WEEK_COUNT { + if column > first_column { + spans.push(" ".into()); + } + let index = column * DAY_COUNT + row; + if view == TokenActivityView::Daily + && cell_date(today, index).is_some_and(|date| date > today) + { + spans.push(" ".into()); + } else { + let style = if view == TokenActivityView::Daily { + palette.for_level(levels[index]) + } else { + palette.for_bar_level(levels[index]) + }; + spans.push(Span::styled(palette.glyph(view, levels[index]), style)); + } + } + lines.push(spans.into()); + } + // Separate the calendar from the legend/footer below. + lines.push(Line::default()); + match view { + TokenActivityView::Daily => lines.push(legend_line(&palette)), + TokenActivityView::Weekly | TokenActivityView::Cumulative => { + lines.push(bar_caption(view, &values)) + } + } + lines +} + +fn shown_columns(width: u16) -> usize { + (usize::from(width) + .saturating_sub(CHART_LEFT_WIDTH) + .saturating_add(/*rhs*/ 1) + / 2) + .min(WEEK_COUNT) +} + +fn numeric_style() -> Style { + foreground_style_for_scopes(&["constant.numeric", "constant"]) + .unwrap_or_else(|| Style::default().green()) +} + +fn label_style() -> Style { + foreground_style_for_scopes(&["comment"]).unwrap_or_else(|| Style::default().dim()) +} + +fn weekday_label(view: TokenActivityView, row: usize) -> Span<'static> { + if view != TokenActivityView::Daily { + // Bar views fill from the bottom (row 6) upward, so the gutter doubles + // as a coarse Y-axis: peak at the top, baseline at the bottom. + return Span::styled( + match row { + 0 => "max ", + 6 => " 0 ", + _ => " ", + }, + label_style(), + ); + } + Span::styled( + match row { + 0 => " Su ", + 1 => " Mo ", + 2 => " Tu ", + 3 => " We ", + 4 => " Th ", + 5 => " Fr ", + 6 => " Sa ", + _ => " ", + }, + label_style(), + ) +} + +fn legend_line(palette: &TokenActivityPalette) -> Line<'static> { + let mut spans = vec![Span::styled(" Less ", label_style())]; + for level in 0..=4 { + if level > 0 { + spans.push(" ".into()); + } + spans.push(Span::styled( + palette.glyph(TokenActivityView::Daily, level), + palette.for_level(level), + )); + } + spans.push(Span::styled(" More", label_style())); + spans.into() +} + +/// Caption for the bar-chart views, where the 5-step daily legend would be +/// misleading. States what each bar represents and the peak it is scaled to. +fn bar_caption(view: TokenActivityView, values: &[i64]) -> Line<'static> { + let weeks = weekly_totals(values); + let (lead, peak) = match view { + TokenActivityView::Weekly => ( + "Each column = 1 week · tallest ", + weeks.iter().copied().max().unwrap_or(/*default*/ 0), + ), + TokenActivityView::Cumulative => ( + "Running total · top ", + weeks + .iter() + .fold(/*init*/ 0_i64, |sum, value| sum.saturating_add(*value)), + ), + TokenActivityView::Daily => ("", 0), + }; + if peak <= 0 { + return Span::styled(" No token activity in the last 12 months", label_style()).into(); + } + vec![ + Span::styled(format!(" {lead}"), label_style()), + Span::styled(super::data::compact_amount(peak as f64), numeric_style()), + ] + .into() +} + +fn month_labels(today: NaiveDate, first_column: usize, shown_columns: usize) -> Line<'static> { + let mut cells = vec![' '; shown_columns * 2 - 1]; + let start = chart_start(today); + let mut last_end = 0; + for column in first_column..WEEK_COUNT { + let date = start + Duration::days((column * DAY_COUNT) as i64); + if date.day() > 7 { + continue; + } + let label = date.format("%b").to_string(); + let offset = (column - first_column) * 2; + if offset < last_end || offset + label.len() > cells.len() { + continue; + } + for (index, ch) in label.chars().enumerate() { + cells[offset + index] = ch; + } + last_end = offset + label.len() + 1; + } + vec![ + " ".into(), + Span::styled(cells.into_iter().collect::(), label_style()), + ] + .into() +} + +/// Normalizes backend daily buckets into the fixed 52-week display window. +/// +/// The returned vector is ordered by chart cell, starting with the oldest Sunday. +/// Invalid, out-of-window, and future dates are ignored. Duplicate dates are +/// accumulated and negative token values do not reduce activity. +fn daily_values( + buckets: &[codex_backend_client::TokenUsageProfileDailyBucket], + today: NaiveDate, +) -> Vec { + let start = chart_start(today); + let end = start + Duration::days(CELL_COUNT as i64); + let mut by_date = BTreeMap::new(); + for bucket in buckets { + let Ok(date) = NaiveDate::parse_from_str(&bucket.start_date, "%Y-%m-%d") else { + continue; + }; + if date < start || date >= end || date > today { + continue; + } + let value: &mut i64 = by_date.entry(date).or_insert(/*default*/ 0); + *value = value.saturating_add(bucket.tokens.max(/*other*/ 0)); + } + (0..CELL_COUNT) + .map(|offset| { + by_date + .get(&(start + Duration::days(offset as i64))) + .copied() + .unwrap_or(/*default*/ 0) + }) + .collect() +} + +fn levels_for_view(values: &[i64], view: TokenActivityView) -> Vec { + match view { + TokenActivityView::Daily => graded_levels(values), + TokenActivityView::Weekly => bar_levels(&weekly_totals(values)), + TokenActivityView::Cumulative => { + let cumulative = weekly_totals(values) + .into_iter() + .scan(/*initial_state*/ 0, |sum, value| { + *sum = i64::saturating_add(*sum, value); + Some(*sum) + }) + .collect::>(); + bar_levels(&cumulative) + } + } +} + +fn graded_levels(values: &[i64]) -> Vec { + let max = values.iter().copied().max().unwrap_or(/*default*/ 0); + values + .iter() + .map(|value| match (*value, max) { + (0, _) | (_, 0) => 0, + (value, max) if i128::from(value) * 4 > i128::from(max) * 3 => 4, + (value, max) if i128::from(value) * 2 > i128::from(max) => 3, + (value, max) if i128::from(value) * 4 > i128::from(max) => 2, + _ => 1, + }) + .collect() +} + +fn weekly_totals(values: &[i64]) -> Vec { + values + .chunks(DAY_COUNT) + .map(|week| { + week.iter() + .fold(/*init*/ 0_i64, |sum, value| sum.saturating_add(*value)) + }) + .collect() +} + +fn bar_levels(totals: &[i64]) -> Vec { + let max = totals.iter().copied().max().unwrap_or(/*default*/ 0); + totals + .iter() + .flat_map(|value| { + let height = if *value <= 0 || max <= 0 { + 0 + } else { + ((i128::from(*value) * DAY_COUNT as i128 + i128::from(max) - 1) / i128::from(max)) + as usize + }; + (0..DAY_COUNT).map(move |row| if DAY_COUNT - row <= height { 4 } else { 0 }) + }) + .collect() +} + +fn chart_start(today: NaiveDate) -> NaiveDate { + let week_start = today - Duration::days(i64::from(today.weekday().num_days_from_sunday())); + week_start - Duration::weeks((WEEK_COUNT - 1) as i64) +} + +fn cell_date(today: NaiveDate, index: usize) -> Option { + chart_start(today).checked_add_signed(Duration::days(index as i64)) +} + +#[cfg(test)] +#[path = "activity_chart_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/activity_chart/palette.rs b/codex-rs/tui/src/analytics/activity_chart/palette.rs new file mode 100644 index 0000000000000000000000000000000000000000..6be694dcd0963c8e0a9dedfe525cb32b203e5c9f --- /dev/null +++ b/codex-rs/tui/src/analytics/activity_chart/palette.rs @@ -0,0 +1,151 @@ +//! Builds terminal-aware styles and glyph choices for token activity charts. +//! +//! The palette adapts theme colors to the active terminal color level while +//! keeping chart-specific glyph policy local to the token activity renderer. + +use ratatui::style::Color; +use ratatui::style::Style; + +use super::TokenActivityView; +use crate::color::blend; +use crate::render::highlight::foreground_style_for_scopes; +use crate::style::accent_style; +use crate::terminal_palette::StdoutColorLevel; +use crate::terminal_palette::best_color_for_level; +use crate::terminal_palette::default_bg; +use crate::terminal_palette::default_fg; +use crate::terminal_palette::stdout_color_level; + +// In low-color terminals we distinguish empty vs active cells by glyph (a +// width-matched filled/hollow pair). In truecolor terminals the grid uses a +// single glyph and lets color carry the intensity (GitHub-style), which keeps +// the grid perfectly aligned and free of texture noise. +const EMPTY_CELL_GLYPH: &str = "□"; +const ACTIVE_CELL_GLYPH: &str = "■"; +const BAR_CELL_GLYPH: &str = "█"; + +/// Stores the terminal-specific styles and glyph strategy for token activity cells. +pub(super) struct TokenActivityPalette { + styles: [Style; 5], + bar_style: Style, + /// True when the terminal supports a truecolor gradient, so the grid can + /// encode intensity purely by color and render every cell with a single + /// glyph. False on low-color terminals, where we fall back to a + /// filled/hollow glyph pair so empty and active cells stay distinguishable. + uses_color: bool, +} + +impl TokenActivityPalette { + pub(super) fn current() -> Self { + Self::from_parts( + default_fg(), + default_bg(), + stdout_color_level(), + theme_activity_style(), + ) + } + + fn from_parts( + default_fg: Option<(u8, u8, u8)>, + default_bg: Option<(u8, u8, u8)>, + color_level: StdoutColorLevel, + active_style: Style, + ) -> Self { + let fallback_palette = || Self::fallback(active_style); + let (Some(fg), Some(bg), Some(anchor)) = + (default_fg, default_bg, activity_anchor_rgb(active_style)) + else { + return fallback_palette(); + }; + if matches!( + color_level, + StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown + ) { + return fallback_palette(); + } + + let empty_alpha = if crate::color::is_light(bg) { + 0.18 + } else { + 0.14 + }; + let alphas = [empty_alpha, 0.22, 0.42, 0.68, 1.00]; + let styles = std::array::from_fn(|index| { + let color = if index == 0 { + blend(fg, bg, alphas[index]) + } else { + blend(anchor, bg, alphas[index]) + }; + Style::default().fg(best_color_for_level(color, color_level)) + }); + let bar_style = Style::default().fg(best_color_for_level( + blend(anchor, bg, /*alpha*/ 0.78), + color_level, + )); + Self { + styles, + bar_style, + uses_color: true, + } + } + + fn fallback(active_style: Style) -> Self { + let empty_style = Style::default().dim(); + Self { + styles: [ + empty_style, + active_style, + active_style, + active_style, + active_style, + ], + bar_style: active_style, + uses_color: false, + } + } + + pub(super) fn for_level(&self, level: usize) -> Style { + self.styles[level.min(/*other*/ 4)] + } + + pub(super) fn for_bar_level(&self, level: usize) -> Style { + if level == 0 { + self.for_level(/*level*/ 0) + } else { + self.bar_style + } + } + + /// The glyph for a cell at `level`. Daily truecolor renders every visible + /// cell with the same square glyph and lets color carry the intensity; in + /// low-color we use the hollow glyph for empty cells so they remain visible + /// without a color gradient. Bar views use full blocks for filled height and + /// spaces for empty height so the silhouette reads as a column chart. + pub(super) fn glyph(&self, view: TokenActivityView, level: usize) -> &'static str { + if view != TokenActivityView::Daily { + return if level == 0 { " " } else { BAR_CELL_GLYPH }; + } + if self.uses_color || level > 0 { + ACTIVE_CELL_GLYPH + } else { + EMPTY_CELL_GLYPH + } + } +} + +fn theme_activity_style() -> Style { + foreground_style_for_scopes(&["entity.name.type", "support.type", "variable"]) + .unwrap_or_else(accent_style) + .bold() +} + +fn activity_anchor_rgb(style: Style) -> Option<(u8, u8, u8)> { + match style.fg? { + Color::Rgb(r, g, b) => Some((r, g, b)), + _ => None, + } +} + +#[cfg(test)] +#[path = "palette_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/activity_chart/palette_tests.rs b/codex-rs/tui/src/analytics/activity_chart/palette_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..458f950f324a4280b94bcb0e1a816b55d6f7551e --- /dev/null +++ b/codex-rs/tui/src/analytics/activity_chart/palette_tests.rs @@ -0,0 +1,115 @@ +use super::*; +use crate::terminal_palette::rgb_color; +use pretty_assertions::assert_eq; +use ratatui::style::Modifier; + +#[test] +fn truecolor_palette_blends_theme_accent_against_dark_background() { + let default_fg = Some((240, 240, 240)); + let default_bg = Some((0, 0, 0)); + let active_style = Style::default().fg(rgb_color((100, 200, 50))).bold(); + let palette = TokenActivityPalette::from_parts( + default_fg, + default_bg, + StdoutColorLevel::TrueColor, + active_style, + ); + + assert_eq!( + palette.for_level(/*level*/ 0).fg, + Some(rgb_color((33, 33, 33))) + ); + assert_eq!( + palette.for_level(/*level*/ 1).fg, + Some(rgb_color((22, 44, 11))) + ); + assert_eq!( + palette.for_level(/*level*/ 4).fg, + Some(rgb_color((100, 200, 50))) + ); + assert_eq!( + palette.for_bar_level(/*level*/ 4).fg, + Some(rgb_color((78, 156, 39))) + ); + assert!(palette.uses_color); +} + +#[test] +fn truecolor_palette_blends_empty_cell_for_light_background() { + let default_fg = Some((0, 0, 0)); + let default_bg = Some((255, 255, 255)); + let active_style = Style::default().fg(rgb_color((0, 95, 135))).bold(); + let palette = TokenActivityPalette::from_parts( + default_fg, + default_bg, + StdoutColorLevel::TrueColor, + active_style, + ); + + assert_eq!( + palette.for_level(/*level*/ 0).fg, + Some(rgb_color((209, 209, 209))) + ); + assert_eq!( + palette.for_level(/*level*/ 4).fg, + Some(rgb_color((0, 95, 135))) + ); + assert!(palette.uses_color); +} + +#[test] +fn ansi16_palette_uses_theme_accent_without_green_fallback() { + let default_fg = Some((240, 240, 240)); + let default_bg = Some((0, 0, 0)); + let active_style = Style::default().fg(Color::Magenta).bold(); + let palette = TokenActivityPalette::from_parts( + default_fg, + default_bg, + StdoutColorLevel::Ansi16, + active_style, + ); + + assert_eq!(palette.for_level(/*level*/ 0), Style::default().dim()); + assert_eq!(palette.for_level(/*level*/ 1), active_style); + assert_eq!(palette.for_bar_level(/*level*/ 4), active_style); + assert!(!palette.uses_color); +} + +#[test] +fn non_rgb_theme_accent_remains_active_fallback() { + let default_fg = Some((240, 240, 240)); + let default_bg = Some((0, 0, 0)); + let active_style = Style::default().fg(Color::Cyan).bold(); + let palette = TokenActivityPalette::from_parts( + default_fg, + default_bg, + StdoutColorLevel::TrueColor, + active_style, + ); + + assert_eq!(palette.for_level(/*level*/ 1), active_style); + assert!( + palette + .for_level(/*level*/ 1) + .add_modifier + .contains(Modifier::BOLD) + ); + assert!(!palette.uses_color); +} + +#[test] +fn missing_terminal_colors_use_theme_accent_fallback() { + let default_fg = None; + let default_bg = Some((0, 0, 0)); + let active_style = Style::default().fg(Color::Blue).bold(); + let palette = TokenActivityPalette::from_parts( + default_fg, + default_bg, + StdoutColorLevel::TrueColor, + active_style, + ); + + assert_eq!(palette.for_level(/*level*/ 0), Style::default().dim()); + assert_eq!(palette.for_level(/*level*/ 4), active_style); + assert!(!palette.uses_color); +} diff --git a/codex-rs/tui/src/analytics/activity_chart_tests.rs b/codex-rs/tui/src/analytics/activity_chart_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..35faaa7c75cfaab13211a6cb4b66e35f6c591ed0 --- /dev/null +++ b/codex-rs/tui/src/analytics/activity_chart_tests.rs @@ -0,0 +1,239 @@ +//! Calendar, aggregation, and chart rendering regressions. +use super::*; +use codex_backend_client::TokenUsageProfileDailyBucket as AccountTokenUsageDailyBucket; +use insta::assert_snapshot; +use pretty_assertions::assert_eq; + +fn graph_width(width: u16) -> u16 { + if width == u16::MAX { + return width; + } + (CHART_LEFT_WIDTH + shown_columns(width) * 2 - 1) as u16 +} + +#[test] +fn duplicate_dates_sum_and_negative_values_clamp() { + let today = + NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date"); + let buckets = vec![ + AccountTokenUsageDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 10, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 5, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-28".to_string(), + tokens: -4, + }, + ]; + + let values = daily_values(&buckets, today); + + assert_eq!(values.iter().sum::(), 15); +} + +#[test] +fn bar_levels_fill_from_bottom() { + let levels = bar_levels(&[0, 10]); + + assert_eq!(&levels[..DAY_COUNT], &[0; DAY_COUNT]); + assert_eq!(&levels[DAY_COUNT..], &[4; DAY_COUNT]); +} + +#[test] +fn token_activity_view_aliases_parse() { + assert_eq!(TokenActivityView::parse(""), Some(TokenActivityView::Daily)); + assert_eq!( + TokenActivityView::parse("day"), + Some(TokenActivityView::Daily) + ); + assert_eq!( + TokenActivityView::parse("week"), + Some(TokenActivityView::Weekly) + ); + assert_eq!( + TokenActivityView::parse("cumulative"), + Some(TokenActivityView::Cumulative) + ); + assert_eq!(TokenActivityView::parse("year"), None); +} + +#[test] +fn daily_graph_snapshot_uses_distinct_empty_and_active_cells() { + let today = + NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date"); + let buckets = vec![ + AccountTokenUsageDailyBucket { + start_date: "2026-05-25".to_string(), + tokens: 1, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 4, + }, + ]; + + let rendered = chart_lines(TokenActivityView::Daily, &buckets, today, /*width*/ 22) + .into_iter() + .map(|line| line.to_string().trim_end().to_string()) + .collect::>() + .join("\n"); + + assert_snapshot!(rendered, @r" + Apr May + Su □ □ □ □ □ □ □ □ □ + Mo □ □ □ □ □ □ □ □ ■ + Tu □ □ □ □ □ □ □ □ □ + We □ □ □ □ □ □ □ □ □ + Th □ □ □ □ □ □ □ □ □ + Fr □ □ □ □ □ □ □ □ ■ + Sa □ □ □ □ □ □ □ □ + + Less □ ■ ■ ■ ■ More + "); +} + +#[test] +fn daily_graph_snapshot_stays_left_aligned_in_wide_terminal() { + assert_eq!(graph_width(/*width*/ 160), 107); + assert_eq!(graph_width(/*width*/ u16::MAX), u16::MAX); + + let today = + NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date"); + let lines = chart_lines(TokenActivityView::Daily, &[], today, /*width*/ 160); + let rendered = [&lines[0], &lines[1], lines.last().expect("legend line")] + .into_iter() + .map(|line| line.to_string().trim_end().to_string()) + .collect::>() + .join("\n"); + + assert_snapshot!(rendered, @" + Jun Jul Aug Sep Oct Nov Dec Jan Feb Mar Apr May + Su □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ + Less □ ■ ■ ■ ■ More + "); +} + +#[test] +fn weekly_graph_snapshot_renders_bar_chart_and_caption() { + let today = + NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date"); + let buckets = vec![ + AccountTokenUsageDailyBucket { + start_date: "2026-05-11".to_string(), + tokens: 3, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-18".to_string(), + tokens: 6, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-25".to_string(), + tokens: 9, + }, + ]; + + let rendered = chart_lines( + TokenActivityView::Weekly, + &buckets, + today, + /*width*/ 22, + ) + .into_iter() + .map(|line| line.to_string().trim_end().to_string()) + .collect::>() + .join("\n"); + + assert_snapshot!(rendered, @" + Apr May + max █ + █ + █ █ + █ █ + █ █ █ + █ █ █ + 0 █ █ █ + + Each column = 1 week · tallest 9 + "); +} + +#[test] +fn cumulative_graph_snapshot_renders_running_total_bar_chart_and_caption() { + let today = + NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date"); + let buckets = vec![ + AccountTokenUsageDailyBucket { + start_date: "2026-05-11".to_string(), + tokens: 3, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-18".to_string(), + tokens: 6, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-25".to_string(), + tokens: 9, + }, + ]; + + let rendered = chart_lines( + TokenActivityView::Cumulative, + &buckets, + today, + /*width*/ 22, + ) + .into_iter() + .map(|line| line.to_string().trim_end().to_string()) + .collect::>() + .join("\n"); + + assert_snapshot!(rendered, @" + Apr May + max █ + █ + █ + █ █ + █ █ + █ █ █ + 0 █ █ █ + + Running total · top 18 + "); +} + +#[test] +fn year_window_ignores_other_dates_and_saturates_extreme_totals() { + let today: NaiveDate = "2026-09-09".parse().unwrap(); + let buckets = [ + ("2025-01-01", 100), + ("2026-09-10", 100), + ("invalid", 100), + ("2026-09-08", i64::MAX), + ("2026-09-08", i64::MAX), + ("2026-09-09", i64::MAX), + ] + .map(|(date, tokens)| AccountTokenUsageDailyBucket { + start_date: date.into(), + tokens, + }); + let values = daily_values(&buckets, today); + let mut expected = vec![0; CELL_COUNT]; + let index = (today - chart_start(today)).num_days() as usize; + expected[index - 1] = i64::MAX; + expected[index] = i64::MAX; + assert_eq!(values, expected); + for view in [ + TokenActivityView::Daily, + TokenActivityView::Weekly, + TokenActivityView::Cumulative, + ] { + let levels = levels_for_view(&values, view); + assert!(levels.iter().all(|level| *level <= 4)); + assert!(levels.contains(&4)); + assert!(!chart_lines(view, &buckets, today, /*width*/ 110).is_empty()); + } +} diff --git a/codex-rs/tui/src/analytics/chart.rs b/codex-rs/tui/src/analytics/chart.rs new file mode 100644 index 0000000000000000000000000000000000000000..32d31dd816592b48e90c306a7e3e4e9a758bc029 --- /dev/null +++ b/codex-rs/tui/src/analytics/chart.rs @@ -0,0 +1,338 @@ +//! Daily history with a stable chart and a compact, separated breakdown table. +//! Chart series rank by the full range; table rows rank by the selected day and retain series colors. +//! Missing and zero days retain the same row count; only viewport changes and explicit expansion change height. + +use super::AnalyticsView; +use super::data; +use super::plot; +use super::render::categories; +use super::render::columns; +use super::styles::number; +use super::styles::secondary_style; +use super::styles::series_colors; +use crate::analytics::models::AccountAnalyticsUnit; +use crate::analytics::models::AccountAnalyticsValue; +use crate::analytics::sections::Section; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; + +impl AnalyticsView { + pub(super) fn day_has_details(&self, section: Section, day_index: usize) -> bool { + let Some(history) = self.sections[section].history.ready() else { + return false; + }; + let date = self + .section_date_range(section) + .start() + .iter_days() + .nth(day_index); + history.data.iter().any(|day| { + date.is_some_and(|date| day.date == date) + && day.values.iter().any(|value| value.value != 0.0) + }) + } + + pub(super) fn history_lines( + &self, + section: Section, + width: usize, + height: usize, + ) -> plot::Chart { + let state = &self.sections[section].history; + let Some(history) = state.ready() else { + return plot::Chart { + lines: state + .message() + .into_iter() + .map(|message| message.to_string().set_style(secondary_style()).into()) + .collect(), + bands: 0, + }; + }; + if history.data.is_empty() { + return plot::Chart { + lines: vec![ + "No data reported for this range." + .set_style(secondary_style()) + .into(), + ], + bands: 0, + }; + } + let range = self.section_date_range(section); + // At most 30 daily slots; index by date if report ranges grow. + let days = range + .start() + .iter_days() + .take_while(|date| date <= range.end()) + .map(|date| { + let day = history.data.iter().find(|day| day.date == date); + (date, day) + }) + .collect::>(); + let cursor = self.sections[section].cursor.min(days.len() - 1); + let selected_date = days[cursor].0.format("%b %-d").to_string(); + let day = days[cursor].1; + let expanded = self.zoomed && self.sections[section].detail.is_some(); + let colors = series_colors(); + let mut categories = categories(history); + if matches!(section, Section::Plugins | Section::Skills) { + for category in &mut categories { + category.label = super::tool_panel::tool_name(&category.label); + } + } + if self.sections[section].group == 2 { + for category in &mut categories { + category.label = self.model_name(&category.label).to_string(); + } + } + let unit = match section { + Section::Usage if history.unit == AccountAnalyticsUnit::Tokens => "tokens", + Section::Summary => "tokens", + Section::Activity => "messages", + Section::Plugins => "calls", + Section::Skills => "uses", + Section::Usage | Section::Credits | Section::Chats | Section::Plan => "credits", + }; + let empty_breakdown = matches!( + section, + Section::Plugins | Section::Activity | Section::Skills + ) && day.is_none_or(|day| { + day.total == 0.0 && day.values.iter().all(|value| value.value == 0.0) + }); + let relative = history.unit == AccountAnalyticsUnit::RelativeUsage; + let format_amount = if history.unit == AccountAnalyticsUnit::Credits { + data::credit_amount + } else { + data::amount + }; + let total: f64 = history.data.iter().map(|day| day.total).sum(); + let formatted_total = if total.abs() >= 1_000.0 { + data::compact_amount(total) + } else { + format_amount(total) + }; + let mut lines = vec![if relative { + "Total usage · relative units" + .set_style(secondary_style()) + .into() + } else { + number(format!( + "{} {}{unit}", + formatted_total, + if section == Section::Activity { + "reported " + } else { + "" + } + )) + .into() + }]; + if section == Section::Usage && !self.business() && self.zoomed { + lines.push( + "Approximate · may be delayed up to 6 hours" + .set_style(secondary_style()) + .into(), + ); + } + if self.sections[section].group == 3 { + lines.push( + "All usage by how each turn started" + .set_style(secondary_style()) + .into(), + ); + } + let has_activity = history + .data + .iter() + .any(|day| day.total != 0.0 || day.values.iter().any(|value| value.value != 0.0)); + let mut bands = 0; + if has_activity { + let chart = plot::chart(&days, &categories, cursor, width, height, history.unit); + bands = chart.bands; + lines.extend(chart.lines); + } else { + lines.push( + if matches!( + section, + Section::Plugins | Section::Activity | Section::Skills + ) { + format!("No {unit} reported in this range.") + .set_style(secondary_style()) + .into() + } else { + "No activity reported in this range." + .set_style(secondary_style()) + .into() + }, + ); + } + + let table_width = width.min(/*other*/ 56); + lines.push(Line::default()); + if self.zoomed { + lines.push("─".repeat(table_width).dim().into()); + } + let table_start = lines.len(); + let summary = match day { + None => " · Not reported".set_style(secondary_style()), + Some(day) if day.total == 0.0 && day.values.iter().all(|value| value.value == 0.0) => { + " · No activity (0)".set_style(secondary_style()) + } + Some(day) if relative => number(format!(" · {} usage units", data::amount(day.total))), + Some(day) => number(format!( + " · {} {unit}", + if expanded { + day.total.to_string() + } else { + format_amount(day.total) + } + )), + }; + lines.push(truncate_line_with_ellipsis_if_overflow( + vec![selected_date.clone().bold(), summary].into(), + width, + )); + if self.zoomed { + lines.push(columns( + match section { + Section::Plugins => "Plugin", + Section::Skills => "Skill", + Section::Chats => "Chat", + Section::Summary + | Section::Usage + | Section::Credits + | Section::Activity + | Section::Plan => self.group_label(section, self.sections[section].group), + } + .bold() + .into(), + if history.unit == AccountAnalyticsUnit::Tokens { + "Tokens" + } else if section == Section::Usage { + "Share" + } else if section == Section::Activity { + "Messages" + } else if section == Section::Plugins { + "Calls" + } else if section == Section::Skills { + "Uses" + } else { + "Credits" + } + .bold() + .into(), + table_width, + )); + } + let mut values = categories + .iter() + .enumerate() + .map(|(index, category)| { + ( + index, + AccountAnalyticsValue { + value: day + .and_then(|day| { + day.values.iter().find(|value| value.key == category.key) + }) + .map_or(/*default*/ 0.0, |value| value.value), + ..category.clone() + }, + ) + }) + .collect::>(); + values.sort_by(|(_, a), (_, b)| b.value.abs().total_cmp(&a.value.abs())); + let row_budget = if self.zoomed { + (self.viewport_height / 4).max(/*other*/ 4) + } else { + 4 + }; + let remainder = if !expanded && values.len() > row_budget { + let remaining = values.split_off(row_budget - 1); + Some(( + format!("{} more", remaining.len()), + remaining.iter().map(|(_, value)| value.value).sum::(), + )) + } else { + None + }; + let rows = values + .iter() + .map(|(index, value)| { + ( + Line::from(vec![ + "● ".fg(colors[(*index).min(/*other*/ 3)]), + value.label.clone().into(), + ]), + value.value, + ) + }) + .chain( + remainder.map(|(label, value)| (Line::from(vec!["… ".dim(), label.dim()]), value)), + ); + for (label, value) in rows.take(if self.zoomed { usize::MAX } else { 2 }) { + let amount = match day { + None => "—".into(), + Some(day) + if section == Section::Usage + && history.unit != AccountAnalyticsUnit::Tokens => + { + format!( + "{:.1}%", + if day.total == 0.0 { + 0.0 + } else { + value / day.total * 100.0 + } + ) + } + Some(_) if expanded => value.to_string(), + Some(_) => format_amount(value), + }; + lines.push(columns( + truncate_line_with_ellipsis_if_overflow( + label, + table_width.saturating_sub(amount.len() + 2), + ), + if day.is_none() || value == 0.0 { + amount.dim() + } else { + amount.into() + } + .into(), + table_width, + )); + } + if empty_breakdown { + // Preserve the measured table allocation when the selected day has no breakdown. + let table_end = lines.len(); + lines.truncate(table_start); + lines.push(truncate_line_with_ellipsis_if_overflow( + if day.is_none() { + format!("Data not reported for {selected_date}.") + } else { + format!("No {unit} reported for {selected_date}.") + } + .set_style(secondary_style()) + .into(), + width, + )); + lines.resize(table_end, Line::default()); + } + if !self.zoomed + && let Some(updated) = history + .updated_at + .and_then(|value| chrono::DateTime::from_timestamp(value, /*nsecs*/ 0)) + { + lines.push( + format!("Updated {} UTC", updated.format("%b %-d %H:%M")) + .set_style(secondary_style()) + .into(), + ); + } + plot::Chart { lines, bands } + } +} diff --git a/codex-rs/tui/src/analytics/chat_panel.rs b/codex-rs/tui/src/analytics/chat_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..19d6ff114d79a57de41fa44bf73692d87b480781 --- /dev/null +++ b/codex-rs/tui/src/analytics/chat_panel.rs @@ -0,0 +1,317 @@ +//! Bounded chat tables with inline credit breakdowns and a viewport-sized row window. +//! Zero-credit groups are optional; missing dimensions and signed adjustments stay distinct. + +use super::AnalyticsView; +use super::data; +use super::render::columns; +use super::styles::number; +use super::styles::secondary_style; +use crate::analytics::sections::Section; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::style::accent_style; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::ops::Range; + +impl AnalyticsView { + pub(super) fn chat_lines(&self, width: usize) -> (Vec>, Range) { + let width = width.clamp(/*min*/ 1, /*max*/ 96); + let wrap = |lines: Vec>| word_wrap_lines(lines, RtOptions::new(width)); + let mut lines = wrap(vec![ + "Active in past 30 days · sorted by lifetime credits" + .set_style(secondary_style()) + .into(), + ]); + if let Some(message) = self.chats.message() { + lines.extend(wrap(vec![ + message.to_string().set_style(secondary_style()).into(), + ])); + } + let Some(chats) = self.chats.ready() else { + return (lines, 0..1); + }; + if chats.rows.is_empty() { + lines.extend(wrap(vec![ + "No recent local chats.".set_style(secondary_style()).into(), + ])); + return (lines, 0..1); + } + let missing = chats.rows.iter().filter(|row| row.usage.is_none()).count(); + if missing > 0 { + lines.extend(wrap(vec![ + format!( + "{missing} chat estimate{} unavailable", + if missing == 1 { "" } else { "s" } + ) + .set_style(secondary_style()) + .into(), + ])); + } + lines.push(Line::default()); + let show_usd = chats.rows.iter().any(|chat| { + chat.usage + .as_ref() + .is_some_and(|usage| usage.estimated_usage_usd_micros.is_some()) + }); + let stacked_amounts = show_usd && width < 70; + lines.extend(wrap(vec![columns( + " Chat".bold().into(), + if stacked_amounts { + "Est. $ / credits" + } else if show_usd { + "Est. $ spent Lifetime credits" + } else { + "Lifetime credits" + } + .bold() + .into(), + width, + )])); + let rows = chats + .rows + .iter() + .enumerate() + .map(|(index, chat)| { + let selected = + self.section == Section::Chats && index == self.sections[Section::Chats].cursor; + let amount = chat.usage.as_ref().map_or_else( + || "—".into(), + |usage| data::credits(usage.estimated_usage_credits_micros), + ); + let amount = if show_usd { + let usd = chat + .usage + .as_ref() + .and_then(|usage| usage.estimated_usage_usd_micros) + .map(|micros| format!("${:.2}", micros as f64 / 1_000_000.0)) + .unwrap_or_else(|| "—".into()); + format!("{usd:>12} {amount:>16}") + } else { + amount + }; + let label = format!( + "{} {}", + if selected { "›" } else { " " }, + chat.display_title() + ); + let label: Line<'static> = if selected { + label.set_style(accent_style()).into() + } else { + label.into() + }; + let label_width = if stacked_amounts { + width + } else { + width.saturating_sub(amount.len() + 1) + }; + let truncated = label.width() > label_width; + let mut row = if stacked_amounts { + vec![ + truncate_line_with_ellipsis_if_overflow(label, label_width), + format!(" {}", amount.trim()).into(), + ] + } else { + vec![columns( + truncate_line_with_ellipsis_if_overflow(label, label_width), + if chat.usage.is_none() { + amount.set_style(secondary_style()).into() + } else if selected { + number(amount).into() + } else { + amount.into() + }, + width, + )] + }; + if self.sections[Section::Chats].detail == Some(index) + && let Some(usage) = &chat.usage + { + row.push( + format!(" {}", "─".repeat(width.saturating_sub(/*rhs*/ 2))) + .dim() + .into(), + ); + if truncated { + row.extend(word_wrap_lines( + [chat.display_title().to_string().bold()], + RtOptions::new(width) + .initial_indent(" ".into()) + .subsequent_indent(" ".into()), + )); + } + let zeros = usage + .groups + .iter() + .filter(|group| group.estimated_usage_credits_micros == 0) + .count(); + let mut groups = usage + .groups + .iter() + .filter(|group| { + self.show_zero_credit_groups + || group.estimated_usage_credits_micros != 0 + }) + .collect::>(); + groups.sort_by_key(|group| { + std::cmp::Reverse(group.estimated_usage_credits_micros) + }); + // On narrow terminals, retain every dimension on a second line. + let table = width >= 70; + if !groups.is_empty() { + row.push(columns( + if table { + format!( + " {: width { + row.push(label); + " ".into() + } else { + label + } + }; + row.push(columns(label, amount.into(), width)); + if !table { + row.push( + format!(" {effort} · {speed}") + .set_style(secondary_style()) + .into(), + ); + } + } + if usage.groups.is_empty() { + row.push( + " Breakdown unavailable" + .set_style(secondary_style()) + .into(), + ); + } + if zeros > 0 { + row.push( + format!( + " {zeros} zero-credit {}{} · a {}", + if zeros == 1 { "group" } else { "groups" }, + if self.show_zero_credit_groups { + "" + } else { + " hidden" + }, + if self.show_zero_credit_groups { + "hide zeros" + } else { + "show all" + }, + ) + .set_style(secondary_style()) + .into(), + ); + } + row.push(Line::default()); + } + wrap(row) + }) + .collect::>(); + let coverage = wrap(vec![ + "Local chats · excludes archived chats and subagent usage" + .set_style(secondary_style()) + .into(), + ]); + self.chat_window(lines, rows, coverage, width) + } + + pub(super) fn chat_window( + &self, + mut lines: Vec>, + rows: Vec>>, + coverage: Vec>, + width: usize, + ) -> (Vec>, Range) { + let wrap = |lines: Vec>| word_wrap_lines(lines, RtOptions::new(width)); + let count = rows.len(); + let range_height = wrap(vec![ + format!("Showing {count}–{count} of {count} chats").into(), + ]) + .len(); + // Measure wrapped details before choosing neighbors so the range describes the rendered rows. + let budget = if self.zoomed { + self.viewport_height + .saturating_sub(lines.len() + coverage.len() + range_height + 4) + } else { + usize::MAX + }; + let row_limit = if self.zoomed { count } else { 5 }; + let cursor = self.sections[Section::Chats].cursor.min(count - 1); + let mut first = cursor; + let mut end = cursor + 1; + let mut used = rows[cursor].len(); + while first > 0 && end - first < row_limit && used + rows[first - 1].len() <= budget { + first -= 1; + used += rows[first].len(); + } + while end < count && end - first < row_limit && used + rows[end].len() <= budget { + used += rows[end].len(); + end += 1; + } + let mut selection = 0..1; + for (index, row) in rows.into_iter().enumerate().take(end).skip(first) { + if index == cursor { + selection = lines.len()..lines.len() + row.len(); + } + lines.extend(row); + } + lines.push(Line::default()); + lines.extend(wrap(vec![ + format!("Showing {}–{end} of {count} chats", first + 1) + .set_style(secondary_style()) + .into(), + ])); + lines.extend(coverage); + (lines, selection) + } +} diff --git a/codex-rs/tui/src/analytics/chat_panel_tests.rs b/codex-rs/tui/src/analytics/chat_panel_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..709d99271b48c4fe92b73a932896ab57465a01c7 --- /dev/null +++ b/codex-rs/tui/src/analytics/chat_panel_tests.rs @@ -0,0 +1,215 @@ +//! Chat list coverage, responsive expansion, and controls for fixed-period data. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn chats_range_controls_and_visible_rows() { + let mut view = fixture::view(models::AccountKind::Enterprise); + let mut chats = fixture::chats(); + let usage = chats.rows[0].usage.clone(); + chats.rows = (1..=30) + .map(|index| chats::Chat { + title: format!("Chat {index:02}"), + usage: usage.clone(), + }) + .collect(); + view.chats = Load::Ready(chats); + press(&mut view, KeyCode::Char('6')); + let before = ( + view.ranges, + view.sections.0.each_ref().map(|state| state.cursor), + view.sections.0.each_ref().map(|state| state.detail), + ); + press(&mut view, KeyCode::Char('r')); + assert_eq!( + ( + view.ranges, + view.sections.0.each_ref().map(|state| state.cursor), + view.sections.0.each_ref().map(|state| state.detail) + ), + before + ); + let mut screens = Vec::new(); + for key in [KeyCode::Home, KeyCode::End, KeyCode::Enter] { + press(&mut view, key); + let output = screen(&mut view, /*width*/ 110, /*height*/ 24); + assert!(!output.contains("7d") && !output.contains("r 7/30d")); + let visible = (1..=30) + .filter(|index| output.contains(&format!("Chat {index:02}"))) + .collect::>(); + assert!(output.contains(&format!( + "Showing {}–{} of 30 chats", + visible.first().unwrap(), + visible.last().unwrap() + ))); + screens.push(output); + } + press(&mut view, KeyCode::Char('z')); + press(&mut view, KeyCode::Char('r')); + assert_eq!(view.ranges, [0; 3]); + press(&mut view, KeyCode::Char('2')); + press(&mut view, KeyCode::Char('r')); + press(&mut view, KeyCode::Char('z')); + assert!(screen(&mut view, /*width*/ 110, /*height*/ 24).contains("[30d]")); + insta::assert_snapshot!(screens.join("\n")); +} + +#[test] +fn chats_breakdown_ranks_nonzero_groups_and_reflows() { + let mut view = fixture::view(models::AccountKind::Enterprise); + let mut chats = fixture::chats(); + let usage = chats.rows[0].usage.as_mut().unwrap(); + let seed = usage.groups[0].clone(); + usage.groups = [ + ("Zero model", 0), + ("Small model", 215_610_920), + ("Top model", 19_134_430_635), + ("Refund model", -4), + ] + .map( + |(model, credits)| codex_backend_client::ThreadUsageBreakdownGroup { + model: Some(model.into()), + speed: (credits != 19_134_430_635).then(|| "standard".into()), + estimated_usage_credits_micros: credits, + ..seed.clone() + }, + ) + .to_vec(); + usage.estimated_usage_credits_micros = usage + .groups + .iter() + .map(|group| group.estimated_usage_credits_micros) + .sum(); + view.chats = Load::Ready(chats); + press(&mut view, KeyCode::Char('6')); + press(&mut view, KeyCode::Enter); + let mut screens = Vec::new(); + for width in [110, 58] { + view.follow_selection = true; + let output = screen(&mut view, width, /*height*/ 32); + assert!(output.find("Top model").unwrap() < output.find("Small model").unwrap()); + assert!(output.find("Small model").unwrap() < output.find("Refund model").unwrap()); + assert!(!output.contains("Zero model")); + assert!(output.contains("-0.000004") && output.contains("Not reported")); + assert!(output.contains("19,350.04") && output.contains("19,134.43")); + assert!(output.contains("1 zero-credit group hidden · a show all")); + assert!(output.contains("Refactor billing usage")); + screens.push(output); + } + press(&mut view, KeyCode::Char('a')); + let expanded = screen(&mut view, /*width*/ 110, /*height*/ 32); + assert!(expanded.contains("Zero model") && expanded.contains("a hide zeros")); + screens.push(expanded); + press(&mut view, KeyCode::Char('a')); + assert!(!view.show_zero_credit_groups); + let wide = view.chat_lines(/*width*/ 220).0; + assert!(wide.iter().all(|line| line.width() <= 96)); + insta::assert_snapshot!(screens.join("\n")); +} + +#[test] +fn chats_long_title_keeps_detail_indent_and_counts_missing_estimates() { + let mut view = fixture::view(models::AccountKind::Enterprise); + let mut chats = fixture::chats(); + chats.rows[0].title = "Create a comprehensive list of areas to investigate across the runtime and describe how each area affects performance in a long conversation".into(); + chats.rows[1].usage = None; + chats.rows[2].usage = None; + view.chats = Load::Ready(chats); + press(&mut view, KeyCode::Char('6')); + press(&mut view, KeyCode::Enter); + let output = screen(&mut view, /*width*/ 110, /*height*/ 30); + assert!(output.contains("2 chat estimates unavailable")); + insta::assert_snapshot!(output); +} + +#[tokio::test] +async fn unavailable_chats_do_not_open_invisible_details() { + for key in [KeyCode::Enter, KeyCode::Right] { + for state in 0..5 { + let mut view = fixture::view(models::AccountKind::Enterprise); + view.section = Section::Chats; + view.chats = match state { + 0 => Load::Unavailable, + 1 => Load::Error("Temporary failure".into()), + 2 => Load::Ready(chats::Chats::default()), + 3 => Load::Ready(chats::Chats { + rows: vec![chats::Chat { + title: "Another workspace's private title".into(), + usage: None, + }], + }), + _ => Load::start(std::future::pending(), FrameRequester::test_dummy()), + }; + press(&mut view, key); + assert_eq!(view.sections[Section::Chats].detail, None); + press(&mut view, KeyCode::Esc); + assert!(view.is_done); + } + } +} + +#[test] +fn unavailable_chat_titles_are_hidden_in_panel_and_overview() { + let mut view = fixture::view(models::AccountKind::Enterprise); + view.section = Section::Chats; + view.chats = Load::Ready(chats::Chats { + rows: vec![chats::Chat { + title: "Another workspace's private title".into(), + usage: None, + }], + }); + let mut screens = Vec::new(); + for zoomed in [true, false] { + view.zoomed = zoomed; + view.follow_selection = true; + let output = screen(&mut view, /*width*/ 110, /*height*/ 28); + assert!(!output.contains("Another workspace's private title")); + assert!(output.contains("Chat usage unavailable")); + screens.push(output); + } + insta::assert_snapshot!(screens.join("\n")); +} + +#[test] +fn refreshing_chats_clears_details_before_replacing_the_ranked_rows() { + for unavailable in [true, false] { + let mut view = fixture::view(models::AccountKind::Enterprise); + view.section = Section::Chats; + press(&mut view, KeyCode::Enter); + assert_eq!(view.sections[Section::Chats].detail, Some(0)); + press(&mut view, KeyCode::Char('R')); + assert_eq!(view.sections[Section::Chats].detail, None); + view.account = Load::Ready(codex_protocol::account::PlanType::EnterpriseCbpUsageBased); + view.chats = if unavailable { + Load::Error("Temporarily unavailable".into()) + } else { + Load::Ready(chats::Chats::default()) + }; + press(&mut view, KeyCode::Esc); + assert!(view.is_done); + } +} + +#[test] +fn moving_chat_selection_collapses_details_before_they_leave_the_viewport() { + for (start, key) in [ + (0, KeyCode::Down), + (1, KeyCode::Up), + (0, KeyCode::End), + (2, KeyCode::Home), + ] { + let mut view = fixture::view(models::AccountKind::Enterprise); + view.section = Section::Chats; + view.sections[Section::Chats].cursor = start; + press(&mut view, KeyCode::Enter); + assert_eq!(view.sections[Section::Chats].detail, Some(start)); + press(&mut view, key); + assert_eq!(view.sections[Section::Chats].detail, None); + if key == KeyCode::End { + insta::assert_snapshot!(screen(&mut view, /*width*/ 110, /*height*/ 24)); + } + press(&mut view, KeyCode::Esc); + assert!(view.is_done); + } +} diff --git a/codex-rs/tui/src/analytics/chats.rs b/codex-rs/tui/src/analytics/chats.rs new file mode 100644 index 0000000000000000000000000000000000000000..61f88609c651239ee35f3fd045aec493e6c15955 --- /dev/null +++ b/codex-rs/tui/src/analytics/chats.rs @@ -0,0 +1,195 @@ +//! Discover recent local roots and load business chat credit estimates. + +use super::client::Live; +use super::client::Session; +use super::data; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadSortKey; +use codex_backend_client::ThreadUsage; +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; + +pub(super) struct Chat { + pub title: String, + pub usage: Option, +} + +impl Chat { + /// Local history can span accounts; expose titles only with a returned account estimate. + pub(super) fn display_title(&self) -> &str { + if self.usage.is_some() { + &self.title + } else { + "Chat usage unavailable" + } + } +} + +#[derive(Default)] +pub(super) struct Chats { + pub rows: Vec, +} + +pub(super) async fn read( + handle: AppServerRequestHandle, + live: Arc, +) -> Result, String> { + let session = live.session().await?; + if !super::models::thread_usage_supported(session.backend.account().plan_type) { + return Ok(None); + } + session.backend.ensure_identity().await?; + let threads = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 60), roots(&handle)) + .await + .map_err(|_| "Chat listing timed out. Press R to retry.".to_string())??; + session.backend.ensure_identity().await?; + if threads.is_empty() { + return Ok(Some(Chats { rows: Vec::new() })); + } + let ids = threads + .iter() + .map(|thread| thread.id.as_str()) + .collect::>(); + // Repairing local history has its own budget; it must not consume estimate time. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(/*secs*/ 25); + let mut data = Vec::new(); + for pair in ids.chunks(/*chunk_size*/ 200) { + let midpoint = pair.len().min(/*other*/ 100); + let (first, second) = tokio::join!( + tokio::time::timeout_at(deadline, estimates(session, &pair[..midpoint])), + tokio::time::timeout_at(deadline, estimates(session, &pair[midpoint..])), + ); + for batch in [first, second].into_iter().flatten() { + data.extend(batch?); + } + if tokio::time::Instant::now() >= deadline { + break; + } + } + session.backend.ensure_identity().await?; + let mut usage: HashMap<_, _> = data + .into_iter() + .map(|row| (row.thread_id.clone(), row)) + .collect(); + let mut rows = threads + .into_iter() + .map(|thread| Chat { + usage: usage.remove(&thread.id), + title: thread + .name + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| { + if thread.preview.trim().is_empty() { + "Untitled chat".into() + } else { + thread.preview + } + }), + }) + .collect::>(); + rows.sort_by_key(|row| { + std::cmp::Reverse( + row.usage + .as_ref() + .map(|usage| usage.estimated_usage_credits_micros), + ) + }); + Ok(Some(Chats { rows })) +} + +/// Recent local roots are shared by consumer task and business credit reporting. +pub(super) async fn roots( + handle: &AppServerRequestHandle, +) -> Result, String> { + let cutoff = chrono::Utc::now().timestamp() - 30 * 24 * 60 * 60; + let mut threads = Vec::new(); + let mut cursor = None; + let mut seen_cursors = HashSet::new(); + loop { + let page: ThreadListResponse = handle + .request_typed(ClientRequest::ThreadList { + request_id: RequestId::String(uuid::Uuid::new_v4().to_string()), + params: ThreadListParams { + cursor, + ..list_params() + }, + }) + .await + .map_err(data::error)?; + let reached_cutoff = page.data.iter().any(|thread| thread.updated_at < cutoff); + threads.extend(page.data.into_iter().filter(|thread| { + thread.updated_at >= cutoff && !thread.ephemeral && thread.parent_thread_id.is_none() + })); + match page.next_cursor { + Some(next) if !seen_cursors.insert(next.clone()) => { + return Err("Chat listing repeated a cursor. Press R to retry.".into()); + } + Some(next) if !reached_cutoff => cursor = Some(next), + _ => break, + } + } + // Keep repeated rows from overlapping pages out of the backend's distinct-ID batches. + let mut seen_ids = HashSet::new(); + threads.retain(|thread| seen_ids.insert(thread.id.clone())); + Ok(threads) +} + +pub(super) fn list_params() -> ThreadListParams { + ThreadListParams { + limit: Some(100), + sort_key: Some(ThreadSortKey::UpdatedAt), + sort_direction: Some(SortDirection::Desc), + model_providers: Some(Vec::new()), + source_kinds: None, + originators: None, + archived: Some(false), + cursor: None, + section_id: None, + project_id: None, + cwd: None, + use_state_db_only: false, + search_term: None, + parent_thread_id: None, + ancestor_thread_id: None, + } +} + +/// Isolate unavailable estimates within the existing concurrency slot; keep healthy rows. +async fn estimates(session: &Session, ids: &[&str]) -> Result, String> { + let mut pending = if ids.is_empty() { + Vec::new() + } else { + vec![ids] + }; + let mut rows = Vec::new(); + // At most 15 requests per batch; leave remaining estimates unavailable during an outage. + for _ in 0..15 { + let Some(ids) = pending.pop() else { break }; + match session + .backend + .request(|client| async move { client.get_threads_usage(ids).await }) + .await + { + Ok(batch) => rows.extend(batch), + Err(error) if error.status().is_some_and(|status| status.as_u16() == 503) => { + if ids.len() > 1 { + let midpoint = ids.len().div_ceil(/*rhs*/ 2); + pending.push(&ids[midpoint..]); + pending.push(&ids[..midpoint]); + } + } + Err(error) => return Err(super::client::request_error(error)), + } + } + Ok(rows) +} + +#[cfg(test)] +#[path = "chats_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/chats_tests.rs b/codex-rs/tui/src/analytics/chats_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..1f932973df616a90e20b209520cedb12a78ff9ff --- /dev/null +++ b/codex-rs/tui/src/analytics/chats_tests.rs @@ -0,0 +1,178 @@ +//! Verify bounded chat discovery, account eligibility, and partial estimate handling. + +use super::super::client::tests::live; +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test] +async fn incomplete_chat_batches_retain_available_estimates_without_inventing_zeroes() { + let server = MockServer::start().await; + let (_home, live) = live(&server, "enterprise_cbp_usage_based").await; + Mock::given(method("POST")).and(path("/backend-api/wham/usage/thread_usage/query")) + .respond_with(|request: &wiremock::Request| { + let body: serde_json::Value = request.body_json().unwrap(); + let ids = body["thread_ids"].as_array().unwrap(); + if ids.len() > 1 || ids[0] == "incomplete" { + ResponseTemplate::new(/*s*/ 503).set_body_json(json!({"detail": "Thread usage is temporarily unavailable."})) + } else { + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"threads": [{ + "thread_id": ids[0], "estimated_usage_credits_micros": if ids[0] == "missing" { None } else { Some(1250000) }, + "estimated_usage_usd_micros": null, "groups": null, + }]})) + } + }).expect(/*r*/ 5).mount(&server).await; + let session = live.session().await.unwrap(); + assert_eq!( + estimates(session, &["available", "incomplete", "missing"]) + .await + .unwrap(), + vec![ThreadUsage { + thread_id: "available".into(), + estimated_usage_credits_micros: 1250000, + estimated_usage_usd_micros: None, + groups: Vec::new(), + }] + ); +} + +#[tokio::test] +async fn slow_repairing_chat_listing_keeps_estimate_budget_and_ranks_across_pages() { + use codex_app_server_client::RemoteAppServerClient; + use codex_app_server_client::RemoteAppServerConnectArgs; + use codex_app_server_client::RemoteAppServerEndpoint; + use futures::SinkExt; + use futures::StreamExt; + use tokio_tungstenite::tungstenite::Message; + + let server = MockServer::start().await; + let (_home, live) = live(&server, "enterprise_cbp_usage_based").await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let websocket_url = format!("ws://{}", listener.local_addr().unwrap()); + let rpc = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let mut pages = Vec::new(); + while let Some(Ok(Message::Text(text))) = socket.next().await { + let request: serde_json::Value = serde_json::from_str(&text).unwrap(); + let result = match request["method"].as_str() { + Some("initialize") => { + json!({"userAgent": "analytics-test", "codexHome": "/unused"}) + } + Some("thread/list") => { + let params = &request["params"]; + pages.push(json!([ + params["cursor"], + params["limit"], + params["useStateDbOnly"].as_bool().unwrap_or_default(), + params["modelProviders"] + ])); + if pages.len() == 1 { + // Simulate repair exceeding the HTTP estimate budget without a wall-clock wait. + tokio::time::pause(); + tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 26)).await; + tokio::time::resume(); + } + let range = if pages.len() == 1 { 0..100 } else { 100..101 }; + let data = range.map(|index| json!({ + "id": format!("chat-{index}"), "sessionId": format!("chat-{index}"), + "preview": "", "name": format!("Chat {index}"), "ephemeral": false, + "modelProvider": "openai", "createdAt": 0, + "updatedAt": chrono::Utc::now().timestamp(), "status": {"type": "idle"}, + "cwd": std::env::temp_dir(), "cliVersion": "test", "source": "cli", "turns": [], + })).collect::>(); + json!({"data": data, "nextCursor": if pages.len() == 1 { Some("next") } else { None }}) + } + _ => continue, + }; + socket + .send(Message::Text( + json!({"jsonrpc": "2.0", "id": request["id"], "result": result}) + .to_string() + .into(), + )) + .await + .unwrap(); + if pages.len() == 2 { + break; + } + } + pages + }); + let remote = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token: None, + }, + client_name: "analytics-test".into(), + client_version: "test".into(), + experimental_api: false, + mcp_server_openai_form_elicitation: false, + opt_out_notification_methods: Vec::new(), + channel_capacity: 16, + }) + .await + .unwrap(); + Mock::given(method("POST")).and(path("/backend-api/wham/usage/thread_usage/query")) + .respond_with(|request: &wiremock::Request| { + let body: serde_json::Value = request.body_json().unwrap(); + let ids = body["thread_ids"].as_array().unwrap(); + assert!(ids.len() <= 100); + if ids.iter().any(|id| id == "chat-35") { + return ResponseTemplate::new(/*s*/ 503) + .set_body_json(json!({"detail": "Thread usage is temporarily unavailable."})); + } + let threads = ids.iter().map(|id| json!({ + "thread_id": id, + "estimated_usage_credits_micros": id.as_str().unwrap().strip_prefix("chat-").unwrap().parse::().unwrap(), + })).collect::>(); + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"threads": threads})) + }).expect(/*r*/ 2..=16).mount(&server).await; + let chats = read( + AppServerRequestHandle::Remote(remote.request_handle()), + Arc::new(live), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + ( + chats.rows.len(), + chats.rows[0].title.as_str(), + chats.rows.last().unwrap().title.as_str(), + chats.rows.last().unwrap().usage.clone() + ), + (101, "Chat 100", "Chat 35", None) + ); + assert_eq!( + rpc.await.unwrap(), + vec![ + json!([null, 100, false, []]), + json!(["next", 100, false, []]) + ] + ); +} + +#[tokio::test] +async fn unavailable_chat_batches_bound_requests_during_an_outage() { + let server = MockServer::start().await; + let (_home, live) = live(&server, "enterprise_cbp_usage_based").await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(/*s*/ 503)) + .expect(/*r*/ 15) + .mount(&server) + .await; + let ids = (0..100).map(|i| format!("chat-{i}")).collect::>(); + let ids = ids.iter().map(String::as_str).collect::>(); + assert_eq!( + estimates(live.session().await.unwrap(), &ids) + .await + .unwrap(), + Vec::::new() + ); +} diff --git a/codex-rs/tui/src/analytics/client.rs b/codex-rs/tui/src/analytics/client.rs new file mode 100644 index 0000000000000000000000000000000000000000..f99003527036984b5c62b1c5adf61c3ffb3c2e7a --- /dev/null +++ b/codex-rs/tui/src/analytics/client.rs @@ -0,0 +1,267 @@ +//! Local account report loading with backend-owned authentication and identity checks. +//! Cached responses belong to one local account and user, independently of the connected server. +//! Every report uses the view's captured end date until refresh replaces the session. + +use super::models::AccountAnalyticsGrouping as Grouping; +use super::models::AccountAnalyticsReport as Report; +use super::models::AccountKind; +use super::report_data::AnalyticsData; +use crate::legacy_core::config::Config; +use codex_backend_client::AnalyticsReport; +use codex_backend_client::AnalyticsSession; +use codex_backend_client::RequestError; +use codex_protocol::account::PlanType; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::sync::OnceCell; + +pub(super) struct Live { + config: Arc, + end_date: chrono::NaiveDate, + session: OnceCell, + token_models: std::sync::RwLock>, + attributed_usage: std::sync::atomic::AtomicBool, +} + +pub(super) struct Session { + pub(super) kind: AccountKind, + pub(super) backend: AnalyticsSession, + credit_groups: Vec, + cache: Mutex>, +} + +impl Live { + pub(super) fn new(config: Arc, end_date: chrono::NaiveDate) -> Self { + Self { + config, + end_date, + session: OnceCell::new(), + token_models: std::sync::RwLock::new(Vec::new()), + attributed_usage: std::sync::atomic::AtomicBool::new(/*v*/ true), + } + } + + pub(super) fn account_label(&self) -> Option { + let session = self.session.get()?; + session.backend.account().email.clone() + } + + pub(super) fn credit_groups(&self) -> &[usize] { + self.session + .get() + .map_or(&[0], |session| &session.credit_groups) + } + + pub(super) fn attributed_usage(&self) -> bool { + self.attributed_usage + .load(std::sync::atomic::Ordering::Relaxed) + } + + pub(super) fn token_models(&self) -> Vec { + self.token_models + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + pub(super) async fn session(&self) -> Result<&Session, String> { + self.session + .get_or_try_init(|| async { + let session = AnalyticsSession::from_config( + self.config.as_ref(), + self.config.http_client_factory(), + ) + .await?; + let credit_groups = Grouping::credit_groupings(session.account().plan_type) + .iter() + .filter_map(|group| { + super::data::GROUPINGS + .iter() + .position(|candidate| candidate == group) + }) + .collect(); + Ok(Session { + kind: AccountKind::from(session.account().plan_type), + credit_groups, + backend: session, + cache: Mutex::new(HashMap::new()), + }) + }) + .await + } + + pub(super) async fn plan_history(&self) -> Result, String> { + let session = self.session().await?; + if session.kind != AccountKind::Consumer { + return Ok(None); + } + let history = session + .backend + .request(|client| async move { client.get_plan_limit_history().await }) + .await; + session.backend.ensure_identity().await?; + history + .map_err(request_error)? + .map(super::plan::Report::parse) + .transpose() + .map(Option::flatten) + } + + pub(super) async fn history( + &self, + report: Report, + days: u32, + grouping: Grouping, + ) -> Result, String> { + self.filtered_history(report, days, grouping, /*model_filter*/ None) + .await + } + + pub(super) async fn filtered_history( + &self, + report: Report, + days: u32, + grouping: Grouping, + model_filter: Option<&str>, + ) -> Result, String> { + let end = self.end_date; + let start = days + .checked_sub(/*rhs*/ 1) + .and_then(|offset| end.checked_sub_days(chrono::Days::new(u64::from(offset)))) + .ok_or("Invalid analytics date range.")?; + let session = self.session().await?; + session.backend.ensure_identity().await?; + let enterprise_tokens = report == Report::Usage + && matches!( + session.kind, + AccountKind::Enterprise | AccountKind::Business + ); + let grouping = + if enterprise_tokens && !matches!(grouping, Grouping::Model | Grouping::TokenType) { + Grouping::TokenType + } else { + grouping + }; + let route = + route(report, grouping, session.backend.account().plan_type).ok_or_else(|| { + "This credit breakdown is not supported for this account type.".to_string() + })?; + // Credit events have no range parameters. Other grouping changes reuse the same payload. + let key = if route == AnalyticsReport::Credits { + (route, String::new(), String::new()) + } else { + (route, start.to_string(), end.to_string()) + }; + let cached = session.cache.lock().await.get(&key).cloned(); + let response = if let Some(response) = cached { + Ok(response) + } else { + session + .backend + .request(|client| { + let (_, start, end) = key.clone(); + async move { client.get_account_analytics(route, &start, &end).await } + }) + .await + .map(AnalyticsData::from) + }; + session.backend.ensure_identity().await?; + let response = response.map_err(request_error)?; + let history = (|| { + if enterprise_tokens { + let models = super::tokens::history(response.clone(), Grouping::Model, start, end)?; + *self + .token_models + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + super::render::categories(&models) + .into_iter() + .map(|model| model.key) + .collect(); + super::tokens::filtered_history( + response.clone(), + grouping, + start, + end, + model_filter, + ) + .map(Some) + } else { + let grouping = if report == Report::Usage { + let attributed = + super::normalize::has_complete_attribution(&response, start, end)?; + self.attributed_usage + .store(attributed, std::sync::atomic::Ordering::Relaxed); + if !attributed && matches!(grouping, Grouping::Feature | Grouping::TaskStart) { + Grouping::Surface + } else { + grouping + } + } else { + grouping + }; + super::normalize::history(response.clone(), report, grouping, start, end) + } + })(); + let mut cache = session.cache.lock().await; + if history.is_ok() { + cache.insert(key, response); + } else { + cache.remove(&key); + } + history + } +} + +// Account type selects billing semantics, never activity-report eligibility. +fn route(report: Report, grouping: Grouping, plan: Option) -> Option { + let kind = AccountKind::from(plan); + let enterprise = kind == AccountKind::Enterprise; + Some(match report { + Report::Usage if enterprise => AnalyticsReport::EnterpriseTokens, + Report::Usage if kind == AccountKind::Business => AnalyticsReport::WorkspaceCredits, + Report::Usage => AnalyticsReport::Usage, + Report::Messages => AnalyticsReport::Messages, + Report::Credits if !Grouping::credit_groupings(plan).contains(&grouping) => return None, + Report::Credits if enterprise => AnalyticsReport::EnterpriseCredits { + breakdown: match grouping { + Grouping::Surface => "product", + Grouping::Model => "model", + Grouping::Speed => "speed", + Grouping::Reasoning => "reasoning_effort", + Grouping::Feature | Grouping::TaskStart | Grouping::TokenType => return None, + }, + }, + Report::Credits if kind == AccountKind::Business => AnalyticsReport::WorkspaceCredits, + Report::Credits => AnalyticsReport::Credits, + Report::Plugins => AnalyticsReport::Plugins { + limit: if enterprise { 8 } else { 10 }, + }, + Report::Skills => AnalyticsReport::Skills { + limit: if enterprise { 6 } else { 10 }, + }, + }) +} + +pub(super) fn request_error(error: RequestError) -> String { + match error.status().map(|status| status.as_u16()) { + Some(401) => "Sign in again to load this report.", + Some(403) => "Access denied for this report.", + Some(404) => "This report endpoint is unavailable. Press R to retry.", + _ => "Report request failed. Press R to retry.", + } + .into() +} + +#[cfg(test)] +#[path = "client_tests.rs"] +pub(super) mod tests; + +#[cfg(test)] +#[path = "connection_tests.rs"] +mod connection_tests; + +#[cfg(test)] +#[path = "account_plan_tests.rs"] +mod account_plan_tests; diff --git a/codex-rs/tui/src/analytics/client_tests.rs b/codex-rs/tui/src/analytics/client_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..872d462ed330d542ac5f3c61bc5336cd0b550867 --- /dev/null +++ b/codex-rs/tui/src/analytics/client_tests.rs @@ -0,0 +1,851 @@ +//! Account-scoped authentication and request identity regression coverage. + +use super::*; +use crate::analytics::sections::Section; +use crate::legacy_core::config::ConfigBuilder; +use base64::Engine; +use codex_config::LoaderOverrides; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +pub(super) fn sign_in(home: &std::path::Path, account: &str, user: &str, plan: &str) { + let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + json!({ + "exp": 4102444800_i64, "email": "analytics@example.test", + "https://api.openai.com/auth": { + "chatgpt_account_id": account, "chatgpt_user_id": user, "chatgpt_plan_type": plan, + }, + }) + .to_string(), + ); + let token = format!("e30.{claims}.test"); + let auth = serde_json::from_value(json!({ + "auth_mode": "chatgpt", "tokens": {"id_token": token, "access_token": token, + "refresh_token": "test-refresh", "account_id": account}, + "last_refresh": chrono::Utc::now(), + })) + .unwrap(); + codex_login::save_auth( + home, + &auth, + codex_login::AuthCredentialsStoreMode::File, + codex_login::AuthKeyringBackendKind::default(), + ) + .unwrap(); +} + +pub(in crate::analytics) async fn live( + server: &MockServer, + plan: &str, +) -> (tempfile::TempDir, Live) { + let home = tempfile::tempdir().unwrap(); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await + .unwrap(); + config.chatgpt_base_url = format!("{}/backend-api", server.uri()); + config.cli_auth_credentials_store_mode = codex_login::AuthCredentialsStoreMode::File; + sign_in(home.path(), "account-a", "user-a", plan); + Mock::given(method("GET")) + .and(path("/backend-api/wham/accounts/check")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "accounts": [{"id": "account-a", "plan_type": plan}], + "account_ordering": ["account-a"] + }))) + .with_priority(/*p*/ 2) + .mount(server) + .await; + ( + home, + Live::new(Arc::new(config), "2026-01-15".parse().unwrap()), + ) +} + +async fn report_requests(server: &MockServer) -> Vec { + server + .received_requests() + .await + .unwrap() + .into_iter() + .filter(|request| request.url.path() != "/backend-api/wham/accounts/check") + .collect() +} + +#[tokio::test] +async fn analytics_rejects_identity_changes_during_requests() { + let server = MockServer::start().await; + let (home, live) = live(&server, "business").await; + let session = live.session().await.unwrap(); + let authenticated = &session.backend; + let result = authenticated + .request(|_| async { + sign_in(home.path(), "account-b", "user-a", "business"); + Ok(123) + }) + .await; + assert_eq!( + result.unwrap_err().to_string(), + "Account changed. Press R to refresh Analytics." + ); + assert!(report_requests(&server).await.is_empty()); +} + +#[tokio::test] +async fn analytics_rejects_account_and_user_changes_before_requests() { + for (account, user) in [("account-b", "user-a"), ("account-a", "user-b")] { + let server = MockServer::start().await; + let (home, live) = live(&server, "plus").await; + let session = live.session().await.unwrap(); + let authenticated = &session.backend; + sign_in(home.path(), account, user, "plus"); + let requested = std::cell::Cell::new(/*value*/ false); + let result = authenticated + .request(|_| async { + requested.set(/*val*/ true); + Ok(()) + }) + .await; + assert!(!requested.get()); + assert_eq!( + result.unwrap_err().to_string(), + "Account changed. Press R to refresh Analytics." + ); + } +} + +#[tokio::test] +async fn analytics_requires_local_chatgpt_authentication() { + for auth in [None, Some(json!({"OPENAI_API_KEY": "sk-test-only"}))] { + let server = MockServer::start().await; + let (home, live) = live(&server, "plus").await; + let path = home.path().join("auth.json"); + match auth { + Some(auth) => std::fs::write(path, serde_json::to_vec(&auth).unwrap()).unwrap(), + None => std::fs::remove_file(path).unwrap(), + } + assert_eq!( + live.session().await.err(), + Some("Sign in locally with ChatGPT to view Analytics.".to_string()) + ); + assert!(report_requests(&server).await.is_empty()); + } +} + +#[tokio::test] +async fn analytics_requests_use_reloaded_credentials_for_the_same_identity() { + let server = MockServer::start().await; + let (home, live) = live(&server, "plus").await; + let session = live.session().await.unwrap(); + let authenticated = &session.backend; + assert_eq!( + live.account_label().as_deref(), + Some("analytics@example.test") + ); + let auth_path = home.path().join("auth.json"); + let mut auth: serde_json::Value = + serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap(); + auth["tokens"]["access_token"] = json!("refreshed-access-token"); + std::fs::write(auth_path, serde_json::to_vec(&auth).unwrap()).unwrap(); + Mock::given(method("GET")) + .and(path("/backend-api/wham/usage/daily-token-usage-breakdown")) + .and(header("chatgpt-account-id", "account-a")) + .and(header("authorization", "Bearer refreshed-access-token")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": []}))) + .expect(/*r*/ 1) + .mount(&server) + .await; + let result = authenticated + .request(|client| async move { + client + .get_account_analytics( + codex_backend_client::AnalyticsReport::Usage, + "2026-09-01", + "2026-09-07", + ) + .await + }) + .await + .unwrap(); + assert_eq!( + result, + codex_backend_client::AnalyticsResponse::Usage( + codex_backend_client::analytics_models::DailyProductSurfaceUsageResponse::default() + ) + ); + server.verify().await; +} + +#[tokio::test] +async fn analytics_retries_unauthorized_requests_after_credentials_reload() { + let server = MockServer::start().await; + let (home, live) = live(&server, "plus").await; + let session = live.session().await.unwrap(); + let authenticated = &session.backend; + let auth_path = home.path().join("auth.json"); + let mut auth: serde_json::Value = + serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap(); + let original_token = auth["tokens"]["access_token"].as_str().unwrap().to_owned(); + auth["tokens"]["access_token"] = json!("recovered-access-token"); + let updated_auth = serde_json::to_vec(&auth).unwrap(); + Mock::given(method("GET")) + .and(header("authorization", format!("Bearer {original_token}"))) + .respond_with(move |_: &wiremock::Request| { + std::fs::write(&auth_path, &updated_auth).unwrap(); + ResponseTemplate::new(/*s*/ 401) + }) + .expect(/*r*/ 1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(header("authorization", "Bearer recovered-access-token")) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": []}))) + .expect(/*r*/ 1) + .mount(&server) + .await; + let result = authenticated + .request(|client| async move { + client + .get_account_analytics( + codex_backend_client::AnalyticsReport::Usage, + "2026-09-01", + "2026-09-07", + ) + .await + }) + .await + .unwrap(); + assert_eq!( + result, + codex_backend_client::AnalyticsResponse::Usage( + codex_backend_client::analytics_models::DailyProductSurfaceUsageResponse::default() + ) + ); + server.verify().await; +} + +#[tokio::test] +async fn analytics_retries_session_initialization_after_sign_in() { + let server = MockServer::start().await; + let (home, live) = live(&server, "plus").await; + std::fs::remove_file(home.path().join("auth.json")).unwrap(); + assert!(live.session().await.is_err()); + sign_in(home.path(), "account-a", "user-a", "plus"); + assert_eq!( + live.session().await.unwrap().backend.account().id, + "account-a" + ); +} + +#[tokio::test] +async fn analytics_matches_app_account_routes_and_parameters() { + let server = MockServer::start().await; + for (plan, report, grouping, endpoint, extra) in [ + ( + "plus", + Report::Usage, + Grouping::Model, + "usage/daily-token-usage-breakdown", + vec![("group_by", "day")], + ), + ( + "business", + Report::Usage, + Grouping::TokenType, + "usage/daily-workspace-user-token-usage-breakdown", + vec![ + ("group_by", "day"), + ("breakdown_by", "model"), + ("modes", "codex"), + ("modes", "work"), + ], + ), + ( + "business", + Report::Messages, + Grouping::Model, + "analytics/daily-workspace-usage-counts", + vec![("group_by", "day"), ("workspace_user", "true")], + ), + ( + "unknown", + Report::Usage, + Grouping::Surface, + "usage/daily-token-usage-breakdown", + vec![("group_by", "day")], + ), + ( + "unknown", + Report::Messages, + Grouping::Model, + "analytics/daily-workspace-usage-counts", + vec![("group_by", "day"), ("workspace_user", "true")], + ), + ( + "unknown", + Report::Plugins, + Grouping::Feature, + "analytics/daily-plugin-usage-metrics", + vec![ + ("group_by", "day"), + ("workspace_user", "true"), + ("top_plugin_limit", "10"), + ], + ), + ( + "unknown", + Report::Skills, + Grouping::Feature, + "analytics/daily-skill-usage-metrics", + vec![ + ("group_by", "day"), + ("workspace_user", "true"), + ("top_skill_limit", "10"), + ], + ), + ( + "edu", + Report::Credits, + Grouping::Model, + "usage/daily-workspace-user-credit-usage", + vec![("breakdown", "model")], + ), + ( + "plus", + Report::Credits, + Grouping::Surface, + "usage/credit-usage-events", + vec![], + ), + ( + "team", + Report::Usage, + Grouping::Model, + "usage/daily-workspace-user-token-usage-breakdown", + vec![("group_by", "day")], + ), + ( + "team", + Report::Credits, + Grouping::Speed, + "usage/daily-workspace-user-token-usage-breakdown", + vec![("group_by", "day")], + ), + ( + "business", + Report::Credits, + Grouping::Reasoning, + "usage/daily-workspace-user-credit-usage", + vec![("breakdown", "reasoning_effort")], + ), + ( + "plus", + Report::Messages, + Grouping::Model, + "analytics/daily-workspace-usage-counts", + vec![("group_by", "day"), ("workspace_user", "true")], + ), + ( + "plus", + Report::Plugins, + Grouping::Feature, + "analytics/daily-plugin-usage-metrics", + vec![ + ("group_by", "day"), + ("workspace_user", "true"), + ("top_plugin_limit", "10"), + ], + ), + ( + "business", + Report::Plugins, + Grouping::Feature, + "analytics/daily-plugin-usage-metrics", + vec![ + ("group_by", "day"), + ("workspace_user", "true"), + ("top_plugin_limit", "8"), + ], + ), + ( + "business", + Report::Skills, + Grouping::Feature, + "analytics/daily-skill-usage-metrics", + vec![ + ("group_by", "day"), + ("workspace_user", "true"), + ("top_skill_limit", "6"), + ], + ), + ] { + server.reset().await; + let (_home, live) = live(&server, plan).await; + let today = live.end_date; + let start = (today - chrono::Days::new(/*num*/ 6)).to_string(); + let response = if endpoint == "usage/daily-workspace-user-credit-usage" { + let breakdown = extra.iter().find(|(key, _)| *key == "breakdown").unwrap().1; + json!({"data": [], "series": [], "breakdown": breakdown}) + } else { + json!({"data": []}) + }; + Mock::given(method("GET")) + .and(path(format!("/backend-api/wham/{endpoint}"))) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(response)) + .expect(/*r*/ 1) + .mount(&server) + .await; + assert!( + live.history(report, /*days*/ 7, grouping) + .await + .unwrap() + .is_some() + ); + let requests = report_requests(&server).await; + let mut actual = requests[0] + .url + .query_pairs() + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + let mut expected = extra + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect::>(); + if endpoint != "usage/credit-usage-events" { + expected.push(("start_date".into(), start.clone())); + expected.push(("end_date".into(), today.to_string())); + } + actual.sort(); + expected.sort(); + assert_eq!(actual, expected); + assert!(requests[0].headers.get("authorization").is_some()); + } +} + +#[tokio::test] +async fn analytics_uses_api_response_for_usage_and_turns_availability() { + let server = MockServer::start().await; + for report in [Report::Usage, Report::Messages] { + for status in [403, 404] { + server.reset().await; + let (_home, live) = live(&server, "business").await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(status)) + .expect(/*r*/ 1) + .mount(&server) + .await; + assert_eq!( + live.history(report, /*days*/ 7, Grouping::Surface) + .await + .unwrap_err(), + if status == 403 { + "Access denied for this report." + } else { + "This report endpoint is unavailable. Press R to retry." + } + ); + server.verify().await; + } + } +} + +#[tokio::test] +async fn analytics_reuses_payload_for_grouping_and_rejects_account_changes() { + let server = MockServer::start().await; + let (home, live) = live(&server, "team").await; + let today = live.end_date.to_string(); + Mock::given(method("GET")) + .and(path( + "/backend-api/wham/usage/daily-workspace-user-token-usage-breakdown", + )) + .respond_with( + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": [{ + "date": today, "product_surface_usage_values": {}, "premium_usage_values": { + "credit_usage_credits": {"cli": 12.5}, "total_usage_credits": {}, + "uncached_text_input_tokens_by_surface": {}, "cached_text_input_tokens_by_surface": {}, + "text_output_tokens_by_surface": {}, "text_total_tokens_by_surface": {} + }, + "models": [{"model": "model-a", "credits": 12.5, "speed": "fast", "on_demand_credits": 12.5}] + }]})), + ) + .expect(/*r*/ 1) + .mount(&server) + .await; + let surface = live + .history(Report::Credits, /*days*/ 7, Grouping::Surface) + .await + .unwrap() + .unwrap(); + let model = live + .history(Report::Credits, /*days*/ 7, Grouping::Model) + .await + .unwrap() + .unwrap(); + assert_eq!( + ( + surface.data.last().unwrap().total, + model.data.last().unwrap().total + ), + (12.5, 12.5) + ); + sign_in(home.path(), "account-a", "user-b", "team"); + assert_eq!( + live.history(Report::Credits, /*days*/ 7, Grouping::Model) + .await + .unwrap_err(), + "Account changed. Press R to refresh Analytics." + ); +} + +#[tokio::test] +async fn token_model_filter_reuses_the_account_scoped_response() { + let server = MockServer::start().await; + let (_home, live) = live(&server, "business").await; + let today = live.end_date.to_string(); + Mock::given(method("GET")) + .and(path("/backend-api/wham/usage/daily-workspace-user-token-usage-breakdown")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data":[{ + "date":today, "product_surface_usage_values": {}, "groups":[ + {"dimensions":{"model":"alpha"}, "credits": 0, "uncached_text_input_tokens":10,"cached_text_input_tokens":20,"text_output_tokens":30}, + {"dimensions":{"model":"beta"}, "credits": 0, "uncached_text_input_tokens":100,"cached_text_input_tokens":200,"text_output_tokens":300} + ] + }]}))) + .expect(/*r*/ 1).mount(&server).await; + let all = live + .history(Report::Usage, /*days*/ 7, Grouping::TokenType) + .await + .unwrap() + .unwrap(); + let alpha = live + .filtered_history( + Report::Usage, + /*days*/ 7, + Grouping::TokenType, + Some("alpha"), + ) + .await + .unwrap() + .unwrap(); + let beta = live + .filtered_history( + Report::Usage, + /*days*/ 7, + Grouping::TokenType, + Some("beta"), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + (all.data[0].total, alpha.data[0].total, beta.data[0].total), + (660.0, 60.0, 600.0) + ); + assert_eq!( + live.token_models(), + vec!["beta".to_string(), "alpha".to_string()] + ); + assert_eq!(report_requests(&server).await.len(), 1); +} + +#[tokio::test] +async fn legacy_usage_falls_back_to_surface_for_attribution_only_groupings() { + let server = MockServer::start().await; + let (_home, live) = live(&server, "plus").await; + let today = live.end_date.to_string(); + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": [{ + "date": today, "product_surface_usage_values": {"cli": 12.5} + }]})), + ) + .expect(/*r*/ 1) + .mount(&server) + .await; + let surface = live + .history(Report::Usage, /*days*/ 7, Grouping::Surface) + .await + .unwrap(); + for grouping in [Grouping::Feature, Grouping::TaskStart] { + let history = live + .history(Report::Usage, /*days*/ 7, grouping) + .await + .unwrap(); + assert_eq!((live.attributed_usage(), history), (false, surface.clone())); + } + server.verify().await; +} + +#[tokio::test] +async fn invalid_ranges_fail_without_requesting_reports() { + let server = MockServer::start().await; + let (_home, live) = live(&server, "plus").await; + for days in [0, u32::MAX] { + assert_eq!( + live.history(Report::Usage, days, Grouping::Surface) + .await + .unwrap_err(), + "Invalid analytics date range." + ); + } + assert!(report_requests(&server).await.is_empty()); +} + +#[tokio::test] +async fn unknown_plan_exposes_no_credit_breakdowns() { + let server = MockServer::start().await; + let (_home, live) = live(&server, "unknown").await; + live.session().await.unwrap(); + assert!(live.credit_groups().is_empty()); + assert!( + live.history(Report::Credits, /*days*/ 7, Grouping::Surface) + .await + .is_err() + ); + assert!(report_requests(&server).await.is_empty()); +} + +#[tokio::test] +async fn in_flight_account_switch_retains_refresh_guidance() { + let server = MockServer::start().await; + let (home, live) = live(&server, "plus").await; + let home_path = home.path().to_path_buf(); + Mock::given(method("GET")) + .respond_with(move |_: &wiremock::Request| { + sign_in(&home_path, "account-b", "user-a", "plus"); + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": []})) + }) + .expect(/*r*/ 1) + .mount(&server) + .await; + assert_eq!( + live.history(Report::Usage, /*days*/ 7, Grouping::Surface) + .await + .unwrap_err(), + "Account changed. Press R to refresh Analytics." + ); + server.verify().await; +} + +#[tokio::test] +async fn missing_breakdowns_remain_unavailable() { + let server = MockServer::start().await; + let (_home, live) = live(&server, "plus").await; + let today = live.end_date.to_string(); + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(/*s*/ 200).set_body_json( + json!({"data": [{"date": today, "product_surface_usage_values": {}}]}), + ), + ) + .expect(/*r*/ 1) + .mount(&server) + .await; + assert_eq!( + live.history(Report::Usage, /*days*/ 7, Grouping::Model) + .await, + Ok(None) + ); + server.verify().await; +} + +#[tokio::test] +async fn out_of_range_legacy_rows_do_not_disable_attribution() { + let server = MockServer::start().await; + let (_home, live) = live(&server, "plus").await; + let today = live.end_date.to_string(); + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": [ + {"date": "2000-01-01", "product_surface_usage_values": {}}, + {"date": today, "product_surface_usage_values": {}, "attribution": [{"thread_source": "user", "turn_trigger": "composer", "model": "example", "surface": "cli", "value": 12.0}]} + ]})), + ) + .expect(/*r*/ 1) + .mount(&server) + .await; + let history = live + .history(Report::Usage, /*days*/ 7, Grouping::Feature) + .await + .unwrap() + .unwrap(); + assert_eq!( + history.data, + vec![super::super::models::AccountAnalyticsDay { + date: live.end_date, + total: 12.0, + values: vec![super::super::models::AccountAnalyticsValue { + key: "user".into(), + label: "Tasks".into(), + value: 12.0, + }], + }] + ); + assert!(live.attributed_usage()); +} + +#[tokio::test] +async fn invalid_reports_can_retry_without_recreating_the_session() { + for plan in ["plus", "business"] { + let server = MockServer::start().await; + let (_home, live) = live(&server, plan).await; + let requests = Arc::new(std::sync::atomic::AtomicUsize::new(/*v*/ 0)); + let count = Arc::clone(&requests); + Mock::given(method("GET")) + .respond_with(move |_: &wiremock::Request| { + let body = if count.fetch_add(/*val*/ 1, std::sync::atomic::Ordering::SeqCst) == 0 { + json!({"data": [{"date": "invalid"}]}) + } else { + json!({"data": []}) + }; + ResponseTemplate::new(/*s*/ 200).set_body_json(body) + }) + .expect(/*r*/ 2) + .mount(&server) + .await; + assert!( + live.history(Report::Usage, /*days*/ 7, Grouping::Surface) + .await + .is_err() + ); + assert!( + live.history(Report::Usage, /*days*/ 7, Grouping::Surface) + .await + .is_ok() + ); + assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 2); + server.verify().await; + } +} + +#[tokio::test] +async fn invalid_cached_breakdowns_are_evicted_before_retry() { + let server = MockServer::start().await; + let (_home, live) = live(&server, "plus").await; + let today = live.end_date.to_string(); + let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(/*v*/ 0)); + let count = Arc::clone(&attempts); + Mock::given(method("GET")) + .respond_with(move |_: &wiremock::Request| { + let model = if count.fetch_add(/*val*/ 1, std::sync::atomic::Ordering::SeqCst) == 0 { + json!({"model": "alpha", "credits": -1}) + } else { + json!({"model": "alpha", "credits": 12}) + }; + ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": [{ + "date": today, "product_surface_usage_values": {"cli": 12}, "models": [model] + }]})) + }) + .expect(/*r*/ 2) + .mount(&server) + .await; + live.history(Report::Usage, /*days*/ 7, Grouping::Surface) + .await + .unwrap(); + assert!( + live.history(Report::Usage, /*days*/ 7, Grouping::Model) + .await + .is_err() + ); + assert!( + live.history(Report::Usage, /*days*/ 7, Grouping::Model) + .await + .unwrap() + .is_some() + ); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2); + server.verify().await; +} + +pub(in crate::analytics) async fn connected_view( + server: &MockServer, + plan: &str, +) -> ( + tempfile::TempDir, + crate::AppServerSession, + crate::analytics::AnalyticsView, +) { + let (home, live) = live(server, plan).await; + let config = Arc::clone(&live.config); + let app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .unwrap(); + let mut view = + crate::analytics::AnalyticsView::new(crate::keymap::RuntimeKeymap::defaults().list); + view.open( + app_server.request_handle(), + crate::tui::FrameRequester::test_dummy(), + Vec::new(), + config, + ); + (home, app_server, view) +} + +#[tokio::test] +async fn live_account_identity_is_visible_and_closing_aborts_pending_loads() { + let server = MockServer::start().await; + let (_home, live) = live(&server, "plus").await; + live.session().await.unwrap(); + let mut view = + crate::analytics::AnalyticsView::new(crate::keymap::RuntimeKeymap::defaults().list); + view.live = Some(Arc::new(live)); + view.sections[Section::Usage].history = + super::super::data::Load::Error("Couldn't load usage. Press R to retry.".into()); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + /*width*/ 100, /*height*/ 24, + )) + .unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + insta::assert_snapshot!(terminal.backend().to_string()); + let (draw_tx, _) = tokio::sync::broadcast::channel(/*capacity*/ 1); + let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel::<()>(); + view.sections[Section::Usage].history = super::super::data::Load::start( + async move { + let _cancelled = cancelled_tx; + std::future::pending().await + }, + crate::tui::FrameRequester::new(draw_tx), + ); + view.cancel_loads(); + assert!(cancelled_rx.await.is_err()); +} + +#[tokio::test] +async fn plan_history_rejects_account_change_during_response() { + let server = MockServer::start().await; + for status in [200, 404, 503] { + server.reset().await; + let (home, live) = live(&server, "plus").await; + let home_path = home.path().to_path_buf(); + Mock::given(method("GET")) + .and(path("/backend-api/wham/usage/plan_limit_history")) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(move |_: &wiremock::Request| { + sign_in(&home_path, "account-a", "user-b", "plus"); + ResponseTemplate::new(status).set_body_json(json!({ + "data_as_of":"2026-09-02T00:00:00Z", "coverage_start":null, + "coverage_complete":true, "periods":[] + })) + }) + .expect(/*r*/ 1) + .mount(&server) + .await; + assert_eq!( + live.plan_history().await.err().unwrap(), + "Account changed. Press R to refresh Analytics." + ); + } +} + +#[path = "profile_identity_tests.rs"] +mod profile_identity; diff --git a/codex-rs/tui/src/analytics/compact_tests.rs b/codex-rs/tui/src/analytics/compact_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..a500d01b3fdd0c7a81e25b615c1a185daa3e6105 --- /dev/null +++ b/codex-rs/tui/src/analytics/compact_tests.rs @@ -0,0 +1,48 @@ +//! Large report totals and axes stay compact while selected-day details retain precision. +use super::*; + +#[test] +fn report_headlines_and_axes_compact_large_values() { + let mut screenshots = Vec::new(); + for (section, unit, total, headline) in [ + ( + Section::Usage, + models::AccountAnalyticsUnit::Tokens, + 12_280_365_226.0, + "12.3B tokens", + ), + ( + Section::Plugins, + models::AccountAnalyticsUnit::Count, + 9_749.0, + "9.7K calls", + ), + ( + Section::Skills, + models::AccountAnalyticsUnit::Count, + 2_901.0, + "2.9K uses", + ), + ] { + let mut view = fixture::view(models::AccountKind::Enterprise); + view.section = section; + view.sections[section].history = Load::Ready(models::AccountAnalyticsHistory { + unit, + updated_at: None, + data: vec![models::AccountAnalyticsDay { + date: view.end_date, + total, + values: vec![models::AccountAnalyticsValue { + key: "test".into(), + label: "Example".into(), + value: total, + }], + }], + }); + let rendered = screen(&mut view, /*width*/ 100, /*height*/ 30); + assert!(rendered.contains(headline)); + assert!(rendered.contains(&data::amount(total))); + screenshots.push(rendered); + } + insta::assert_snapshot!(screenshots.join("\n")); +} diff --git a/codex-rs/tui/src/analytics/connection_tests.rs b/codex-rs/tui/src/analytics/connection_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..101e8dfa79b117b5c20da47946c7c59ef61a574f --- /dev/null +++ b/codex-rs/tui/src/analytics/connection_tests.rs @@ -0,0 +1,237 @@ +//! Exercise local Analytics plan discovery and report recovery while connected to an app-server. + +use super::tests::live; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::RemoteAppServerClient; +use codex_app_server_client::RemoteAppServerConnectArgs; +use codex_app_server_client::RemoteAppServerEndpoint; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Message; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; + +async fn read(socket: &mut WebSocketStream) -> Value { + let frame = socket.next().await.unwrap().unwrap(); + serde_json::from_str(frame.to_text().unwrap()).unwrap() +} + +async fn remote() -> (RemoteAppServerClient, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let initialize = read(&mut socket).await; + assert_eq!(initialize["method"], "initialize"); + socket + .send(Message::Text( + json!({"id": initialize["id"], "result": { + "userAgent": "analytics-test", "codexHome": "/server/.codex", + }}) + .to_string() + .into(), + )) + .await + .unwrap(); + assert_eq!(read(&mut socket).await["method"], "initialized"); + // Analytics uses the local account without requesting connected-server account data. + assert!(socket.next().await.unwrap().unwrap().is_close()); + }); + let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url: format!("ws://{address}"), + auth_token: None, + }, + client_name: "analytics-test".into(), + client_version: "0.0.0-test".into(), + experimental_api: true, + mcp_server_openai_form_elicitation: false, + opt_out_notification_methods: Vec::new(), + channel_capacity: 8, + }) + .await + .unwrap(); + (client, server) +} + +#[tokio::test] +async fn local_reports_share_an_account_and_recover_profile_errors() { + use crate::analytics::AnalyticsView; + use crate::analytics::data::Load; + use crate::analytics::sections::Section; + + let http = crate::analytics::test_support::server().await; + let (_home, local) = live(&http, "enterprise").await; + let (client, server) = remote().await; + let failed_profile = Mock::given(method("GET")) + .and(wiremock::matchers::path("/backend-api/wham/profiles/me")) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(ResponseTemplate::new(/*s*/ 503)) + .expect(/*r*/ 1) + .mount_as_scoped(&http) + .await; + let mut view = AnalyticsView::new(crate::keymap::RuntimeKeymap::defaults().list); + view.open( + AppServerRequestHandle::Remote(client.request_handle()), + crate::tui::FrameRequester::test_dummy(), + Vec::new(), + std::sync::Arc::clone(&local.config), + ); + crate::analytics::test_support::settle(&mut view).await; + assert_eq!( + view.visible_sections(), + &[ + Section::Summary, + Section::Credits, + Section::Usage, + Section::Plugins, + Section::Skills, + ] + ); + assert!(matches!(view.profile, Load::Error(_))); + for section in view.visible_sections() { + if section.report().is_some() { + assert!(view.sections[*section].history.ready().is_some()); + } + } + view.select_summary(/*view*/ None); + view.end_date = "2026-09-09".parse().unwrap(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + /*width*/ 100, /*height*/ 48, + )) + .unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + insta::assert_snapshot!("local_profile_error", terminal.backend().to_string()); + drop(failed_profile); + + let profile = json!({ + "profile":{"display_name":"Local account","username":"local-user"}, + "stats":{"lifetime_tokens":12345,"peak_daily_tokens":12345, + "daily_usage_buckets":[{"start_date":"2026-09-09","tokens":12345}]} + }); + Mock::given(method("GET")) + .and(wiremock::matchers::path("/backend-api/wham/profiles/me")) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(profile.clone())) + .expect(/*r*/ 1) + .mount(&http) + .await; + view.refresh(); + crate::analytics::test_support::settle(&mut view).await; + assert_eq!( + view.profile.ready(), + Some(&serde_json::from_value(profile).unwrap()) + ); + view.end_date = "2026-09-09".parse().unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + insta::assert_snapshot!("local_profile_recovered", terminal.backend().to_string()); + for request in http.received_requests().await.unwrap() { + assert_eq!( + request.headers.get("chatgpt-account-id").unwrap(), + "account-a" + ); + } + view.cancel_loads(); + client.shutdown().await.unwrap(); + server.await.unwrap(); +} + +#[tokio::test] +async fn account_lookup_failure_recovers_on_refresh_with_the_server_plan() { + use crate::analytics::AnalyticsView; + use crate::analytics::data::Load; + use crate::analytics::sections::Section; + + let http = crate::analytics::test_support::server().await; + let (_home, local) = live(&http, "plus").await; + let (client, server) = remote().await; + Mock::given(method("GET")) + .and(wiremock::matchers::path("/backend-api/wham/accounts/check")) + .respond_with(ResponseTemplate::new(/*s*/ 503)) + .with_priority(/*p*/ 1) + .expect(/*r*/ 1) + .up_to_n_times(/*n*/ 1) + .mount(&http) + .await; + let mut view = AnalyticsView::new(crate::keymap::RuntimeKeymap::defaults().list); + view.open( + AppServerRequestHandle::Remote(client.request_handle()), + crate::tui::FrameRequester::test_dummy(), + Vec::new(), + std::sync::Arc::clone(&local.config), + ); + crate::analytics::test_support::settle(&mut view).await; + assert!(matches!(view.account, Load::Error(_))); + assert_eq!( + http.received_requests() + .await + .unwrap() + .iter() + .map(|request| request.url.path()) + .collect::>(), + ["/backend-api/wham/accounts/check"] + ); + view.end_date = "2026-09-09".parse().unwrap(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + /*width*/ 100, /*height*/ 40, + )) + .unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + insta::assert_snapshot!("account_plan_error", terminal.backend().to_string()); + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/backend-api/wham/accounts/check")) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "accounts": [{"id": "account-a", "plan_type": "enterprise"}] + }))) + .with_priority(/*p*/ 1) + .expect(/*r*/ 1) + .mount(&http) + .await; + Mock::given(method("GET")) + .and(wiremock::matchers::path("/backend-api/wham/profiles/me")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"stats": {}}))) + .mount(&http) + .await; + view.refresh(); + crate::analytics::test_support::settle(&mut view).await; + assert_eq!( + view.visible_sections(), + &[ + Section::Summary, + Section::Credits, + Section::Usage, + Section::Plugins, + Section::Skills, + ] + ); + assert_eq!( + view.account.ready(), + Some(&codex_protocol::account::PlanType::Enterprise) + ); + for section in view.visible_sections() { + if section.report().is_some() { + assert!(view.sections[*section].history.ready().is_some()); + } + } + http.verify().await; + view.cancel_loads(); + client.shutdown().await.unwrap(); + server.await.unwrap(); +} diff --git a/codex-rs/tui/src/analytics/consumer_refresh_tests.rs b/codex-rs/tui/src/analytics/consumer_refresh_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..d29c272cf54e298e862c83d6f473f959a0296a18 --- /dev/null +++ b/codex-rs/tui/src/analytics/consumer_refresh_tests.rs @@ -0,0 +1,105 @@ +//! Consumer chat columns follow reported metrics without substituting billing estimates. +use super::*; +use crate::analytics::tasks::Chat; +use crate::analytics::tasks::Chats; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn consumer_chats_keep_missing_rows_and_only_offer_known_metrics() { + let mut view = fixture::view(models::AccountKind::Consumer); + view.section = Section::Chats; + view.tasks = Load::Ready(Chats { + rows: vec![ + Chat { title: "Private title from another account".into(), task: None }, + Chat { title: "Unverified backend row".into(), task: Some(serde_json::from_value(json!({ + "thread_id":"unavailable", "data_status":"unavailable", "usage_source":"unknown", + "weekly_limit_percent":null,"five_hour_limit_percent":null,"balance_usage_credits":null,"groups":[] + })).unwrap()) }, + Chat { title: "Refunded task".into(), task: Some(serde_json::from_value(json!({ + "thread_id":"refund", "data_status":"partial", "usage_source":"credits", + "weekly_limit_percent":null,"five_hour_limit_percent":null,"balance_usage_credits":"-0.000004","groups":[{ + "product_experience":"codex", "model":"gpt-5.5", "reasoning_effort":"high", "speed":"fast", + "weekly_limit_percent":null,"five_hour_limit_percent":null,"balance_usage_credits":"-0.000004" + }] + })).unwrap()) }, + ], ..Chats::default() + }); + assert_eq!(view.task_metrics(), vec![2]); + assert_eq!( + view.task_rows() + .iter() + .map(|row| row.title.as_str()) + .collect::>(), + vec![ + "Refunded task", + "Private title from another account", + "Unverified backend row" + ] + ); + press(&mut view, KeyCode::Down); + press(&mut view, KeyCode::Enter); + assert_eq!(view.sections[Section::Chats].detail, None); + let hidden = screen(&mut view, /*width*/ 90, /*height*/ 30); + assert!(!hidden.contains("Private title") && !hidden.contains("Unverified backend row")); + press(&mut view, KeyCode::Up); + press(&mut view, KeyCode::Enter); + insta::assert_snapshot!(screen(&mut view, /*width*/ 90, /*height*/ 30)); + let chats = match &mut view.tasks { + Load::Ready(chats) => chats, + _ => unreachable!(), + }; + chats.rows[2] + .task + .as_mut() + .unwrap() + .amounts + .five_hour_limit_percent = Some(0.0); + chats.rows[2] + .task + .as_mut() + .unwrap() + .amounts + .weekly_limit_percent = Some(125.0); + assert_eq!(view.task_metrics(), vec![0, 1, 2]); + insta::assert_snapshot!( + "consumer_chat_all_metrics", + screen(&mut view, /*width*/ 110, /*height*/ 30) + ); + press(&mut view, KeyCode::Char('s')); + insta::assert_snapshot!( + "consumer_chat_available_limits", + screen(&mut view, /*width*/ 58, /*height*/ 30) + ); +} + +#[test] +fn consumer_overview_shows_top_five_and_closes_with_retained_details() { + let mut view = fixture::view(models::AccountKind::Consumer); + view.section = Section::Chats; + view.tasks = Load::Ready(Chats { + rows: (0..6).map(|index| Chat { + title: format!("Task {index}"), + task: Some(serde_json::from_value(json!({ + "thread_id":format!("task-{index}"), "data_status":"available", "usage_source":"credits", + "balance_usage_credits":index.to_string(), "groups":[] + })).unwrap()), + }).collect(), + ..Chats::default() + }); + press(&mut view, KeyCode::End); + press(&mut view, KeyCode::Enter); + assert_eq!(view.sections[Section::Chats].detail, Some(5)); + press(&mut view, KeyCode::Char('z')); + let content = view + .task_lines(/*width*/ 70) + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert!(content.contains("Task 5") && !content.contains("Task 0")); + insta::assert_snapshot!(content); + press(&mut view, KeyCode::Esc); + assert!(view.is_done); +} diff --git a/codex-rs/tui/src/analytics/dashboard.rs b/codex-rs/tui/src/analytics/dashboard.rs new file mode 100644 index 0000000000000000000000000000000000000000..57a58349a63d37e219290ac5016b8a537efa600a --- /dev/null +++ b/codex-rs/tui/src/analytics/dashboard.rs @@ -0,0 +1,225 @@ +//! Bordered, aligned overview cards. Detailed reports remain in maximized mode. +//! Card heights depend on the viewport, never on the selected day or load state. + +use super::AnalyticsView; +use super::data; +use super::render::columns; +use super::render::join_columns; +use super::styles::number; +use super::styles::secondary_style; +use crate::analytics::sections::Section; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow as truncate; +use crate::style::accent_style; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::ops::Range; + +impl AnalyticsView { + pub(super) fn dashboard_lines( + &self, + width: usize, + height: usize, + ) -> (Vec>, Range) { + let count = if width >= 104 { 2 } else { 1 }; + let card_width = if count == 2 { (width - 4) / 2 } else { width }; + let visible = self.visible_sections(); + let rows = visible.len().div_ceil(count).max(/*other*/ 1); + let card_height = + (height.saturating_sub(/*rhs*/ 2) / rows).clamp(/*min*/ 16, /*max*/ 24); + let inner_width = card_width.saturating_sub(/*rhs*/ 4).max(/*other*/ 1); + let inner_height = card_height - 4; + let mut lines = Vec::new(); + let mut selected = 0..0; + for (row, chunk) in visible.chunks(count).enumerate() { + let mut cards = Vec::new(); + for (column, section) in chunk.iter().copied().enumerate() { + let focused = self.section == section; + let border = if focused { + accent_style() + } else { + secondary_style().dim() + }; + let mut content = Vec::new(); + if !matches!(section, Section::Chats | Section::Plan | Section::Summary) { + content.push( + format!( + "{}d{}", + if self.ranges[self.range_group(section) as usize] == 0 { + 7 + } else { + 30 + }, + if section == Section::Usage && self.business() { + format!( + " · {}", + self.token_model.as_deref().unwrap_or("All models") + ) + } else { + String::new() + } + ) + .set_style(secondary_style()) + .into(), + ); + } + if matches!( + section, + Section::Usage | Section::Credits | Section::Activity | Section::Plan + ) { + content.push( + format!( + "By {}", + self.group_label(section, self.sections[section].group) + .to_lowercase() + ) + .set_style(secondary_style()) + .into(), + ); + } + match section { + Section::Summary => content.extend(self.summary_lines(inner_width)), + Section::Usage + | Section::Plugins + | Section::Credits + | Section::Activity + | Section::Skills => content.extend( + self.history_lines( + section, + inner_width, + inner_height.saturating_sub(/*rhs*/ 13).max(/*other*/ 1), + ) + .lines, + ), + Section::Plan => content.extend(self.plan_lines(inner_width).0), + Section::Chats if !self.business() => { + content.extend(self.task_lines(inner_width).0) + } + Section::Chats => { + content.push( + "30d active · lifetime credits" + .set_style(secondary_style()) + .into(), + ); + content.push(Line::default()); + if let Some(chats) = self.chats.ready() { + if chats.rows.is_empty() { + content.push( + "No recent local chats.".set_style(secondary_style()).into(), + ); + } + for chat in chats.rows.iter().take(/*n*/ 5) { + let amount = chat + .usage + .as_ref() + .map(|usage| { + data::credits(usage.estimated_usage_credits_micros) + }) + .unwrap_or_else(|| "—".into()); + content.push(columns( + truncate( + chat.display_title().to_string().into(), + inner_width.saturating_sub(amount.len() + 1), + ), + number(amount).into(), + inner_width, + )); + } + content.push(Line::default()); + content.push( + "Local chats · excludes subagents" + .set_style(secondary_style()) + .into(), + ); + if chats.rows.iter().any(|chat| chat.usage.is_none()) { + content.push( + "Some estimates unavailable" + .set_style(secondary_style()) + .into(), + ); + } + } else if let Some(message) = self.chats.message() { + content.push(message.to_string().set_style(secondary_style()).into()); + } + } + } + let mut content = word_wrap_lines(content, RtOptions::new(inner_width)); + if content.len() > inner_height { + content.truncate(inner_height); + content[inner_height - 1] = + "… z to maximize".set_style(secondary_style()).into(); + } + content.resize(inner_height, Line::default()); + let label = format!( + " {} {} ", + row * count + column + 1, + self.section_title(section) + ); + let title = truncate( + Line::from(vec![ + " ".into(), + if focused { + "▸".set_style(accent_style()) + } else { + " ".into() + }, + if focused { + label.bold() + } else { + label.set_style(secondary_style()) + }, + ]), + card_width.saturating_sub(/*rhs*/ 3), + ); + let mut top = vec!["╭─".set_style(border)]; + top.extend(title.spans.iter().cloned()); + top.push( + "─" + .repeat(card_width.saturating_sub(title.width() + 3)) + .set_style(border), + ); + top.push("╮".set_style(border)); + let blank = columns( + "│".set_style(border).into(), + "│".set_style(border).into(), + card_width, + ); + let mut card = vec![Line::from(top), blank.clone()]; + card.extend(content.into_iter().map(|line| { + let line = truncate(line, inner_width); + let padding = card_width.saturating_sub(line.width() + 3); + let mut padded = vec!["│".set_style(border), " ".into()]; + padded.extend(line.spans); + padded.push(" ".repeat(padding).into()); + padded.push("│".set_style(border)); + Line::from(padded) + })); + card.push(blank); + card.push( + format!("╰{}╯", "─".repeat(card_width.saturating_sub(/*rhs*/ 2))) + .set_style(border) + .into(), + ); + cards.push( + card.into_iter() + .map(|line| truncate(line, card_width)) + .collect::>(), + ); + } + if chunk.contains(&self.section) { + selected = lines.len()..lines.len() + card_height; + } + lines.extend(if cards.len() == 2 { + join_columns(&cards[0], &cards[1], card_width) + } else { + cards.remove(/*index*/ 0) + }); + if (row + 1) * count < visible.len() { + lines.push(Line::default()); + } + } + (lines, selected) + } +} diff --git a/codex-rs/tui/src/analytics/dashboard_tests.rs b/codex-rs/tui/src/analytics/dashboard_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..066facfbfb5dce9d7b972544d3ae6496e798f92e --- /dev/null +++ b/codex-rs/tui/src/analytics/dashboard_tests.rs @@ -0,0 +1,99 @@ +//! Overview cards retain alignment and geometry while selection and report states change. +use super::*; +use crate::style::accent_style; +use pretty_assertions::assert_eq; +use ratatui::style::Modifier; + +use crate::analytics::sections::Section; +#[test] +fn dashboard_focus_uses_marker_and_default_foreground_title() { + let mut view = fixture::view(models::AccountKind::Consumer); + view.zoomed = false; + for section in [Section::Usage, Section::Activity] { + view.section = section; + let (lines, _) = view.dashboard_lines(/*width*/ 140, /*height*/ 56); + let title = lines + .iter() + .find(|line| line.spans.iter().any(|span| span.content == "▸")) + .unwrap(); + let marker = title.spans.iter().find(|span| span.content == "▸").unwrap(); + assert_eq!(marker.style, accent_style()); + let focused_title = title + .spans + .iter() + .find(|span| span.content.contains(view.section_title(section))) + .unwrap(); + assert_eq!(focused_title.style.fg, None); + assert!(focused_title.style.add_modifier.contains(Modifier::BOLD)); + assert_eq!(title.to_string().matches('▸').count(), 1); + } +} + +#[test] +fn dashboard_cards_align_and_keep_stable_summary_heights() { + let mut view = fixture::view(models::AccountKind::Consumer); + press(&mut view, KeyCode::Char('z')); + let wide = screen(&mut view, /*width*/ 144, /*height*/ 64); + for (left, right) in [ + ("1 Summary", "2 Total usage history"), + ("3 Messages", "4 Plugins called"), + ("5 Skills used", "6 Top chats"), + ] { + assert!( + wide.lines() + .any(|line| line.contains(left) && line.contains(right)) + ); + } + assert!(!wide.contains("Coverage from")); + let before = view.dashboard_lines(/*width*/ 140, /*height*/ 56); + press(&mut view, KeyCode::Left); + view.sections[Section::Plugins].history = Load::Error("Access denied for this report.".into()); + let after = view.dashboard_lines(/*width*/ 140, /*height*/ 56); + assert_eq!((before.0.len(), before.1), (after.0.len(), after.1)); + let edges = |lines: &[ratatui::text::Line<'_>]| { + lines + .iter() + .enumerate() + .filter(|(_, line)| line.to_string().starts_with(['╭', '╰'])) + .map(|(index, _)| index) + .collect::>() + }; + assert_eq!(edges(&before.0), edges(&after.0)); + for width in [1, 4, 40, 100, 140] { + assert!( + view.dashboard_lines(width, /*height*/ 56) + .0 + .iter() + .all(|line| line.width() <= width) + ); + } + let narrow = screen(&mut view, /*width*/ 72, /*height*/ 48); + assert!(narrow.contains("1 Summary")); + let context = ( + view.section, + view.sections.0.each_ref().map(|state| state.cursor), + view.sections.0.each_ref().map(|state| state.group), + ); + press(&mut view, KeyCode::Enter); + assert!(view.zoomed); + assert_eq!( + context, + ( + view.section, + view.sections.0.each_ref().map(|state| state.cursor), + view.sections.0.each_ref().map(|state| state.group) + ) + ); + insta::assert_snapshot!(format!("{wide}\n{narrow}")); +} + +#[test] +fn overview_closes_on_escape_with_retained_day_details() { + let mut view = fixture::view(models::AccountKind::Consumer); + press(&mut view, KeyCode::Enter); + assert_eq!(view.sections[Section::Usage].detail, Some(6)); + press(&mut view, KeyCode::Char('z')); + assert!(!view.zoomed); + press(&mut view, KeyCode::Esc); + assert!(view.is_done); +} diff --git a/codex-rs/tui/src/analytics/data.rs b/codex-rs/tui/src/analytics/data.rs new file mode 100644 index 0000000000000000000000000000000000000000..f4421db2a8604ab932c6a05d50ad40aee9830d25 --- /dev/null +++ b/codex-rs/tui/src/analytics/data.rs @@ -0,0 +1,216 @@ +//! Report loading and grouping metadata for the account dashboard. +//! Replacing a pending load aborts it; its response cannot populate a newer range or account. + +use super::models::AccountAnalyticsGrouping as Grouping; +use crate::tui::FrameRequester; +use futures::FutureExt; +use std::panic::AssertUnwindSafe; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; + +pub(super) const GROUPINGS: [Grouping; 7] = [ + Grouping::Surface, + Grouping::Feature, + Grouping::Model, + Grouping::TaskStart, + Grouping::Speed, + Grouping::Reasoning, + Grouping::TokenType, +]; + +pub(super) const GROUP_LABELS: [&str; 7] = [ + "Surface", + "Feature", + "Model", + "Turn start", + "Speed", + "Reasoning", + "Token type", +]; + +pub(super) struct Pending { + receiver: oneshot::Receiver, String>>, + task: JoinHandle<()>, +} + +impl Drop for Pending { + fn drop(&mut self) { + self.task.abort(); + } +} + +pub(super) enum Load { + Loading(Pending), + Ready(T), + Unavailable, + Error(String), +} + +impl Load { + pub(super) fn start( + future: impl std::future::Future, String>> + Send + 'static, + frame: FrameRequester, + ) -> Self { + Self::start_with_timeout(future, frame, std::time::Duration::from_secs(/*secs*/ 30)) + } + + pub(super) fn start_with_timeout( + future: impl std::future::Future, String>> + Send + 'static, + frame: FrameRequester, + timeout: std::time::Duration, + ) -> Self { + let (sender, receiver) = oneshot::channel(); + let task = + tokio::spawn(async move { + let result = + match tokio::time::timeout(timeout, AssertUnwindSafe(future).catch_unwind()) + .await + { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err("Request interrupted. Press R to retry.".into()), + Err(_) => Err("Request timed out. Press R to retry.".into()), + }; + let _ = sender.send(result); + frame.schedule_frame(); + }); + Self::Loading(Pending { receiver, task }) + } + + pub(super) fn poll(&mut self) { + if let Self::Loading(pending) = self { + *self = match pending.receiver.try_recv() { + Ok(Ok(Some(data))) => Self::Ready(data), + Ok(Ok(None)) => Self::Unavailable, + Ok(Err(message)) => Self::Error(message), + Err(oneshot::error::TryRecvError::Closed) => { + Self::Error("Request interrupted. Press R to retry.".into()) + } + Err(oneshot::error::TryRecvError::Empty) => return, + }; + } + } + + pub(super) fn ready(&self) -> Option<&T> { + if let Self::Ready(value) = self { + Some(value) + } else { + None + } + } + + pub(super) fn message(&self) -> Option<&str> { + match self { + Self::Ready(_) => None, + Self::Loading(_) => Some("Loading…"), + Self::Unavailable => Some("No history has been reported."), + Self::Error(message) => Some(message), + } + } +} + +/// Keep untyped server and transport diagnostics out of account reports. +pub(super) fn error(_error: codex_app_server_client::TypedRequestError) -> String { + "Couldn't load analytics. Press R to retry.".into() +} + +/// Keep tiny refunds visible while avoiding noise on ordinary credit amounts. +pub(super) fn amount(value: f64) -> String { + if value == 0.0 { + return "0".into(); + } + if value.abs() < 0.000001 { + return format!("{value:.2e}"); + } + let rounded = if value.abs() < 0.01 { + format!("{value:.6}") + } else { + format!("{value:.2}") + }; + let (whole, fraction) = rounded.split_once('.').unwrap_or((&rounded, "")); + let mut grouped = String::new(); + for (index, ch) in whole.chars().enumerate() { + if index > 0 + && ch.is_ascii_digit() + && (whole.len() - index).is_multiple_of(/*rhs*/ 3) + && !grouped.ends_with('-') + { + grouped.push(','); + } + grouped.push(ch); + } + let fraction = fraction.trim_end_matches('0'); + if !fraction.is_empty() { + grouped.push('.'); + grouped.push_str(fraction); + } + grouped +} + +/// Compact headline and axis labels to one decimal, preserving small signed adjustments. +pub(super) fn compact_amount(value: f64) -> String { + if value.abs() < 1_000.0 || !value.is_finite() { + return amount(value); + } + let units = ["K", "M", "B", "T"]; + let mut scaled = value / 1_000.0; + let mut unit = 0; + // Promote before rounding would produce a label such as 1000K. + while scaled.abs() >= 999.95 && unit + 1 < units.len() { + scaled /= 1_000.0; + unit += 1; + } + let rounded = format!("{:.1}", (scaled * 10.0).round() / 10.0); + format!( + "{}{}", + rounded.trim_end_matches('0').trim_end_matches('.'), + units[unit] + ) +} + +/// Align ordinary credit amounts to two decimals without hiding tiny adjustments. +pub(super) fn credit_amount(value: f64) -> String { + let formatted = amount(value); + if value != 0.0 && value.abs() < 0.01 { + return formatted; + } + match formatted.split_once('.') { + None => format!("{formatted}.00"), + Some((_, fraction)) if fraction.len() == 1 => format!("{formatted}0"), + Some(_) => formatted, + } +} + +pub(super) fn credits(micros: i64) -> String { + let magnitude = micros.unsigned_abs(); + if magnitude > 0 && magnitude < 10_000 { + let fractional = format!("{magnitude:06}"); + return format!( + "{}0.{}", + if micros < 0 { "-" } else { "" }, + fractional.trim_end_matches('0') + ); + } + let cents = (magnitude + 5_000) / 10_000; + let mut whole = (cents / 100).to_string(); + let digits = whole.len(); + for index in (1..digits).rev() { + if (digits - index).is_multiple_of(/*rhs*/ 3) { + whole.insert(index, ','); + } + } + format!( + "{}{whole}.{:02}", + if micros < 0 { "-" } else { "" }, + cents % 100 + ) +} + +pub(super) fn date(date: &str) -> String { + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") + .map(|date| date.format("%b %-d").to_string()) + .unwrap_or_else(|_| date.to_string()) +} + +#[cfg(test)] +#[path = "data_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/data_tests.rs b/codex-rs/tui/src/analytics/data_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..133f54440e4997ac03a499f1e882f7ba47c8f5f8 --- /dev/null +++ b/codex-rs/tui/src/analytics/data_tests.rs @@ -0,0 +1,132 @@ +//! Loading transitions and cancellation when a view replaces pending work. + +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn completed_loads_publish_data_unavailability_and_errors() { + for (result, expected) in [ + (Ok(Some(7)), (Some(7), None)), + (Ok(None), (None, Some("No history has been reported."))), + ( + Err("temporary failure".to_string()), + (None, Some("temporary failure")), + ), + ] { + let mut load = Load::start(async move { result }, FrameRequester::test_dummy()); + let Load::Loading(pending) = &mut load else { + unreachable!() + }; + (&mut pending.task).await.unwrap(); + load.poll(); + assert_eq!((load.ready().copied(), load.message()), expected); + } +} + +#[tokio::test] +async fn replacing_a_pending_load_cancels_its_request() { + let (cancelled_tx, cancelled_rx) = oneshot::channel::<()>(); + let load = Load::<()>::start( + async move { + let _cancelled = cancelled_tx; + std::future::pending().await + }, + FrameRequester::test_dummy(), + ); + tokio::task::yield_now().await; + drop(load); + assert!(cancelled_rx.await.is_err()); +} + +#[tokio::test] +async fn panicking_loads_request_a_frame_and_report_interruption() { + let (draw, mut frames) = tokio::sync::broadcast::channel(/*capacity*/ 1); + let frame = FrameRequester::new(draw); + let mut load = Load::<()>::start(async { panic!("report task failed") }, frame.clone()); + let Load::Loading(pending) = &mut load else { + unreachable!() + }; + let _ = (&mut pending.task).await; + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 1), frames.recv()) + .await + .unwrap() + .unwrap(); + load.poll(); + assert_eq!( + load.message(), + Some("Request interrupted. Press R to retry.") + ); +} + +#[test] +fn rpc_errors_do_not_expose_server_diagnostics() { + let error = codex_app_server_client::TypedRequestError::Server { + method: "thread/list".into(), + source: codex_app_server_protocol::JSONRPCErrorError { + code: -32603, + message: "Failed opening /private/workspace: token=secret".into(), + data: Some(serde_json::json!({"credential": "secret"})), + }, + }; + assert_eq!( + super::error(error), + "Couldn't load analytics. Press R to retry." + ); +} + +#[tokio::test(start_paused = true)] +async fn extended_chat_load_budget_still_times_out_and_can_be_cancelled() { + let (cancelled_tx, cancelled_rx) = oneshot::channel::<()>(); + let mut load = Load::<()>::start_with_timeout( + async move { + let _cancelled = cancelled_tx; + std::future::pending().await + }, + FrameRequester::test_dummy(), + std::time::Duration::from_secs(/*secs*/ 90), + ); + tokio::task::yield_now().await; + tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 31)).await; + load.poll(); + assert!(matches!(load, Load::Loading(_))); + tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 60)).await; + assert!(cancelled_rx.await.is_err()); + load.poll(); + assert_eq!(load.message(), Some("Request timed out. Press R to retry.")); +} + +#[test] +fn compact_counts_round_promote_units_and_preserve_small_adjustments() { + let values = [ + 0.0, + 959.0, + 1_000.0, + 9_749.0, + 2_901.0, + 12_280_365_226.0, + 142_300_000_000.0, + 999_999.0, + 999_999_999.0, + -1_250.0, + -0.000004, + 1_200_000_000_000.0, + ]; + assert_eq!( + values.map(compact_amount), + [ + "0", + "959", + "1K", + "9.7K", + "2.9K", + "12.3B", + "142.3B", + "1M", + "1B", + "-1.3K", + "-0.000004", + "1.2T" + ] + .map(str::to_string) + ); +} diff --git a/codex-rs/tui/src/analytics/models.rs b/codex-rs/tui/src/analytics/models.rs new file mode 100644 index 0000000000000000000000000000000000000000..a30c2c96dd2e01ebdd4c03a8d9e85cbf543131ba --- /dev/null +++ b/codex-rs/tui/src/analytics/models.rs @@ -0,0 +1,99 @@ +//! Private analytics display types, independent of the app-server wire protocol. + +use codex_protocol::account::PlanType; + +/// Billing families match the App's consumer, business, and workspace scopes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum AccountKind { + Consumer, + Business, + Enterprise, + Unknown, +} + +impl From> for AccountKind { + fn from(plan: Option) -> Self { + match plan { + None | Some(PlanType::Unknown) => Self::Unknown, + Some(plan) if plan.is_team_like() => Self::Business, + Some(plan) if plan.is_workspace_account() => Self::Enterprise, + Some(_) => Self::Consumer, + } + } +} + +/// Match the existing CLI thread-usage capability without widening other workspace reports. +pub(super) fn thread_usage_supported(plan: Option) -> bool { + matches!( + plan, + Some( + PlanType::Business + | PlanType::EnterpriseCbpUsageBased + | PlanType::EnterpriseCbpAutomation + ) + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountAnalyticsReport { + Usage, + Credits, + Messages, + Plugins, + Skills, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountAnalyticsGrouping { + Feature, + Model, + Surface, + TaskStart, + Speed, + Reasoning, + TokenType, +} + +impl AccountAnalyticsGrouping { + /// Credit breakdowns supported by the account's billing report. + pub(crate) fn credit_groupings(plan: Option) -> &'static [Self] { + match AccountKind::from(plan) { + AccountKind::Business => &[Self::Surface, Self::Model, Self::Speed], + AccountKind::Enterprise => &[Self::Surface, Self::Model, Self::Speed, Self::Reasoning], + AccountKind::Consumer => &[Self::Surface], + AccountKind::Unknown => &[], + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountAnalyticsUnit { + RelativeUsage, + Credits, + Count, + Tokens, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AccountAnalyticsHistory { + pub(crate) unit: AccountAnalyticsUnit, + /// Source freshness in Unix seconds, when provided. Not a completeness watermark. + pub(crate) updated_at: Option, + pub(crate) data: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AccountAnalyticsDay { + /// Source reporting date; display formatting belongs to the UI. + pub(crate) date: chrono::NaiveDate, + /// Full daily denominator, including non-task usage when grouped by task start. + pub(crate) total: f64, + pub(crate) values: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AccountAnalyticsValue { + pub(crate) key: String, + pub(crate) label: String, + pub(crate) value: f64, +} diff --git a/codex-rs/tui/src/analytics/navigation_tests.rs b/codex-rs/tui/src/analytics/navigation_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..7e0897fce3e25bc215592e02602755ccfeb20250 --- /dev/null +++ b/codex-rs/tui/src/analytics/navigation_tests.rs @@ -0,0 +1,272 @@ +//! Vim navigation aliases share arrow behavior while preserving configured bindings. + +use super::*; +use crate::analytics::models::AccountKind; +use codex_config::types::KeybindingSpec; +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; +use pretty_assertions::assert_eq; +use serde_json::json; + +fn navigate(views: &mut [AnalyticsView; 2], keys: &[(char, KeyCode)], size: (u16, u16)) -> String { + let mut output = String::new(); + for &(alias, arrow) in keys { + press(&mut views[0], KeyCode::Char(alias)); + press(&mut views[1], arrow); + output = screen(&mut views[0], size.0, size.1); + assert_eq!(output, screen(&mut views[1], size.0, size.1), "{alias}"); + } + output +} + +#[test] +fn vim_chart_navigation_matches_arrows_in_focused_and_dashboard_views() { + for (kind, section) in [ + (AccountKind::Consumer, Section::Usage), + (AccountKind::Consumer, Section::Activity), + (AccountKind::Consumer, Section::Plugins), + (AccountKind::Consumer, Section::Skills), + (AccountKind::Business, Section::Usage), + (AccountKind::Business, Section::Credits), + ] { + for zoomed in [true, false] { + let mut views = [fixture::view(kind), fixture::view(kind)]; + for view in &mut views { + view.section = section; + view.zoomed = zoomed; + view.sections[section].cursor = 3; + view.sections[section].detail = Some(3); + } + let output = navigate( + &mut views, + &[ + ('h', KeyCode::Left), + ('h', KeyCode::Left), + ('l', KeyCode::Right), + ('j', KeyCode::Down), + ('k', KeyCode::Up), + ], + /*size*/ (96, 32), + ); + assert_eq!( + ( + views[0].section, + views[0].sections[section].cursor, + views[0].sections[section].detail + ), + (section, 2, Some(2)) + ); + if kind == AccountKind::Consumer && section == Section::Usage && zoomed { + insta::assert_snapshot!("vim_chart_navigation", output); + } + } + } +} + +#[test] +fn vim_plan_navigation_preserves_independent_windows_and_collapses_old_details() { + let mut views = [ + fixture::view(AccountKind::Consumer), + fixture::view(AccountKind::Consumer), + ]; + for view in &mut views { + let as_of = "2026-09-02T12:00:00Z" + .parse::>() + .unwrap(); + view.plan.enabled = true; + view.plan.report = Load::Ready(plan::Report { + as_of, + coverage_start: None, + coverage_complete: true, + approximate: false, + periods: std::array::from_fn(|window| { + let length = if window == 0 { + chrono::Duration::hours(/*hours*/ 5) + } else { + chrono::Duration::days(/*days*/ 7) + }; + (0..2) + .map(|index| plan::Period { + id: format!("{window}-{index}"), + start: as_of - length * index, + end: as_of - length * index + length, + complete: true, + used: Some(2500.0), + breakdowns: None, + }) + .collect() + }), + }); + view.plan.poll(); + view.plan.expanded = [Some("0-0".into()), Some("1-0".into())]; + view.section = Section::Plan; + } + navigate( + &mut views, + &[ + ('l', KeyCode::Right), + ('j', KeyCode::Down), + ('k', KeyCode::Up), + ('h', KeyCode::Left), + ('j', KeyCode::Down), + ], + /*size*/ (120, 30), + ); + assert_eq!( + ( + views[0].plan.window, + views[0].plan.cursor, + &views[0].plan.expanded + ), + (0, [1, 0], &[None, None]) + ); +} + +#[test] +fn vim_summary_navigation_scrolls_like_arrows() { + let mut views = [ + fixture::view(AccountKind::Consumer), + fixture::view(AccountKind::Consumer), + ]; + for view in &mut views { + view.section = Section::Summary; + view.profile = Load::Ready( + serde_json::from_value(json!({ + "stats": {"lifetime_tokens": 123, "daily_usage_buckets": [ + {"start_date": "2026-09-02", "tokens": 123} + ]} + })) + .unwrap(), + ); + assert!(screen(view, /*width*/ 64, /*height*/ 18).contains("scroll")); + } + navigate( + &mut views, + &[ + ('h', KeyCode::Left), + ('l', KeyCode::Right), + ('j', KeyCode::Down), + ('j', KeyCode::Down), + ('k', KeyCode::Up), + ], + /*size*/ (64, 18), + ); + assert_eq!(views[0].scroll_offset, 1); +} + +#[test] +fn vim_chat_navigation_expands_collapses_and_moves_like_arrows() { + for kind in [AccountKind::Consumer, AccountKind::Business] { + let mut views = [fixture::view(kind), fixture::view(kind)]; + for view in &mut views { + view.section = Section::Chats; + if kind == AccountKind::Consumer { + view.tasks = Load::Ready(tasks::Chats { + rows: (0..2) + .map(|index| tasks::Chat { + title: format!("Example chat {index}"), + task: Some( + serde_json::from_value(json!({ + "thread_id": format!("chat-{index}"), + "data_status": "available", "usage_source": "accounting", + "weekly_limit_percent": 20 - index, + "five_hour_limit_percent": null, "balance_usage_credits": null, + "groups": [] + })) + .unwrap(), + ), + }) + .collect(), + ..tasks::Chats::default() + }); + } + } + navigate( + &mut views, + &[ + ('l', KeyCode::Right), + ('h', KeyCode::Left), + ('l', KeyCode::Right), + ('j', KeyCode::Down), + ('l', KeyCode::Right), + ('k', KeyCode::Up), + ], + /*size*/ (96, 32), + ); + assert_eq!( + ( + views[0].sections[Section::Chats].cursor, + views[0].sections[Section::Chats].detail + ), + (0, None) + ); + } +} + +#[test] +fn configured_vim_keys_win_over_horizontal_aliases() { + let mut config = TuiKeymap::default(); + config.list.jump_top = Some(KeybindingsSpec::Many(vec![ + KeybindingSpec("h".into()), + KeybindingSpec("l".into()), + ])); + let mut view = fixture::view(AccountKind::Consumer); + view.keymap = RuntimeKeymap::from_config(&config).unwrap().list; + for alias in ['h', 'l'] { + view.sections[Section::Usage].cursor = 3; + press(&mut view, KeyCode::Char(alias)); + assert_eq!(view.sections[Section::Usage].cursor, 0); + } +} + +#[test] +fn vim_aliases_respect_removed_and_remapped_arrows() { + let mut config = TuiKeymap::default(); + config.list.move_left = Some(KeybindingsSpec::Many(Vec::new())); + config.list.move_right = Some(KeybindingsSpec::Many(Vec::new())); + config.list.move_up = Some(KeybindingsSpec::One(KeybindingSpec("up".into()))); + config.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("down".into()))); + let mut view = fixture::view(AccountKind::Consumer); + view.keymap = RuntimeKeymap::from_config(&config).unwrap().list; + view.sections[Section::Usage].cursor = 3; + for alias in ['h', 'j', 'k', 'l'] { + press(&mut view, KeyCode::Char(alias)); + assert_eq!(view.sections[Section::Usage].cursor, 3); + } + + config.list.move_up = Some(KeybindingsSpec::One(KeybindingSpec("right".into()))); + config.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("left".into()))); + view.keymap = RuntimeKeymap::from_config(&config).unwrap().list; + press(&mut view, KeyCode::Char('h')); + assert_eq!(view.sections[Section::Usage].cursor, 4); + press(&mut view, KeyCode::Char('l')); + assert_eq!(view.sections[Section::Usage].cursor, 3); +} + +#[test] +fn horizontal_aliases_ignore_modifiers_and_release_but_accept_repeat() { + let mut view = fixture::view(AccountKind::Consumer); + view.sections[Section::Usage].cursor = 3; + for alias in ['h', 'l'] { + for modifiers in [ + KeyModifiers::ALT, + KeyModifiers::SHIFT, + KeyModifiers::CONTROL | KeyModifiers::ALT, + ] { + view.handle_key(KeyEvent::new(KeyCode::Char(alias), modifiers)); + assert_eq!(view.sections[Section::Usage].cursor, 3); + } + view.handle_key(KeyEvent { + kind: KeyEventKind::Release, + ..KeyEvent::new(KeyCode::Char(alias), KeyModifiers::NONE) + }); + assert_eq!(view.sections[Section::Usage].cursor, 3); + } + for (alias, cursor) in [('h', 2), ('l', 3)] { + view.handle_key(KeyEvent { + kind: KeyEventKind::Repeat, + ..KeyEvent::new(KeyCode::Char(alias), KeyModifiers::NONE) + }); + assert_eq!(view.sections[Section::Usage].cursor, cursor); + } +} diff --git a/codex-rs/tui/src/analytics/normalize.rs b/codex-rs/tui/src/analytics/normalize.rs new file mode 100644 index 0000000000000000000000000000000000000000..0dad8d7eb128b0b45bfbc865c11a30e511845f50 --- /dev/null +++ b/codex-rs/tui/src/analytics/normalize.rs @@ -0,0 +1,364 @@ +//! Converts backend reports into display data while preserving missing values and full denominators. + +use super::models::AccountAnalyticsGrouping as Grouping; +use super::models::AccountAnalyticsReport as Report; +use super::models::*; +use super::report_data::AnalyticsData; +use chrono::DateTime; +use chrono::NaiveDate; +use std::collections::BTreeMap; + +/// Out-of-range legacy rows do not change attribution semantics for the requested period. +pub(super) fn has_complete_attribution( + response: &AnalyticsData, + start: NaiveDate, + end: NaiveDate, +) -> Result { + response + .data + .iter() + .try_fold(/*init*/ true, |complete, record| { + let date = NaiveDate::parse_from_str( + record.date.get(..10).unwrap_or(&record.date), + "%Y-%m-%d", + ) + .map_err(|_| String::from("Analytics returned an invalid date."))?; + Ok(complete && (date < start || date > end || record.attribution.is_some())) + }) +} + +pub(super) fn history( + response: AnalyticsData, + report: Report, + grouping: Grouping, + start: NaiveDate, + end: NaiveDate, +) -> Result, String> { + if report == Report::Messages { + match grouping { + Grouping::Model | Grouping::Surface => {} + Grouping::Feature + | Grouping::TaskStart + | Grouping::Speed + | Grouping::Reasoning + | Grouping::TokenType => { + return Err(String::from("Unsupported message count grouping.")); + } + } + } + let attributed = has_complete_attribution(&response, start, end)?; + let series = response.series.as_ref(); + let mut days: BTreeMap)> = BTreeMap::new(); + for record in response.data { + let date = + NaiveDate::parse_from_str(record.date.get(..10).unwrap_or(&record.date), "%Y-%m-%d") + .map_err(|_| String::from("Analytics returned an invalid date."))?; + if date < start || date > end { + continue; + } + let (total, values) = days.entry(date).or_default(); + match report { + Report::Usage => { + let daily = if attributed { + None + } else { + match grouping { + Grouping::Surface => record.product_surface_usage_values, + Grouping::Model => record + .models + .map(|models| -> Result<_, String> { + let mut values = BTreeMap::new(); + for model in models { + let amount = model + .credits + .ok_or("Plan usage amount was not reported.")?; + *values.entry(model.model).or_insert(/*default*/ 0.0) += amount; + } + Ok(values) + }) + .transpose()?, + _ => None, + } + }; + if let Some(daily) = daily { + for (key, amount) in daily { + if !amount.is_finite() || amount < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + let key = if grouping == Grouping::Surface { + if key.starts_with("work_") { + "work" + } else { + "codex" + } + .to_string() + } else if key.eq_ignore_ascii_case("other") { + "other".to_string() + } else { + key + }; + *total += amount; + *values.entry(key).or_default() += amount; + } + } else if let Some(attribution) = record.attribution.filter(|_| attributed) { + for entry in attribution { + if !entry.value.is_finite() || entry.value < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + *total += entry.value; + let key = match grouping { + Grouping::Feature => entry.thread_source, + Grouping::Model => entry.model, + Grouping::Surface => entry.surface, + Grouping::TaskStart => { + entry.turn_trigger.map(|trigger| format!("start-{trigger}")) + } + Grouping::Speed | Grouping::Reasoning | Grouping::TokenType => { + return Err(String::from("unsupported analytics grouping")); + } + } + .unwrap_or_else(|| "unknown".to_string()); + *values.entry(key).or_default() += entry.value; + } + } else { + return Ok(None); + } + } + + Report::Credits => { + if series.is_some() { + let Some(credits) = record.values else { + return Ok(None); + }; + for (key, amount) in credits { + *values.entry(key).or_default() += amount; + *total += amount; + } + } else if matches!(grouping, Grouping::Model | Grouping::Speed) { + let Some(models) = record.models else { + return Ok(None); + }; + for model in models { + let amount = model.on_demand_credits.unwrap_or(/*default*/ 0.0); + let key = if grouping == Grouping::Speed { + model.speed.unwrap_or_else(|| "unknown".to_string()) + } else { + model.model + }; + *values.entry(key).or_default() += amount; + *total += amount; + } + } else if let Some(premium) = record.premium_usage_values { + for (surface, amount) in premium.credit_usage_credits { + if !amount.is_finite() { + return Err(String::from("Analytics returned an invalid amount.")); + } + *values.entry(surface).or_default() += amount; + *total += amount; + } + } else if let (Some(amount), Some(surface)) = + (record.credit_amount, record.product_surface) + { + *values.entry(surface).or_default() += amount; + *total += amount; + } else { + return Ok(None); + } + } + Report::Messages => { + let authoritative = record + .totals + .ok_or_else(|| String::from("Message count is unavailable."))? + .turns; + if !authoritative.is_finite() || authoritative < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + let mut record_values = BTreeMap::::new(); + if grouping == Grouping::Model { + for model in record.models.unwrap_or_default() { + let key = if model.model.eq_ignore_ascii_case("codex-auto-review") + || model.model.eq_ignore_ascii_case("other") + { + "other".to_string() + } else { + model.model + }; + let count = model + .turns + .ok_or_else(|| String::from("Turn count is unavailable."))?; + if !count.is_finite() || count < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + *record_values.entry(key).or_default() += count; + } + } else { + for client in record.clients.unwrap_or_default() { + if !client.turns.is_finite() || client.turns < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + let key = match client.client_id.as_str() { + "CODEX_CLI" => "cli", + "CODEX_IDE_VSCODE" => "vscode", + "CODEX_WEB" => "web", + "CODEX_DESKTOP_APP" | "CODEX_WORK_DESKTOP" => "desktop_app", + "CODEX_WORK_WEB" => "work_web", + "CODEX_WORK_MOBILE" => "mobile", + "CODEX_GITHUB" => "github_code_review", + _ => "other", + }; + *record_values.entry(key.to_string()).or_default() += client.turns; + } + } + let remainder = + (authoritative - record_values.values().sum::()).max(/*other*/ 0.0); + if remainder > 0.0 { + *record_values.entry("other".to_string()).or_default() += remainder; + } + for (key, count) in record_values { + *values.entry(key).or_default() += count; + } + *total += authoritative; + } + Report::Plugins | Report::Skills => { + let overviews = if report == Report::Skills { + record.skill_usage_overviews + } else { + record.plugin_usage_overviews + } + .ok_or_else(|| String::from("Tool activity is unavailable."))?; + for tool in overviews { + if !tool.invocation_counts.is_finite() || tool.invocation_counts < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + *values.entry(tool.display_name).or_default() += tool.invocation_counts; + *total += tool.invocation_counts; + } + } + } + if !total.is_finite() + || values + .values() + .any(|value| !value.is_finite() || (report != Report::Credits && *value < 0.0)) + { + return Err(String::from("Analytics returned an invalid amount.")); + } + } + if report == Report::Usage + && !attributed + && grouping == Grouping::Model + && response.units.as_deref() != Some("credits") + { + let mut totals = BTreeMap::::new(); + for (_, values) in days.values() { + for (key, value) in values { + *totals.entry(key.clone()).or_default() += value; + } + } + let total: f64 = totals.values().sum(); + for (_, values) in days.values_mut() { + let mut other = 0.0; + values.retain(|key, value| { + let amount = totals[key]; + if key.eq_ignore_ascii_case("other") || (amount > 0.0 && amount < total * 0.01) { + other += *value; + false + } else { + true + } + }); + if other > 0.0 { + values.insert("other".to_string(), other); + } + } + } + if report == Report::Credits { + for date in start.iter_days().take_while(|date| *date <= end) { + days.entry(date).or_default(); + } + } + Ok(Some(AccountAnalyticsHistory { + unit: match report { + Report::Usage if !attributed && response.units.as_deref() == Some("credits") => { + AccountAnalyticsUnit::Credits + } + Report::Usage => AccountAnalyticsUnit::RelativeUsage, + Report::Credits => AccountAnalyticsUnit::Credits, + Report::Messages | Report::Plugins | Report::Skills => AccountAnalyticsUnit::Count, + }, + updated_at: response + .data_freshness_ts + .as_deref() + .and_then(|timestamp| DateTime::parse_from_rfc3339(timestamp).ok()) + .map(|timestamp| timestamp.timestamp()), + data: days + .into_iter() + .map(|(date, (total, values))| AccountAnalyticsDay { + date, + total, + values: values + .into_iter() + .map(|(key, value)| AccountAnalyticsValue { + label: series + .and_then(|series| series.iter().find(|series| series.key == key)) + .map(|series| series.label.clone()) + .unwrap_or_else(|| label(&key).to_string()), + key, + value, + }) + .collect(), + }) + .collect(), + })) +} + +pub(super) fn label(key: &str) -> &str { + match key { + "user" => "Tasks", + "subagent" => "Subagents", + "image_generation" => "Image generation", + "automation" => "Automations", + "guardian_review" => "Auto review", + "guardian_classifier" => "Auto review classifier", + "thread_title" => "Thread title", + "system" => "System", + "automated_review" => "Auto review", + "agent_identity" => "Workspace agents", + "memory_consolidation" => "Memory consolidation", + "cli" => "CLI", + "desktop_app" => "Desktop app", + "vscode" => "VS Code", + "web" => "Web", + "work_web" => "Work web", + "work_desktop" => "Work desktop", + "work_mobile" => "Work mobile", + "mobile" => "Mobile", + "slack" => "Slack", + "linear" => "Linear", + "jetbrains" => "JetBrains", + "sdk" => "SDK", + "exec" => "Exec", + "github" => "GitHub", + "codex" => "Codex", + "work" => "Work", + "code_review" | "github_code_review" => "Code review", + "start-user" | "start-composer" => "User messages", + "start-goal" => "Goals", + "start-composer_queue" => "Queued messages", + "start-composer_queue_run_now" => "Queued messages run now", + "start-automation_cron_scheduled" => "Scheduled automations", + "start-automation_cron_run_now" => "Automations run now", + "start-automation_heartbeat_scheduled" => "Scheduled follow-ups", + "start-automation_heartbeat_run_now" => "Follow-ups run now", + "start-app_tool_create_thread" => "Agent-created tasks", + "start-app_tool_send_message" => "Agent follow-ups", + "unknown" | "start-unknown" => "Unknown", + "fast" => "Fast", + "standard" => "Standard", + "other" | "start-other" => "Other", + _ => key, + } +} + +#[cfg(test)] +#[path = "normalize_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/normalize_tests.rs b/codex-rs/tui/src/analytics/normalize_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..140166f9e9f52fd07bc683701c372c589866abc2 --- /dev/null +++ b/codex-rs/tui/src/analytics/normalize_tests.rs @@ -0,0 +1,358 @@ +//! Regression coverage for daily report normalization and billing semantics. + +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn turn_start_includes_all_features_and_missing_attribution_is_unavailable() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + let response = serde_json::from_value(json!({"data": [{"date": "2026-01-09", "attribution": [ + {"thread_source": "user", "turn_trigger": "composer", "value": 30}, + {"thread_source": "subagent", "value": 70} + ]}]})) + .unwrap(); + assert_eq!( + history(response, Report::Usage, Grouping::TaskStart, date, date).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::RelativeUsage, + updated_at: None, + data: vec![AccountAnalyticsDay { + date: "2026-01-09".parse().unwrap(), + total: 100.0, + values: vec![ + AccountAnalyticsValue { + key: "start-composer".to_string(), + label: "User messages".to_string(), + value: 30.0 + }, + AccountAnalyticsValue { + key: "unknown".to_string(), + label: "Unknown".to_string(), + value: 70.0 + } + ], + }], + }) + ); + let response = serde_json::from_value(json!({"data": [{"date": "2026-01-09"}]})).unwrap(); + assert_eq!( + history(response, Report::Usage, Grouping::Feature, date, date).unwrap(), + None + ); +} + +#[test] +fn signed_credits_keep_daily_activity_when_the_period_nets_to_zero() { + let start = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 8).unwrap(); + let end = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + let response = serde_json::from_value(json!({"data": [ + {"date": "2026-01-08", "product_surface": "cli", "credit_amount": 0.004}, + {"date": "2026-01-09", "product_surface": "desktop_app", "credit_amount": -0.004} + ]})) + .unwrap(); + assert_eq!( + history(response, Report::Credits, Grouping::Surface, start, end).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Credits, + updated_at: None, + data: [ + ("2026-01-08", 0.004, "cli", "CLI"), + ("2026-01-09", -0.004, "desktop_app", "Desktop app") + ] + .into_iter() + .map(|(date, amount, key, label)| AccountAnalyticsDay { + date: date.parse().unwrap(), + total: amount, + values: vec![AccountAnalyticsValue { + key: key.to_string(), + label: label.to_string(), + value: amount + }], + }) + .collect(), + }) + ); +} + +#[test] +fn message_surfaces_include_both_desktop_clients_and_unmapped_counts() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + let response = serde_json::from_value( + json!({"data": [{"date": "2026-01-09", "totals": {"turns": 10}, "clients": [ + {"client_id": "CODEX_DESKTOP_APP", "turns": 2}, + {"client_id": "CODEX_WORK_DESKTOP", "turns": 3}, + {"client_id": "new_client", "turns": 1} + ]}]}), + ) + .unwrap(); + assert_eq!( + history(response, Report::Messages, Grouping::Surface, date, date) + .unwrap() + .unwrap() + .data, + vec![AccountAnalyticsDay { + date: "2026-01-09".parse().unwrap(), + total: 10.0, + values: vec![ + AccountAnalyticsValue { + key: "desktop_app".to_string(), + label: "Desktop app".to_string(), + value: 5.0 + }, + AccountAnalyticsValue { + key: "other".to_string(), + label: "Other".to_string(), + value: 5.0 + }, + ], + }] + ); +} + +#[test] +fn enterprise_credit_series_keep_labels_signed_values_and_freshness() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + let response = serde_json::from_value(json!({ + "series": [{"key": "medium", "label": "Medium", "total": 12.5}, {"key": "high", "label": "High", "total": -0.004}], + "data_freshness_ts": "2026-01-09T10:00:00Z", + "data": [{"date": "2026-01-09", "values": {"medium": 12.5, "high": -0.004}}] + })).unwrap(); + assert_eq!( + history(response, Report::Credits, Grouping::Reasoning, date, date).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Credits, + updated_at: Some(1767952800), + data: vec![AccountAnalyticsDay { + date, + total: 12.496, + values: vec![ + AccountAnalyticsValue { + key: "high".into(), + label: "High".into(), + value: -0.004 + }, + AccountAnalyticsValue { + key: "medium".into(), + label: "Medium".into(), + value: 12.5 + } + ], + }], + }) + ); +} + +#[test] +fn credit_timestamps_and_legacy_model_usage_follow_app_fallbacks() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + let credits = serde_json::from_value(json!({"data": [{"date": "2026-01-09T05:00:00Z", "product_surface": "cli", "credit_amount": 12.5}]})).unwrap(); + let usage = serde_json::from_value(json!({"data": [{"date": "2026-01-09", "models": [{"model": "model-a", "credits": 12.5}]}]})).unwrap(); + let credits = history(credits, Report::Credits, Grouping::Surface, date, date) + .unwrap() + .unwrap(); + let usage = history(usage, Report::Usage, Grouping::Model, date, date) + .unwrap() + .unwrap(); + assert_eq!( + ( + credits.data[0].total, + usage.data[0].total, + usage.data[0].values[0].key.as_str() + ), + (12.5, 12.5, "model-a") + ); +} + +#[test] +fn count_reports_preserve_explicit_zero_and_omit_unreported_days() { + let start = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 8).unwrap(); + let end = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 10).unwrap(); + for (report, field) in [ + (Report::Messages, "totals"), + (Report::Plugins, "plugin_usage_overviews"), + (Report::Skills, "skill_usage_overviews"), + ] { + let value = if report == Report::Messages { + json!({"turns": 0}) + } else { + json!([]) + }; + let response = + serde_json::from_value(json!({"data": [{"date": "2026-01-08", (field): value}]})) + .unwrap(); + assert_eq!( + history(response, report, Grouping::Surface, start, end).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Count, + updated_at: None, + data: vec![AccountAnalyticsDay { + date: start, + total: 0.0, + values: Vec::new() + }], + }) + ); + } +} + +#[test] +fn legacy_consumer_history_keeps_daily_products_and_models() { + let date = "2026-09-01".parse().unwrap(); + let response: AnalyticsData = serde_json::from_value(json!({"units":"relative", "data":[{ + "date":"2026-09-01", + "product_surface_usage_values":{"work_desktop":12,"work_web":8,"cli":30,"desktop_app":50}, + "models":[{"model":"major","credits":98.5},{"model":"one-percent","credits":1},{"model":"tiny","credits":0.3},{"model":"OTHER","credits":0.2}] + }]})).unwrap(); + for (grouping, expected) in [ + ( + Grouping::Surface, + vec![("codex", "Codex", 80.0), ("work", "Work", 20.0)], + ), + ( + Grouping::Model, + vec![ + ("major", "major", 98.5), + ("one-percent", "one-percent", 1.0), + ("other", "Other", 0.5), + ], + ), + ] { + assert_eq!( + history(response.clone(), Report::Usage, grouping, date, date).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::RelativeUsage, + updated_at: None, + data: vec![AccountAnalyticsDay { + date, + total: 100.0, + values: expected + .into_iter() + .map(|(key, label, value)| AccountAnalyticsValue { + key: key.into(), + label: label.into(), + value + }) + .collect() + }], + }) + ); + } + let mut credits = response; + credits.units = Some("credits".into()); + let model = history(credits, Report::Usage, Grouping::Model, date, date) + .unwrap() + .unwrap(); + assert_eq!( + (model.unit, model.data[0].values.len()), + (AccountAnalyticsUnit::Credits, 4) + ); +} + +#[test] +fn complete_attribution_wins_consistently_and_incomplete_attribution_is_not_mixed() { + let date = "2026-09-01".parse().unwrap(); + let response: AnalyticsData = serde_json::from_value(json!({"units":"credits", "data":[{ + "date":"2026-09-01", "product_surface_usage_values":{"cli":999}, + "models":[{"model":"legacy","credits":999}], + "attribution":[ + {"thread_source":"user","turn_trigger":"composer","surface":"desktop_app","model":"alpha","value":30}, + {"thread_source":"subagent","turn_trigger":"goal","surface":"cli","model":"tiny","value":0.1} + ] + }]})).unwrap(); + for grouping in [ + Grouping::Feature, + Grouping::Model, + Grouping::Surface, + Grouping::TaskStart, + ] { + let normalized = history(response.clone(), Report::Usage, grouping, date, date) + .unwrap() + .unwrap(); + assert_eq!( + ( + normalized.unit, + normalized.data[0].total, + normalized.data[0].values.len() + ), + (AccountAnalyticsUnit::RelativeUsage, 30.1, 2) + ); + } + let incomplete = serde_json::from_value(json!({"data":[ + {"date":"2026-09-01", "attribution":[]}, {"date":"2026-09-02"} + ]})) + .unwrap(); + assert_eq!( + history( + incomplete, + Report::Usage, + Grouping::Feature, + date, + "2026-09-02".parse().unwrap() + ) + .unwrap(), + None + ); +} + +#[test] +fn duplicate_message_dates_preserve_each_records_other_remainder() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + for grouping in [Grouping::Model, Grouping::Surface] { + let response = serde_json::from_value(json!({"data": [ + {"date": "2026-01-09", "totals": {"turns": 10}, "models": [{"model": "example", "turns": 8}], "clients": [{"client_id": "CODEX_CLI", "turns": 8}]}, + {"date": "2026-01-09", "totals": {"turns": 10}, "models": [{"model": "example", "turns": 8}], "clients": [{"client_id": "CODEX_CLI", "turns": 8}]} + ]})).unwrap(); + let (key, label) = if grouping == Grouping::Model { + ("example", "example") + } else { + ("cli", "CLI") + }; + assert_eq!( + history(response, Report::Messages, grouping, date, date).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Count, + updated_at: None, + data: vec![AccountAnalyticsDay { + date, + total: 20.0, + values: vec![ + AccountAnalyticsValue { + key: key.into(), + label: label.into(), + value: 16.0 + }, + AccountAnalyticsValue { + key: "other".into(), + label: "Other".into(), + value: 4.0 + }, + ] + }], + }) + ); + } +} + +#[test] +fn unsupported_message_groupings_fail_even_without_records() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + for grouping in [ + Grouping::Feature, + Grouping::TaskStart, + Grouping::Speed, + Grouping::Reasoning, + Grouping::TokenType, + ] { + for data in [ + json!([]), + json!([{"date": "2026-01-09", "totals": {"turns": 10}, "clients": []}]), + ] { + let response = serde_json::from_value(json!({"data": data})).unwrap(); + assert_eq!( + history(response, Report::Messages, grouping, date, date), + Err("Unsupported message count grouping.".into()) + ); + } + } +} diff --git a/codex-rs/tui/src/analytics/panels.rs b/codex-rs/tui/src/analytics/panels.rs new file mode 100644 index 0000000000000000000000000000000000000000..a30031ffd9bbe349400d47961646be1bc110f773 --- /dev/null +++ b/codex-rs/tui/src/analytics/panels.rs @@ -0,0 +1,106 @@ +//! Responsive panels with consistent category colors and signed daily amounts. + +use super::AnalyticsView; +use super::render::columns; +use super::styles::secondary_style; +use crate::analytics::sections::Section; +use crate::style::accent_style; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::ops::Range; + +/// Panel content and measured geometry shared by layout and scrolling. +pub(super) struct Panel { + pub(super) lines: Vec>, + pub(super) selection: Range, + pub(super) chart_bands: usize, +} + +impl AnalyticsView { + pub(super) fn panel(&self, section: Section, width: usize, chart_height: usize) -> Panel { + let focused = section == self.section; + let title = format!( + "{} {}", + if focused { "▎" } else { " " }, + self.section_title(section) + ); + let heading = if focused { + title.set_style(accent_style()).into() + } else { + title.bold().into() + }; + let group = self.sections[section].group; + let heading = if matches!( + section, + Section::Usage | Section::Credits | Section::Activity | Section::Plan + ) { + columns( + heading, + format!("By {}", self.group_label(section, group).to_lowercase()) + .set_style(secondary_style()) + .into(), + width, + ) + } else { + heading + }; + let mut lines = vec![heading, "─".repeat(width).dim().into()]; + if section == Section::Usage && self.business() { + lines.push( + format!( + "{} · m change model", + self.token_model.as_deref().unwrap_or("All models") + ) + .set_style(secondary_style()) + .into(), + ); + } + let selection; + let mut chart_bands = 0; + + if section == Section::Summary { + selection = 0..1; + lines.extend(self.summary_lines(width)); + } else if section == Section::Plan { + let (plan, detail) = self.plan_lines(width); + selection = lines.len() + detail.start..lines.len() + detail.end; + lines.extend(plan); + } else if section == Section::Chats { + let (chats, detail) = if self.business() { + self.chat_lines(width) + } else { + self.task_lines(width) + }; + selection = lines.len() + detail.start..lines.len() + detail.end; + lines.extend(chats); + } else { + let history = self.history_lines(section, width, chart_height); + chart_bands = history.bands; + lines.extend(history.lines); + selection = 0..lines.len(); + } + + let mut wrapped = Vec::new(); + let mut wrapped_selection = 0..0; + for (index, line) in lines.into_iter().enumerate() { + if index == selection.start { + wrapped_selection.start = wrapped.len(); + } + wrapped.extend(word_wrap_lines( + [line], + RtOptions::new(width.max(/*other*/ 1)), + )); + if index + 1 == selection.end { + wrapped_selection.end = wrapped.len(); + } + } + Panel { + lines: wrapped, + selection: wrapped_selection, + chart_bands, + } + } +} diff --git a/codex-rs/tui/src/analytics/plan.rs b/codex-rs/tui/src/analytics/plan.rs new file mode 100644 index 0000000000000000000000000000000000000000..d4d9d1ec2b8c207acff936b2171e977950fa8cef --- /dev/null +++ b/codex-rs/tui/src/analytics/plan.rs @@ -0,0 +1,121 @@ +//! Historical allowance snapshots, separate from daily usage and current chat allowances. +//! Period IDs are snapshot-scoped; unknown accounting is never converted to zero. +use super::data::Load; +use chrono::DateTime; +use chrono::Utc; +use codex_backend_client::PlanLimitBreakdown; +use codex_backend_client::PlanLimitHistory; + +pub(super) struct Period { + pub id: String, + pub start: DateTime, + pub end: DateTime, + pub complete: bool, + pub used: Option, + pub breakdowns: Option>, +} + +pub(super) struct Report { + pub as_of: DateTime, + pub coverage_start: Option>, + pub coverage_complete: bool, + pub approximate: bool, + pub periods: [Vec; 2], +} + +impl Report { + pub(super) fn parse(history: PlanLimitHistory) -> Result, String> { + let parse = |value: &str| { + DateTime::parse_from_rfc3339(value) + .map(|date| date.with_timezone(&Utc)) + .map_err(|_| "Invalid plan history timestamp.".to_string()) + }; + let Some(as_of) = history.data_as_of.as_deref().map(parse).transpose()? else { + return Ok(None); + }; + let mut periods = [Vec::new(), Vec::new()]; + let mut ids = std::collections::HashSet::new(); + if history.periods.len() > 1_000 { + return Err("Plan history contains too many periods.".into()); + } + for period in history.periods { + let window = match period.window_minutes { + 300 => 0, + 10080 => 1, + _ => return Err("Unsupported plan history window.".into()), + }; + let start = parse(&period.starts_at)?; + let end = parse(&period.ends_at)?; + if start >= end + || !ids.insert(period.id.clone()) + || period + .used_basis_points + .is_some_and(|amount| !amount.is_finite()) + || period + .breakdowns + .iter() + .flatten() + .flat_map(|group| &group.rows) + .any(|row| !row.basis_points.is_finite()) + { + return Err("Invalid plan history period.".into()); + } + periods[window].push(Period { + id: period.id, + start, + end, + complete: period.accounting_complete, + used: period.used_basis_points, + breakdowns: period.breakdowns, + }); + } + for window in &mut periods { + window.sort_by_key(|period| std::cmp::Reverse(period.start)); + } + Ok(Some(Self { + as_of, + coverage_start: history.coverage_start.as_deref().map(parse).transpose()?, + coverage_complete: history.coverage_complete, + approximate: history.approximate, + periods, + })) + } +} + +pub(super) struct State { + pub enabled: bool, + pub report: Load, + pub window: usize, + pub cursor: [usize; 2], + pub expanded: [Option; 2], + snapshot: Option>, +} + +impl Default for State { + fn default() -> Self { + Self { + enabled: false, + report: Load::Unavailable, + window: 0, + cursor: [0; 2], + expanded: [None, None], + snapshot: None, + } + } +} + +impl State { + pub(super) fn poll(&mut self) { + self.report.poll(); + let snapshot = self.report.ready().map(|report| report.as_of); + if snapshot != self.snapshot { + self.snapshot = snapshot; + self.cursor = [0; 2]; + self.expanded = [None, None]; + } + } +} + +#[cfg(test)] +#[path = "plan_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/plan_panel.rs b/codex-rs/tui/src/analytics/plan_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..ee3bfc78017389445ba2e8ff3910b750d0e9eaf5 --- /dev/null +++ b/codex-rs/tui/src/analytics/plan_panel.rs @@ -0,0 +1,259 @@ +//! Five-hour and weekly period tables share grouping, with independent row expansion. +use super::AnalyticsView; +use super::data; +use super::plan; +use super::render::columns; +use super::render::join_columns; +use super::sections::Section; +use super::styles::secondary_style; +use crate::keymap::ListAction; +use crate::style::accent_style; +use codex_backend_client::PlanLimitDimension; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::ops::Range; + +impl AnalyticsView { + pub(super) fn plan_action(&mut self, action: Option) { + let window = self.plan.window; + let periods = self + .plan + .report + .ready() + .map(|report| &report.periods[window]); + let count = periods.map_or(/*default*/ 0, Vec::len); + let cursor = &mut self.plan.cursor[window]; + let previous_cursor = *cursor; + match action { + Some(ListAction::MoveLeft | ListAction::MoveRight) => self.plan.window ^= 1, + Some(ListAction::MoveUp) => *cursor = cursor.saturating_sub(/*rhs*/ 1), + Some(ListAction::MoveDown) => { + *cursor = (*cursor + 1).min(count.saturating_sub(/*rhs*/ 1)) + } + Some(ListAction::JumpTop) => *cursor = 0, + Some(ListAction::JumpBottom) => *cursor = count.saturating_sub(/*rhs*/ 1), + Some(ListAction::Accept) => { + if let Some(period) = periods.and_then(|periods| periods.get(*cursor)) { + self.plan.expanded[window] = + if self.plan.expanded[window].as_ref() == Some(&period.id) { + None + } else { + Some(period.id.clone()) + }; + } + } + Some(ListAction::Cancel) => { + self.is_done |= !self.zoomed || self.plan.expanded[window].take().is_none(); + } + Some(ListAction::PageDown) => { + self.scroll_offset += self.viewport_height; + self.follow_selection = false; + } + Some(ListAction::PageUp) => { + self.scroll_offset = self.scroll_offset.saturating_sub(self.viewport_height); + self.follow_selection = false; + } + None => {} + } + if *cursor != previous_cursor { + self.plan.expanded[window] = None; + } + } + + pub(super) fn plan_lines(&self, width: usize) -> (Vec>, Range) { + let side_by_side = width >= 100; + let table_width = if side_by_side { (width - 4) / 2 } else { width }; + let group = self.sections[Section::Plan].group; + let dimension = match group { + 1 => PlanLimitDimension::ThreadSource, + 2 => PlanLimitDimension::Model, + 3 => PlanLimitDimension::TurnTrigger, + _ => PlanLimitDimension::Surface, + }; + let mut tables = Vec::new(); + let mut selections = Vec::new(); + for (window, title) in ["5-hour limits", "Weekly limits"].into_iter().enumerate() { + let heading = format!( + "{} {title}", + if self.plan.window == window { + "›" + } else { + " " + } + ); + let mut lines = vec![heading.bold().into(), Line::default()]; + let mut selection = 0..2; + if let Some(report) = self.plan.report.ready() { + if report.periods[window].is_empty() { + lines.push( + if report.coverage_complete { + "No limit periods in this date range" + } else { + "Limit history isn't available yet" + } + .set_style(secondary_style()) + .into(), + ); + } + for (index, period) in report.periods[window] + .iter() + .take(if self.zoomed { usize::MAX } else { 1 }) + .enumerate() + { + let start = lines.len(); + let selected = self.plan.cursor[window] == index; + let label = if !self.zoomed { + format!("Latest period · {} total", report.periods[window].len()) + } else { + format!( + "{} {} – {}{}", + if selected { "›" } else { " " }, + period.start.format("%b %-d %H:%M"), + period.end.format("%b %-d %H:%M"), + if period.start <= report.as_of && period.end > report.as_of { + " *" + } else { + "" + } + ) + }; + let amount = period + .used + .map(|value| format!("{}%", data::amount(value / 100.0))) + .unwrap_or_else(|| "Not available".into()); + let row = columns(label.into(), amount.into(), table_width); + lines.push(if selected && self.plan.window == window { + row.set_style(accent_style()) + } else { + row + }); + if self.zoomed && self.plan.expanded[window].as_ref() == Some(&period.id) { + if !period.complete { + lines.push( + " Some usage is unavailable; this period may be incomplete" + .dim() + .into(), + ); + } + if let Some(breakdown) = period.breakdowns.as_ref().and_then(|groups| { + groups.iter().find(|group| group.dimension == dimension) + }) { + for row in &breakdown.rows { + let key = if dimension == PlanLimitDimension::TurnTrigger { + format!("start-{}", row.key) + } else { + row.key.clone() + }; + let label = if dimension == PlanLimitDimension::Model { + self.model_name(&key) + } else { + super::normalize::label(&key) + }; + lines.push(columns( + format!(" {label}").into(), + format!("{}%", data::amount(row.basis_points / 100.0)).into(), + table_width, + )); + } + } else { + lines.push(" Breakdown isn't available for this period".dim().into()); + } + } + if selected { + selection = start..lines.len(); + } + } + } else { + lines.push( + match &self.plan.report { + data::Load::Unavailable => "Limit history isn't available yet", + state => state + .message() + .unwrap_or("Limit history isn't available yet"), + } + .to_string() + .set_style(secondary_style()) + .into(), + ); + } + // Wrap each column before joining so long timestamps never collide with its neighbor. + let mut wrapped = Vec::new(); + let mut wrapped_selection = 0..0; + for (index, line) in lines.into_iter().enumerate() { + if index == selection.start { + wrapped_selection.start = wrapped.len(); + } + wrapped.extend(crate::wrapping::word_wrap_lines( + [line], + crate::wrapping::RtOptions::new(table_width.max(/*other*/ 1)), + )); + if index + 1 == selection.end { + wrapped_selection.end = wrapped.len(); + } + } + tables.push(wrapped); + selections.push(wrapped_selection); + } + let mut selection = selections[self.plan.window].clone(); + let mut lines = if side_by_side { + join_columns(&tables[0], &tables[1], table_width) + } else { + if self.plan.window == 1 { + selection = + selection.start + tables[0].len() + 1..selection.end + tables[0].len() + 1; + } + let mut lines = std::mem::take(&mut tables[0]); + lines.push(Line::default()); + lines.append(&mut tables[1]); + lines + }; + if group == 3 { + lines.push( + "By turn start includes Tasks only; percentages use the full period limit" + .dim() + .into(), + ); + } + if let Some(plan::Report { + as_of, + coverage_start, + coverage_complete, + approximate, + .. + }) = self.plan.report.ready() + { + if !*coverage_complete { + lines.push("Some periods aren't available yet".dim().into()); + } + lines.push( + format!( + "Usage as of {} UTC{}", + as_of.format("%b %-d %H:%M"), + if self.zoomed { + " · * current at last update" + } else { + "" + } + ) + .dim() + .into(), + ); + if let Some(start) = coverage_start { + lines.push( + format!("Available since {} UTC", start.format("%b %-d %H:%M")) + .dim() + .into(), + ); + } + if *approximate { + lines.push( + "Amounts and period boundaries are approximate; recent activity may be delayed" + .dim() + .into(), + ); + } + } + (lines, selection) + } +} diff --git a/codex-rs/tui/src/analytics/plan_tests.rs b/codex-rs/tui/src/analytics/plan_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..bbb902dd62de8acf2feeda715840016a88701b04 --- /dev/null +++ b/codex-rs/tui/src/analytics/plan_tests.rs @@ -0,0 +1,252 @@ +//! Snapshot semantics and independent plan-period navigation without a live billing service. +use super::*; +use crate::analytics::fixture; +use crate::analytics::models::AccountKind; +use crate::analytics::sections::Section; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use serde_json::json; + +fn response() -> PlanLimitHistory { + serde_json::from_value(json!({ + "data_as_of":"2026-09-02T12:00:00Z", "coverage_start":"2026-09-01T00:00:00Z", + "coverage_complete":false, "boundary_tolerance_seconds":60, + "periods":[ + {"id":"five", "window_minutes":300,"plan_type":"plus", + "starts_at":"2026-09-02T10:00:00Z","ends_at":"2026-09-02T15:00:00Z", + "accounting_complete":false,"used_basis_points":12500, + "breakdowns":[ + {"dimension":"future_dimension","rows":[{"key":"future","basis_points":500}]}, + {"dimension":"thread_source","rows":[{"key":"user","basis_points":12500}]}]}, + {"id":"weekly", "window_minutes":10080,"plan_type":"plus", + "starts_at":"2026-09-01T00:00:00Z","ends_at":"2026-09-08T00:00:00Z", + "accounting_complete":true,"used_basis_points":null,"breakdowns":null} + ] + })) + .unwrap() +} + +#[test] +fn period_navigation_preserves_unknowns_and_resets_on_new_snapshot() { + let mut view = fixture::view(AccountKind::Consumer); + view.plan.enabled = true; + view.plan.report = Load::Ready(Report::parse(response()).unwrap().unwrap()); + view.plan.poll(); + view.section = Section::Plan; + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + view.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + view.plan.expanded, + [Some("five".into()), Some("weekly".into())] + ); + let mut output = Vec::new(); + for width in [120, 58] { + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + width, /*height*/ 30, + )) + .unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + output.push(terminal.backend().to_string()); + } + insta::assert_snapshot!(output.join("\n")); + let mut next = response(); + next.data_as_of = Some("2026-09-02T13:00:00Z".into()); + view.plan.report = Load::Ready(Report::parse(next).unwrap().unwrap()); + view.plan.poll(); + assert_eq!( + (view.plan.cursor, view.plan.expanded), + ([0, 0], [None, None]) + ); +} + +#[test] +fn unavailable_empty_and_zero_history_are_distinct() { + let mut view = fixture::view(AccountKind::Consumer); + view.plan.enabled = true; + view.section = Section::Plan; + let mut states = Vec::new(); + states.push( + view.plan_lines(/*width*/ 100) + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ); + let mut history = response(); + history.coverage_complete = true; + history.periods.clear(); + view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap()); + states.push( + view.plan_lines(/*width*/ 100) + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ); + let mut history = response(); + history.periods[0].used_basis_points = Some(0.0); + view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap()); + states.push( + view.plan_lines(/*width*/ 100) + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ); + let mut future = response(); + future.periods[0].starts_at = "2026-09-02T13:00:00Z".into(); + view.plan.report = Load::Ready(Report::parse(future).unwrap().unwrap()); + states.push( + view.plan_lines(/*width*/ 100) + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ); + let snapshot = states.join("\n\n"); + let snapshot = snapshot + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + insta::assert_snapshot!(snapshot); + let mut missing = response(); + missing.data_as_of = None; + assert!(Report::parse(missing).unwrap().is_none()); +} + +#[tokio::test] +async fn plan_gate_prevents_requests_and_unavailable_plan_does_not_block_other_reports() { + use crate::analytics::client; + use crate::analytics::test_support; + use wiremock::Mock; + use wiremock::ResponseTemplate; + use wiremock::matchers::path; + let server = test_support::server().await; + let (_home, _server, mut view) = client::tests::connected_view(&server, "plus").await; + test_support::settle(&mut view).await; + assert!( + !server + .received_requests() + .await + .unwrap() + .iter() + .any(|request| request.url.path().ends_with("plan_limit_history")) + ); + assert!(!view.visible_sections().contains(&Section::Plan)); + Mock::given(path("/backend-api/wham/usage/plan_limit_history")) + .respond_with(ResponseTemplate::new(/*s*/ 404)) + .with_priority(/*p*/ 1) + .expect(/*r*/ 1) + .mount(&server) + .await; + let (mut config, handle, frame) = view.connection.clone().unwrap(); + std::sync::Arc::make_mut(&mut config) + .features + .enable(codex_features::Feature::AnalyticsPlanHistory) + .unwrap(); + view.open(handle, frame, Vec::new(), config); + test_support::settle(&mut view).await; + assert!(matches!(view.plan.report, Load::Unavailable)); + assert!(view.sections[Section::Usage].history.ready().is_some()); + assert!(view.visible_sections().contains(&Section::Plan)); + // Closing cancels the pending plan task along with the other account-owned work. + let (sender, receiver) = tokio::sync::oneshot::channel::<()>(); + view.plan.report = Load::start( + async move { + let _sender = sender; + std::future::pending().await + }, + crate::tui::FrameRequester::test_dummy(), + ); + view.cancel_loads(); + assert!(receiver.await.is_err()); + server.verify().await; + let server = test_support::server().await; + let (_home, _server, mut business) = client::tests::connected_view(&server, "business").await; + let (mut config, handle, frame) = business.connection.clone().unwrap(); + std::sync::Arc::make_mut(&mut config) + .features + .enable(codex_features::Feature::AnalyticsPlanHistory) + .unwrap(); + business.open(handle, frame, Vec::new(), config); + test_support::settle(&mut business).await; + assert!(!business.visible_sections().contains(&Section::Plan)); + assert!(matches!(business.plan.report, Load::Unavailable)); + server.verify().await; +} + +#[test] +fn plan_overview_escape_closes_with_retained_expansion() { + let mut history = response(); + for index in 1..12 { + let mut period = history.periods[0].clone(); + period.id = format!("five-{index}"); + history.periods.push(period); + } + let mut view = fixture::view(AccountKind::Consumer); + view.plan.enabled = true; + view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap()); + view.plan.poll(); + view.section = Section::Plan; + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + view.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + /*width*/ 120, /*height*/ 60, + )) + .unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + let output = terminal.backend().to_string(); + assert!(output.contains("Latest period · 12 total") && output.contains("Weekly limits")); + insta::assert_snapshot!(output); + view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(view.is_done); +} + +#[test] +fn moving_period_selection_collapses_its_previous_details() { + let mut history = response(); + let mut older = history.periods[0].clone(); + older.id = "older-five".into(); + older.starts_at = "2026-09-02T05:00:00Z".into(); + older.ends_at = "2026-09-02T10:00:00Z".into(); + history.periods.push(older); + let mut view = fixture::view(AccountKind::Consumer); + view.plan.enabled = true; + view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap()); + view.plan.poll(); + view.section = Section::Plan; + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!( + (view.plan.cursor, view.plan.expanded), + ([1, 0], [None, None]) + ); +} + +#[test] +fn invalid_history_returns_safe_errors() { + for invalid in 0..5 { + let mut history = response(); + match invalid { + 0 => history.periods[0].starts_at = "private wire value".into(), + 1 => history.periods[0].ends_at = history.periods[0].starts_at.clone(), + 2 => history.periods.push(history.periods[0].clone()), + 3 => history.periods[0].window_minutes = 1, + _ => history.periods[0].used_basis_points = Some(f64::INFINITY), + } + let error = Report::parse(history).err().unwrap(); + assert!(!error.contains("private wire value")); + } +} diff --git a/codex-rs/tui/src/analytics/plot.rs b/codex-rs/tui/src/analytics/plot.rs new file mode 100644 index 0000000000000000000000000000000000000000..e9f0357adbe09709655fa3254b1c88ceeb1ebc35 --- /dev/null +++ b/codex-rs/tui/src/analytics/plot.rs @@ -0,0 +1,176 @@ +//! Daily stacked bars with fixed calendar ticks, a selection cursor, and an anchored value. +//! Relative usage retains the server's scale; signed credits keep both bands. + +mod annotations; +mod layout; +mod painting; + +use self::layout::Layout; +use self::painting::Band; +use super::data; +use super::data::compact_amount as tick; +use super::render::parts; +use crate::analytics::models::AccountAnalyticsDay; +use crate::analytics::models::AccountAnalyticsUnit; +use crate::analytics::models::AccountAnalyticsValue; +use chrono::NaiveDate; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::text::Span; +use unicode_width::UnicodeWidthStr; + +/// Share the chart's scale with the selected-day summary, including signed values. +pub(super) fn peak( + days: &[(NaiveDate, Option<&AccountAnalyticsDay>)], + categories: &[AccountAnalyticsValue], +) -> f64 { + days.iter() + .filter_map(|(_, day)| *day) + .map(|day| { + parts(&day.values, categories) + .iter() + .map(|value| value.abs()) + .sum::() + .max(day.total.abs()) + }) + .fold(/*init*/ 0.0, f64::max) +} + +/// Two readable intervals cover the range; counts never use fractional ticks. +pub(super) fn axis_max(peak: f64, unit: AccountAnalyticsUnit) -> f64 { + if peak == 0.0 { + return if matches!( + unit, + AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens + ) { + 2.0 + } else { + 1.0 + }; + } + let step = if matches!( + unit, + AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens + ) { + (peak / 2.0).max(/*other*/ 1.0) + } else { + peak / 2.0 + }; + let magnitude = 10.0_f64.powf(step.log10().floor()); + let normalized = step / magnitude; + let multiple = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 7.5, 10.0] + .into_iter() + .find(|multiple| { + *multiple >= normalized + && (!matches!( + unit, + AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens + ) || (multiple * magnitude).fract() == 0.0) + }) + .unwrap_or(/*default*/ 10.0); + let ceiling = multiple * magnitude * 2.0; + if ceiling.is_finite() && ceiling >= peak { + ceiling + } else { + peak + } +} + +/// Rendered chart geometry exposes its positive and optional negative value bands. +#[derive(Default)] +pub(super) struct Chart { + pub(super) lines: Vec>, + pub(super) bands: usize, +} + +/// Render a calendar selection; the cursor must index days and dimensions fit the terminal. +pub(super) fn chart( + days: &[(NaiveDate, Option<&AccountAnalyticsDay>)], + categories: &[AccountAnalyticsValue], + cursor: usize, + width: usize, + height: usize, + unit: AccountAnalyticsUnit, +) -> Chart { + if days.is_empty() || width == 0 { + return Chart::default(); + } + let values = days + .iter() + .map(|(_, day)| { + day.map_or( + /*default*/ [0.0; 4], + |day| parts(&day.values, categories), + ) + }) + .collect::>(); + let bands = if values.iter().flatten().any(|value| *value < 0.0) { + &[Band::Positive, Band::Negative][..] + } else { + &[Band::Positive][..] + }; + let peak = axis_max(peak(days, categories), unit); + let label_width = tick(peak).width().max(tick(peak / 2.0).width()) + bands.len() - 1 + 2; + let layout = Layout::new(days.len(), cursor, width, height, label_width, bands); + let renderer = Renderer { + days, + values, + cursor, + peak, + unit, + bands, + layout, + }; + let mut buf = Buffer::empty(Rect::new( + /*x*/ 0, + /*y*/ 0, + width as u16, + (layout.marker_row + 2) as u16, + )); + renderer.paint_bars(&mut buf); + renderer.annotate_missing(&mut buf); + renderer.paint_axis(&mut buf); + renderer.annotate_selection(&mut buf); + renderer.annotate_calendar(&mut buf); + let lines = buf + .content + .chunks(width) + .map(|row| { + Line::from( + row.iter() + .map(|cell| Span::styled(cell.symbol().to_owned(), cell.style())) + .collect::>(), + ) + }) + .collect(); + Chart { + lines, + bands: bands.len(), + } +} + +/// Shared inputs for painting and annotations; all phases use the same calendar geometry. +struct Renderer<'a> { + days: &'a [(NaiveDate, Option<&'a AccountAnalyticsDay>)], + values: Vec<[f64; 4]>, + cursor: usize, + peak: f64, + unit: AccountAnalyticsUnit, + bands: &'static [Band], + layout: Layout, +} + +fn amount(value: f64, unit: AccountAnalyticsUnit) -> String { + match unit { + AccountAnalyticsUnit::RelativeUsage => data::amount(value), + AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens => { + data::compact_amount(value.round()) + } + AccountAnalyticsUnit::Credits => data::credit_amount(value), + } +} + +#[cfg(test)] +#[path = "plot_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/plot/annotations.rs b/codex-rs/tui/src/analytics/plot/annotations.rs new file mode 100644 index 0000000000000000000000000000000000000000..0ebd2639067ea26117d5cd61db9a37c30083a013 --- /dev/null +++ b/codex-rs/tui/src/analytics/plot/annotations.rs @@ -0,0 +1,213 @@ +//! Missing-day markers, daily-value callouts, and stable calendar labels. + +use super::Renderer; +use super::amount; +use super::layout::Layout; +use super::painting::Band; +use crate::analytics::styles::secondary_style; +use crate::color::is_light; +use crate::style::accent_style; +use crate::terminal_palette::default_bg; +use ratatui::buffer::Buffer; +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use unicode_width::UnicodeWidthStr; + +impl Renderer<'_> { + pub(super) fn annotate_missing(&self, buf: &mut Buffer) { + let Layout { + count, + height, + start, + .. + } = self.layout; + for (index, parts) in self.values[start..start + count].iter().enumerate() { + // The selected zero/missing value gets its own callout below; avoid repeating it. + if start + index != self.cursor { + let marker = match self.days[start + index].1 { + None => Some('—'), + Some(day) if day.total == 0.0 && parts.iter().all(|value| *value == 0.0) => { + Some('0') + } + Some(_) => None, + }; + if let Some(marker) = marker { + buf[(self.layout.center(index) as u16, height as u16)] + .set_char(marker) + .set_style(Style::default().dim()); + } + } + } + } + + pub(super) fn annotate_selection(&self, buf: &mut Buffer) { + let Layout { + axis_width, + baseline, + height, + marker_row, + plot_width, + start, + width, + .. + } = self.layout; + // Give the selected value first choice of space; the other labels never overwrite it. + for index in + std::iter::once(self.cursor).chain((start..start + self.layout.count).filter(|index| { + self.days.len() <= 7 + && *index != self.cursor + && self.days[*index].1.is_some_and(|day| { + day.total != 0.0 || self.values[*index].iter().any(|value| *value != 0.0) + }) + })) + { + let total = self.days[index].1.map_or(/*default*/ 0.0, |day| day.total); + let label = self.days[index] + .1 + .map_or_else(|| "—".into(), |_| amount(total, self.unit)); + // A partial number can mean a different amount; leave the cursor when the value cannot fit. + if label.width() > plot_width { + continue; + } + let x = self.layout.center(index - start); + let label_width = label.width(); + let label_x = x + .saturating_sub(label_width / 2) + .clamp(axis_width, width - label_width); + let occupied = label_x.saturating_sub(/*rhs*/ 1).max(axis_width) + ..(label_x + label_width + 1).min(width); + let below = total < 0.0; + let band = if below { + Band::Negative + } else { + Band::Positive + }; + let extent = self.values[index] + .iter() + .map(|value| band.amount(*value)) + .sum::(); + let rows = ((extent * height as f64 / self.peak).ceil() as usize).min(height); + let cap = if below { + baseline + rows + } else { + baseline - rows + }; + let mut label_y = if below { + cap + 1 + } else { + cap.saturating_sub(/*rhs*/ 1) + }; + // Keep the callout close without painting over a neighboring bar. A guide connects any gap. + while occupied + .clone() + .any(|col| buf[(col as u16, label_y as u16)].symbol() != " ") + { + if below && label_y + 1 < marker_row { + label_y += 1; + } else if !below && label_y > 0 { + label_y -= 1; + } else { + break; + } + } + if occupied + .clone() + .any(|col| buf[(col as u16, label_y as u16)].symbol() != " ") + { + continue; + } + // Use white on dark backgrounds and the terminal foreground on light backgrounds. + let style = if index == self.cursor { + let selected = Style::default().bold().not_dim(); + if default_bg().is_some_and(is_light) { + selected + } else { + selected.white() + } + } else { + Style::default().dim() + }; + let guide = if below { + cap + 1..label_y + } else { + label_y + 1..cap + }; + for y in guide { + let cell = &mut buf[(x as u16, y as u16)]; + if cell.symbol() == " " { + cell.set_char('┊').set_style(style); + } + } + buf.set_line( + label_x as u16, + label_y as u16, + &Line::from(label.set_style(style)), + label_width as u16, + ); + } + let x = self.layout.center(self.cursor - start); + buf[(x as u16, marker_row as u16)] + .set_char('▲') + .set_style(accent_style()); + } + + pub(super) fn annotate_calendar(&self, buf: &mut Buffer) { + let Layout { + axis_width, + count, + marker_row, + plot_width, + start, + width, + .. + } = self.layout; + // Calendar ticks depend only on the range/width, never on the current selection. + let mut occupied = vec![false; width]; + // Give the two range endpoints priority over intermediate weekly ticks. + for index in std::iter::once(/*value*/ 0).chain((1..count).rev()) { + if self.days.len() > 7 + && !(start + index).is_multiple_of(/*rhs*/ 7) + && index + 1 != count + { + continue; + } + let format = if self.days.len() <= 7 && plot_width / count < 7 { + "%-d" + } else { + "%b %-d" + }; + let mut label = self.days[start + index].0.format(format).to_string(); + if label.width() > plot_width { + label = self.days[start + index].0.format("%-d").to_string(); + } + if label.width() > plot_width { + continue; + } + let offset = self + .layout + .center(index) + .saturating_sub(label.len() / 2) + .clamp(axis_width, width - label.len()); + if occupied[offset.saturating_sub(/*rhs*/ 1)..(offset + label.len() + 1).min(width)] + .iter() + .any(|used| *used) + { + continue; + } + occupied[offset..offset + label.len()].fill(/*value*/ true); + let label = if start + index == self.cursor { + label.set_style(accent_style()).underlined() + } else { + label.set_style(secondary_style()) + }; + buf.set_line( + offset as u16, + (marker_row + 1) as u16, + &Line::from(label), + (width - offset) as u16, + ); + } + } +} diff --git a/codex-rs/tui/src/analytics/plot/layout.rs b/codex-rs/tui/src/analytics/plot/layout.rs new file mode 100644 index 0000000000000000000000000000000000000000..e3d8a9e2d6cd0839b9a35413a5ecea63e4b8b89e --- /dev/null +++ b/codex-rs/tui/src/analytics/plot/layout.rs @@ -0,0 +1,55 @@ +//! Calendar viewport and shared coordinates for bars, axes, and annotations. + +use super::painting::Band; + +#[derive(Clone, Copy)] +pub(super) struct Layout { + pub(super) width: usize, + pub(super) height: usize, + pub(super) axis_width: usize, + pub(super) plot_width: usize, + pub(super) count: usize, + pub(super) start: usize, + pub(super) baseline: usize, + pub(super) marker_row: usize, +} + +impl Layout { + pub(super) fn new( + day_count: usize, + cursor: usize, + width: usize, + height: usize, + label_width: usize, + bands: &[Band], + ) -> Self { + // Hide the axis when a complete label would crowd out the bars. + let axis_width = if width >= 30 && label_width <= width / 3 { + label_width + } else { + 0 + }; + let plot_width = width - axis_width; + let count = day_count.min(plot_width); + let start = cursor.saturating_sub(count - 1).min(day_count - count); + let baseline = height + 1; + let marker_row = baseline + if bands.len() == 2 { height + 2 } else { 1 }; + Self { + width, + height, + axis_width, + plot_width, + count, + start, + baseline, + marker_row, + } + } + + pub(super) fn center(self, index: usize) -> usize { + self.axis_width + + (index * self.plot_width / self.count + (index + 1) * self.plot_width / self.count + - 1) + / 2 + } +} diff --git a/codex-rs/tui/src/analytics/plot/painting.rs b/codex-rs/tui/src/analytics/plot/painting.rs new file mode 100644 index 0000000000000000000000000000000000000000..03f6633cf1a2ac875b358b9b5d36126d514a31c0 --- /dev/null +++ b/codex-rs/tui/src/analytics/plot/painting.rs @@ -0,0 +1,132 @@ +//! Paint signed bar cells and numeric axes using the shared layout and scale. + +use super::Renderer; +use super::layout::Layout; +use super::tick; +use crate::analytics::styles::secondary_style; +use crate::analytics::styles::series_colors; +use ratatui::buffer::Buffer; +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::text::Line; + +/// A signed stack grows away from the baseline; amounts are nonnegative within each band. +#[derive(Clone, Copy)] +pub(super) enum Band { + Positive, + Negative, +} + +impl Band { + pub(super) fn amount(self, value: f64) -> f64 { + match self { + Self::Positive => value, + Self::Negative => -value, + } + .max(/*other*/ 0.0) + } + + fn row_y(self, baseline: usize, row: usize) -> usize { + match self { + Self::Positive => baseline - row - 1, + Self::Negative => baseline + row + 1, + } + } + + fn partial_glyph(self, level: usize) -> char { + // Reversing the complementary lower block paints a top-anchored partial refund. + let index = match self { + Self::Negative if level < 8 => 8 - level, + Self::Positive | Self::Negative => level, + }; + [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][index] + } +} + +impl Renderer<'_> { + pub(super) fn paint_bars(&self, buf: &mut Buffer) { + let Layout { + axis_width, + baseline, + count, + height, + plot_width, + start, + .. + } = self.layout; + let colors = series_colors(); + for (index, parts) in self.values[start..start + count].iter().enumerate() { + let left = axis_width + index * plot_width / count; + let step = (index + 1) * plot_width / count - index * plot_width / count; + let bar_width = (step * 2 / 3).clamp(/*min*/ 1, /*max*/ 12); + let x = left + (step - bar_width) / 2; + for band in self.bands { + let plotted = parts.iter().map(|value| band.amount(*value)).sum::(); + let scaled = ((plotted * (height * 8) as f64 / self.peak) as usize) + .max(usize::from(plotted > 0.0)); + for row in 0..height { + let level = scaled.saturating_sub(row * 8).min(/*other*/ 8); + if level == 0 { + continue; + } + let reverse = matches!(band, Band::Negative) && level < 8; + let glyph = band.partial_glyph(level); + let bottom = (row * 8) as f64 * self.peak / (height * 8) as f64; + let top = + ((row * 8 + level) as f64 * self.peak / (height * 8) as f64).min(plotted); + let sample = bottom + (top - bottom) / 2.0; + let mut cumulative = 0.0; + let color = parts + .iter() + .position(|value| { + cumulative += band.amount(*value); + sample < cumulative + }) + .unwrap_or(/*default*/ 3); + let y = band.row_y(baseline, row); + for col in x..x + bar_width { + let cell = &mut buf[(col as u16, y as u16)]; + cell.set_char(glyph).set_fg(colors[color]); + if reverse { + cell.set_style(Style::default().reversed()); + } + } + } + } + } + } + + pub(super) fn paint_axis(&self, buf: &mut Buffer) { + let Layout { + axis_width, + baseline, + height, + width, + .. + } = self.layout; + for x in axis_width..width { + buf[(x as u16, baseline as u16)] + .set_char('─') + .set_style(Style::default().dim()); + } + if axis_width > 0 { + let mut ticks = vec![(1, self.peak), (baseline, 0.0)]; + if height >= 4 { + ticks.push((1 + height / 2, self.peak / 2.0)); + } + if self.bands.len() == 2 { + ticks.push((baseline + height, -self.peak)); + } + for (y, value) in ticks { + let label = tick(value); + let x = axis_width.saturating_sub(label.len() + 1); + buf.set_line( + x as u16, + y as u16, + &Line::from(label.set_style(secondary_style())), + axis_width as u16, + ); + } + } + } +} diff --git a/codex-rs/tui/src/analytics/plot_tests.rs b/codex-rs/tui/src/analytics/plot_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..b78a6ebe7ef72b2baa149379032e0b7fec37b90d --- /dev/null +++ b/codex-rs/tui/src/analytics/plot_tests.rs @@ -0,0 +1,535 @@ +//! Chart scales, signed bands, missing days, narrow viewports, and terminal palettes. + +use super::*; +use crate::analytics::models::AccountAnalyticsHistory; +use crate::analytics::render::categories; +use crate::analytics::styles::series_colors; +use crate::terminal_palette::with_test_default_colors; +use crate::terminal_probe::DefaultColors; +use pretty_assertions::assert_eq; + +fn value(key: &str, value: f64) -> AccountAnalyticsValue { + AccountAnalyticsValue { + key: key.into(), + label: key.into(), + value, + } +} + +fn display(chart: &Chart) -> String { + let text = chart + .lines + .iter() + .map(|line| line.to_string().trim_end().to_owned()) + .collect::>() + .join("\n"); + let mut palette = Vec::new(); + let mut rows = Vec::new(); + for (row, line) in chart.lines.iter().enumerate() { + let mut previous = None; + let mut runs = Vec::new(); + let mut column = 0; + for span in &line.spans { + let style = format!("{:?}", span.style); + let index = palette + .iter() + .position(|candidate| *candidate == style) + .unwrap_or_else(|| { + palette.push(style); + palette.len() - 1 + }); + if previous != Some(index) { + runs.push(format!("{column}:{index}")); + previous = Some(index); + } + column += span.width(); + } + rows.push(format!("{row}: {}", runs.join(" "))); + } + let palette = palette + .iter() + .enumerate() + .map(|(index, style)| format!("{index}: {style}")) + .collect::>() + .join("\n"); + format!( + "{text}\n\nPalette:\n{palette}\nRows (column:palette):\n{}", + rows.join("\n") + ) +} + +#[test] +fn numeric_axes_use_readable_intervals() { + use AccountAnalyticsUnit::Count; + use AccountAnalyticsUnit::Credits; + use AccountAnalyticsUnit::RelativeUsage; + use AccountAnalyticsUnit::Tokens; + let cases = [ + (82.0, Count, 100.0), + (4_400.0, Credits, 5_000.0), + (4_999.0, Credits, 5_000.0), + (5_001.0, Credits, 6_000.0), + (49.0, Count, 50.0), + (1.0, Count, 2.0), + (3.0, Count, 4.0), + (1_337.0, Credits, 1_500.0), + (0.07, Credits, 0.08), + (0.000004, Credits, 0.000004), + (82.0, RelativeUsage, 100.0), + (1.0, Tokens, 2.0), + ]; + assert_eq!( + cases.map(|(peak, unit, _)| axis_max(peak, unit)), + cases.map(|(_, _, expected)| expected) + ); +} + +#[test] +fn stacked_parts_keep_the_remainder_and_full_denominator() { + let values = vec![ + value("a", /*value*/ 4.0), + value("b", /*value*/ 2.0), + value("c", /*value*/ 1.0), + value("d", /*value*/ 6.0), + value("e", /*value*/ -1.0), + ]; + let categories = vec![values[1].clone(), values[0].clone(), values[2].clone()]; + assert_eq!(parts(&values, &categories), [2.0, 4.0, 1.0, 5.0]); + let day = AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total: 20.0, + values, + }; + let date = day.date; + assert_eq!(peak(&[(date, Some(&day))], &categories), 20.0); + let refund = AccountAnalyticsDay { + total: -1.0, + values: vec![value("a", /*value*/ 4.0), value("b", /*value*/ -5.0)], + ..day + }; + assert_eq!(peak(&[(date, Some(&refund))], &categories), 9.0); +} + +#[test] +fn signed_chart_on_light_terminal() { + signed_chart( + "light", + DefaultColors { + fg: (0, 0, 0), + bg: (255, 255, 255), + }, + ); +} + +#[test] +fn signed_chart_on_dark_terminal() { + signed_chart( + "dark", + DefaultColors { + fg: (230, 230, 230), + bg: (16, 16, 16), + }, + ); +} + +fn signed_chart(theme: &str, colors: DefaultColors) { + let history = AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Credits, + updated_at: None, + data: vec![ + AccountAnalyticsDay { + date: "2026-01-13".parse().unwrap(), + total: 8.0, + values: vec![value("a", /*value*/ 10.0), value("b", /*value*/ -2.0)], + }, + AccountAnalyticsDay { + date: "2026-01-14".parse().unwrap(), + total: -3.0, + values: vec![value("a", /*value*/ 1.0), value("b", /*value*/ -4.0)], + }, + AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total: 0.0, + values: vec![value("a", /*value*/ 3.0), value("b", /*value*/ -3.0)], + }, + ], + }; + let days = history + .data + .iter() + .map(|day| (day.date, Some(day))) + .collect::>(); + let categories = categories(&history); + with_test_default_colors(colors, || { + let rendered = chart( + &days, + &categories, + /*cursor*/ 1, + /*width*/ 44, + /*height*/ 4, + history.unit, + ); + assert_eq!(rendered.bands, 2); + insta::assert_snapshot!(format!("signed_chart_{theme}"), display(&rendered)); + }); +} + +#[test] +fn missing_zero_and_narrow_charts_keep_the_selection_visible() { + let zero = AccountAnalyticsDay { + date: "2026-01-14".parse().unwrap(), + total: 0.0, + values: vec![value("a", /*value*/ 0.0)], + }; + let used = AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total: 5.0, + values: vec![value("a", /*value*/ 5.0)], + }; + let days = [ + ("2026-01-13".parse().unwrap(), None), + (zero.date, Some(&zero)), + (used.date, Some(&used)), + ]; + with_test_default_colors( + DefaultColors { + fg: (230, 230, 230), + bg: (16, 16, 16), + }, + || { + let mut snapshots = Vec::new(); + for (width, cursor) in [(36, 0), (36, 1), (8, 2), (1, 2)] { + let rendered = chart( + &days, + &used.values, + cursor, + width, + /*height*/ 3, + AccountAnalyticsUnit::Count, + ); + assert_eq!(rendered.bands, 1); + assert!(rendered.lines.iter().all(|line| line.width() <= width)); + assert_eq!( + rendered + .lines + .iter() + .flat_map(|line| &line.spans) + .filter(|span| span.content == "▲") + .count(), + 1 + ); + if cursor == 0 { + let dash = rendered + .lines + .iter() + .find_map(|line| line.to_string().find('—')); + let cursor = rendered + .lines + .iter() + .find_map(|line| line.to_string().find('▲')); + assert_eq!(dash, cursor); + } + snapshots.push(format!( + "width={width}, cursor={cursor}\n{}", + display(&rendered) + )); + } + insta::assert_snapshot!(snapshots.join("\n\n")); + }, + ); +} + +#[test] +fn zero_credit_history_uses_a_readable_scale() { + let history = crate::analytics::normalize::history( + serde_json::from_str(r#"{"data":[]}"#).unwrap(), + crate::analytics::models::AccountAnalyticsReport::Credits, + crate::analytics::models::AccountAnalyticsGrouping::Surface, + "2026-01-09".parse().unwrap(), + "2026-01-15".parse().unwrap(), + ) + .unwrap() + .unwrap(); + let days = history + .data + .iter() + .map(|day| (day.date, Some(day))) + .collect::>(); + let rendered = chart( + &days, + &[], + /*cursor*/ 6, + /*width*/ 44, + /*height*/ 4, + history.unit, + ); + assert_eq!(axis_max(peak(&days, &[]), history.unit), 1.0); + insta::assert_snapshot!(display(&rendered)); +} + +#[test] +fn partial_cells_use_the_midpoint_and_descend_from_the_baseline() { + let categories = vec![value("a", /*value*/ 0.8), value("b", /*value*/ 0.95)]; + let mut snapshots = Vec::new(); + for sign in [1.0, -1.0] { + let day = AccountAnalyticsDay { + date: "2026-01-14".parse().unwrap(), + total: 1.75 * sign, + values: categories + .iter() + .map(|value| AccountAnalyticsValue { + value: value.value * sign, + ..value.clone() + }) + .collect(), + }; + let days = [ + (day.date, Some(&day)), + ("2026-01-15".parse().unwrap(), None), + ]; + let rendered = chart( + &days, + &categories, + /*cursor*/ 1, + /*width*/ 12, + /*height*/ 1, + AccountAnalyticsUnit::Credits, + ); + let cell = &rendered.lines[if sign > 0.0 { 1 } else { 3 }].spans[1]; + assert_eq!(cell.style.fg, Some(series_colors()[1])); + assert_eq!(cell.content.as_ref(), if sign > 0.0 { "▇" } else { "▁" }); + assert_eq!( + cell.style + .add_modifier + .contains(ratatui::style::Modifier::REVERSED), + sign < 0.0 + ); + snapshots.push(format!("sign={sign}\n{}", display(&rendered))); + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} + +#[test] +fn oversized_axes_are_hidden_instead_of_truncated() { + let mut snapshots = Vec::new(); + for (width, total) in [ + (8, 100_000_000.0), + (30, 100_000_000.0), + (35, 100_000_000.0), + (44, 100_000_000.0), + (30, 1e30), + ] { + let day = AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total, + values: vec![value("a", total)], + }; + let days = [(day.date, Some(&day))]; + + let rendered = chart( + &days, + &day.values, + /*cursor*/ 0, + width, + /*height*/ 4, + AccountAnalyticsUnit::Tokens, + ); + let baseline = rendered.lines[5].to_string(); + assert_eq!(baseline.starts_with('─'), width < 30 || total == 1e30); + if width == 8 { + assert!(rendered.lines[0].to_string().contains("100M")); + } + snapshots.push(format!("width={width}\n{}", display(&rendered))); + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} + +#[test] +fn selected_callout_keeps_the_authoritative_daily_total() { + let day = AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total: 20.0, + values: vec![value("a", /*value*/ 8.0), value("b", /*value*/ 4.0)], + }; + let days = [(day.date, Some(&day))]; + let rendered = chart( + &days, + &day.values, + /*cursor*/ 0, + /*width*/ 44, + /*height*/ 4, + AccountAnalyticsUnit::Credits, + ); + assert!(display(&rendered).contains("20.00")); + assert!(!display(&rendered).contains("12.00")); + insta::assert_snapshot!(display(&rendered)); +} + +#[test] +fn narrow_long_range_charts_never_truncate_dates() { + let start: NaiveDate = "2026-01-01".parse().unwrap(); + let history = start + .iter_days() + .take(/*n*/ 30) + .map(|date| AccountAnalyticsDay { + date, + total: 1.0, + values: vec![value("a", /*value*/ 1.0)], + }) + .collect::>(); + let days = history + .iter() + .map(|day| (day.date, Some(day))) + .collect::>(); + let mut snapshots = Vec::new(); + for (width, expected) in [(1, ""), (2, "15"), (5, "15"), (6, "Jan 15")] { + let rendered = chart( + &days, + &history[0].values, + /*cursor*/ 14, + width, + /*height*/ 3, + AccountAnalyticsUnit::Count, + ); + assert_eq!(rendered.lines.last().unwrap().to_string().trim(), expected); + snapshots.push(format!("width={width}\n{}", display(&rendered))); + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} + +#[test] +fn tiny_segments_keep_their_series_color_beside_a_large_day() { + let large = AccountAnalyticsDay { + date: "2026-01-14".parse().unwrap(), + total: 100.0, + values: vec![value("a", /*value*/ 100.0)], + }; + let mut snapshots = Vec::new(); + for sign in [1.0, -1.0] { + let tiny = AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total: 0.01 * sign, + values: vec![value("a", 0.01 * sign)], + }; + let days = [(large.date, Some(&large)), (tiny.date, Some(&tiny))]; + let rendered = chart( + &days, + &large.values, + /*cursor*/ 0, + /*width*/ 44, + /*height*/ 4, + AccountAnalyticsUnit::Credits, + ); + let glyph = if sign > 0.0 { "▁" } else { "▇" }; + let cells = rendered + .lines + .iter() + .flat_map(|line| &line.spans) + .filter(|span| span.content == glyph) + .collect::>(); + assert!(!cells.is_empty()); + assert!( + cells + .iter() + .all(|cell| cell.style.fg == Some(series_colors()[0])) + ); + snapshots.push(format!("sign={sign}\n{}", display(&rendered))); + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} + +#[test] +fn duplicate_message_dates_render_the_full_other_remainder() { + use crate::analytics::models::AccountAnalyticsGrouping; + use crate::analytics::models::AccountAnalyticsReport; + let date = "2026-01-15".parse().unwrap(); + let response = serde_json::from_value(serde_json::json!({"data": [ + {"date": "2026-01-15", "totals": {"turns": 10}, "clients": [{"client_id": "CODEX_CLI", "turns": 8}]}, + {"date": "2026-01-15", "totals": {"turns": 10}, "clients": [{"client_id": "CODEX_CLI", "turns": 8}]} + ]})).unwrap(); + let history = crate::analytics::normalize::history( + response, + AccountAnalyticsReport::Messages, + AccountAnalyticsGrouping::Surface, + date, + date, + ) + .unwrap() + .unwrap(); + let day = &history.data[0]; + let rendered = chart( + &[(date, Some(day))], + &day.values, + /*cursor*/ 0, + /*width*/ 44, + /*height*/ 10, + AccountAnalyticsUnit::Count, + ); + insta::assert_snapshot!(display(&rendered)); +} + +#[test] +fn seven_day_totals_render_without_selecting_each_bar() { + let start: NaiveDate = "2026-01-01".parse().unwrap(); + let rows = (0..7) + .map(|index| AccountAnalyticsDay { + date: start + chrono::Duration::days(index), + total: 111.0 + index as f64 * 100.0, + values: vec![value("a", 111.0 + index as f64 * 100.0)], + }) + .collect::>(); + let days = rows + .iter() + .map(|day| (day.date, Some(day))) + .collect::>(); + let rendered = chart( + &days, + &rows[0].values, + /*cursor*/ 3, + /*width*/ 100, + /*height*/ 8, + AccountAnalyticsUnit::Count, + ); + let text = display(&rendered); + for day in &rows { + assert!(text.contains(&day.total.to_string())); + } + insta::assert_snapshot!("seven_day_totals", text); +} + +#[test] +fn narrow_equal_height_totals_remain_separate_numbers() { + let start: NaiveDate = "2026-01-01".parse().unwrap(); + let rows = (0..7) + .map(|index| AccountAnalyticsDay { + date: start + chrono::Duration::days(index), + total: 111.0, + values: vec![value("a", /*value*/ 111.0)], + }) + .collect::>(); + let days = rows + .iter() + .map(|day| (day.date, Some(day))) + .collect::>(); + let mut screens = Vec::new(); + for width in [21, 28, 35] { + let rendered = chart( + &days, + &rows[0].values, + /*cursor*/ 3, + width, + /*height*/ 4, + AccountAnalyticsUnit::Count, + ); + let text = rendered + .lines + .iter() + .map(|line| line.to_string().trim_end().to_owned()) + .collect::>() + .join("\n"); + assert!(text.contains("111")); + assert!(!text.contains("111111")); + screens.push(text); + } + insta::assert_snapshot!(screens.join("\n\n")); +} diff --git a/codex-rs/tui/src/analytics/profile_identity_tests.rs b/codex-rs/tui/src/analytics/profile_identity_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..0b5c04301bcd120937e51c60680660255eb9a886 --- /dev/null +++ b/codex-rs/tui/src/analytics/profile_identity_tests.rs @@ -0,0 +1,28 @@ +//! In-flight profile responses cannot expose data after the active account changes. +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn profile_rejects_account_change_during_success_or_error_response() { + let server = MockServer::start().await; + for status in [200, 404, 503] { + server.reset().await; + let (home, live) = live(&server, "plus").await; + let home_path = home.path().to_path_buf(); + Mock::given(method("GET")) + .and(path("/backend-api/wham/profiles/me")) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(move |_: &wiremock::Request| { + sign_in(&home_path, "account-a", "user-b", "plus"); + ResponseTemplate::new(status) + .set_body_json(json!({"stats":{"lifetime_tokens":123}})) + }) + .expect(/*r*/ 1) + .mount(&server) + .await; + assert_eq!( + live.profile().await.err().unwrap(), + "Account changed. Press R to refresh Analytics." + ); + } +} diff --git a/codex-rs/tui/src/analytics/render.rs b/codex-rs/tui/src/analytics/render.rs new file mode 100644 index 0000000000000000000000000000000000000000..5f37e274609f0294dc5106319fba827f30dcb859 --- /dev/null +++ b/codex-rs/tui/src/analytics/render.rs @@ -0,0 +1,391 @@ +//! Maximized sections and a responsive overview share the same panels and scrolling behavior. +//! Zoom, section changes, and reflow preserve report selections and inline details. + +use super::AnalyticsView; +use super::data; +use super::styles::secondary_style; +use crate::analytics::models::AccountAnalyticsHistory; +use crate::analytics::models::AccountAnalyticsValue; +use crate::analytics::sections::Section; +use crate::keymap::ListAction; +use crate::style::accent_style; +use crate::style::footer_hint_key_style; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use std::collections::BTreeMap; + +pub(super) fn columns(left: Line<'static>, right: Line<'static>, width: usize) -> Line<'static> { + let padding = width + .saturating_sub(left.width() + right.width()) + .max(/*other*/ 1); + let mut spans = left.spans; + spans.push(" ".repeat(padding).into()); + spans.extend(right.spans); + Line::from(spans) +} + +/// Join independently stacked panels without leaving holes below shorter sections. +pub(super) fn join_columns( + left: &[Line<'static>], + right: &[Line<'static>], + column_width: usize, +) -> Vec> { + (0..left.len().max(right.len())) + .map(|row| { + let left = left.get(row).cloned().unwrap_or_default(); + let mut line = columns(left, Line::default(), column_width + 4); + if let Some(right) = right.get(row) { + line.spans.extend(right.spans.iter().cloned()); + } + line + }) + .collect() +} + +pub(super) fn categories(history: &AccountAnalyticsHistory) -> Vec { + let mut totals = BTreeMap::::new(); + for value in history.data.iter().flat_map(|day| &day.values) { + totals + .entry(value.key.clone()) + .or_insert_with(|| AccountAnalyticsValue { + value: 0.0, + ..value.clone() + }) + .value += value.value; + } + let mut values = totals.into_values().collect::>(); + values.sort_by(|a, b| { + b.value + .abs() + .total_cmp(&a.value.abs()) + .then_with(|| a.key.cmp(&b.key)) + }); + values +} + +/// The last plotted category collects all remaining values without renormalizing them. +pub(super) fn parts( + values: &[AccountAnalyticsValue], + categories: &[AccountAnalyticsValue], +) -> [f64; 4] { + let mut parts = [0.0; 4]; + for value in values { + let index = categories + .iter() + .take(/*n*/ 3) + .position(|category| category.key == value.key) + .unwrap_or(/*default*/ 3); + parts[index] += value.value; + } + parts +} + +impl AnalyticsView { + pub(super) fn render(&mut self, area: Rect, buf: &mut Buffer) { + self.poll_reports(); + let width = usize::from(area.width.saturating_sub(/*rhs*/ 4)).max(/*other*/ 1); + let show_range = !matches!( + self.section, + Section::Chats | Section::Plan | Section::Summary + ) && !self.visible_sections().is_empty(); + let hint = |action| { + self.keymap + .primary_hint(action) + .map(crate::key_hint::ShortcutHint::display_label) + .unwrap_or_default() + }; + let mut controls = if self.visible_sections().is_empty() { + format!("R refresh · {} back · q close", hint(ListAction::Cancel)) + } else { + let navigation = if !self.zoomed { + format!("{}/z maximize", hint(ListAction::Accept)) + } else if self.section == Section::Summary { + format!( + "{}/{} scroll", + hint(ListAction::MoveUp), + hint(ListAction::MoveDown) + ) + } else if self.section == Section::Plan { + format!( + "{}/{} window · {}/{} period · {} details", + hint(ListAction::MoveLeft), + hint(ListAction::MoveRight), + hint(ListAction::MoveUp), + hint(ListAction::MoveDown), + hint(ListAction::Accept) + ) + } else if self.section != Section::Chats { + format!( + "{}/{} day · {} details", + hint(ListAction::MoveLeft), + hint(ListAction::MoveRight), + hint(ListAction::Accept) + ) + } else { + format!( + "{}/{} row · {} details", + hint(ListAction::MoveUp), + hint(ListAction::MoveDown), + hint(ListAction::Accept) + ) + }; + format!( + "tab/1–{} section · {}{navigation}\n{}{}R refresh · {}/{} scroll · {} back · q close", + self.visible_sections().len(), + if self.section == Section::Chats && !self.business() && !self.zoomed { + "s sort · " + } else if self.section == Section::Chats && !self.business() { + "s sort · z dashboard · " + } else if self.zoomed { + "z dashboard · " + } else { + "" + }, + if show_range { "r 7/30d · " } else { "" }, + if self.group_options().len() < 2 { + "" + } else if self.section == Section::Summary { + "g view · " + } else { + "g group · " + }, + hint(ListAction::PageUp), + hint(ListAction::PageDown), + hint(ListAction::Cancel) + ) + }; + let control_line = |line: &str| { + let mut spans = Vec::new(); + for (index, control) in line.split(" · ").enumerate() { + if index > 0 { + spans.push(" · ".into()); + } + let (keys, description) = control.rsplit_once(' ').unwrap_or((control, "")); + spans.push( + keys.to_owned() + .fg(Color::Reset) + .patch_style(footer_hint_key_style().bold().not_dim()), + ); + if !description.is_empty() { + spans.push(format!(" {description}").into()); + } + } + Line::from(spans) + }; + let updated = self + .zoomed + .then(|| self.sections[self.section].history.ready()) + .flatten() + .and_then(|history| history.updated_at) + .and_then(|value| chrono::DateTime::from_timestamp(value, /*nsecs*/ 0)) + .map(|updated| format!("Updated {} UTC", updated.format("%b %-d %H:%M"))); + let add_updated = |footer: &mut Vec>| { + if let Some(updated) = &updated { + let timestamp = Line::from(updated.clone().set_style(secondary_style())); + if let Some(line) = footer + .last_mut() + .filter(|line| line.width() + timestamp.width() + 3 <= width) + { + *line = columns(line.clone(), timestamp, width); + } else { + footer.extend(word_wrap_lines( + [columns(Line::default(), timestamp, width)], + RtOptions::new(width), + )); + } + } + }; + let mut footer = word_wrap_lines(controls.lines().map(control_line), RtOptions::new(width)); + if matches!( + self.section, + Section::Plugins | Section::Activity | Section::Skills + ) && !self.day_has_details(self.section, self.sections[self.section].cursor) + { + let height = footer.len(); + controls = controls.replace(&format!(" · {} details", hint(ListAction::Accept)), ""); + footer = word_wrap_lines(controls.lines().map(control_line), RtOptions::new(width)); + // Removing a contextual hint must not resize the chart on narrow terminals. + footer.resize(height, Line::default()); + } + add_updated(&mut footer); + let sections = if self.zoomed { + let line = Line::from( + self.visible_sections() + .iter() + .copied() + .enumerate() + .map(|(index, section)| { + let label = self.section_title(section); + let label = if width >= 64 { + format!("{} {label}", index + 1) + } else { + label.to_string() + }; + if section == self.section { + format!("[{label}] ").set_style(accent_style()) + } else { + format!("{label} ").set_style(secondary_style()) + } + }) + .collect::>(), + ); + word_wrap_lines([line], RtOptions::new(width)) + } else { + Vec::new() + }; + let [title, range, tabs, body, hints] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(if sections.is_empty() { + 0 + } else { + sections.len() as u16 + 1 + }), + Constraint::Min(0), + Constraint::Length(footer.len() as u16), + ]) + .areas(Rect { + x: area.x + area.width.min(/*other*/ 2), + width: area.width.saturating_sub(/*rhs*/ 4), + ..area + }); + Line::from(vec![ + "Analytics".bold(), + self.live + .as_ref() + .and_then(|live| live.account_label()) + .map(|account| format!(" · Live account · {account}")) + .unwrap_or_else(|| " · Live account".into()) + .set_style(secondary_style()), + ]) + .render(title, buf); + if show_range { + let dates = self.date_range(); + let (start, end) = (dates.start(), dates.end()); + Line::from(vec![ + if self.ranges[self.range_group(self.section) as usize] == 0 { + "[7d]".set_style(accent_style()) + } else { + "7d".into() + }, + " ".into(), + if self.ranges[self.range_group(self.section) as usize] == 1 { + "[30d]".set_style(accent_style()) + } else { + "30d".into() + }, + format!( + " · {}–{}", + data::date(&start.to_string()), + end.format("%b %-d, %Y") + ) + .set_style(secondary_style()), + ]) + .render(range, buf); + } + Paragraph::new(sections).render(tabs, buf); + self.viewport_height = usize::from(body.height).max(/*other*/ 1); + let (lines, selection) = if self.visible_sections().is_empty() { + let message = self + .account + .message() + .unwrap_or("Analytics is not available for this account type."); + let lines = textwrap::wrap(message, width) + .into_iter() + .map(|line| Line::from(line.into_owned())) + .collect::>(); + let selection = 0..lines.len(); + (lines, selection) + } else if self.zoomed { + let chart_height = + (usize::from(body.height) / 5).clamp(/*min*/ 4, /*max*/ 10); + let mut panel = self.panel(self.section, width, chart_height); + if panel.chart_bands > 0 { + let overhead = panel.lines.len() - chart_height * panel.chart_bands; + let height = + usize::from(body.height).saturating_sub(overhead + 1) / panel.chart_bands; + let height = height.max(/*other*/ 2); + if height != chart_height { + panel = self.panel(self.section, width, height); + } + } + let center_summary = self.section == Section::Summary && self.profile.ready().is_some(); + if center_summary { + // Keep the section heading and rule fixed; center the profile below them. + let padding = usize::from(body.height).saturating_sub(panel.lines.len()) / 2; + panel + .lines + .splice(2..2, std::iter::repeat_n(Line::default(), padding)); + } + let selection = if panel.lines.len() <= usize::from(body.height) { + 0..panel.lines.len() + } else { + panel.selection + }; + if !center_summary && panel.lines.len() > usize::from(body.height) { + panel.lines.push(Line::default()); + } + (panel.lines, selection) + } else { + self.dashboard_lines(width, usize::from(body.height)) + }; + if lines.len() <= self.viewport_height { + let height = footer.len(); + let controls = controls + .replace( + &format!( + " · {}/{} scroll", + hint(ListAction::PageUp), + hint(ListAction::PageDown) + ), + "", + ) + .replace( + &format!( + " · {}/{} scroll", + hint(ListAction::MoveUp), + hint(ListAction::MoveDown) + ), + "", + ); + footer = word_wrap_lines(controls.lines().map(control_line), RtOptions::new(width)); + add_updated(&mut footer); + if updated.is_some() { + let padding = height.saturating_sub(footer.len()); + footer.splice(0..0, std::iter::repeat_n(Line::default(), padding)); + } else { + footer.resize(height, Line::default()); + } + } + if self.follow_selection { + if selection.start < self.scroll_offset { + self.scroll_offset = selection.start; + } else { + let end = selection.start + selection.len().min(self.viewport_height); + if end > self.scroll_offset + self.viewport_height { + self.scroll_offset = end - self.viewport_height; + } + } + } + self.scroll_offset = self + .scroll_offset + .min(lines.len().saturating_sub(self.viewport_height)); + self.follow_selection = false; + Paragraph::new(lines) + .scroll(/*offset*/ (self.scroll_offset as u16, 0)) + .render(body, buf); + Paragraph::new(footer) + .set_style(secondary_style()) + .render(hints, buf); + } +} diff --git a/codex-rs/tui/src/analytics/report_data.rs b/codex-rs/tui/src/analytics/report_data.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e22d112441ebb2e85723995229a28de31f3aeca --- /dev/null +++ b/codex-rs/tui/src/analytics/report_data.rs @@ -0,0 +1,289 @@ +//! Projects endpoint-specific backend models into inputs for display normalization. +//! This representation is never used to deserialize HTTP responses. +//! Optional fields select the inputs used by each report; missing backend values stay missing. + +use codex_backend_client::AnalyticsResponse; +use codex_backend_client::analytics_models as backend; + +/// Report values projected for normalization after endpoint-specific decoding. +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsData { + pub(super) data: Vec, + pub(super) units: Option, + pub(super) data_freshness_ts: Option, + pub(super) series: Option>, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsSeries { + pub(super) key: String, + pub(super) label: String, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsRecord { + pub(super) date: String, + pub(super) values: Option>, + pub(super) product_surface_usage_values: Option>, + pub(super) premium_usage_values: Option, + pub(super) attribution: Option>, + pub(super) credit_amount: Option, + pub(super) product_surface: Option, + pub(super) totals: Option, + pub(super) models: Option>, + pub(super) groups: Option>, + pub(super) clients: Option>, + pub(super) plugin_usage_overviews: Option>, + pub(super) skill_usage_overviews: Option>, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct PremiumUsage { + pub(super) credit_usage_credits: std::collections::BTreeMap, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct UsageAttribution { + pub(super) thread_source: Option, + pub(super) turn_trigger: Option, + pub(super) model: Option, + pub(super) surface: Option, + pub(super) value: f64, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsCount { + pub(super) turns: f64, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsModel { + pub(super) model: String, + pub(super) credits: Option, + pub(super) uncached_text_input_tokens: Option, + pub(super) cached_text_input_tokens: Option, + pub(super) text_output_tokens: Option, + pub(super) text_total_tokens: Option, + pub(super) turns: Option, + pub(super) on_demand_credits: Option, + pub(super) speed: Option, +} + +/// Enterprise model groups carry text token counts independently of billing amounts. +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsTokenGroup { + pub(super) dimensions: std::collections::BTreeMap, + #[cfg_attr(test, serde(default))] + pub(super) is_other: bool, + pub(super) uncached_text_input_tokens: Option, + pub(super) cached_text_input_tokens: Option, + pub(super) text_output_tokens: Option, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsClient { + pub(super) client_id: String, + pub(super) turns: f64, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsTool { + pub(super) display_name: String, + pub(super) invocation_counts: f64, +} + +impl From for AnalyticsData { + fn from(response: AnalyticsResponse) -> Self { + match response { + AnalyticsResponse::Usage(response) => Self { + units: response.units, + data_freshness_ts: response.data_freshness_ts, + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + product_surface_usage_values: Some(record.product_surface_usage_values), + premium_usage_values: record.premium_usage_values.map(|value| { + PremiumUsage { + credit_usage_credits: value.credit_usage_credits, + } + }), + attribution: record.attribution.map(|values| { + values + .into_iter() + .map(|value| UsageAttribution { + thread_source: Some(value.thread_source), + turn_trigger: Some(value.turn_trigger), + model: Some(value.model), + surface: Some(value.surface), + value: value.value, + }) + .collect() + }), + models: record + .models + .map(|values| values.into_iter().map(AnalyticsModel::from).collect()), + groups: record.groups.map(|values| { + values + .into_iter() + .map(|value| AnalyticsTokenGroup { + dimensions: value.dimensions, + is_other: value.is_other.unwrap_or(/*default*/ false), + uncached_text_input_tokens: value + .uncached_text_input_tokens + .map(|count| count as f64), + cached_text_input_tokens: value + .cached_text_input_tokens + .map(|count| count as f64), + text_output_tokens: value + .text_output_tokens + .map(|count| count as f64), + }) + .collect() + }), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + AnalyticsResponse::Credits(response) => Self { + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + credit_amount: Some(record.credit_amount), + product_surface: Some(record.product_surface), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + AnalyticsResponse::EnterpriseCredits(response) => Self { + data_freshness_ts: response.data_freshness_ts, + series: Some( + response + .series + .into_iter() + .map(|value| AnalyticsSeries { + key: value.key, + label: value.label, + }) + .collect(), + ), + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + values: Some(record.values), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + AnalyticsResponse::Messages(response) => Self { + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + totals: Some(AnalyticsCount { + turns: record.totals.turns as f64, + }), + clients: Some( + record + .clients + .into_iter() + .map(|value| AnalyticsClient { + client_id: value.client_id, + turns: value.turns as f64, + }) + .collect(), + ), + models: record + .models + .map(|values| values.into_iter().map(AnalyticsModel::from).collect()), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + AnalyticsResponse::Plugins(response) => Self { + data_freshness_ts: response.data_freshness_ts, + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + plugin_usage_overviews: Some( + record + .plugin_usage_overviews + .into_iter() + .map(|value| AnalyticsTool { + display_name: value.display_name, + invocation_counts: value.invocation_counts as f64, + }) + .collect(), + ), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + AnalyticsResponse::Skills(response) => Self { + data_freshness_ts: response.data_freshness_ts, + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + skill_usage_overviews: Some( + record + .skill_usage_overviews + .into_iter() + .map(|value| AnalyticsTool { + display_name: value.display_name, + invocation_counts: value.invocation_counts as f64, + }) + .collect(), + ), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + } + } +} + +impl From for AnalyticsModel { + fn from(value: backend::ModelUsage) -> Self { + Self { + model: value.model, + credits: Some(value.credits), + uncached_text_input_tokens: value.uncached_text_input_tokens.map(|count| count as f64), + cached_text_input_tokens: value.cached_text_input_tokens.map(|count| count as f64), + text_output_tokens: value.text_output_tokens.map(|count| count as f64), + text_total_tokens: value.text_total_tokens.map(|count| count as f64), + turns: value.turns.map(|count| count as f64), + on_demand_credits: value.on_demand_credits, + speed: value.speed, + } + } +} + +#[cfg(test)] +#[path = "report_data_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/report_data_tests.rs b/codex-rs/tui/src/analytics/report_data_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..237a8e005503287d626f0cb2f9b8ce480bd7a048 --- /dev/null +++ b/codex-rs/tui/src/analytics/report_data_tests.rs @@ -0,0 +1,84 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn typed_usage_preserves_missing_counts_and_supplied_zero() { + let input = backend::DailyProductSurfaceUsageResponse { + data: vec![backend::DailyProductSurfaceUsage { + date: "2026-01-09".into(), + models: Some(vec![backend::ModelUsage { + model: "example".into(), + credits: 1.5, + cached_text_input_tokens: Some(0), + text_output_tokens: Some(42), + ..Default::default() + }]), + ..Default::default() + }], + ..Default::default() + }; + let actual = AnalyticsData::from(AnalyticsResponse::Usage(input)); + let model = &actual.data[0].models.as_ref().unwrap()[0]; + assert_eq!( + ( + model.credits, + model.uncached_text_input_tokens, + model.cached_text_input_tokens, + model.text_output_tokens + ), + (Some(1.5), None, Some(0.0), Some(42.0)) + ); +} + +#[test] +fn typed_message_records_keep_independent_daily_remainders() { + use super::super::models::*; + let date = "2026-01-09".parse().unwrap(); + let row = backend::DailyWorkspaceUsageCount { + date: "2026-01-09".into(), + totals: backend::WorkspaceUsageCount { + turns: 10, + ..Default::default() + }, + clients: vec![backend::ClientWorkspaceUsageCount { + client_id: "CODEX_CLI".into(), + turns: 8, + ..Default::default() + }], + ..Default::default() + }; + let response = AnalyticsResponse::Messages(backend::DailyWorkspaceUsageCountResponse { + data: vec![row.clone(), row], + ..Default::default() + }); + assert_eq!( + super::super::normalize::history( + response.into(), + AccountAnalyticsReport::Messages, + AccountAnalyticsGrouping::Surface, + date, + date + ) + .unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Count, + updated_at: None, + data: vec![AccountAnalyticsDay { + date, + total: 20.0, + values: vec![ + AccountAnalyticsValue { + key: "cli".into(), + label: "CLI".into(), + value: 16.0 + }, + AccountAnalyticsValue { + key: "other".into(), + label: "Other".into(), + value: 4.0 + }, + ] + }], + }) + ); +} diff --git a/codex-rs/tui/src/analytics/sections.rs b/codex-rs/tui/src/analytics/sections.rs new file mode 100644 index 0000000000000000000000000000000000000000..aeac212ce41d02f22e8f5de9b193467d86a9837b --- /dev/null +++ b/codex-rs/tui/src/analytics/sections.rs @@ -0,0 +1,294 @@ +//! Account-specific section order, request eligibility, and independent report ranges. +//! Resolving the account precedes report loading; refreshing drops all account-owned data. + +use super::AnalyticsView; +use super::chats; +use super::data::Load; +use super::models::AccountKind; + +/// Stable identities preserve selections while account layouts change their order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Section { + Usage, + Plugins, + Credits, + Chats, + Activity, + Skills, + Plan, + Summary, +} + +impl Section { + pub(super) fn report(self) -> Option { + use super::models::AccountAnalyticsReport as Report; + match self { + Self::Usage => Some(Report::Usage), + Self::Plugins => Some(Report::Plugins), + Self::Credits => Some(Report::Credits), + Self::Chats | Self::Plan | Self::Summary => None, + Self::Activity => Some(Report::Messages), + Self::Skills => Some(Report::Skills), + } + } +} + +/// Section-only indexing keeps report identity separate from array positions. +pub(super) struct SectionStates(pub(super) [SectionState; 8]); + +impl std::ops::Index
for SectionStates { + type Output = SectionState; + fn index(&self, section: Section) -> &Self::Output { + &self.0[section as usize] + } +} + +impl std::ops::IndexMut
for SectionStates { + fn index_mut(&mut self, section: Section) -> &mut Self::Output { + &mut self.0[section as usize] + } +} + +/// A report and its selection are replaced independently of the other sections. +pub(super) struct SectionState { + pub(super) history: Load, + pub(super) cursor: usize, + pub(super) detail: Option, + pub(super) group: usize, +} + +impl Default for SectionState { + fn default() -> Self { + Self { + history: Load::Unavailable, + cursor: 6, + detail: None, + group: 0, + } + } +} + +/// Usage, product activity, and tools each share one range across their related sections. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum RangeGroup { + Usage, + Activity, + Tools, +} + +impl AnalyticsView { + pub(super) fn poll_reports(&mut self) { + for section in &mut self.sections.0 { + section.history.poll(); + } + self.chats.poll(); + self.tasks.poll(); + self.profile.poll(); + self.plan.poll(); + self.account.poll(); + self.start_reports(); + if !self.business() + && !self.consumer_attribution() + && ![0, 2].contains(&self.sections[Section::Usage].group) + { + self.sections[Section::Usage].group = 0; + } + if let Some(chats) = self.tasks.ready() { + self.sections[Section::Chats].cursor = self.sections[Section::Chats] + .cursor + .min(chats.rows.len().saturating_sub(/*rhs*/ 1)); + } + if let Some(chats) = self.chats.ready() { + self.sections[Section::Chats].cursor = self.sections[Section::Chats] + .cursor + .min(chats.rows.len().saturating_sub(/*rhs*/ 1)); + } + } + + pub(super) fn range_group(&self, section: Section) -> RangeGroup { + match section { + Section::Usage if self.business() => RangeGroup::Activity, + Section::Usage + | Section::Credits + | Section::Chats + | Section::Plan + | Section::Summary => RangeGroup::Usage, + Section::Activity => RangeGroup::Activity, + Section::Plugins | Section::Skills => RangeGroup::Tools, + } + } + + pub(super) fn group_label(&self, section: Section, group: usize) -> &'static str { + if section == Section::Summary { + return super::summary::VIEWS[group].label(); + } + if group == 0 + && (section == Section::Credits + || (section == Section::Usage && !self.consumer_attribution())) + { + "Product" + } else { + super::data::GROUP_LABELS[group] + } + } + + fn account_kind(&self) -> Option { + self.account + .ready() + .copied() + .map(|plan| AccountKind::from(Some(plan))) + } + + pub(super) fn business(&self) -> bool { + matches!( + self.account_kind(), + Some(AccountKind::Business | AccountKind::Enterprise) + ) + } + + pub(super) fn visible_sections(&self) -> &'static [Section] { + match self.account_kind() { + Some(AccountKind::Consumer) if self.plan.enabled => &[ + Section::Summary, + Section::Usage, + Section::Plan, + Section::Activity, + Section::Plugins, + Section::Skills, + Section::Chats, + ], + Some(AccountKind::Consumer) => &[ + Section::Summary, + Section::Usage, + Section::Activity, + Section::Plugins, + Section::Skills, + Section::Chats, + ], + Some(AccountKind::Business | AccountKind::Enterprise) + if super::models::thread_usage_supported(self.account.ready().copied()) => + { + &[ + Section::Summary, + Section::Credits, + Section::Usage, + Section::Plugins, + Section::Skills, + Section::Chats, + ] + } + Some(AccountKind::Business | AccountKind::Enterprise) => &[ + Section::Summary, + Section::Credits, + Section::Usage, + Section::Plugins, + Section::Skills, + ], + Some(AccountKind::Unknown) => &[Section::Summary], + None => &[], + } + } + + pub(super) fn start_reports(&mut self) { + if self.reports_started || self.account.ready().is_none() { + return; + } + self.reports_started = true; + let visible = self.visible_sections(); + let Some(first) = visible.first() else { + return; + }; + if !visible.contains(&self.section) { + self.section = *first; + } + let usage_groups: &[usize] = if self.business() { + &[6, 2] + } else { + &[1, 2, 0, 3] + }; + if !usage_groups.contains(&self.sections[Section::Usage].group) { + self.sections[Section::Usage].group = + if matches!(self.account_kind(), Some(AccountKind::Business)) { + 2 + } else { + usage_groups[0] + }; + } + if visible.contains(&Section::Credits) + && let Some(live) = &self.live + && !live + .credit_groups() + .contains(&self.sections[Section::Credits].group) + { + self.sections[Section::Credits].group = live.credit_groups()[0]; + } + self.load_summary(); + for section in visible { + if section.report().is_some() { + self.load_report(*section); + } + } + if visible.contains(&Section::Plan) + && let (Some((_, _, frame)), Some(live)) = (&self.connection, &self.live) + { + let live = std::sync::Arc::clone(live); + self.plan.report = Load::start(async move { live.plan_history().await }, frame.clone()); + } + if visible.contains(&Section::Chats) && !self.business() { + if let (Some((_, handle, frame)), Some(live)) = (&self.connection, &self.live) { + self.tasks = Load::start_with_timeout( + super::tasks::read(handle.clone(), std::sync::Arc::clone(live)), + frame.clone(), + std::time::Duration::from_secs(/*secs*/ 120), + ); + } + } else if visible.contains(&Section::Chats) { + self.chats = + if let (Some((_, handle, frame)), Some(live)) = (&self.connection, &self.live) { + Load::start_with_timeout( + chats::read(handle.clone(), std::sync::Arc::clone(live)), + frame.clone(), + std::time::Duration::from_secs(/*secs*/ 90), + ) + } else { + Load::Unavailable + }; + } + } + + pub(super) fn section_date_range( + &self, + section: Section, + ) -> std::ops::RangeInclusive { + let end = self.end_date; + let range = self.ranges[self.range_group(section) as usize]; + (end - chrono::Days::new(if range == 0 { 6 } else { 29 }))..=end + } + + pub(super) fn change_range(&mut self) { + let index = self.range_group(self.section); + self.ranges[index as usize] ^= 1; + for section in self.visible_sections() { + if matches!(section, Section::Chats | Section::Plan | Section::Summary) + || self.range_group(*section) != index + { + continue; + } + self.sections[*section].cursor = if self.ranges[index as usize] == 1 { + self.sections[*section].cursor + 23 + } else { + self.sections[*section].cursor.saturating_sub(/*rhs*/ 23) + }; + self.sections[*section].detail = self.sections[*section].detail.and_then(|day| { + if self.ranges[index as usize] == 1 { + Some(day + 23) + } else { + day.checked_sub(/*rhs*/ 23) + } + }); + if section.report().is_some() { + self.load_report(*section); + } + } + } +} diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__account_plan_error.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__account_plan_error.snap new file mode 100644 index 0000000000000000000000000000000000000000..29304acc1a27b7ece0a96becc12925d9c0ad1833 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__account_plan_error.snap @@ -0,0 +1,44 @@ +--- +source: tui/src/analytics/connection_tests.rs +expression: terminal.backend().to_string() +--- +" Analytics · Live account " +" " +" " +" " +" " +" Couldn't load account plan. Press R to retry Analytics. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_error.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_error.snap new file mode 100644 index 0000000000000000000000000000000000000000..b860e2706b178afdd3c1722fb37893d2d1b4d668 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_error.snap @@ -0,0 +1,52 @@ +--- +source: tui/src/analytics/connection_tests.rs +expression: terminal.backend().to_string() +--- +" Analytics · Live account · analytics@example.test " +" " +" " +" [1 Summary] 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" Report request failed. Press R to retry. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–5 section · z dashboard " +" g view · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_recovered.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_recovered.snap new file mode 100644 index 0000000000000000000000000000000000000000..251ba91728ac88166c20b99ec844e6f3f18bf1b0 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_recovered.snap @@ -0,0 +1,52 @@ +--- +source: tui/src/analytics/connection_tests.rs +expression: terminal.backend().to_string() +--- +" Analytics · Live account · analytics@example.test " +" " +" " +" [1 Summary] 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" " +" " +" " +" " +" " +" Local account " +" @local-user " +" Enterprise " +" " +" 12.3K 12.3K — — — " +" Lifetime tokens Peak tokens Longest chat Current streak Longest streak " +" " +" Token activity Daily " +" Last 12 months " +" " +" Nov Dec Jan Feb Mar Apr May Jun Jul Aug " +" Su □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ " +" Mo □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ " +" Tu □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ " +" We □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ ■ " +" Th □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ " +" Fr □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ " +" Sa □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ " +" " +" Less □ ■ ■ ■ ■ More " +" " +" Activity insights Most used plugins and skills " +" " +" Fast Mode — Plugin and skill usage unavailable. " +" Most used reasoning — " +" Skills explored — " +" Total skills used — " +" Total chats — " +" " +" " +" " +" " +" " +" " +" tab/1–5 section · z dashboard " +" g view · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__tests__live_account_identity_is_visible_and_closing_aborts_pending_loads.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__tests__live_account_identity_is_visible_and_closing_aborts_pending_loads.snap new file mode 100644 index 0000000000000000000000000000000000000000..8068475b9c6971f25e965367ff50cb8561c4c91b --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__tests__live_account_identity_is_visible_and_closing_aborts_pending_loads.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/analytics/client_tests.rs +expression: terminal.backend().to_string() +--- +" Analytics · Live account · analytics@example.test " +" " +" " +" " +" " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..b84a18a7a7ac96935e21613a7f488955bee5fd4e --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap @@ -0,0 +1,65 @@ +--- +source: tui/src/analytics/plan_tests.rs +expression: "output.join(\"\\n\")" +--- +" Analytics · Live account " +" " +" " +" 1 Summary 2 Total usage history [3 Plan usage history] 4 Messages 5 Plugins called 6 Skills used 7 Top chats " +" " +" ▎ Plan usage history By feature " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 5-hour limits › Weekly limits " +" " +" › Sep 2 10:00 – Sep 2 15:00 * 125% › Sep 1 00:00 – Sep 8 00:00 * Not available " +" Some usage is unavailable; this period may be Breakdown isn't available for this period " +" incomplete " +" Tasks 125% " +" Some periods aren't available yet " +" Usage as of Sep 2 12:00 UTC · * current at last update " +" Available since Sep 1 00:00 UTC " +" Amounts and period boundaries are approximate; recent activity may be delayed " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–7 section · z dashboard · ←/→ window · ↑/↓ period · enter details " +" g group · R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" Summary Total usage history [Plan usage history] " +" Messages Plugins called Skills used Top chats " +" " +" ▎ Plan usage history By feature " +" ────────────────────────────────────────────────────── " +" 5-hour limits " +" " +" › Sep 2 10:00 – Sep 2 15:00 * 125% " +" Some usage is unavailable; this period may be " +" incomplete " +" Tasks 125% " +" " +" › Weekly limits " +" " +" › Sep 1 00:00 – Sep 8 00:00 * Not available " +" Breakdown isn't available for this period " +" Some periods aren't available yet " +" Usage as of Sep 2 12:00 UTC · * current at last update " +" Available since Sep 1 00:00 UTC " +" Amounts and period boundaries are approximate; recent " +" activity may be delayed " +" " +" " +" tab/1–7 section · z dashboard · ←/→ window · ↑/↓ " +" period · enter details " +" g group · R refresh · esc back · q close " +" " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap new file mode 100644 index 0000000000000000000000000000000000000000..50ba4a826a206e4614bace903c0c55a4034ae16e --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap @@ -0,0 +1,64 @@ +--- +source: tui/src/analytics/plan_tests.rs +expression: output +--- +" Analytics · Live account " +" " +" " +" ╭─ 1 Summary ────────────────────────────────────────╮ ╭─ 2 Total usage history ────────────────────────────╮ " +" │ │ │ │ " +" │ No history has been reported. │ │ 7d │ " +" │ │ │ By feature │ " +" │ │ │ Total usage · relative units │ " +" │ │ │ 20 73 64 91 81 100 86 │ " +" │ │ │ 100 ▁▁▁▁ ▅▅▅▅ ▅▅▅▅ ▇▇▇▇ ▆▆▆▆ ████ ▆▆▆▆ │ " +" │ │ │ 0 ─────────────────────────────────────────────── │ " +" │ │ │ ▲ │ " +" │ │ │ 27 28 29 30 31 1 2 │ " +" │ │ │ │ " +" │ │ │ Sep 2 · 86 usage units │ " +" │ │ │ ● Tasks 64.0% │ " +" │ │ │ ● Code review 14.0% │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ " +" " +" ╭─ ▸ 3 Plan usage history ─────────────────────────────╮ ╭─ 4 Messages ───────────────────────────────────────╮ " +" │ │ │ │ " +" │ By feature │ │ 7d │ " +" │ › 5-hour limits │ │ By model │ " +" │ │ │ 574 reported messages │ " +" │ Latest period · 12 total 125% │ │ 62 83 107 75 59 85 103 │ " +" │ │ │ 150 ▃▃▃▃ ▄▄▄▄ ▅▅▅▅ ▄▄▄▄ ▃▃▃▃ ▄▄▄▄ ▅▅▅▅ │ " +" │ Weekly limits │ │ 0 ─────────────────────────────────────────────── │ " +" │ │ │ ▲ │ " +" │ Latest period · 1 total Not available │ │ 27 28 29 30 31 1 2 │ " +" │ Some periods aren't available yet │ │ │ " +" │ Usage as of Sep 2 12:00 UTC │ │ Sep 2 · 103 messages │ " +" │ Available since Sep 1 00:00 UTC │ │ ● GPT-5.6-Sol 61 │ " +" │ … z to maximize │ │ ● GPT-5.6-Terra 25 │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ " +" " +" ╭─ 5 Plugins called ─────────────────────────────────╮ ╭─ 6 Skills used ────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ 7d │ " +" │ 186 calls │ │ 54 uses │ " +" │ 34 23 29 26 22 23 29 │ │ 8 6 9 8 7 5 11 │ " +" │ 40 ▆▆▆▆ ▄▄▄▄ ▅▅▅▅ ▅▅▅▅ ▄▄▄▄ ▄▄▄▄ ▅▅▅▅ │ │ 20 ▃▃▃▃ ▂▂▂▂ ▃▃▃▃ ▃▃▃▃ ▂▂▂▂ ▂▂▂▂ ▄▄▄▄ │ " +" │ 0 ──────────────────────────────────────────────── │ │ 0 ──────────────────────────────────────────────── │ " +" │ ▲ │ │ ▲ │ " +" │ 27 28 29 30 31 1 2 │ │ 27 28 29 30 31 1 2 │ " +" │ │ │ │ " +" │ Sep 2 · 29 calls │ │ Sep 2 · 11 uses │ " +" │ ● Slack 10 │ │ ● Testing 4 │ " +" │ ● Figma 10 │ │ ● Code review 3 │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ " +" " +" ╭─ 7 Top chats ──────────────────────────────────────╮ " +" │ │ " +" │ No history has been reported. │ " +" │ │ " +" tab/1–7 section · enter/z maximize " +" g group · R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__unavailable_empty_and_zero_history_are_distinct.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__unavailable_empty_and_zero_history_are_distinct.snap new file mode 100644 index 0000000000000000000000000000000000000000..18e7fda2e09f6bb90a03ca9be2c32a3c4bee3167 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__unavailable_empty_and_zero_history_are_distinct.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/analytics/plan_tests.rs +assertion_line: 105 +expression: snapshot +--- +› 5-hour limits Weekly limits + +Limit history isn't available yet Limit history isn't available yet + +› 5-hour limits Weekly limits + +No limit periods in this date range No limit periods in this date range +Usage as of Sep 2 12:00 UTC · * current at last update +Available since Sep 1 00:00 UTC +Amounts and period boundaries are approximate; recent activity may be delayed + +› 5-hour limits Weekly limits + +› Sep 2 10:00 – Sep 2 15:00 * 0% › Sep 1 00:00 – Sep 8 00:00 * Not available +Some periods aren't available yet +Usage as of Sep 2 12:00 UTC · * current at last update +Available since Sep 1 00:00 UTC +Amounts and period boundaries are approximate; recent activity may be delayed + +› 5-hour limits Weekly limits + +› Sep 2 13:00 – Sep 2 15:00 125% › Sep 1 00:00 – Sep 8 00:00 * Not available +Some periods aren't available yet +Usage as of Sep 2 12:00 UTC · * current at last update +Available since Sep 1 00:00 UTC +Amounts and period boundaries are approximate; recent activity may be delayed diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__duplicate_message_dates_render_the_full_other_remainder.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__duplicate_message_dates_render_the_full_other_remainder.snap new file mode 100644 index 0000000000000000000000000000000000000000..6a5d3333c5ee1683c8ae0f30a6d48f71fe8b1c45 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__duplicate_message_dates_render_the_full_other_remainder.snap @@ -0,0 +1,42 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: display(&rendered) +--- + 20 + 20 ████████████ + ████████████ + ████████████ + ████████████ + ████████████ + 10 ████████████ + ████████████ + ████████████ + ████████████ + ████████████ + 0 ──────────────────────────────────────── + ▲ + Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 22:1 24:0 +1: 0:0 1:2 3:0 18:3 30:0 +2: 0:0 18:3 30:0 +3: 0:0 18:4 30:0 +4: 0:0 18:4 30:0 +5: 0:0 18:4 30:0 +6: 0:0 1:2 3:0 18:4 30:0 +7: 0:0 18:4 30:0 +8: 0:0 18:4 30:0 +9: 0:0 18:4 30:0 +10: 0:0 18:4 30:0 +11: 0:0 2:2 3:0 4:2 +12: 0:0 23:5 24:0 +13: 0:0 20:6 26:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__missing_zero_and_narrow_charts_keep_the_selection_visible.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__missing_zero_and_narrow_charts_keep_the_selection_visible.snap new file mode 100644 index 0000000000000000000000000000000000000000..bc23bf58b13bda1315f1951f3ac2860e2bcd1068 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__missing_zero_and_narrow_charts_keep_the_selection_visible.snap @@ -0,0 +1,105 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +width=36, cursor=0 + 5 + 6 ▄▄▄▄▄▄▄ + ███████ + — 0 ███████ + 0 ───────────────────────────────── + ▲ + Jan 13 Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +2: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 30:1 31:0 +1: 0:0 1:2 2:0 27:3 34:0 +2: 0:0 27:3 34:0 +3: 0:0 8:4 9:0 19:1 20:0 27:3 34:0 +4: 0:0 1:2 2:0 3:1 +5: 0:0 8:5 9:0 +6: 0:0 5:6 11:0 16:2 22:0 27:2 33:0 + +width=36, cursor=1 + 5 + 6 ▄▄▄▄▄▄▄ + ███████ + — 0 ███████ + 0 ───────────────────────────────── + ▲ + Jan 13 Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +2: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 30:1 31:0 +1: 0:0 1:2 2:0 27:3 34:0 +2: 0:0 27:3 34:0 +3: 0:0 8:1 9:0 19:4 20:0 27:3 34:0 +4: 0:0 1:2 2:0 3:1 +5: 0:0 19:5 20:0 +6: 0:0 5:2 11:0 16:6 22:0 27:2 33:0 + +width=8, cursor=2 + 5 + ▄▄ + ██ +— 0 ██ +──────── + ▲ +13 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset) +6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 6:1 7:0 +1: 0:0 5:2 7:0 +2: 0:0 5:2 7:0 +3: 0:3 1:0 3:3 4:0 5:2 7:0 +4: 0:3 +5: 0:0 6:4 7:0 +6: 0:5 2:0 5:6 7:0 + +width=1, cursor=2 +5 +▄ +█ +█ +─ +▲ + + +Palette: +0: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +Rows (column:palette): +0: 0:0 +1: 0:1 +2: 0:1 +3: 0:1 +4: 0:2 +5: 0:3 +6: 0:4 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_equal_height_totals_remain_separate_numbers.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_equal_height_totals_remain_separate_numbers.snap new file mode 100644 index 0000000000000000000000000000000000000000..1508a355fde99e9afba9350bc307d6dad5725fd4 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_equal_height_totals_remain_separate_numbers.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: "screens.join(\"\\n\\n\")" +--- + 111 111 111 +111 ┊ 111 ┊ 111 ┊ +▇▇ ▇▇ ▇▇ ▇▇ ▇▇ ▇▇ ▇▇ +██ ██ ██ ██ ██ ██ ██ +██ ██ ██ ██ ██ ██ ██ +───────────────────── + ▲ + 1 2 3 4 5 6 7 + + +111 111 111 111 111 111 111 + ▇▇ ▇▇ ▇▇ ▇▇ ▇▇ ▇▇ ▇▇ + ██ ██ ██ ██ ██ ██ ██ + ██ ██ ██ ██ ██ ██ ██ +──────────────────────────── + ▲ + 1 2 3 4 5 6 7 + + + 150 111 111 111 111 111 111 111 + ▇▇ ▇▇ ▇▇ ▇▇▇ ▇▇ ▇▇ ▇▇▇ + 75 ██ ██ ██ ███ ██ ██ ███ + ██ ██ ██ ███ ██ ██ ███ + 0 ────────────────────────────── + ▲ + 1 2 3 4 5 6 7 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_long_range_charts_never_truncate_dates.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_long_range_charts_never_truncate_dates.snap new file mode 100644 index 0000000000000000000000000000000000000000..fd494d74a163a95b5dc54b7adeabb21459fa4512 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_long_range_charts_never_truncate_dates.snap @@ -0,0 +1,102 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +width=1 + +1 +▄ +█ +─ +▲ + + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +Rows (column:palette): +0: 0:0 +1: 0:1 +2: 0:2 +3: 0:2 +4: 0:3 +5: 0:4 +6: 0:0 + +width=2 + + 1 +▄▄ +██ +── + ▲ +15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 1:1 +2: 0:2 +3: 0:2 +4: 0:3 +5: 0:0 1:4 +6: 0:5 + +width=5 + + 1 +▄▄▄▄▄ +█████ +───── + ▲ + 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 4:1 +2: 0:2 +3: 0:2 +4: 0:3 +5: 0:0 4:4 +6: 0:0 3:5 + +width=6 + + 1 +▄▄▄▄▄▄ +██████ +────── + ▲ +Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 5:1 +2: 0:2 +3: 0:2 +4: 0:3 +5: 0:0 5:4 +6: 0:5 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__oversized_axes_are_hidden_instead_of_truncated.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__oversized_axes_are_hidden_instead_of_truncated.snap new file mode 100644 index 0000000000000000000000000000000000000000..f082b06e12af579cdd48223c42fb3be5ace42e4a --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__oversized_axes_are_hidden_instead_of_truncated.snap @@ -0,0 +1,138 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +width=8 + 100M + █████ + █████ + █████ + █████ +──────── + ▲ +Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 1:1 5:0 +1: 0:0 1:2 6:0 +2: 0:0 1:2 6:0 +3: 0:0 1:2 6:0 +4: 0:0 1:2 6:0 +5: 0:3 +6: 0:0 3:4 4:0 +7: 0:5 6:0 + +width=30 + 100M + 100M ████████████ + ████████████ + 50M ████████████ + ████████████ + 0 ──────────────────────── + ▲ + Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 15:1 19:0 +1: 0:0 1:2 5:0 12:3 24:0 +2: 0:0 12:3 24:0 +3: 0:0 2:2 5:0 12:3 24:0 +4: 0:0 12:3 24:0 +5: 0:0 4:2 5:0 6:2 +6: 0:0 17:4 18:0 +7: 0:0 14:5 20:0 + +width=35 + 100M + 100M ████████████ + ████████████ + 50M ████████████ + ████████████ + 0 ───────────────────────────── + ▲ + Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 18:1 22:0 +1: 0:0 1:2 5:0 14:3 26:0 +2: 0:0 14:3 26:0 +3: 0:0 2:2 5:0 14:3 26:0 +4: 0:0 14:3 26:0 +5: 0:0 4:2 5:0 6:2 +6: 0:0 20:4 21:0 +7: 0:0 17:5 23:0 + +width=44 + 100M + 100M ████████████ + ████████████ + 50M ████████████ + ████████████ + 0 ────────────────────────────────────── + ▲ + Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 22:1 26:0 +1: 0:0 1:2 5:0 19:3 31:0 +2: 0:0 19:3 31:0 +3: 0:0 2:2 5:0 19:3 31:0 +4: 0:0 19:3 31:0 +5: 0:0 4:2 5:0 6:2 +6: 0:0 24:4 25:0 +7: 0:0 21:5 27:0 + +width=30 + + 1000000000000000000T + ▅▅▅▅▅▅▅▅▅▅▅▅ + ████████████ + ████████████ +────────────────────────────── + ▲ + Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 4:1 24:0 +2: 0:0 9:2 21:0 +3: 0:0 9:2 21:0 +4: 0:0 9:2 21:0 +5: 0:3 +6: 0:0 14:4 15:0 +7: 0:0 11:5 17:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__partial_cells_use_the_midpoint_and_descend_from_the_baseline.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__partial_cells_use_the_midpoint_and_descend_from_the_baseline.snap new file mode 100644 index 0000000000000000000000000000000000000000..f8b8f2f1a6b698cc1bff95ad6b527937bc030432 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__partial_cells_use_the_midpoint_and_descend_from_the_baseline.snap @@ -0,0 +1,49 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +sign=1 +1.75 + ▇▇▇▇ — +──────────── + ▲ + 14 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +2: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 4:1 +1: 0:1 1:2 5:1 8:3 9:1 +2: 0:0 +3: 0:1 8:4 9:1 +4: 0:1 1:0 3:1 7:5 9:1 + +sign=-1 + + — +──────────── + ▁▁▁▁ +-1.75 + ▲ + 14 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 8:1 9:0 +2: 0:2 +3: 0:0 1:3 5:0 +4: 0:2 5:0 +5: 0:0 8:4 9:0 +6: 0:0 1:2 3:0 7:5 9:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__selected_callout_keeps_the_authoritative_daily_total.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__selected_callout_keeps_the_authoritative_daily_total.snap new file mode 100644 index 0000000000000000000000000000000000000000..b9a309eac92fd560b3a57b678c487086164fcb41 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__selected_callout_keeps_the_authoritative_daily_total.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: display(&rendered) +--- + + 20 20.00 + ▃▃▃▃▃▃▃▃▃▃▃▃ + 10 ████████████ + ████████████ + 0 ──────────────────────────────────────── + ▲ + Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +2: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 1:1 3:0 21:2 26:0 +2: 0:0 18:3 30:0 +3: 0:0 1:1 3:0 18:4 30:0 +4: 0:0 18:4 30:0 +5: 0:0 2:1 3:0 4:1 +6: 0:0 23:5 24:0 +7: 0:0 20:6 26:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__seven_day_totals.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__seven_day_totals.snap new file mode 100644 index 0000000000000000000000000000000000000000..a1f322d99a9cdad1dfb074d9130f42b015a2bbec --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__seven_day_totals.snap @@ -0,0 +1,37 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: text +--- + 711 + 800 611 + 511 █████████ + 411 █████████ █████████ + 311 ████████ █████████ █████████ + 400 211 █████████ ████████ █████████ █████████ + 111 ████████ █████████ ████████ █████████ █████████ + █████████ ████████ █████████ ████████ █████████ █████████ + ████████ █████████ ████████ █████████ ████████ █████████ █████████ + 0 ─────────────────────────────────────────────────────────────────────────────────────────────── + ▲ + Jan 1 Jan 2 Jan 3 Jan 4 Jan 5 Jan 6 Jan 7 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 91:1 94:0 +1: 0:0 1:1 4:0 77:1 80:0 +2: 0:0 64:1 67:0 88:2 97:0 +3: 0:0 50:3 53:0 74:2 83:0 88:2 97:0 +4: 0:0 37:1 40:0 61:2 69:0 74:2 83:0 88:2 97:0 +5: 0:0 1:1 4:0 23:1 26:0 47:2 56:0 61:2 69:0 74:2 83:0 88:2 97:0 +6: 0:0 10:1 13:0 34:2 42:0 47:2 56:0 61:2 69:0 74:2 83:0 88:2 97:0 +7: 0:0 20:2 29:0 34:2 42:0 47:2 56:0 61:2 69:0 74:2 83:0 88:2 97:0 +8: 0:0 7:2 15:0 20:2 29:0 34:2 42:0 47:2 56:0 61:2 69:0 74:2 83:0 88:2 97:0 +9: 0:0 3:1 4:0 5:1 +10: 0:0 51:4 52:0 +11: 0:0 9:1 14:0 22:1 27:0 36:1 41:0 49:5 54:0 63:1 68:0 76:1 81:0 90:1 95:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_dark.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_dark.snap new file mode 100644 index 0000000000000000000000000000000000000000..82660a65c4e596f0dc1036c494d909cf2b73da8b --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_dark.snap @@ -0,0 +1,42 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: display(&rendered) +--- + + 15 8.00 + ▅▅▅▅▅▅▅▅ + 7.5 ████████ 0.00 + ████████ ▂▂▂▂▂▂▂▂ ▆▆▆▆▆▆▆▆ + 0 ────────────────────────────────────── + ▄▄▄▄▄▄▄▄ ████████ ▂▂▂▂▂▂▂▂ + + -3.00 + -15 + + ▲ + Jan 13 Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset) +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed() +5: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset) +6: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +7: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +8: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 3:1 5:0 9:2 13:0 +2: 0:0 8:3 16:0 +3: 0:0 2:1 5:0 8:3 16:0 35:2 39:0 +4: 0:0 8:3 16:0 20:3 28:0 33:3 41:0 +5: 0:0 4:1 5:0 6:2 +6: 0:0 8:4 16:0 20:5 28:0 33:4 41:0 +7: 0:0 +8: 0:0 22:6 27:0 +9: 0:0 2:1 5:0 +10: 0:0 +11: 0:0 24:7 25:0 +12: 0:0 8:1 14:0 21:8 27:0 34:1 40:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_light.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_light.snap new file mode 100644 index 0000000000000000000000000000000000000000..f100f1c7e8ca2d19f240e48998625f1aa2c1b97c --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_light.snap @@ -0,0 +1,42 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: display(&rendered) +--- + + 15 8.00 + ▅▅▅▅▅▅▅▅ + 7.5 ████████ 0.00 + ████████ ▂▂▂▂▂▂▂▂ ▆▆▆▆▆▆▆▆ + 0 ────────────────────────────────────── + ▄▄▄▄▄▄▄▄ ████████ ▂▂▂▂▂▂▂▂ + + -3.00 + -15 + + ▲ + Jan 13 Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Rgb(76, 76, 76)).bg(Color::Reset).underline_color(Color::Reset) +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed() +5: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset) +6: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold() +7: Style::new().fg(Color::Rgb(0, 95, 135)).bg(Color::Reset).underline_color(Color::Reset).bold() +8: Style::new().fg(Color::Rgb(0, 95, 135)).bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 3:1 5:0 9:2 13:0 +2: 0:0 8:3 16:0 +3: 0:0 2:1 5:0 8:3 16:0 35:2 39:0 +4: 0:0 8:3 16:0 20:3 28:0 33:3 41:0 +5: 0:0 4:1 5:0 6:2 +6: 0:0 8:4 16:0 20:5 28:0 33:4 41:0 +7: 0:0 +8: 0:0 22:6 27:0 +9: 0:0 2:1 5:0 +10: 0:0 +11: 0:0 24:7 25:0 +12: 0:0 8:1 14:0 21:8 27:0 34:1 40:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__tiny_segments_keep_their_series_color_beside_a_large_day.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__tiny_segments_keep_their_series_color_beside_a_large_day.snap new file mode 100644 index 0000000000000000000000000000000000000000..cd4be0edf00bcb63aaa50147d140ee608fcca59d --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__tiny_segments_keep_their_series_color_beside_a_large_day.snap @@ -0,0 +1,68 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +sign=1 + 100.00 + 100 ████████████ + ████████████ + 50 ████████████ 0.01 + ████████████ ▁▁▁▁▁▁▁▁▁▁▁▁ + 0 ─────────────────────────────────────── + ▲ + Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 11:1 17:0 +1: 0:0 1:2 4:0 8:3 20:0 +2: 0:0 8:3 20:0 +3: 0:0 2:2 4:0 8:3 20:0 31:2 35:0 +4: 0:0 8:3 20:0 28:3 40:0 +5: 0:0 3:2 4:0 5:2 +6: 0:0 14:4 15:0 +7: 0:0 11:5 17:0 30:2 36:0 + +sign=-1 + 100.00 + 100 ████████████ + ████████████ + 50 ████████████ + ████████████ + 0 ────────────────────────────────────── + ▇▇▇▇▇▇▇▇▇▇▇▇ + -0.01 + + -100 + + ▲ + Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).reversed() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 12:1 18:0 +1: 0:0 2:2 5:0 9:3 21:0 +2: 0:0 9:3 21:0 +3: 0:0 3:2 5:0 9:3 21:0 +4: 0:0 9:3 21:0 +5: 0:0 4:2 5:0 6:2 +6: 0:0 28:4 40:0 +7: 0:0 32:2 37:0 +8: 0:0 +9: 0:0 1:2 5:0 +10: 0:0 +11: 0:0 15:5 16:0 +12: 0:0 12:6 18:0 31:2 37:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__zero_credit_history_uses_a_readable_scale.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__zero_credit_history_uses_a_readable_scale.snap new file mode 100644 index 0000000000000000000000000000000000000000..8c4964a90b98faa2964432d2d0319dc09821c167 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__zero_credit_history_uses_a_readable_scale.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: display(&rendered) +--- + + 1 + + 0.5 + 0 0 0 0 0 0 0.00 + 0 ─────────────────────────────────────── + ▲ + 9 10 11 12 13 14 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +2: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 3:1 4:0 +2: 0:0 +3: 0:0 1:1 4:0 +4: 0:0 7:1 8:0 12:1 13:0 18:1 19:0 23:1 24:0 29:1 30:0 34:1 35:0 38:2 42:0 +5: 0:0 3:1 4:0 5:1 +6: 0:0 40:3 41:0 +7: 0:0 7:1 8:0 11:1 13:0 17:1 19:0 22:1 24:0 28:1 30:0 33:1 35:0 39:4 41:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__account_layouts_show_reports_and_wrap_navigation.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__account_layouts_show_reports_and_wrap_navigation.snap new file mode 100644 index 0000000000000000000000000000000000000000..7856f68d7669ac419349e55573462050e87f2330 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__account_layouts_show_reports_and_wrap_navigation.snap @@ -0,0 +1,505 @@ +--- +source: tui/src/analytics/account_layout_tests.rs +expression: "screens.join(\"\\n\")" +--- +" Analytics · Live account " +" " +" " +" [1 Summary] 2 Total usage history 3 Messages 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard " +" g view · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary [2 Total usage history] 3 Messages 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Total usage history By feature " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" Total usage · relative units " +" Approximate · may be delayed up to 6 hours " +" 91 100 86 " +" 100 73 ▃▃▃▃▃▃▃▃ 81 ████████ ▁▁▁▁▁▁▁▁ " +" ▃▃▃▃▃▃▃▃ 64 ████████ ▆▆▆▆▆▆▆▆ ████████ ████████ " +" ████████ ▆▆▆▆▆▆▆▆ ████████ ████████ ████████ ████████ " +" 50 20 ████████ ████████ ████████ ████████ ████████ ████████ " +" ▁▁▁▁▁▁▁▁ ████████ ████████ ████████ ████████ ████████ ████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ ████████ " +" 0 ─────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 86 usage units " +" Feature Share " +" ● Tasks 64.0% " +" ● Code review 14.0% " +" ● Images 11.6% " +" ● Other 10.5% " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history [3 Messages] 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Messages By model " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" 574 reported messages " +" " +" 150 " +" 107 103 " +" 83 ▇▇▇▇▇▇▇▇ 75 85 ▆▆▆▆▆▆▆▆ " +" 75 62 ▆▆▆▆▆▆▆▆ ████████ ▄▄▄▄▄▄▄▄ 59 ▇▇▇▇▇▇▇▇ ████████ " +" ▇▇▇▇▇▇▇▇ ████████ ████████ ████████ ▆▆▆▆▆▆▆▆ ████████ ████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ ████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ ████████ " +" 0 ─────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 103 messages " +" Model Messages " +" ● GPT-5.6-Sol 61 " +" ● GPT-5.6-Terra 25 " +" ● GPT-5.5 10 " +" ● Other 7 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history 3 Messages [4 Plugins called] 5 Skills used 6 Top chats " +" " +" ▎ Plugins called " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" 186 calls " +" " +" 40 34 29 29 " +" ▇▇▇▇▇▇▇▇ 23 26 23 " +" ████████ ████████ ▄▄▄▄▄▄▄▄ 22 █████████ " +" 20 ████████ ████████ ████████ ████████ ▆▆▆▆▆▆▆▆ ████████ █████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ █████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ █████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ █████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 29 calls " +" Plugin Calls " +" ● Slack 10 " +" ● Figma 10 " +" ● GitHub 5 " +" ● Notion 4 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history 3 Messages 4 Plugins called [5 Skills used] 6 Top chats " +" " +" ▎ Skills used " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" 54 uses " +" " +" 20 " +" " +" 9 11 " +" 10 8 6 ▁▁▁▁▁▁▁▁ 8 7 ▆▆▆▆▆▆▆▆▆ " +" ▆▆▆▆▆▆▆▆ ████████ ▆▆▆▆▆▆▆▆ ▃▃▃▃▃▃▃▃ 5 █████████ " +" ████████ ████████ ████████ ████████ ████████ ▆▆▆▆▆▆▆▆ █████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ █████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 11 uses " +" Skill Uses " +" ● Testing 4 " +" ● Code review 3 " +" ● Planning 3 " +" ● Debugging 1 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" 1 Summary 2 Total usage history 3 Messages 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · s sort · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" ╭─ 1 Summary ──────────────────────────────────────────────────╮ ╭─ 2 Total usage history ──────────────────────────────────────╮ " +" │ │ │ │ " +" │ No history has been reported. │ │ 7d │ " +" │ │ │ By feature │ " +" │ │ │ Total usage · relative units │ " +" │ │ │ 73 64 91 81 100 86 │ " +" │ │ │ 100 20 ▃▃▃▃▃ ▂▂▂▂▂ ▆▆▆▆▆ ▄▄▄▄▄ █████ ▅▅▅▅▅▅ │ " +" │ │ │ ▃▃▃▃▃ █████ █████ █████ █████ █████ ██████ │ " +" │ │ │ 0 ───────────────────────────────────────────────────────── │ " +" │ │ │ ▲ │ " +" │ │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " +" │ │ │ │ " +" │ │ │ Sep 2 · 86 usage units │ " +" │ │ │ ● Tasks 64.0% │ " +" │ │ │ ● Code review 14.0% │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────╯ " +" " +" ╭─ 3 Messages ─────────────────────────────────────────────────╮ ╭─ 4 Plugins called ───────────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ 7d │ " +" │ By model │ │ 186 calls │ " +" │ 574 reported messages │ │ 34 23 29 26 22 23 29 │ " +" │ 83 107 85 103 │ │ 40 ▅▅▅▅▅ ▁▁▁▁▁ ▃▃▃▃▃ ▂▂▂▂▂▂ ▁▁▁▁▁ ▃▃▃▃▃▃ │ " +" │ 150 62 ▃▃▃▃▃ 75 59 ▁▁▁▁▁ ▂▂▂▂▂▂ │ │ █████ █████ █████ ██████ █████ █████ ██████ │ " +" │ ▆▆▆▆▆ █████ █████ █████ ▆▆▆▆▆ █████ ██████ │ │ 0 ────────────────────────────────────────────────────────── │ " +" │ 0 ───────────────────────────────────────────────────────── │ │ ▲ │ " +" │ ▲ │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " +" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ │ " +" │ │ │ Sep 2 · 29 calls │ " +" │ Sep 2 · 103 messages │ │ ● Slack 10 │ " +" │ ● GPT-5.6-Sol 61 │ │ ● Figma 10 │ " +" │ ● GPT-5.6-Terra 25 │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────╯ " +" " +" ╭─ 5 Skills used ──────────────────────────────────────────────╮ ╭─ ▸ 6 Top chats ────────────────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ No history has been reported. │ " +" │ 54 uses │ │ │ " +" │ 11 │ │ │ " +" │ 20 8 6 9 8 7 5 │ │ │ " +" │ ▆▆▆▆▆ ▄▄▄▄▄ ▇▇▇▇▇ ▆▆▆▆▆▆ ▅▅▅▅▅ ▄▄▄▄▄ ██████ │ │ │ " +" │ 0 ────────────────────────────────────────────────────────── │ │ │ " +" │ ▲ │ │ │ " +" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ │ " +" │ │ │ │ " +" │ Sep 2 · 11 uses │ │ │ " +" │ ● Testing 4 │ │ │ " +" │ ● Code review 3 │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────╯ " +" tab/1–6 section · s sort · enter/z maximize " +" R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" [1 Summary] 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used 6 " +" Top chats " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard " +" g view · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary [2 Credits usage history] 3 Token usage history 4 Plugins called 5 Skills used 6 " +" Top chats " +" " +" ▎ Credits usage history By product " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" 431.00 credits " +" " +" 150 109.00 " +" 85.00 ▂▂▂▂▂▂▂▂ " +" 57.00 ▃▃▃▃▃▃▃▃ 75.00 ████████ 64.00 " +" 75 41.00 ▂▂▂▂▂▂▂▂ ████████ ████████ ████████ ▄▄▄▄▄▄▄▄ " +" ▅▅▅▅▅▅▅▅ ████████ ████████ ████████ ████████ ████████ " +" 0 ████████ ████████ ████████ ████████ ████████ ████████ " +" 0 ─────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 64.00 credits " +" Product Credits " +" ● CLI 23.00 " +" ● Desktop app 22.00 " +" ● IDE extension 10.00 " +" ● Web 9.00 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history [3 Token usage history] 4 Plugins called 5 Skills used 6 " +" Top chats " +" " +" ▎ Token usage history By token type " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" All models · m change model " +" 4.4M tokens " +" 850K " +" 1M 773.5K 688.5K 731K " +" 620.5K 544K ▅▅▅▅▅▅▅▅ ▁▁▁▁▁▁▁▁ ████████ ▃▃▃▃▃▃▃▃ " +" ▅▅▅▅▅▅▅▅ ▂▂▂▂▂▂▂▂ ████████ ████████ ████████ ████████ " +" 500K 170K ████████ ████████ ████████ ████████ ████████ ████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ ████████ " +" 0 ────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 731,000 tokens " +" Token type Tokens " +" ● Cached input 602,000 " +" ● Uncached input 86,000 " +" ● Output 43,000 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close Updated Sep 2 16:00 UTC " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history 3 Token usage history [4 Plugins called] 5 Skills used 6 " +" Top chats " +" " +" ▎ Plugins called " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" 186 calls " +" 34 " +" 40 29 29 " +" ████████ 23 ▂▂▂▂▂▂▂▂ 26 22 23 ▂▂▂▂▂▂▂▂▂ " +" ████████ ▃▃▃▃▃▃▃▃ ████████ ▇▇▇▇▇▇▇▇ ▂▂▂▂▂▂▂▂ ▃▃▃▃▃▃▃▃ █████████ " +" 20 ████████ ████████ ████████ ████████ ████████ ████████ █████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ █████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ █████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 29 calls " +" Plugin Calls " +" ● Slack 10 " +" ● Figma 10 " +" ● GitHub 5 " +" ● Notion 4 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called [5 Skills used] 6 " +" Top chats " +" " +" ▎ Skills used " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" 54 uses " +" " +" 20 " +" 11 " +" 8 9 8 7 ▂▂▂▂▂▂▂▂▂ " +" 10 ▃▃▃▃▃▃▃▃ 6 ▅▅▅▅▅▅▅▅ ▃▃▃▃▃▃▃▃ 5 █████████ " +" ████████ ▆▆▆▆▆▆▆▆ ████████ ████████ ████████ ▄▄▄▄▄▄▄▄ █████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ █████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 11 uses " +" Skill Uses " +" ● Testing 4 " +" ● Code review 3 " +" ● Planning 3 " +" ● Debugging 1 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 " +" Top chats] " +" " +" ▎ Top chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" " +" Chat Lifetime credits " +" › Q3 planning analysis 120.00 " +" Refactor billing usage 113.00 " +" Other usage 109.00 " +" Launch readiness review 85.00 " +" Plan the week 0.00 " +" " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" ╭─ 1 Summary ──────────────────────────────────────────────────╮ ╭─ 2 Credits usage history ────────────────────────────────────╮ " +" │ │ │ │ " +" │ No history has been reported. │ │ 7d │ " +" │ │ │ By product │ " +" │ │ │ 431.00 credits │ " +" │ │ │ 85.00 109.00 │ " +" │ │ │ 150 41.00 57.00 ▁▁▁▁▁ 75.00 ▃▃▃▃▃ 64.00 │ " +" │ │ │ 0 ▄▄▄▄▄ ▆▆▆▆▆ █████ █████ █████ ▆▆▆▆▆▆ │ " +" │ │ │ 0 ───────────────────────────────────────────────────────── │ " +" │ │ │ ▲ │ " +" │ │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " +" │ │ │ │ " +" │ │ │ Sep 2 · 64.00 credits │ " +" │ │ │ ● CLI 23.00 │ " +" │ │ │ ● Desktop app 22.00 │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────╯ " +" " +" ╭─ 3 Token usage history ──────────────────────────────────────╮ ╭─ 4 Plugins called ───────────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d · All models │ │ 7d │ " +" │ By token type │ │ 186 calls │ " +" │ 4.4M tokens │ │ 34 23 29 26 22 23 29 │ " +" │ 620.5K 544K 773.5K 688.5K 850K 731K │ │ 40 ▅▅▅▅▅ ▁▁▁▁▁ ▃▃▃▃▃ ▂▂▂▂▂▂ ▁▁▁▁▁ ▃▃▃▃▃▃ │ " +" │ 1M 170K ▁▁▁▁▁ ▄▄▄▄▄ ▃▃▃▃▃ ▅▅▅▅▅ ▃▃▃▃▃ │ │ █████ █████ █████ ██████ █████ █████ ██████ │ " +" │ ▂▂▂▂▂ █████ █████ █████ █████ █████ █████ │ │ 0 ────────────────────────────────────────────────────────── │ " +" │ 0 ──────────────────────────────────────────────────────── │ │ ▲ │ " +" │ ▲ │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " +" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ │ " +" │ │ │ Sep 2 · 29 calls │ " +" │ Sep 2 · 731,000 tokens │ │ ● Slack 10 │ " +" │ ● Cached input 602,000 │ │ ● Figma 10 │ " +" │ ● Uncached input 86,000 │ │ │ " +" │ Updated Sep 2 16:00 UTC │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────╯ " +" " +" ╭─ 5 Skills used ──────────────────────────────────────────────╮ ╭─ ▸ 6 Top chats ────────────────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ 30d active · lifetime credits │ " +" │ 54 uses │ │ │ " +" │ 11 │ │ Q3 planning analysis 120.00 │ " +" │ 20 8 6 9 8 7 5 │ │ Refactor billing usage 113.00 │ " +" │ ▆▆▆▆▆ ▄▄▄▄▄ ▇▇▇▇▇ ▆▆▆▆▆▆ ▅▅▅▅▅ ▄▄▄▄▄ ██████ │ │ Other usage 109.00 │ " +" │ 0 ────────────────────────────────────────────────────────── │ │ Launch readiness review 85.00 │ " +" │ ▲ │ │ Plan the week 0.00 │ " +" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ │ " +" │ │ │ Local chats · excludes subagents │ " +" │ Sep 2 · 11 uses │ │ │ " +" │ ● Testing 4 │ │ │ " +" │ ● Code review 3 │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────╯ " +" tab/1–6 section · enter/z maximize " +" R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__account_pending_and_error_do_not_start_reports.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__account_pending_and_error_do_not_start_reports.snap new file mode 100644 index 0000000000000000000000000000000000000000..bb29ece2da6c423cec4c2a3d56b78e7bc92ef9ac --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__account_pending_and_error_do_not_start_reports.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/analytics/account_layout_tests.rs +expression: error +--- +" Analytics · Live account " +" " +" " +" " +" " +" Sign in again to view Analytics. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__business_chats_display_only_supplied_dollars.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__business_chats_display_only_supplied_dollars.snap new file mode 100644 index 0000000000000000000000000000000000000000..dcdfc0d403d1d0acc79802e92d8ba81e762a9e24 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__business_chats_display_only_supplied_dollars.snap @@ -0,0 +1,65 @@ +--- +source: tui/src/analytics/account_layout_tests.rs +expression: "format!(\"{dollars}\\n{narrow}\")" +--- +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 " +" Top chats] " +" " +" ▎ Top chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" " +" Chat Est. $ spent Lifetime credits " +" › Q3 planning analysis $123.45 120.00 " +" Refactor billing usage — 113.00 " +" Other usage — 109.00 " +" Launch readiness review — 85.00 " +" Plan the week — 0.00 " +" " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" Summary Credits usage history Token " +" usage history Plugins called Skills " +" used [Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────── " +" Active in past 30 days · sorted by " +" lifetime credits " +" " +" Chat Est. $ / credits " +" › Q3 planning analysis " +" $123.45 120.00 " +" Refactor billing usage " +" — 113.00 " +" Other usage " +" — 109.00 " +" Launch readiness review " +" — 85.00 " +" " +" Showing 1–4 of 5 chats " +" Local chats · excludes archived chats " +" and subagent usage " +" " +" tab/1–6 section · z dashboard · ↑/↓ " +" row · enter details " +" R refresh · esc back · q close " +" " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__business_tokens_filter_models.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__business_tokens_filter_models.snap new file mode 100644 index 0000000000000000000000000000000000000000..d11660c49ed18665195f030852cf390ce2736921 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__business_tokens_filter_models.snap @@ -0,0 +1,65 @@ +--- +source: tui/src/analytics/account_layout_tests.rs +expression: "format!(\"{all}\\n{filtered}\")" +--- +" Analytics · Live account · analytics@example.test " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history [3 Token usage history] 4 Plugins called 5 Skills used 6 " +" Top chats " +" " +" ▎ Token usage history By token type " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" All models · m change model " +" 4.4M tokens " +" 850K " +" 1M 773.5K 688.5K 731K " +" 620.5K 544K ▅▅▅▅▅▅▅▅ ▁▁▁▁▁▁▁▁ ████████ ▃▃▃▃▃▃▃▃ " +" ▅▅▅▅▅▅▅▅ ▂▂▂▂▂▂▂▂ ████████ ████████ ████████ ████████ " +" 500K 170K ████████ ████████ ████████ ████████ ████████ ████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ ████████ " +" 0 ────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 731,000 tokens " +" Token type Tokens " +" ● Cached input 602,000 " +" ● Uncached input 86,000 " +" ● Output 43,000 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close Updated Sep 2 16:00 UTC " + +" Analytics · Live account · analytics@example.test " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history [3 Token usage history] 4 Plugins called 5 Skills used 6 " +" Top chats " +" " +" ▎ Token usage history By token type " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" GPT-5.5 · m change model " +" 4.4M tokens " +" 850K " +" 1M 773.5K 688.5K 731K " +" 620.5K 544K ▅▅▅▅▅▅▅▅ ▁▁▁▁▁▁▁▁ ████████ ▃▃▃▃▃▃▃▃ " +" ▅▅▅▅▅▅▅▅ ▂▂▂▂▂▂▂▂ ████████ ████████ ████████ ████████ " +" 500K 170K ████████ ████████ ████████ ████████ ████████ ████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ ████████ " +" 0 ────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 731,000 tokens " +" Token type Tokens " +" ● Cached input 602,000 " +" ● Uncached input 86,000 " +" ● Output 43,000 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close Updated Sep 2 16:00 UTC " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__legend_tall_report.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__legend_tall_report.snap new file mode 100644 index 0000000000000000000000000000000000000000..7ad344cb2f0e9bbb42c8d113b6a0609a3cd9500a --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__legend_tall_report.snap @@ -0,0 +1,64 @@ +--- +source: tui/src/analytics/account_layout_tests.rs +expression: tall +--- +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary [2 Total usage history] 3 Messages 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Total usage history By feature " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Total usage · relative units " +" Approximate · may be delayed up to 6 hours " +" 100 " +" 100 ██████████ " +" 91 ██████████ " +" ▂▂▂▂▂▂▂▂▂▂ ██████████ " +" ██████████ ██████████ 86 " +" ██████████ 81 ██████████ ▆▆▆▆▆▆▆▆▆▆ " +" ██████████ ▂▂▂▂▂▂▂▂▂▂ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ " +" 73 ██████████ ██████████ ██████████ ██████████ " +" ▇▇▇▇▇▇▇▇▇▇ ██████████ ██████████ ██████████ ██████████ " +" ██████████ 64 ██████████ ██████████ ██████████ ██████████ " +" ██████████ ▁▁▁▁▁▁▁▁▁▁ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 50 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 20 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 86 usage units " +" Feature Share " +" ● Feature 1 10.0% " +" ● Feature 2 10.0% " +" ● Feature 3 10.0% " +" ● Feature 4 10.0% " +" ● Feature 5 10.0% " +" ● Feature 6 10.0% " +" ● Feature 7 10.0% " +" ● Feature 8 10.0% " +" ● Feature 9 10.0% " +" ● Feature 10 10.0% " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close Updated Sep 2 16:00 UTC " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__unknown_plan_keeps_summary_available_without_billing_reports.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__unknown_plan_keeps_summary_available_without_billing_reports.snap new file mode 100644 index 0000000000000000000000000000000000000000..337a1c8a6cd533a6dcdcbead2688fa50014533e6 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__unknown_plan_keeps_summary_available_without_billing_reports.snap @@ -0,0 +1,44 @@ +--- +source: tui/src/analytics/account_layout_tests.rs +expression: terminal.backend().to_string() +--- +" Analytics · Live account · analytics@example.test " +" " +" " +" [1 Summary] " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" " +" " +" " +" " +" " +" " +" " +" Account " +" " +" 12.3K — — — — " +" Lifetime tokens Peak tokens Longest chat Current streak Longest streak " +" " +" Token activity Daily " +" Last 12 months " +" " +" Token activity history unavailable " +" " +" Activity insights Most used plugins and skills " +" " +" Fast Mode — Plugin and skill usage unavailable. " +" Most used reasoning — " +" Skills explored — " +" Total skills used — " +" Total chats — " +" " +" " +" " +" " +" " +" " +" " +" tab/1–1 section · z dashboard " +" g view · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__unsupported_workspace_plans_hide_top_chats_and_skip_loading.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__unsupported_workspace_plans_hide_top_chats_and_skip_loading.snap new file mode 100644 index 0000000000000000000000000000000000000000..4d434f7fb00b7c0c827fb2c9d56f42aa8144f0ba --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__unsupported_workspace_plans_hide_top_chats_and_skip_loading.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/analytics/account_layout_tests.rs +expression: "screen(&mut view, 110, 24)" +--- +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary [2 Credits usage history] 3 Token usage history 4 Plugins called 5 Skills used " +" " +" ▎ Credits usage history By product " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 431.00 credits " +" 85.00 109.00 " +" 150 41.00 57.00 ▁▁▁▁▁▁▁▁▁ 75.00 ▃▃▃▃▃▃▃▃▃ 64.00 " +" 0 ▄▄▄▄▄▄▄▄▄ ▆▆▆▆▆▆▆▆▆▆ █████████ ██████████ █████████ ▆▆▆▆▆▆▆▆▆▆ " +" 0 ───────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 64.00 credits " +" Product Credits " +" ● CLI 23.00 " +" ● Desktop app 22.00 " +" ● IDE extension 10.00 " +" ● Web 9.00 " +" tab/1–5 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_breakdown_ranks_nonzero_groups_and_reflows.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_breakdown_ranks_nonzero_groups_and_reflows.snap new file mode 100644 index 0000000000000000000000000000000000000000..c288c094b4f384efe7f8b043f1cc98539700b62d --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_breakdown_ranks_nonzero_groups_and_reflows.snap @@ -0,0 +1,102 @@ +--- +source: tui/src/analytics/chat_panel_tests.rs +expression: "screens.join(\"\\n\")" +--- +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" " +" Chat Lifetime credits " +" › Q3 planning analysis 19,350.04 " +" ────────────────────────────────────────────────────────────────────────────────────────────── " +" Model Effort Speed Credits " +" Top model high Not reported 19,134.43 " +" Small model high standard 215.61 " +" Refund model high standard -0.000004 " +" 1 zero-credit group hidden · a show all " +" " +" Refactor billing usage 113.00 " +" Other usage 109.00 " +" Launch readiness review 85.00 " +" Plan the week 0.00 " +" " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" Summary Credits usage history Token usage history " +" Plugins called Skills used [Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" " +" Chat Lifetime credits " +" › Q3 planning analysis 19,350.04 " +" ──────────────────────────────────────────────────── " +" Model / effort / speed Credits " +" Top model 19,134.43 " +" high · Not reported " +" Small model 215.61 " +" high · standard " +" Refund model -0.000004 " +" high · standard " +" 1 zero-credit group hidden · a show all " +" " +" Refactor billing usage 113.00 " +" Other usage 109.00 " +" " +" Showing 1–3 of 5 chats " +" Local chats · excludes archived chats and subagent " +" usage " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter " +" details " +" R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" " +" Chat Lifetime credits " +" › Q3 planning analysis 19,350.04 " +" ────────────────────────────────────────────────────────────────────────────────────────────── " +" Model Effort Speed Credits " +" Top model high Not reported 19,134.43 " +" Small model high standard 215.61 " +" Zero model high standard 0.00 " +" Refund model high standard -0.000004 " +" 1 zero-credit group · a hide zeros " +" " +" Refactor billing usage 113.00 " +" Other usage 109.00 " +" Launch readiness review 85.00 " +" Plan the week 0.00 " +" " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" " +" " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_long_title_keeps_detail_indent_and_counts_missing_estimates.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_long_title_keeps_detail_indent_and_counts_missing_estimates.snap new file mode 100644 index 0000000000000000000000000000000000000000..e0cd2f1fceb1b0bc2b42dc56c14180730912c4eb --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_long_title_keeps_detail_indent_and_counts_missing_estimates.snap @@ -0,0 +1,34 @@ +--- +source: tui/src/analytics/chat_panel_tests.rs +expression: output +--- +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" 2 chat estimates unavailable " +" " +" Chat Lifetime credits " +" › Create a comprehensive list of areas to investigate across the runtime and describe ho… 120.00 " +" ────────────────────────────────────────────────────────────────────────────────────────────── " +" Create a comprehensive list of areas to investigate across the runtime and describe how each " +" area affects performance in a long conversation " +" Model Effort Speed Credits " +" GPT-5.6-Sol high standard 120.00 " +" " +" Chat usage unavailable — " +" Chat usage unavailable — " +" Launch readiness review 85.00 " +" Plan the week 0.00 " +" " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_range_controls_and_visible_rows.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_range_controls_and_visible_rows.snap new file mode 100644 index 0000000000000000000000000000000000000000..d03ad5ee9ca5e9bc674ce843e096d5556df2891e --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_range_controls_and_visible_rows.snap @@ -0,0 +1,78 @@ +--- +source: tui/src/analytics/chat_panel_tests.rs +expression: "screens.join(\"\\n\")" +--- +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" " +" Chat Lifetime credits " +" › Chat 01 120.00 " +" Chat 02 120.00 " +" Chat 03 120.00 " +" Chat 04 120.00 " +" Chat 05 120.00 " +" Chat 06 120.00 " +" Chat 07 120.00 " +" Chat 08 120.00 " +" " +" Showing 1–8 of 30 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" " +" Chat Lifetime credits " +" Chat 23 120.00 " +" Chat 24 120.00 " +" Chat 25 120.00 " +" Chat 26 120.00 " +" Chat 27 120.00 " +" Chat 28 120.00 " +" Chat 29 120.00 " +" › Chat 30 120.00 " +" " +" Showing 23–30 of 30 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" " +" Chat Lifetime credits " +" Chat 27 120.00 " +" Chat 28 120.00 " +" Chat 29 120.00 " +" › Chat 30 120.00 " +" ────────────────────────────────────────────────────────────────────────────────────────────── " +" Model Effort Speed Credits " +" GPT-5.6-Sol high standard 120.00 " +" " +" " +" Showing 27–30 of 30 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__moving_chat_selection_collapses_details_before_they_leave_the_viewport.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__moving_chat_selection_collapses_details_before_they_leave_the_viewport.snap new file mode 100644 index 0000000000000000000000000000000000000000..c0d1489e009dd969920d0f1801569b0dd681f6cb --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__moving_chat_selection_collapses_details_before_they_leave_the_viewport.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/analytics/chat_panel_tests.rs +expression: "screen(&mut view, 110, 24)" +--- +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" " +" Chat Lifetime credits " +" Q3 planning analysis 120.00 " +" Refactor billing usage 113.00 " +" Other usage 109.00 " +" Launch readiness review 85.00 " +" › Plan the week 0.00 " +" " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" " +" " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__unavailable_chat_titles_are_hidden_in_panel_and_overview.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__unavailable_chat_titles_are_hidden_in_panel_and_overview.snap new file mode 100644 index 0000000000000000000000000000000000000000..787c135e1087755b66548d9b5b1ac9e9c6e87248 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__unavailable_chat_titles_are_hidden_in_panel_and_overview.snap @@ -0,0 +1,61 @@ +--- +source: tui/src/analytics/chat_panel_tests.rs +expression: "screens.join(\"\\n\")" +--- +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" 1 chat estimate unavailable " +" " +" Chat Lifetime credits " +" › Chat usage unavailable — " +" " +" Showing 1–1 of 1 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " + +" Analytics · Live account " +" " +" " +" │ │ │ Sep 2 · 29 calls │ " +" │ Sep 2 · 731,000 tokens │ │ ● Slack 10 │ " +" │ ● Cached input 602,000 │ │ ● Figma 10 │ " +" │ … z to maximize │ │ │ " +" │ │ │ │ " +" ╰─────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────╯ " +" " +" ╭─ 5 Skills used ───────────────────────────────╮ ╭─ ▸ 6 Top chats ─────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ 30d active · lifetime credits │ " +" │ 54 uses │ │ │ " +" │ 8 6 9 8 7 5 11 │ │ Chat usage unavailable — │ " +" │ 20 ▃▃▃▃ ▂▂▂▂ ▃▃▃▃ ▃▃▃▃ ▂▂▂▂ ▂▂▂▂ ▄▄▄▄ │ │ │ " +" │ 0 ─────────────────────────────────────────── │ │ Local chats · excludes subagents │ " +" │ ▲ │ │ Some estimates unavailable │ " +" │ 27 28 29 30 31 1 2 │ │ │ " +" │ │ │ │ " +" │ Sep 2 · 11 uses │ │ │ " +" │ ● Testing 4 │ │ │ " +" │ ● Code review 3 │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰─────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────╯ " +" tab/1–6 section · enter/z maximize " +" R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__compact__report_headlines_and_axes_compact_large_values.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__compact__report_headlines_and_axes_compact_large_values.snap new file mode 100644 index 0000000000000000000000000000000000000000..7094fc070b69907c2b47be1e6a230ca58d0d8525 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__compact__report_headlines_and_axes_compact_large_values.snap @@ -0,0 +1,96 @@ +--- +source: tui/src/analytics/compact_tests.rs +expression: "screenshots.join(\"\\n\")" +--- +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history [3 Token usage history] 4 Plugins called 5 Skills used 6 " +" Top chats " +" " +" ▎ Token usage history By token type " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" All models · m change model " +" 12.3B tokens " +" " +" 15B 12.3B " +" ▄▄▄▄▄▄▄▄ " +" ████████ " +" ████████ " +" 7.5B ████████ " +" ████████ " +" ████████ " +" — — — — — — ████████ " +" 0 ────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 12,280,365,226 tokens " +" Token type Tokens " +" ● Example 12,280,365,226 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history 3 Token usage history [4 Plugins called] 5 Skills used 6 " +" Top chats " +" " +" ▎ Plugins called " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" 9.7K calls " +" 9.7K " +" 10K ▆▆▆▆▆▆▆▆ " +" ████████ " +" ████████ " +" ████████ " +" 5K ████████ " +" ████████ " +" ████████ " +" ████████ " +" — — — — — — ████████ " +" 0 ─────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 9,749 calls " +" Plugin Calls " +" ● Example 9,749 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called [5 Skills used] 6 " +" Top chats " +" " +" ▎ Skills used " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" 2.9K uses " +" 2.9K " +" 3K ▅▅▅▅▅▅▅▅ " +" ████████ " +" ████████ " +" ████████ " +" 1.5K ████████ " +" ████████ " +" ████████ " +" ████████ " +" — — — — — — ████████ " +" 0 ────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 2,901 uses " +" Skill Uses " +" ● Example 2,901 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_all_metrics.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_all_metrics.snap new file mode 100644 index 0000000000000000000000000000000000000000..67a6d16732a9ba7980be7c9db197c99bb0059d89 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_all_metrics.snap @@ -0,0 +1,34 @@ +--- +source: tui/src/analytics/consumer_refresh_tests.rs +expression: "screen(&mut view, 110, 30)" +--- +" Analytics · Live account " +" " +" " +" 1 Summary 2 Total usage history 3 Messages 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Usage estimates · active in past 30 days · sorted by Weekly % " +" Recorded usage / current full limit · may exceed 100% " +" Partial ranking · 1 partial · 2 unavailable " +" " +" Chat Weekly % 5-hour % Balance credits " +" › Refunded task · partial 125.0% 0.0% -0.000004 " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Refunded task " +" Weekly % 125.0% " +" 5-hour % 0.0% " +" Balance credits -0.000004 " +" " +" Model / effort / speed Weekly % " +" gpt-5.5 — " +" high · fast · codex " +" " +" Chat usage unavailable — — — " +" " +" Showing 1–2 of 3 chats " +" Local chats · includes discovered descendants · excludes archived roots " +" " +" tab/1–6 section · s sort · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_available_limits.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_available_limits.snap new file mode 100644 index 0000000000000000000000000000000000000000..2215a45acd2b22681212978b6e49154b41a80ddb --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_available_limits.snap @@ -0,0 +1,34 @@ +--- +source: tui/src/analytics/consumer_refresh_tests.rs +expression: "screen(&mut view, 58, 30)" +--- +" Analytics · Live account " +" " +" " +" Summary Total usage history Messages Plugins called " +" Skills used [Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────── " +" Usage estimates · active in past 30 days · sorted by " +" 5-hour % " +" Recorded usage / current full limit · may exceed 100% " +" Partial ranking · 1 partial · 2 unavailable " +" " +" Chat 5-hour % " +" › Refunded task · partial 0.0% " +" Chat usage unavailable — " +" Chat usage unavailable — " +" " +" Showing 1–3 of 3 chats " +" Local chats · includes discovered descendants · " +" excludes archived roots " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · s sort · z dashboard · ↑/↓ row · " +" enter details " +" R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chats_keep_missing_rows_and_only_offer_known_metrics.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chats_keep_missing_rows_and_only_offer_known_metrics.snap new file mode 100644 index 0000000000000000000000000000000000000000..4e048b6d5a87dcef6776089d515aa47758cd28a6 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chats_keep_missing_rows_and_only_offer_known_metrics.snap @@ -0,0 +1,34 @@ +--- +source: tui/src/analytics/consumer_refresh_tests.rs +expression: "screen(&mut view, 90, 30)" +--- +" Analytics · Live account " +" " +" " +" 1 Summary 2 Total usage history 3 Messages 4 Plugins called 5 Skills used [6 Top " +" chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────── " +" Usage estimates · active in past 30 days · sorted by Balance credits " +" Credits debited from balance · includes adjustments " +" Partial ranking · 1 partial · 2 unavailable " +" " +" Chat Balance credits " +" › Refunded task · partial -0.000004 " +" ────────────────────────────────────────────────────────────────────────────────────── " +" Refunded task " +" Balance credits -0.000004 " +" " +" Model / effort / speed Balance credits " +" gpt-5.5 -0.000004 " +" high · fast · codex " +" " +" Chat usage unavailable — " +" Chat usage unavailable — " +" " +" Showing 1–3 of 3 chats " +" Local chats · includes discovered descendants · excludes archived roots " +" " +" tab/1–6 section · s sort · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_overview_shows_top_five_and_closes_with_retained_details.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_overview_shows_top_five_and_closes_with_retained_details.snap new file mode 100644 index 0000000000000000000000000000000000000000..72d74b7c6ac886eb3d5d225b403292058da723ee --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_overview_shows_top_five_and_closes_with_retained_details.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/analytics/consumer_refresh_tests.rs +assertion_line: 93 +expression: content +--- +Usage estimates · active in past 30 days · sorted by Balance credits +Credits debited from balance · includes adjustments + + Chat Balance credits + Task 5 5 + Task 4 4 + Task 3 3 + Task 2 2 + Task 1 1 + +Showing top 5 of 6 chats +Local chats · includes discovered descendants · excludes archived +roots diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__dashboard__dashboard_cards_align_and_keep_stable_summary_heights.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__dashboard__dashboard_cards_align_and_keep_stable_summary_heights.snap new file mode 100644 index 0000000000000000000000000000000000000000..dfc4c590adb7420d2125a8f544c3490f51c6fc16 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__dashboard__dashboard_cards_align_and_keep_stable_summary_heights.snap @@ -0,0 +1,117 @@ +--- +source: tui/src/analytics/dashboard_tests.rs +expression: "format!(\"{wide}\\n{narrow}\")" +--- +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" ╭─ 1 Summary ────────────────────────────────────────────────────╮ ╭─ ▸ 2 Total usage history ────────────────────────────────────────╮ " +" │ │ │ │ " +" │ No history has been reported. │ │ 7d │ " +" │ │ │ By feature │ " +" │ │ │ Total usage · relative units │ " +" │ │ │ 73 64 91 81 100 86 │ " +" │ │ │ 100 20 ▃▃▃▃▃ ▂▂▂▂▂▂ ▆▆▆▆▆ ▄▄▄▄▄▄ █████ ▅▅▅▅▅▅ │ " +" │ │ │ ▃▃▃▃▃ █████ ██████ █████ ██████ █████ ██████ │ " +" │ │ │ 0 ─────────────────────────────────────────────────────────── │ " +" │ │ │ ▲ │ " +" │ │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " +" │ │ │ │ " +" │ │ │ Sep 2 · 86 usage units │ " +" │ │ │ ● Tasks 64.0% │ " +" │ │ │ ● Code review 14.0% │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────╯ " +" " +" ╭─ 3 Messages ───────────────────────────────────────────────────╮ ╭─ 4 Plugins called ─────────────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ 7d │ " +" │ By model │ │ 186 calls │ " +" │ 574 reported messages │ │ 34 23 29 26 22 23 29 │ " +" │ 83 107 85 103 │ │ 40 ▅▅▅▅▅ ▁▁▁▁▁▁ ▃▃▃▃▃ ▂▂▂▂▂▂ ▁▁▁▁▁▁ ▃▃▃▃▃▃ │ " +" │ 150 62 ▃▃▃▃▃▃ 75 59 ▁▁▁▁▁ ▂▂▂▂▂▂ │ │ █████ ██████ █████ ██████ █████ ██████ ██████ │ " +" │ ▆▆▆▆▆ █████ ██████ █████ ▆▆▆▆▆▆ █████ ██████ │ │ 0 ──────────────────────────────────────────────────────────── │ " +" │ 0 ─────────────────────────────────────────────────────────── │ │ ▲ │ " +" │ ▲ │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " +" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ │ " +" │ │ │ Sep 2 · 29 calls │ " +" │ Sep 2 · 103 messages │ │ ● Slack 10 │ " +" │ ● GPT-5.6-Sol 61 │ │ ● Figma 10 │ " +" │ ● GPT-5.6-Terra 25 │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────╯ " +" " +" ╭─ 5 Skills used ────────────────────────────────────────────────╮ ╭─ 6 Top chats ──────────────────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ No history has been reported. │ " +" │ 54 uses │ │ │ " +" │ 11 │ │ │ " +" │ 20 8 6 9 8 7 5 │ │ │ " +" │ ▆▆▆▆▆ ▄▄▄▄▄▄ ▇▇▇▇▇ ▆▆▆▆▆▆ ▅▅▅▅▅ ▄▄▄▄▄▄ ██████ │ │ │ " +" │ 0 ──────────────────────────────────────────────────────────── │ │ │ " +" │ ▲ │ │ │ " +" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ │ " +" │ │ │ │ " +" │ Sep 2 · 11 uses │ │ │ " +" │ ● Testing 4 │ │ │ " +" │ ● Code review 3 │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────╯ " +" tab/1–6 section · enter/z maximize " +" r 7/30d · g group · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" ╭─ 1 Summary ────────────────────────────────────────────────────╮ " +" │ │ " +" │ No history has been reported. │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" ╰──────────────────────────────────────────────────────────────────╯ " +" " +" ╭─ ▸ 2 Total usage history ────────────────────────────────────────╮ " +" │ │ " +" │ 7d │ " +" │ By feature │ " +" │ Total usage · relative units │ " +" │ 20 73 64 91 81 100 86 │ " +" │ 100 ▁▁▁▁▁ ▅▅▅▅▅ ▅▅▅▅▅▅ ▇▇▇▇▇ ▆▆▆▆▆▆ █████ ▆▆▆▆▆▆ │ " +" │ 0 ─────────────────────────────────────────────────────────── │ " +" │ ▲ │ " +" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " +" │ │ " +" │ Sep 1 · 100 usage units │ " +" │ ● Tasks 65.0% │ " +" │ ● Code review 15.0% │ " +" │ │ " +" ╰──────────────────────────────────────────────────────────────────╯ " +" " +" ╭─ 3 Messages ───────────────────────────────────────────────────╮ " +" │ │ " +" │ 7d │ " +" │ By model │ " +" │ 574 reported messages │ " +" │ 62 83 107 75 59 85 103 │ " +" │ 150 ▃▃▃▃▃ ▄▄▄▄▄ ▅▅▅▅▅▅ ▄▄▄▄▄ ▃▃▃▃▃▃ ▄▄▄▄▄ ▅▅▅▅▅▅ │ " +" │ 0 ─────────────────────────────────────────────────────────── │ " +" tab/1–6 section · enter/z maximize " +" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q " +" close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__navigation__vim_chart_navigation.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__navigation__vim_chart_navigation.snap new file mode 100644 index 0000000000000000000000000000000000000000..cd5d4772b00a069e8ed6e8540ca755ebbc457c7e --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__navigation__vim_chart_navigation.snap @@ -0,0 +1,37 @@ +--- +source: tui/src/analytics/navigation_tests.rs +assertion_line: 60 +expression: output +--- +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary [2 Total usage history] 3 Messages 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Total usage history By feature " +" ──────────────────────────────────────────────────────────────────────────────────────────── " +" Total usage · relative units " +" Approximate · may be delayed up to 6 hours " +" 91 100 " +" 100 ▂▂▂▂▂▂▂▂ 81 ████████ 86 " +" 73 64 ████████ ▃▃▃▃▃▃▃▃ ████████ ▇▇▇▇▇▇▇▇ " +" ▆▆▆▆▆▆▆▆ ████████ ████████ ████████ ████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ " +" 50 ████████ ████████ ████████ ████████ ████████ ████████ " +" 20 ████████ ████████ ████████ ████████ ████████ ████████ " +" ▄▄▄▄▄▄▄▄ ████████ ████████ ████████ ████████ ████████ ████████ " +" ████████ ████████ ████████ ████████ ████████ ████████ ████████ " +" 0 ─────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Aug 29 · 64 usage units " +" Feature Share " +" ● Tasks 64.1% " +" ● Code review 14.1% " +" ● Images 10.9% " +" ● Other 10.9% " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_cumulative_narrow.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_cumulative_narrow.snap new file mode 100644 index 0000000000000000000000000000000000000000..7ac4ca264bb4f40f403a01968c2788c1655ce03c --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_cumulative_narrow.snap @@ -0,0 +1,64 @@ +--- +source: tui/src/analytics/summary_tests.rs +expression: "screen(&mut view, 64, 60)" +--- +" Analytics · Live account " +" " +" " +" [Summary] Total usage history Messages Plugins called " +" Skills used Top chats " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────── " +" " +" " +" " +" " +" " +" " +" Example User " +" @example.user " +" Plus " +" " +" 142.3B 7B 44h 7m " +" Lifetime tokens Peak tokens Longest chat " +" " +" 158 days 160 days " +" Current streak Longest streak " +" " +" Token activity Cumulative " +" Last 12 months " +" " +" Mar Apr May Jun Jul Aug " +" max █ " +" █ " +" █ " +" █ " +" █ " +" █ " +" 0 █ " +" " +" Running total · top 300 " +" " +" Activity insights " +" " +" Fast Mode 37% " +" Most used reasoning high · 26% " +" Skills explored 147 " +" Total skills used 13,190 " +" Total chats 21,482 " +" " +" Most used plugins and skills " +" " +" $review 1,142 runs " +" @GitHub 1,059 runs " +" " +" Statistics as of Sep 9, 2026 " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard " +" g view · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_daily_wide.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_daily_wide.snap new file mode 100644 index 0000000000000000000000000000000000000000..e9c3cb0deb44b598184752f80d58b71d85b219fd --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_daily_wide.snap @@ -0,0 +1,48 @@ +--- +source: tui/src/analytics/summary_tests.rs +expression: "screen(&mut view, 140, 44)" +--- +" Analytics · Live account " +" " +" " +" [1 Summary] 2 Total usage history 3 Messages 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" " +" " +" Example User " +" @example.user " +" Plus " +" " +" 142.3B 7B 44h 7m 158 days 160 days " +" Lifetime tokens Peak tokens Longest chat Current streak Longest streak " +" " +" Token activity Daily " +" Last 12 months " +" " +" Oct Nov Dec Jan Feb Mar Apr May Jun Jul Aug " +" Su □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ " +" Mo □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ " +" Tu □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ ■ " +" We □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ ■ " +" Th □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ " +" Fr □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ " +" Sa □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ " +" " +" Less □ ■ ■ ■ ■ More " +" " +" Activity insights Most used plugins and skills " +" " +" Fast Mode 37% $review 1,142 runs " +" Most used reasoning high · 26% @GitHub 1,059 runs " +" Skills explored 147 " +" Total skills used 13,190 " +" Total chats 21,482 " +" " +" Statistics as of Sep 9, 2026 " +" " +" " +" " +" tab/1–6 section · z dashboard " +" g view · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_overview.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_overview.snap new file mode 100644 index 0000000000000000000000000000000000000000..610cbe249b52f087299ff14176f07777ba547145 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_overview.snap @@ -0,0 +1,68 @@ +--- +source: tui/src/analytics/summary_tests.rs +expression: "screen(&mut view, 144, 64)" +--- +" Analytics · Live account " +" " +" " +" ╭─ ▸ 1 Summary ────────────────────────────────────────────────────╮ ╭─ 2 Total usage history ────────────────────────────────────────╮ " +" │ │ │ │ " +" │ Example User │ │ 7d │ " +" │ @example.user │ │ By feature │ " +" │ Plus │ │ Total usage · relative units │ " +" │ │ │ │ " +" │ Lifetime tokens 142.3B │ │ 1 │ " +" │ Peak tokens 7B │ │ — — — — — — — │ " +" │ Longest chat 44h 7m │ │ 0 ─────────────────────────────────────────────────────────── │ " +" │ Current streak 158 days │ │ ▲ │ " +" │ Longest streak 160 days │ │ Sep 3 Sep 4 Sep 5 Sep 6 Sep 7 Sep 8 Sep 9 │ " +" │ │ │ │ " +" │ │ │ Sep 9 · Not reported │ " +" │ │ │ ● Tasks — │ " +" │ │ │ ● Code review — │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────╯ " +" " +" ╭─ 3 Messages ───────────────────────────────────────────────────╮ ╭─ 4 Plugins called ─────────────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ 7d │ " +" │ By model │ │ 186 calls │ " +" │ 574 reported messages │ │ │ " +" │ │ │ 2 │ " +" │ 2 │ │ — — — — — — — │ " +" │ — — — — — — — │ │ 0 ───────────────────────────────────────────────────────────── │ " +" │ 0 ───────────────────────────────────────────────────────────── │ │ ▲ │ " +" │ ▲ │ │ Sep 3 Sep 4 Sep 5 Sep 6 Sep 7 Sep 8 Sep 9 │ " +" │ Sep 3 Sep 4 Sep 5 Sep 6 Sep 7 Sep 8 Sep 9 │ │ │ " +" │ │ │ Data not reported for Sep 9. │ " +" │ Data not reported for Sep 9. │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────╯ " +" " +" ╭─ 5 Skills used ────────────────────────────────────────────────╮ ╭─ 6 Top chats ──────────────────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ No history has been reported. │ " +" │ 54 uses │ │ │ " +" │ │ │ │ " +" │ 2 │ │ │ " +" │ — — — — — — — │ │ │ " +" │ 0 ───────────────────────────────────────────────────────────── │ │ │ " +" │ ▲ │ │ │ " +" │ Sep 3 Sep 4 Sep 5 Sep 6 Sep 7 Sep 8 Sep 9 │ │ │ " +" │ │ │ │ " +" │ Data not reported for Sep 9. │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────╯ " +" tab/1–6 section · enter/z maximize " +" g view · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_partial.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_partial.snap new file mode 100644 index 0000000000000000000000000000000000000000..a3d4e23815185c7f539e40a6c287b25151b77984 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_partial.snap @@ -0,0 +1,44 @@ +--- +source: tui/src/analytics/summary_tests.rs +expression: rendered +--- +" Analytics · Live account " +" " +" " +" [1 Summary] 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" " +" " +" " +" " +" " +" " +" Business " +" " +" 0 — — — — " +" Lifetime tokens Peak tokens Longest chat Current streak Longest streak " +" " +" Token activity Daily " +" Last 12 months " +" " +" Token activity history unavailable " +" " +" Activity insights Most used plugins and skills " +" " +" Fast Mode 0% No reported plugins or skills. " +" Most used reasoning — " +" Skills explored — " +" Total skills used — " +" Total chats 0 " +" " +" Some profile statistics are unavailable. " +" " +" " +" " +" " +" " +" " +" tab/1–5 section · z dashboard " +" g view · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_scrolled.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_scrolled.snap new file mode 100644 index 0000000000000000000000000000000000000000..811912d0a68ccc8487587d945a67e8edd6027c70 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_scrolled.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/analytics/summary_tests.rs +expression: "screen(&mut view, 64, 18)" +--- +" Analytics · Live account " +" " +" " +" [Summary] Total usage history Messages Plugins called " +" Skills used Top chats " +" " +" Skills explored 147 " +" Total skills used 13,190 " +" Total chats 21,482 " +" " +" Most used plugins and skills " +" " +" $review 1,142 runs " +" @GitHub 1,059 runs " +" " +" Statistics as of Sep 9, 2026 " +" tab/1–6 section · z dashboard · ↑/↓ scroll " +" g view · R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_weekly.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_weekly.snap new file mode 100644 index 0000000000000000000000000000000000000000..b27b760030ad0016b164735a13ea24768a64f17c --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_weekly.snap @@ -0,0 +1,48 @@ +--- +source: tui/src/analytics/summary_tests.rs +expression: "screen(&mut view, 110, 44)" +--- +" Analytics · Live account " +" " +" " +" [1 Summary] 2 Total usage history 3 Messages 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Summary " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" " +" " +" Example User " +" @example.user " +" Plus " +" " +" 142.3B 7B 44h 7m 158 days 160 days " +" Lifetime tokens Peak tokens Longest chat Current streak Longest streak " +" " +" Token activity Weekly " +" Last 12 months " +" " +" Oct Nov Dec Jan Feb Mar Apr May Jun Jul Aug " +" max █ " +" █ " +" █ " +" █ " +" █ " +" █ " +" 0 █ " +" " +" Each column = 1 week · tallest 300 " +" " +" Activity insights Most used plugins and skills " +" " +" Fast Mode 37% $review 1,142 runs " +" Most used reasoning high · 26% @GitHub 1,059 runs " +" Skills explored 147 " +" Total skills used 13,190 " +" Total chats 21,482 " +" " +" Statistics as of Sep 9, 2026 " +" " +" " +" " +" tab/1–6 section · z dashboard " +" g view · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_1.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_1.snap new file mode 100644 index 0000000000000000000000000000000000000000..bfbea7505527b0e086a2db74c559fa2de68c12b1 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_1.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history [3 Token usage history] 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Token usage history By token type " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" All models · m change model " +" 4.4M tokens " +" " +" 1M " +" 850K " +" ▁▁▁▁▁▁▁▁▁▁ " +" 773.5K ██████████ " +" ▅▅▅▅▅▅▅▅▅▅ 688.5K ██████████ 731K " +" ██████████ ██████████ ▇▇▇▇▇▇▇▇▇▇ " +" 620.5K ██████████ ██████████ ██████████ ██████████ " +" ▆▆▆▆▆▆▆▆▆▆ 544K ██████████ ██████████ ██████████ ██████████ " +" ██████████ ▂▂▂▂▂▂▂▂▂▂ ██████████ ██████████ ██████████ ██████████ " +" 500K ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 170K ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ▁▁▁▁▁▁▁▁▁▁ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 731,000 tokens " +" Token type Tokens " +" ● Cached input 602,000 " +" ● Uncached input 86,000 " +" ● Output 43,000 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close Updated Sep 2 16:00 UTC " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Green|Reset|Reset|NONE +6: Magenta|Reset|Reset|NONE +7: White|Reset|Reset|BOLD +8: Cyan|Reset|Reset|NONE +9: Cyan|Reset|Reset|BOLD | UNDERLINED +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 38:3 63:2 107:0 +4: 0:0 +5: 0:0 2:3 23:0 105:2 118:0 +6: 0:0 2:4 118:0 +7: 0:0 2:2 29:0 +8: 0:0 2:3 13:0 +9: 0:0 +10: 0:0 5:2 7:0 +11: 0:0 91:4 95:0 +12: 0:0 89:5 99:0 +13: 0:0 59:4 65:0 89:5 99:0 +14: 0:0 57:5 67:0 74:4 80:0 89:6 99:0 107:7 111:0 +15: 0:0 57:6 67:0 89:6 99:0 105:5 115:0 +16: 0:0 27:4 33:0 57:6 67:0 73:5 83:0 89:8 99:0 105:6 115:0 +17: 0:0 26:5 36:0 44:4 48:0 57:8 67:0 73:6 83:0 89:8 99:0 105:6 115:0 +18: 0:0 26:6 36:0 42:5 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +19: 0:0 3:2 7:0 26:8 36:0 42:6 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +20: 0:0 26:8 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +21: 0:0 26:8 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +22: 0:0 26:8 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +23: 0:0 26:8 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +24: 0:0 13:4 17:0 26:8 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +25: 0:0 10:5 20:0 26:8 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +26: 0:0 10:8 20:0 26:8 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +27: 0:0 10:8 20:0 26:8 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +28: 0:0 10:8 20:0 26:8 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +29: 0:0 6:2 7:0 8:4 118:0 +30: 0:0 109:3 110:0 +31: 0:0 12:2 18:0 27:2 33:0 43:2 49:0 59:2 65:0 74:2 80:0 91:2 96:0 107:9 112:0 +32: 0:0 +33: 0:0 2:4 58:0 +34: 0:0 2:1 7:3 24:0 +35: 0:0 2:1 12:0 52:1 58:0 +36: 0:0 2:8 4:0 +37: 0:0 2:6 4:0 +38: 0:0 2:5 4:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0 +41: 0:0 2:1 3:2 12:1 13:2 22:1 23:2 34:1 37:2 45:1 46:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_2.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_2.snap new file mode 100644 index 0000000000000000000000000000000000000000..4b02d8fb9d105380542b882c0202fa84216c515c --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_2.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history 3 Token usage history [4 Plugins called] 5 Skills used 6 Top chats " +" " +" ▎ Plugins called " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 186 calls " +" " +" 40 " +" 34 " +" ▁▁▁▁▁▁▁▁▁▁ " +" ██████████ " +" ██████████ 29 29 " +" ██████████ ▆▆▆▆▆▆▆▆▆▆ 26 ▆▆▆▆▆▆▆▆▆▆ " +" ██████████ ██████████ ▂▂▂▂▂▂▂▂▂▂ ██████████ " +" ██████████ 23 ██████████ ██████████ 22 23 ██████████ " +" ██████████ ▇▇▇▇▇▇▇▇▇▇ ██████████ ██████████ ▃▃▃▃▃▃▃▃▃▃ ▇▇▇▇▇▇▇▇▇▇ ██████████ " +" 20 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 29 calls " +" Plugin Calls " +" ● Slack 10 " +" ● Figma 10 " +" ● GitHub 5 " +" ● Notion 4 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: White|Reset|Reset|BOLD +6: Green|Reset|Reset|NONE +7: Magenta|Reset|Reset|NONE +8: Cyan|Reset|Reset|NONE +9: Cyan|Reset|Reset|BOLD | UNDERLINED +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 61:3 81:2 107:0 +4: 0:0 +5: 0:0 2:3 18:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 11:0 +8: 0:0 +9: 0:0 3:2 5:0 +10: 0:0 12:4 14:0 +11: 0:0 +12: 0:0 +13: 0:0 44:4 46:0 108:5 110:0 +14: 0:0 9:6 19:0 60:4 62:0 +15: 0:0 9:6 19:0 +16: 0:0 9:6 19:0 28:4 30:0 41:6 51:0 76:4 78:0 92:4 94:0 105:6 115:0 +17: 0:0 9:7 19:0 41:6 51:0 105:6 115:0 +18: 0:0 3:2 5:0 9:7 19:0 41:6 51:0 57:6 67:0 105:6 115:0 +19: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:6 99:0 105:7 115:0 +20: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0 +21: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +22: 0:0 9:8 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +23: 0:0 9:8 19:0 25:7 35:0 41:8 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:8 115:0 +24: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:7 83:0 89:8 99:0 105:8 115:0 +25: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +26: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +27: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +28: 0:0 4:2 5:0 6:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:9 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 18:0 +34: 0:0 2:1 8:0 53:1 58:0 +35: 0:0 2:8 4:0 +36: 0:0 2:7 4:0 +37: 0:0 2:6 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0 +41: 0:0 2:1 3:2 12:1 13:2 24:1 27:2 35:1 36:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_3.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_3.snap new file mode 100644 index 0000000000000000000000000000000000000000..2374745fef648a5be37d5ea8dc8e178b858fb78f --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_3.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary [2 Credits usage history] 3 Token usage history 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Credits usage history By product " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 17.2K credits " +" " +" 5K " +" 4,360.00 " +" ▄▄▄▄▄▄▄▄▄▄ " +" ██████████ " +" ██████████ " +" 3,400.00 ██████████ " +" ▇▇▇▇▇▇▇▇▇▇ 3,000.00 ██████████ " +" ██████████ ▃▃▃▃▃▃▃▃▃▃ ██████████ " +" ██████████ ██████████ ██████████ 2,560.00 " +" 2.5K 2,280.00 ██████████ ██████████ ██████████ ▅▅▅▅▅▅▅▅▅▅ " +" ▅▅▅▅▅▅▅▅▅▅ ██████████ ██████████ ██████████ ██████████ " +" 1,640.00 ██████████ ██████████ ██████████ ██████████ ██████████ " +" ▁▁▁▁▁▁▁▁▁▁ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 2,560.00 credits " +" Product Credits " +" ● CLI 920.00 " +" ● Desktop app 880.00 " +" ● IDE extension 400.00 " +" ● Web 360.00 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Green|Reset|Reset|NONE +6: Magenta|Reset|Reset|NONE +7: White|Reset|Reset|BOLD +8: Cyan|Reset|Reset|NONE +9: Cyan|Reset|Reset|BOLD | UNDERLINED +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 13:3 40:2 107:0 +4: 0:0 +5: 0:0 2:3 25:0 108:2 118:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 15:0 +8: 0:0 +9: 0:0 5:2 7:0 +10: 0:0 89:4 97:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 58:4 66:0 89:5 99:0 +15: 0:0 73:4 81:0 89:5 99:0 +16: 0:0 89:6 99:0 +17: 0:0 57:5 67:0 89:6 99:0 105:7 113:0 +18: 0:0 3:2 7:0 42:4 50:0 57:5 67:0 73:5 83:0 89:6 99:0 +19: 0:0 57:6 67:0 73:5 83:0 89:6 99:0 +20: 0:0 26:4 34:0 57:6 67:0 73:6 83:0 89:6 99:0 105:5 115:0 +21: 0:0 42:5 52:0 57:6 67:0 73:6 83:0 89:6 99:0 105:6 115:0 +22: 0:0 42:6 52:0 57:6 67:0 73:6 83:0 89:8 99:0 105:6 115:0 +23: 0:0 26:5 36:0 42:6 52:0 57:8 67:0 73:6 83:0 89:8 99:0 105:6 115:0 +24: 0:0 26:6 36:0 42:6 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:6 115:0 +25: 0:0 26:6 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +26: 0:0 26:8 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +27: 0:0 15:4 16:0 26:8 36:0 42:8 52:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +28: 0:0 6:2 7:0 8:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 12:2 18:0 27:2 33:0 43:2 49:0 59:2 65:0 74:2 80:0 91:2 96:0 107:9 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 26:0 +34: 0:0 2:1 9:0 51:1 58:0 +35: 0:0 2:8 4:0 +36: 0:0 2:6 4:0 +37: 0:0 2:5 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0 +41: 0:0 2:1 3:2 12:1 13:2 24:1 27:2 35:1 36:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_4.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_4.snap new file mode 100644 index 0000000000000000000000000000000000000000..b23c8ebfdb13ede2a58fd85e6e420aae44a7858a --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_4.snap @@ -0,0 +1,98 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" " +" Chat Lifetime credits " +" › Q3 planning analysis 120.00 " +" ────────────────────────────────────────────────────────────────────────────────────────────── " +" Model Effort Speed Credits " +" GPT-5.6-Sol high standard 120.00 " +" " +" Refactor billing usage 113.00 " +" Other usage 109.00 " +" Launch readiness review 85.00 " +" Plan the week 0.00 " +" " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 +2: 0:0 +3: 0:0 2:2 94:3 107:0 +4: 0:0 +5: 0:0 2:3 13:0 +6: 0:0 2:4 118:0 +7: 0:0 2:2 53:0 +8: 0:0 +9: 0:0 2:1 8:0 82:1 98:0 +10: 0:0 2:3 24:0 92:3 98:0 +11: 0:0 2:4 98:0 +12: 0:0 2:2 82:0 91:1 98:0 +13: 0:0 2:1 54:2 82:0 +14: 0:0 +15: 0:0 +16: 0:0 +17: 0:0 +18: 0:0 +19: 0:0 +20: 0:0 2:2 24:0 +21: 0:0 2:2 58:0 +22: 0:0 +23: 0:0 +24: 0:0 +25: 0:0 +26: 0:0 +27: 0:0 +28: 0:0 +29: 0:0 +30: 0:0 +31: 0:0 +32: 0:0 +33: 0:0 +34: 0:0 +35: 0:0 +36: 0:0 +37: 0:0 +38: 0:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0 +41: 0:0 2:1 3:2 14:1 17:2 25:1 26:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_6.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_6.snap new file mode 100644 index 0000000000000000000000000000000000000000..0a4af96b8e78886de122c38a1a4fcd0e14de1949 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_6.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called [5 Skills used] 6 Top chats " +" " +" ▎ Skills used " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 54 uses " +" " +" 20 " +" " +" " +" " +" " +" " +" " +" 11 " +" ▃▃▃▃▃▃▃▃▃▃ " +" 10 9 ██████████ " +" 8 ▄▄▄▄▄▄▄▄▄▄ 8 ██████████ " +" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▄▄▄▄▄▄▄▄▄▄ 7 ██████████ " +" ██████████ 6 ██████████ ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ " +" ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ ██████████ ██████████ 5 ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 11 uses " +" Skill Uses " +" ● Testing 4 " +" ● Code review 3 " +" ● Planning 3 " +" ● Debugging 1 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: White|Reset|Reset|BOLD +6: Green|Reset|Reset|NONE +7: Magenta|Reset|Reset|NONE +8: Cyan|Reset|Reset|NONE +9: Cyan|Reset|Reset|BOLD | UNDERLINED +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 79:3 96:2 107:0 +4: 0:0 +5: 0:0 2:3 15:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 9:0 +8: 0:0 +9: 0:0 3:2 5:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 108:5 110:0 +17: 0:0 +18: 0:0 3:2 5:0 45:4 46:0 +19: 0:0 13:4 14:0 61:4 62:0 105:6 115:0 +20: 0:0 41:6 51:0 77:4 78:0 105:6 115:0 +21: 0:0 9:6 19:0 29:4 30:0 41:6 51:0 57:6 67:0 105:7 115:0 +22: 0:0 9:6 19:0 41:6 51:0 57:6 67:0 73:6 83:0 93:4 94:0 105:7 115:0 +23: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:6 99:0 105:7 115:0 +24: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:8 115:0 +25: 0:0 9:8 19:0 25:7 35:0 41:7 51:0 57:8 67:0 73:7 83:0 89:8 99:0 105:8 115:0 +26: 0:0 9:8 19:0 25:7 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +27: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +28: 0:0 4:2 5:0 6:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:9 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 17:0 +34: 0:0 2:1 7:0 54:1 58:0 +35: 0:0 2:8 4:0 +36: 0:0 2:7 4:0 +37: 0:0 2:6 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0 +41: 0:0 2:1 3:2 12:1 13:2 24:1 27:2 35:1 36:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_8.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_8.snap new file mode 100644 index 0000000000000000000000000000000000000000..d13c95b507f1f0afc92b5239dc5da0d8b6393072 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_business_8.snap @@ -0,0 +1,98 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" " +" " +" [1 Summary] 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard " +" g view · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 +2: 0:0 +3: 0:0 2:3 15:2 107:0 +4: 0:0 +5: 0:0 2:3 11:0 +6: 0:0 2:4 118:0 +7: 0:0 +8: 0:0 +9: 0:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 +17: 0:0 +18: 0:0 +19: 0:0 +20: 0:0 +21: 0:0 +22: 0:0 +23: 0:0 +24: 0:0 +25: 0:0 +26: 0:0 +27: 0:0 +28: 0:0 +29: 0:0 +30: 0:0 +31: 0:0 +32: 0:0 +33: 0:0 +34: 0:0 +35: 0:0 +36: 0:0 +37: 0:0 +38: 0:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 118:0 +41: 0:0 2:1 3:2 11:1 12:2 23:1 26:2 34:1 35:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_1.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_1.snap new file mode 100644 index 0000000000000000000000000000000000000000..623a419afe9929cb0346fbbcd5f55782c5c889fe --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_1.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary [2 Total usage history] 3 Messages 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Total usage history By feature " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Total usage · relative units " +" Approximate · may be delayed up to 6 hours " +" 100 " +" 100 91 ██████████ " +" ▃▃▃▃▃▃▃▃▃▃ ██████████ 86 " +" ██████████ 81 ██████████ ▃▃▃▃▃▃▃▃▃▃ " +" 73 ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ " +" ▁▁▁▁▁▁▁▁▁▁ ██████████ ██████████ ██████████ ██████████ " +" ██████████ 64 ██████████ ██████████ ██████████ ██████████ " +" ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 50 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 20 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 86 usage units " +" Feature Share " +" ● Tasks 64.0% " +" ● Code review 14.0% " +" ● Images 11.6% " +" ● Other 10.5% " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Green|Reset|Reset|NONE +6: White|Reset|Reset|BOLD +7: Magenta|Reset|Reset|NONE +8: Cyan|Reset|Reset|NONE +9: Cyan|Reset|Reset|BOLD | UNDERLINED +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 13:3 38:2 94:0 +4: 0:0 +5: 0:0 2:3 23:0 108:2 118:0 +6: 0:0 2:4 118:0 +7: 0:0 2:2 30:0 +8: 0:0 2:2 44:0 +9: 0:0 92:4 95:0 +10: 0:0 3:2 6:0 60:4 62:0 +11: 0:0 89:5 99:0 108:6 110:0 +12: 0:0 76:4 78:0 89:5 99:0 +13: 0:0 28:4 30:0 57:5 67:0 89:5 99:0 +14: 0:0 57:5 67:0 89:7 99:0 105:5 115:0 +15: 0:0 44:4 46:0 57:7 67:0 73:5 83:0 89:7 99:0 105:5 115:0 +16: 0:0 25:5 35:0 57:7 67:0 73:7 83:0 89:8 99:0 105:7 115:0 +17: 0:0 25:5 35:0 57:8 67:0 73:7 83:0 89:8 99:0 105:7 115:0 +18: 0:0 25:7 35:0 41:5 51:0 57:8 67:0 73:7 83:0 89:8 99:0 105:8 115:0 +19: 0:0 4:2 6:0 25:7 35:0 41:7 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +20: 0:0 25:8 35:0 41:7 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +21: 0:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +22: 0:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +23: 0:0 13:4 15:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +24: 0:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +25: 0:0 9:7 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +26: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +27: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +28: 0:0 5:2 6:0 7:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 11:2 17:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:9 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 24:0 +34: 0:0 2:1 9:0 53:1 58:0 +35: 0:0 2:8 4:0 +36: 0:0 2:7 4:0 +37: 0:0 2:5 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0 +41: 0:0 2:1 3:2 12:1 13:2 22:1 23:2 34:1 37:2 45:1 46:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_2.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_2.snap new file mode 100644 index 0000000000000000000000000000000000000000..ec951728940f3f2d055511a70f61903e868f1746 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_2.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history 3 Messages [4 Plugins called] 5 Skills used 6 Top chats " +" " +" ▎ Plugins called " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 186 calls " +" " +" 40 " +" 34 " +" ▁▁▁▁▁▁▁▁▁▁ " +" ██████████ " +" ██████████ 29 29 " +" ██████████ ▆▆▆▆▆▆▆▆▆▆ 26 ▆▆▆▆▆▆▆▆▆▆ " +" ██████████ ██████████ ▂▂▂▂▂▂▂▂▂▂ ██████████ " +" ██████████ 23 ██████████ ██████████ 22 23 ██████████ " +" ██████████ ▇▇▇▇▇▇▇▇▇▇ ██████████ ██████████ ▃▃▃▃▃▃▃▃▃▃ ▇▇▇▇▇▇▇▇▇▇ ██████████ " +" 20 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 29 calls " +" Plugin Calls " +" ● Slack 10 " +" ● Figma 10 " +" ● GitHub 5 " +" ● Notion 4 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: White|Reset|Reset|BOLD +6: Green|Reset|Reset|NONE +7: Magenta|Reset|Reset|NONE +8: Cyan|Reset|Reset|NONE +9: Cyan|Reset|Reset|BOLD | UNDERLINED +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 48:3 68:2 94:0 +4: 0:0 +5: 0:0 2:3 18:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 11:0 +8: 0:0 +9: 0:0 3:2 5:0 +10: 0:0 12:4 14:0 +11: 0:0 +12: 0:0 +13: 0:0 44:4 46:0 108:5 110:0 +14: 0:0 9:6 19:0 60:4 62:0 +15: 0:0 9:6 19:0 +16: 0:0 9:6 19:0 28:4 30:0 41:6 51:0 76:4 78:0 92:4 94:0 105:6 115:0 +17: 0:0 9:7 19:0 41:6 51:0 105:6 115:0 +18: 0:0 3:2 5:0 9:7 19:0 41:6 51:0 57:6 67:0 105:6 115:0 +19: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:6 99:0 105:7 115:0 +20: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0 +21: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +22: 0:0 9:8 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +23: 0:0 9:8 19:0 25:7 35:0 41:8 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:8 115:0 +24: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:7 83:0 89:8 99:0 105:8 115:0 +25: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +26: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +27: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +28: 0:0 4:2 5:0 6:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:9 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 18:0 +34: 0:0 2:1 8:0 53:1 58:0 +35: 0:0 2:8 4:0 +36: 0:0 2:7 4:0 +37: 0:0 2:6 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0 +41: 0:0 2:1 3:2 12:1 13:2 24:1 27:2 35:1 36:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_4.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_4.snap new file mode 100644 index 0000000000000000000000000000000000000000..ec82b2cc005acc360f6a7e84b4ad8e1650df9692 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_4.snap @@ -0,0 +1,98 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" " +" " +" 1 Summary 2 Total usage history 3 Messages 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · s sort · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 +2: 0:0 +3: 0:0 2:2 81:3 94:0 +4: 0:0 +5: 0:0 2:3 13:0 +6: 0:0 2:4 118:0 +7: 0:0 +8: 0:0 +9: 0:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 +17: 0:0 +18: 0:0 +19: 0:0 +20: 0:0 +21: 0:0 +22: 0:0 +23: 0:0 +24: 0:0 +25: 0:0 +26: 0:0 +27: 0:0 +28: 0:0 +29: 0:0 +30: 0:0 +31: 0:0 +32: 0:0 +33: 0:0 +34: 0:0 +35: 0:0 +36: 0:0 +37: 0:0 +38: 0:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 29:1 30:2 43:1 46:2 53:1 58:2 118:0 +41: 0:0 2:1 3:2 14:1 17:2 25:1 26:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_5.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_5.snap new file mode 100644 index 0000000000000000000000000000000000000000..c2f68771d238893d058c3b6391b52777f8446969 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_5.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history [3 Messages] 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Messages By model " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 574 reported messages " +" " +" 150 " +" " +" " +" " +" 107 103 " +" ▄▄▄▄▄▄▄▄▄▄ " +" ██████████ ██████████ " +" 83 ██████████ 85 ██████████ " +" ▄▄▄▄▄▄▄▄▄▄ ██████████ 75 ▆▆▆▆▆▆▆▆▆▆ ██████████ " +" 75 ██████████ ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ " +" 62 ██████████ ██████████ ██████████ 59 ██████████ ██████████ " +" ▆▆▆▆▆▆▆▆▆▆ ██████████ ██████████ ██████████ ▃▃▃▃▃▃▃▃▃▃ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 103 messages " +" Model Messages " +" ● GPT-5.6-Sol 61 " +" ● GPT-5.6-Terra 25 " +" ● GPT-5.5 10 " +" ● Other 7 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: White|Reset|Reset|BOLD +6: Green|Reset|Reset|NONE +7: Magenta|Reset|Reset|NONE +8: Cyan|Reset|Reset|NONE +9: Cyan|Reset|Reset|BOLD | UNDERLINED +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 36:3 50:2 94:0 +4: 0:0 +5: 0:0 2:3 12:0 110:2 118:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 23:0 +8: 0:0 +9: 0:0 3:2 6:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 44:4 47:0 108:5 111:0 +14: 0:0 +15: 0:0 41:6 51:0 +16: 0:0 28:4 30:0 41:6 51:0 92:4 94:0 105:6 115:0 +17: 0:0 41:7 51:0 60:4 62:0 105:7 115:0 +18: 0:0 4:2 6:0 25:6 35:0 41:7 51:0 89:6 99:0 105:7 115:0 +19: 0:0 13:4 15:0 25:7 35:0 41:7 51:0 57:6 67:0 76:4 78:0 89:7 99:0 105:7 115:0 +20: 0:0 25:7 35:0 41:8 51:0 57:7 67:0 89:7 99:0 105:8 115:0 +21: 0:0 9:7 19:0 25:7 35:0 41:8 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:8 115:0 +22: 0:0 9:7 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:7 83:0 89:8 99:0 105:8 115:0 +23: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:7 83:0 89:8 99:0 105:8 115:0 +24: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +25: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +26: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +27: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +28: 0:0 5:2 6:0 7:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 11:2 17:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:9 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 22:0 +34: 0:0 2:1 7:0 50:1 58:0 +35: 0:0 2:8 4:0 +36: 0:0 2:7 4:0 +37: 0:0 2:6 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0 +41: 0:0 2:1 3:2 12:1 13:2 22:1 23:2 34:1 37:2 45:1 46:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_6.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_6.snap new file mode 100644 index 0000000000000000000000000000000000000000..7055b2e97ef9e0635a3c3c1757c1b28b69c2975c --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_6.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history 3 Messages 4 Plugins called [5 Skills used] 6 Top chats " +" " +" ▎ Skills used " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 54 uses " +" " +" 20 " +" " +" " +" " +" " +" " +" " +" 11 " +" ▃▃▃▃▃▃▃▃▃▃ " +" 10 9 ██████████ " +" 8 ▄▄▄▄▄▄▄▄▄▄ 8 ██████████ " +" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▄▄▄▄▄▄▄▄▄▄ 7 ██████████ " +" ██████████ 6 ██████████ ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ " +" ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ ██████████ ██████████ 5 ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 11 uses " +" Skill Uses " +" ● Testing 4 " +" ● Code review 3 " +" ● Planning 3 " +" ● Debugging 1 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: White|Reset|Reset|BOLD +6: Green|Reset|Reset|NONE +7: Magenta|Reset|Reset|NONE +8: Cyan|Reset|Reset|NONE +9: Cyan|Reset|Reset|BOLD | UNDERLINED +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 66:3 83:2 94:0 +4: 0:0 +5: 0:0 2:3 15:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 9:0 +8: 0:0 +9: 0:0 3:2 5:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 108:5 110:0 +17: 0:0 +18: 0:0 3:2 5:0 45:4 46:0 +19: 0:0 13:4 14:0 61:4 62:0 105:6 115:0 +20: 0:0 41:6 51:0 77:4 78:0 105:6 115:0 +21: 0:0 9:6 19:0 29:4 30:0 41:6 51:0 57:6 67:0 105:7 115:0 +22: 0:0 9:6 19:0 41:6 51:0 57:6 67:0 73:6 83:0 93:4 94:0 105:7 115:0 +23: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:6 99:0 105:7 115:0 +24: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:8 115:0 +25: 0:0 9:8 19:0 25:7 35:0 41:7 51:0 57:8 67:0 73:7 83:0 89:8 99:0 105:8 115:0 +26: 0:0 9:8 19:0 25:7 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +27: 0:0 9:8 19:0 25:8 35:0 41:8 51:0 57:8 67:0 73:8 83:0 89:8 99:0 105:8 115:0 +28: 0:0 4:2 5:0 6:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:9 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 17:0 +34: 0:0 2:1 7:0 54:1 58:0 +35: 0:0 2:8 4:0 +36: 0:0 2:7 4:0 +37: 0:0 2:6 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0 +41: 0:0 2:1 3:2 12:1 13:2 24:1 27:2 35:1 36:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_8.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_8.snap new file mode 100644 index 0000000000000000000000000000000000000000..28745da194585af20237762bcdfb1bd131edf8f1 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_8.snap @@ -0,0 +1,98 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" " +" " +" [1 Summary] 2 Total usage history 3 Messages 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard " +" g view · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 +2: 0:0 +3: 0:0 2:3 15:2 94:0 +4: 0:0 +5: 0:0 2:3 11:0 +6: 0:0 2:4 118:0 +7: 0:0 +8: 0:0 +9: 0:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 +17: 0:0 +18: 0:0 +19: 0:0 +20: 0:0 +21: 0:0 +22: 0:0 +23: 0:0 +24: 0:0 +25: 0:0 +26: 0:0 +27: 0:0 +28: 0:0 +29: 0:0 +30: 0:0 +31: 0:0 +32: 0:0 +33: 0:0 +34: 0:0 +35: 0:0 +36: 0:0 +37: 0:0 +38: 0:0 +39: 0:0 +40: 0:0 2:1 9:2 20:1 21:2 118:0 +41: 0:0 2:1 3:2 11:1 12:2 23:1 26:2 34:1 35:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_1.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_1.snap new file mode 100644 index 0000000000000000000000000000000000000000..1f63635aeacc3ed8df04ada0eae93cf99676fd8d --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_1.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history [3 Token usage history] 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Token usage history By token type " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" All models · m change model " +" 4.4M tokens " +" " +" 1M " +" 850K " +" ▁▁▁▁▁▁▁▁▁▁ " +" 773.5K ██████████ " +" ▅▅▅▅▅▅▅▅▅▅ 688.5K ██████████ 731K " +" ██████████ ██████████ ▇▇▇▇▇▇▇▇▇▇ " +" 620.5K ██████████ ██████████ ██████████ ██████████ " +" ▆▆▆▆▆▆▆▆▆▆ 544K ██████████ ██████████ ██████████ ██████████ " +" ██████████ ▂▂▂▂▂▂▂▂▂▂ ██████████ ██████████ ██████████ ██████████ " +" 500K ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 170K ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ▁▁▁▁▁▁▁▁▁▁ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 731,000 tokens " +" Token type Tokens " +" ● Cached input 602,000 " +" ● Uncached input 86,000 " +" ● Output 43,000 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close Updated Sep 2 16:00 UTC " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Green|Reset|Reset|NONE +6: Magenta|Reset|Reset|NONE +7: Cyan|Reset|Reset|NONE +8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED +9: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 38:3 63:2 107:0 +4: 0:0 +5: 0:0 2:3 23:0 105:2 118:0 +6: 0:0 2:4 118:0 +7: 0:0 2:2 29:0 +8: 0:0 2:3 13:0 +9: 0:0 +10: 0:0 5:2 7:0 +11: 0:0 91:4 95:0 +12: 0:0 89:5 99:0 +13: 0:0 59:4 65:0 89:5 99:0 +14: 0:0 57:5 67:0 74:4 80:0 89:6 99:0 107:1 111:0 +15: 0:0 57:6 67:0 89:6 99:0 105:5 115:0 +16: 0:0 27:4 33:0 57:6 67:0 73:5 83:0 89:7 99:0 105:6 115:0 +17: 0:0 26:5 36:0 44:4 48:0 57:7 67:0 73:6 83:0 89:7 99:0 105:6 115:0 +18: 0:0 26:6 36:0 42:5 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +19: 0:0 3:2 7:0 26:7 36:0 42:6 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +20: 0:0 26:7 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +21: 0:0 26:7 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +22: 0:0 26:7 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +23: 0:0 26:7 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +24: 0:0 13:4 17:0 26:7 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +25: 0:0 10:5 20:0 26:7 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +26: 0:0 10:7 20:0 26:7 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +27: 0:0 10:7 20:0 26:7 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +28: 0:0 10:7 20:0 26:7 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +29: 0:0 6:2 7:0 8:4 118:0 +30: 0:0 109:3 110:0 +31: 0:0 12:2 18:0 27:2 33:0 43:2 49:0 59:2 65:0 74:2 80:0 91:2 96:0 107:8 112:0 +32: 0:0 +33: 0:0 2:4 58:0 +34: 0:0 2:1 7:3 24:0 +35: 0:0 2:1 12:0 52:1 58:0 +36: 0:0 2:7 4:0 +37: 0:0 2:6 4:0 +38: 0:0 2:5 4:0 +39: 0:0 +40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0 +41: 0:0 2:9 3:2 12:9 13:2 22:9 23:2 34:9 37:2 45:9 46:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_2.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_2.snap new file mode 100644 index 0000000000000000000000000000000000000000..a15617282c066e500e07cd8a75b453654fda4c91 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_2.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history 3 Token usage history [4 Plugins called] 5 Skills used 6 Top chats " +" " +" ▎ Plugins called " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 186 calls " +" " +" 40 " +" 34 " +" ▁▁▁▁▁▁▁▁▁▁ " +" ██████████ " +" ██████████ 29 29 " +" ██████████ ▆▆▆▆▆▆▆▆▆▆ 26 ▆▆▆▆▆▆▆▆▆▆ " +" ██████████ ██████████ ▂▂▂▂▂▂▂▂▂▂ ██████████ " +" ██████████ 23 ██████████ ██████████ 22 23 ██████████ " +" ██████████ ▇▇▇▇▇▇▇▇▇▇ ██████████ ██████████ ▃▃▃▃▃▃▃▃▃▃ ▇▇▇▇▇▇▇▇▇▇ ██████████ " +" 20 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 29 calls " +" Plugin Calls " +" ● Slack 10 " +" ● Figma 10 " +" ● GitHub 5 " +" ● Notion 4 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Green|Reset|Reset|NONE +6: Magenta|Reset|Reset|NONE +7: Cyan|Reset|Reset|NONE +8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED +9: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 61:3 81:2 107:0 +4: 0:0 +5: 0:0 2:3 18:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 11:0 +8: 0:0 +9: 0:0 3:2 5:0 +10: 0:0 12:4 14:0 +11: 0:0 +12: 0:0 +13: 0:0 44:4 46:0 108:1 110:0 +14: 0:0 9:5 19:0 60:4 62:0 +15: 0:0 9:5 19:0 +16: 0:0 9:5 19:0 28:4 30:0 41:5 51:0 76:4 78:0 92:4 94:0 105:5 115:0 +17: 0:0 9:6 19:0 41:5 51:0 105:5 115:0 +18: 0:0 3:2 5:0 9:6 19:0 41:5 51:0 57:5 67:0 105:5 115:0 +19: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:5 83:0 89:5 99:0 105:6 115:0 +20: 0:0 9:6 19:0 25:6 35:0 41:6 51:0 57:6 67:0 73:5 83:0 89:6 99:0 105:6 115:0 +21: 0:0 9:6 19:0 25:6 35:0 41:6 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:6 115:0 +22: 0:0 9:7 19:0 25:6 35:0 41:6 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:6 115:0 +23: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:7 115:0 +24: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0 +25: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +26: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +27: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +28: 0:0 4:2 5:0 6:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 18:0 +34: 0:0 2:1 8:0 53:1 58:0 +35: 0:0 2:7 4:0 +36: 0:0 2:6 4:0 +37: 0:0 2:5 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0 +41: 0:0 2:9 3:2 12:9 13:2 24:9 27:2 35:9 36:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_3.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_3.snap new file mode 100644 index 0000000000000000000000000000000000000000..49269593e29cdb2259910bae780727942ada13e9 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_3.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary [2 Credits usage history] 3 Token usage history 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Credits usage history By product " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 17.2K credits " +" " +" 5K " +" 4,360.00 " +" ▄▄▄▄▄▄▄▄▄▄ " +" ██████████ " +" ██████████ " +" 3,400.00 ██████████ " +" ▇▇▇▇▇▇▇▇▇▇ 3,000.00 ██████████ " +" ██████████ ▃▃▃▃▃▃▃▃▃▃ ██████████ " +" ██████████ ██████████ ██████████ 2,560.00 " +" 2.5K 2,280.00 ██████████ ██████████ ██████████ ▅▅▅▅▅▅▅▅▅▅ " +" ▅▅▅▅▅▅▅▅▅▅ ██████████ ██████████ ██████████ ██████████ " +" 1,640.00 ██████████ ██████████ ██████████ ██████████ ██████████ " +" ▁▁▁▁▁▁▁▁▁▁ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 2,560.00 credits " +" Product Credits " +" ● CLI 920.00 " +" ● Desktop app 880.00 " +" ● IDE extension 400.00 " +" ● Web 360.00 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Green|Reset|Reset|NONE +6: Magenta|Reset|Reset|NONE +7: Cyan|Reset|Reset|NONE +8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED +9: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 13:3 40:2 107:0 +4: 0:0 +5: 0:0 2:3 25:0 108:2 118:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 15:0 +8: 0:0 +9: 0:0 5:2 7:0 +10: 0:0 89:4 97:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 58:4 66:0 89:5 99:0 +15: 0:0 73:4 81:0 89:5 99:0 +16: 0:0 89:6 99:0 +17: 0:0 57:5 67:0 89:6 99:0 105:1 113:0 +18: 0:0 3:2 7:0 42:4 50:0 57:5 67:0 73:5 83:0 89:6 99:0 +19: 0:0 57:6 67:0 73:5 83:0 89:6 99:0 +20: 0:0 26:4 34:0 57:6 67:0 73:6 83:0 89:6 99:0 105:5 115:0 +21: 0:0 42:5 52:0 57:6 67:0 73:6 83:0 89:6 99:0 105:6 115:0 +22: 0:0 42:6 52:0 57:6 67:0 73:6 83:0 89:7 99:0 105:6 115:0 +23: 0:0 26:5 36:0 42:6 52:0 57:7 67:0 73:6 83:0 89:7 99:0 105:6 115:0 +24: 0:0 26:6 36:0 42:6 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:6 115:0 +25: 0:0 26:6 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +26: 0:0 26:7 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +27: 0:0 15:4 16:0 26:7 36:0 42:7 52:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +28: 0:0 6:2 7:0 8:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 12:2 18:0 27:2 33:0 43:2 49:0 59:2 65:0 74:2 80:0 91:2 96:0 107:8 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 26:0 +34: 0:0 2:1 9:0 51:1 58:0 +35: 0:0 2:7 4:0 +36: 0:0 2:6 4:0 +37: 0:0 2:5 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0 +41: 0:0 2:9 3:2 12:9 13:2 24:9 27:2 35:9 36:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_4.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_4.snap new file mode 100644 index 0000000000000000000000000000000000000000..8662eb5e28fa6dee61aaf9707a17d4c939685de5 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_4.snap @@ -0,0 +1,99 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Active in past 30 days · sorted by lifetime credits " +" " +" Chat Lifetime credits " +" › Q3 planning analysis 120.00 " +" ────────────────────────────────────────────────────────────────────────────────────────────── " +" Model Effort Speed Credits " +" GPT-5.6-Sol high standard 120.00 " +" " +" Refactor billing usage 113.00 " +" Other usage 109.00 " +" Launch readiness review 85.00 " +" Plan the week 0.00 " +" " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 +2: 0:0 +3: 0:0 2:2 94:3 107:0 +4: 0:0 +5: 0:0 2:3 13:0 +6: 0:0 2:4 118:0 +7: 0:0 2:2 53:0 +8: 0:0 +9: 0:0 2:1 8:0 82:1 98:0 +10: 0:0 2:3 24:0 92:3 98:0 +11: 0:0 2:4 98:0 +12: 0:0 2:2 82:0 91:1 98:0 +13: 0:0 2:1 54:2 82:0 +14: 0:0 +15: 0:0 +16: 0:0 +17: 0:0 +18: 0:0 +19: 0:0 +20: 0:0 2:2 24:0 +21: 0:0 2:2 58:0 +22: 0:0 +23: 0:0 +24: 0:0 +25: 0:0 +26: 0:0 +27: 0:0 +28: 0:0 +29: 0:0 +30: 0:0 +31: 0:0 +32: 0:0 +33: 0:0 +34: 0:0 +35: 0:0 +36: 0:0 +37: 0:0 +38: 0:0 +39: 0:0 +40: 0:0 2:5 9:2 20:5 21:2 34:5 37:2 44:5 49:2 118:0 +41: 0:0 2:5 3:2 14:5 17:2 25:5 26:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_6.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_6.snap new file mode 100644 index 0000000000000000000000000000000000000000..9f093b8d45f5b7e90407e49728376589eaeae3b4 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_6.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called [5 Skills used] 6 Top chats " +" " +" ▎ Skills used " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 54 uses " +" " +" 20 " +" " +" " +" " +" " +" " +" " +" 11 " +" ▃▃▃▃▃▃▃▃▃▃ " +" 10 9 ██████████ " +" 8 ▄▄▄▄▄▄▄▄▄▄ 8 ██████████ " +" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▄▄▄▄▄▄▄▄▄▄ 7 ██████████ " +" ██████████ 6 ██████████ ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ " +" ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ ██████████ ██████████ 5 ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 11 uses " +" Skill Uses " +" ● Testing 4 " +" ● Code review 3 " +" ● Planning 3 " +" ● Debugging 1 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Green|Reset|Reset|NONE +6: Magenta|Reset|Reset|NONE +7: Cyan|Reset|Reset|NONE +8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED +9: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 79:3 96:2 107:0 +4: 0:0 +5: 0:0 2:3 15:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 9:0 +8: 0:0 +9: 0:0 3:2 5:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 108:1 110:0 +17: 0:0 +18: 0:0 3:2 5:0 45:4 46:0 +19: 0:0 13:4 14:0 61:4 62:0 105:5 115:0 +20: 0:0 41:5 51:0 77:4 78:0 105:5 115:0 +21: 0:0 9:5 19:0 29:4 30:0 41:5 51:0 57:5 67:0 105:6 115:0 +22: 0:0 9:5 19:0 41:5 51:0 57:5 67:0 73:5 83:0 93:4 94:0 105:6 115:0 +23: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:5 83:0 89:5 99:0 105:6 115:0 +24: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:7 115:0 +25: 0:0 9:7 19:0 25:6 35:0 41:6 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0 +26: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +27: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +28: 0:0 4:2 5:0 6:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 17:0 +34: 0:0 2:1 7:0 54:1 58:0 +35: 0:0 2:7 4:0 +36: 0:0 2:6 4:0 +37: 0:0 2:5 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0 +41: 0:0 2:9 3:2 12:9 13:2 24:9 27:2 35:9 36:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_8.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_8.snap new file mode 100644 index 0000000000000000000000000000000000000000..ca433ec2fa646d58053992ad445a5838f88b0f30 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_business_8.snap @@ -0,0 +1,99 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" " +" " +" [1 Summary] 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard " +" g view · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 +2: 0:0 +3: 0:0 2:3 15:2 107:0 +4: 0:0 +5: 0:0 2:3 11:0 +6: 0:0 2:4 118:0 +7: 0:0 +8: 0:0 +9: 0:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 +17: 0:0 +18: 0:0 +19: 0:0 +20: 0:0 +21: 0:0 +22: 0:0 +23: 0:0 +24: 0:0 +25: 0:0 +26: 0:0 +27: 0:0 +28: 0:0 +29: 0:0 +30: 0:0 +31: 0:0 +32: 0:0 +33: 0:0 +34: 0:0 +35: 0:0 +36: 0:0 +37: 0:0 +38: 0:0 +39: 0:0 +40: 0:0 2:5 9:2 20:5 21:2 118:0 +41: 0:0 2:5 3:2 11:5 12:2 23:5 26:2 34:5 35:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_1.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_1.snap new file mode 100644 index 0000000000000000000000000000000000000000..e645f00b8c70182bb42e1f65e1c183de5b257890 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_1.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary [2 Total usage history] 3 Messages 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Total usage history By feature " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Total usage · relative units " +" Approximate · may be delayed up to 6 hours " +" 100 " +" 100 91 ██████████ " +" ▃▃▃▃▃▃▃▃▃▃ ██████████ 86 " +" ██████████ 81 ██████████ ▃▃▃▃▃▃▃▃▃▃ " +" 73 ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ " +" ▁▁▁▁▁▁▁▁▁▁ ██████████ ██████████ ██████████ ██████████ " +" ██████████ 64 ██████████ ██████████ ██████████ ██████████ " +" ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 50 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 20 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 86 usage units " +" Feature Share " +" ● Tasks 64.0% " +" ● Code review 14.0% " +" ● Images 11.6% " +" ● Other 10.5% " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Green|Reset|Reset|NONE +6: Magenta|Reset|Reset|NONE +7: Cyan|Reset|Reset|NONE +8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED +9: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 13:3 38:2 94:0 +4: 0:0 +5: 0:0 2:3 23:0 108:2 118:0 +6: 0:0 2:4 118:0 +7: 0:0 2:2 30:0 +8: 0:0 2:2 44:0 +9: 0:0 92:4 95:0 +10: 0:0 3:2 6:0 60:4 62:0 +11: 0:0 89:5 99:0 108:1 110:0 +12: 0:0 76:4 78:0 89:5 99:0 +13: 0:0 28:4 30:0 57:5 67:0 89:5 99:0 +14: 0:0 57:5 67:0 89:6 99:0 105:5 115:0 +15: 0:0 44:4 46:0 57:6 67:0 73:5 83:0 89:6 99:0 105:5 115:0 +16: 0:0 25:5 35:0 57:6 67:0 73:6 83:0 89:7 99:0 105:6 115:0 +17: 0:0 25:5 35:0 57:7 67:0 73:6 83:0 89:7 99:0 105:6 115:0 +18: 0:0 25:6 35:0 41:5 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0 +19: 0:0 4:2 6:0 25:6 35:0 41:6 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +20: 0:0 25:7 35:0 41:6 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +21: 0:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +22: 0:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +23: 0:0 13:4 15:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +24: 0:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +25: 0:0 9:6 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +26: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +27: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +28: 0:0 5:2 6:0 7:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 11:2 17:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 24:0 +34: 0:0 2:1 9:0 53:1 58:0 +35: 0:0 2:7 4:0 +36: 0:0 2:6 4:0 +37: 0:0 2:5 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0 +41: 0:0 2:9 3:2 12:9 13:2 22:9 23:2 34:9 37:2 45:9 46:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_2.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_2.snap new file mode 100644 index 0000000000000000000000000000000000000000..1ce6707eaecca694f3522143bf2f32b3b5ac7974 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_2.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history 3 Messages [4 Plugins called] 5 Skills used 6 Top chats " +" " +" ▎ Plugins called " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 186 calls " +" " +" 40 " +" 34 " +" ▁▁▁▁▁▁▁▁▁▁ " +" ██████████ " +" ██████████ 29 29 " +" ██████████ ▆▆▆▆▆▆▆▆▆▆ 26 ▆▆▆▆▆▆▆▆▆▆ " +" ██████████ ██████████ ▂▂▂▂▂▂▂▂▂▂ ██████████ " +" ██████████ 23 ██████████ ██████████ 22 23 ██████████ " +" ██████████ ▇▇▇▇▇▇▇▇▇▇ ██████████ ██████████ ▃▃▃▃▃▃▃▃▃▃ ▇▇▇▇▇▇▇▇▇▇ ██████████ " +" 20 ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 29 calls " +" Plugin Calls " +" ● Slack 10 " +" ● Figma 10 " +" ● GitHub 5 " +" ● Notion 4 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Green|Reset|Reset|NONE +6: Magenta|Reset|Reset|NONE +7: Cyan|Reset|Reset|NONE +8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED +9: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 48:3 68:2 94:0 +4: 0:0 +5: 0:0 2:3 18:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 11:0 +8: 0:0 +9: 0:0 3:2 5:0 +10: 0:0 12:4 14:0 +11: 0:0 +12: 0:0 +13: 0:0 44:4 46:0 108:1 110:0 +14: 0:0 9:5 19:0 60:4 62:0 +15: 0:0 9:5 19:0 +16: 0:0 9:5 19:0 28:4 30:0 41:5 51:0 76:4 78:0 92:4 94:0 105:5 115:0 +17: 0:0 9:6 19:0 41:5 51:0 105:5 115:0 +18: 0:0 3:2 5:0 9:6 19:0 41:5 51:0 57:5 67:0 105:5 115:0 +19: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:5 83:0 89:5 99:0 105:6 115:0 +20: 0:0 9:6 19:0 25:6 35:0 41:6 51:0 57:6 67:0 73:5 83:0 89:6 99:0 105:6 115:0 +21: 0:0 9:6 19:0 25:6 35:0 41:6 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:6 115:0 +22: 0:0 9:7 19:0 25:6 35:0 41:6 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:6 115:0 +23: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:7 115:0 +24: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0 +25: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +26: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +27: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +28: 0:0 4:2 5:0 6:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 18:0 +34: 0:0 2:1 8:0 53:1 58:0 +35: 0:0 2:7 4:0 +36: 0:0 2:6 4:0 +37: 0:0 2:5 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0 +41: 0:0 2:9 3:2 12:9 13:2 24:9 27:2 35:9 36:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_4.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_4.snap new file mode 100644 index 0000000000000000000000000000000000000000..7ad65de1c72ff190f8a2592819777b17928be7db --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_4.snap @@ -0,0 +1,99 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" " +" " +" 1 Summary 2 Total usage history 3 Messages 4 Plugins called 5 Skills used [6 Top chats] " +" " +" ▎ Top chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · s sort · z dashboard · ↑/↓ row · enter details " +" R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 +2: 0:0 +3: 0:0 2:2 81:3 94:0 +4: 0:0 +5: 0:0 2:3 13:0 +6: 0:0 2:4 118:0 +7: 0:0 +8: 0:0 +9: 0:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 +17: 0:0 +18: 0:0 +19: 0:0 +20: 0:0 +21: 0:0 +22: 0:0 +23: 0:0 +24: 0:0 +25: 0:0 +26: 0:0 +27: 0:0 +28: 0:0 +29: 0:0 +30: 0:0 +31: 0:0 +32: 0:0 +33: 0:0 +34: 0:0 +35: 0:0 +36: 0:0 +37: 0:0 +38: 0:0 +39: 0:0 +40: 0:0 2:5 9:2 20:5 21:2 29:5 30:2 43:5 46:2 53:5 58:2 118:0 +41: 0:0 2:5 3:2 14:5 17:2 25:5 26:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_5.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_5.snap new file mode 100644 index 0000000000000000000000000000000000000000..c3bc5b7441cecff5f34f3cf17e4ac654674090aa --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_5.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history [3 Messages] 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Messages By model " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 574 reported messages " +" " +" 150 " +" " +" " +" " +" 107 103 " +" ▄▄▄▄▄▄▄▄▄▄ " +" ██████████ ██████████ " +" 83 ██████████ 85 ██████████ " +" ▄▄▄▄▄▄▄▄▄▄ ██████████ 75 ▆▆▆▆▆▆▆▆▆▆ ██████████ " +" 75 ██████████ ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ " +" 62 ██████████ ██████████ ██████████ 59 ██████████ ██████████ " +" ▆▆▆▆▆▆▆▆▆▆ ██████████ ██████████ ██████████ ▃▃▃▃▃▃▃▃▃▃ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 103 messages " +" Model Messages " +" ● GPT-5.6-Sol 61 " +" ● GPT-5.6-Terra 25 " +" ● GPT-5.5 10 " +" ● Other 7 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · g group · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Green|Reset|Reset|NONE +6: Magenta|Reset|Reset|NONE +7: Cyan|Reset|Reset|NONE +8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED +9: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 36:3 50:2 94:0 +4: 0:0 +5: 0:0 2:3 12:0 110:2 118:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 23:0 +8: 0:0 +9: 0:0 3:2 6:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 44:4 47:0 108:1 111:0 +14: 0:0 +15: 0:0 41:5 51:0 +16: 0:0 28:4 30:0 41:5 51:0 92:4 94:0 105:5 115:0 +17: 0:0 41:6 51:0 60:4 62:0 105:6 115:0 +18: 0:0 4:2 6:0 25:5 35:0 41:6 51:0 89:5 99:0 105:6 115:0 +19: 0:0 13:4 15:0 25:6 35:0 41:6 51:0 57:5 67:0 76:4 78:0 89:6 99:0 105:6 115:0 +20: 0:0 25:6 35:0 41:7 51:0 57:6 67:0 89:6 99:0 105:7 115:0 +21: 0:0 9:6 19:0 25:6 35:0 41:7 51:0 57:6 67:0 73:5 83:0 89:6 99:0 105:7 115:0 +22: 0:0 9:6 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0 +23: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0 +24: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +25: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +26: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +27: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +28: 0:0 5:2 6:0 7:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 11:2 17:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 22:0 +34: 0:0 2:1 7:0 50:1 58:0 +35: 0:0 2:7 4:0 +36: 0:0 2:6 4:0 +37: 0:0 2:5 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0 +41: 0:0 2:9 3:2 12:9 13:2 22:9 23:2 34:9 37:2 45:9 46:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_6.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_6.snap new file mode 100644 index 0000000000000000000000000000000000000000..1637106ca756910779993ede063f92b9b51efaf9 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_6.snap @@ -0,0 +1,103 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history 3 Messages 4 Plugins called [5 Skills used] 6 Top chats " +" " +" ▎ Skills used " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 54 uses " +" " +" 20 " +" " +" " +" " +" " +" " +" " +" 11 " +" ▃▃▃▃▃▃▃▃▃▃ " +" 10 9 ██████████ " +" 8 ▄▄▄▄▄▄▄▄▄▄ 8 ██████████ " +" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▄▄▄▄▄▄▄▄▄▄ 7 ██████████ " +" ██████████ 6 ██████████ ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ " +" ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ ██████████ ██████████ 5 ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 11 uses " +" Skill Uses " +" ● Testing 4 " +" ● Code review 3 " +" ● Planning 3 " +" ● Debugging 1 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Green|Reset|Reset|NONE +6: Magenta|Reset|Reset|NONE +7: Cyan|Reset|Reset|NONE +8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED +9: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 2:3 6:0 11:2 34:0 +2: 0:0 +3: 0:0 2:2 66:3 83:2 94:0 +4: 0:0 +5: 0:0 2:3 15:0 +6: 0:0 2:4 118:0 +7: 0:0 2:3 9:0 +8: 0:0 +9: 0:0 3:2 5:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 108:1 110:0 +17: 0:0 +18: 0:0 3:2 5:0 45:4 46:0 +19: 0:0 13:4 14:0 61:4 62:0 105:5 115:0 +20: 0:0 41:5 51:0 77:4 78:0 105:5 115:0 +21: 0:0 9:5 19:0 29:4 30:0 41:5 51:0 57:5 67:0 105:6 115:0 +22: 0:0 9:5 19:0 41:5 51:0 57:5 67:0 73:5 83:0 93:4 94:0 105:6 115:0 +23: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:5 83:0 89:5 99:0 105:6 115:0 +24: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:7 115:0 +25: 0:0 9:7 19:0 25:6 35:0 41:6 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0 +26: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +27: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0 +28: 0:0 4:2 5:0 6:4 118:0 +29: 0:0 109:3 110:0 +30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0 +31: 0:0 +32: 0:0 2:4 58:0 +33: 0:0 2:1 7:3 17:0 +34: 0:0 2:1 7:0 54:1 58:0 +35: 0:0 2:7 4:0 +36: 0:0 2:6 4:0 +37: 0:0 2:5 4:0 +38: 0:0 +39: 0:0 +40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0 +41: 0:0 2:9 3:2 12:9 13:2 24:9 27:2 35:9 36:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_8.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_8.snap new file mode 100644 index 0000000000000000000000000000000000000000..11182eb8a341f4fa9758118f219c2893c81a82ea --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_8.snap @@ -0,0 +1,99 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 145 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" " +" " +" [1 Summary] 2 Total usage history 3 Messages 4 Plugins called 5 Skills used 6 Top chats " +" " +" ▎ Summary " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard " +" g view · R refresh · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: Black|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 +2: 0:0 +3: 0:0 2:3 15:2 94:0 +4: 0:0 +5: 0:0 2:3 11:0 +6: 0:0 2:4 118:0 +7: 0:0 +8: 0:0 +9: 0:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 +17: 0:0 +18: 0:0 +19: 0:0 +20: 0:0 +21: 0:0 +22: 0:0 +23: 0:0 +24: 0:0 +25: 0:0 +26: 0:0 +27: 0:0 +28: 0:0 +29: 0:0 +30: 0:0 +31: 0:0 +32: 0:0 +33: 0:0 +34: 0:0 +35: 0:0 +36: 0:0 +37: 0:0 +38: 0:0 +39: 0:0 +40: 0:0 2:5 9:2 20:5 21:2 118:0 +41: 0:0 2:5 3:2 11:5 12:2 23:5 26:2 34:5 35:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_handle_empty_days_and_independent_failures.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_handle_empty_days_and_independent_failures.snap new file mode 100644 index 0000000000000000000000000000000000000000..d1fce9fa868ab88550159573c8a04a54e755ad4e --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_handle_empty_days_and_independent_failures.snap @@ -0,0 +1,138 @@ +--- +source: tui/src/analytics/tool_panel_tests.rs +expression: "format!(\"{empty}\\n{missing}\\n{partial}\")" +--- +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history 3 Messages 4 Plugins called [5 Skills used] 6 Top chats " +" " +" ▎ Skills used " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 38 uses " +" " +" 10 " +" 9 " +" ▇▇▇▇▇▇▇▇▇▇▇▇ " +" 8 ████████████ 8 " +" ▆▆▆▆▆▆▆▆▆▆▆▆ ████████████ ▆▆▆▆▆▆▆▆▆▆▆▆ " +" ████████████ ████████████ ████████████ 7 " +" ████████████ ████████████ ████████████ ▅▅▅▅▅▅▅▅▅▅▅▅ " +" ████████████ 6 ████████████ ████████████ ████████████ " +" ████████████ ▄▄▄▄▄▄▄▄▄▄▄▄ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" 5 ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ — 0 " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" No uses reported for Sep 2. " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard · ←/→ day " +" r 7/30d · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history 3 Messages 4 Plugins called [5 Skills used] 6 Top chats " +" " +" ▎ Skills used " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 38 uses " +" " +" 10 " +" 9 " +" ▇▇▇▇▇▇▇▇▇▇▇▇ " +" 8 ████████████ 8 " +" ▆▆▆▆▆▆▆▆▆▆▆▆ ████████████ ▆▆▆▆▆▆▆▆▆▆▆▆ " +" ████████████ ████████████ ████████████ 7 " +" ████████████ ████████████ ████████████ ▅▅▅▅▅▅▅▅▅▅▅▅ " +" ████████████ 6 ████████████ ████████████ ████████████ " +" ████████████ ▄▄▄▄▄▄▄▄▄▄▄▄ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" 5 ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ — 0 " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Data not reported for Sep 1. " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard · ←/→ day " +" r 7/30d · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history 3 Messages 4 Plugins called [5 Skills used] 6 Top chats " +" " +" ▎ Skills used " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Skills could not be loaded. Press R to retry. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–6 section · z dashboard · ←/→ day " +" r 7/30d · R refresh · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_reflow_and_keep_independent_days.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_reflow_and_keep_independent_days.snap new file mode 100644 index 0000000000000000000000000000000000000000..484164656c50cfffd507270854fbf8a2cd24b207 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_reflow_and_keep_independent_days.snap @@ -0,0 +1,85 @@ +--- +source: tui/src/analytics/tool_panel_tests.rs +expression: "format!(\"{wide}\\n{narrow}\")" +--- +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" 1 Summary 2 Total usage history 3 Messages [4 Plugins called] 5 Skills used 6 Top chats " +" " +" ▎ Plugins called " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 186 calls " +" " +" 40 " +" " +" 34 " +" ▂▂▂▂▂▂▂▂▂▂▂▂ " +" ████████████ " +" ████████████ 29 29 " +" ████████████ ▁▁▁▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁▁▁▁▁ " +" ████████████ ████████████ 26 ████████████ " +" ████████████ ████████████ ▂▂▂▂▂▂▂▂▂▂▂▂ ████████████ " +" ████████████ 23 ████████████ ████████████ 23 ████████████ " +" ████████████ ▃▃▃▃▃▃▃▃▃▃▃▃ ████████████ ████████████ 22 ▃▃▃▃▃▃▃▃▃▃▃▃ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ▆▆▆▆▆▆▆▆▆▆▆▆ ████████████ ████████████ " +" 20 ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ ████████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ──────────────────────────────────────────────────────── " +" Sep 2 · 29 calls " +" Plugin Calls " +" ● Slack 10 " +" ● Figma 10 " +" ● GitHub 5 " +" ● Notion 4 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter details " +" r 7/30d · R refresh · esc back · q close " + +" Analytics · Live account " +" [7d] 30d · Aug 27–Sep 2, 2026 " +" " +" Summary Total usage history Messages Plugins called " +" [Skills used] Top chats " +" " +" ▎ Skills used " +" ────────────────────────────────────────────────────── " +" 54 uses " +" " +" 20 " +" 11 " +" 8 9 8 7 ▂▂▂▂▂ " +" 10 ▃▃▃▃ 6 ▅▅▅▅ ▃▃▃▃ 5 █████ " +" ████ ▆▆▆▆ ████ ████ ████ ▄▄▄▄ █████ " +" ████ ████ ████ ████ ████ ████ █████ " +" 0 ────────────────────────────────────────────────── " +" ▲ " +" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " +" " +" ────────────────────────────────────────────────────── " +" Sep 1 · 5 uses " +" Skill Uses " +" ● Testing 3 " +" ● Code review 1 " +" ● Planning 1 " +" ● Debugging 0 " +" " +" tab/1–6 section · z dashboard · ←/→ day · enter " +" details " +" r 7/30d · R refresh · esc back · q close " +" " diff --git a/codex-rs/tui/src/analytics/styles.rs b/codex-rs/tui/src/analytics/styles.rs new file mode 100644 index 0000000000000000000000000000000000000000..b37737fc5ccc6807c09b5c1232399e720cc384aa --- /dev/null +++ b/codex-rs/tui/src/analytics/styles.rs @@ -0,0 +1,37 @@ +//! Charts and legends share the terminal's ANSI palette. +//! Secondary text blends probed defaults only when the terminal supports that color depth. + +use crate::color::blend; +use crate::style::accent_style; +use crate::terminal_palette::StdoutColorLevel; +use crate::terminal_palette::best_color; +use crate::terminal_palette::default_bg; +use crate::terminal_palette::default_fg; +use crate::terminal_palette::effective_stdout_color_level; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::text::Span; + +pub(super) fn series_colors() -> [Color; 4] { + [Color::Cyan, Color::Magenta, Color::Green, Color::Reset] +} + +pub(super) fn number(text: impl Into) -> Span<'static> { + Span::styled(text.into(), accent_style()) +} + +/// Labels and instructions need more contrast than decorative rules or inactive values. +pub(super) fn secondary_style() -> Style { + if !matches!( + effective_stdout_color_level(), + StdoutColorLevel::TrueColor | StdoutColorLevel::Ansi256 + ) { + return Style::default().dim(); + } + match (default_fg(), default_bg()) { + (Some(fg), Some(bg)) => { + Style::default().fg(best_color(blend(fg, bg, /*alpha*/ 0.70))) + } + _ => Style::default().dim(), + } +} diff --git a/codex-rs/tui/src/analytics/styles_tests.rs b/codex-rs/tui/src/analytics/styles_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..25f7e3a62e69b0fe6543c1c4b38138688b5a6c38 --- /dev/null +++ b/codex-rs/tui/src/analytics/styles_tests.rs @@ -0,0 +1,161 @@ +//! Render both account layouts with explicit terminal palettes, including color and selection styles. + +use super::*; +use crate::analytics::sections::Section; +use crate::style::accent_style; +use crate::terminal_palette::with_test_default_colors; +use crate::terminal_probe::DefaultColors; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Modifier; + +fn luminance(rgb: (u8, u8, u8)) -> f64 { + let [r, g, b] = [rgb.0, rgb.1, rgb.2].map(|channel| { + let value = f64::from(channel) / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(/*n*/ 2.4) + } + }); + 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +#[test] +fn analytics_sections_adapt_to_terminal_colors() { + for (theme, colors) in [ + ( + "light", + DefaultColors { + fg: (0, 0, 0), + bg: (255, 255, 255), + }, + ), + ( + "dark", + DefaultColors { + fg: (230, 230, 230), + bg: (16, 16, 16), + }, + ), + ] { + with_test_default_colors(colors, || { + for business in [false, true] { + let mut view = fixture::view(models::AccountKind::Consumer); + if business { + view = fixture::view(models::AccountKind::Enterprise); + } + view.sections[Section::Chats].detail = Some(0); + // Exercise the reported credit range and preserve exact amounts below the plot. + let mut credits = + fixture::history(/*report*/ 1, /*range*/ 0, /*group*/ 0); + for day in &mut credits.data { + day.total *= 40.0; + for value in &mut day.values { + value.value *= 40.0; + } + } + view.sections[Section::Credits].history = Load::Ready(credits); + for section in view.visible_sections() { + let section = *section; + view.section = section; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 120, /*height*/ 42, + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + let heading = buffer + .content + .iter() + .find(|cell| cell.symbol() == "▎") + .unwrap(); + assert_eq!(heading.fg, accent_style().fg.unwrap()); + assert!(heading.modifier.contains(Modifier::BOLD)); + for marker in buffer.content.iter().filter(|cell| cell.symbol() == "▲") { + assert_eq!(marker.fg, accent_style().fg.unwrap()); + assert_eq!(marker.bg, Color::Reset); + assert!(marker.modifier.contains(Modifier::BOLD)); + } + if theme == "light" { + for cell in buffer + .content + .iter() + .filter(|cell| !cell.symbol().trim().is_empty()) + { + let foreground = match cell.fg { + Color::Rgb(r, g, b) => (r, g, b), + Color::White => (255, 255, 255), + Color::Black => (0, 0, 0), + Color::Reset => colors.fg, + _ => continue, + }; + let contrast = + (luminance(colors.bg) + 0.05) / (luminance(foreground) + 0.05); + assert!( + contrast >= 4.5, + "{}: {:?} has contrast {contrast}", + cell.symbol(), + cell.fg + ); + } + } + // Preserve every symbol and style while sharing repeated styles in a palette. + let mut palette = Vec::new(); + let mut text = Vec::new(); + let mut runs = Vec::new(); + for (y, row) in buffer.content.chunks(usize::from(area.width)).enumerate() { + text.push(format!( + "{:?}", + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + )); + let mut previous = None; + let mut changes = Vec::new(); + for (x, cell) in row.iter().enumerate() { + let style = format!( + "{:?}|{:?}|{:?}|{:?}", + cell.fg, cell.bg, cell.underline_color, cell.modifier + ); + let index = palette + .iter() + .position(|candidate| *candidate == style) + .unwrap_or_else(|| { + palette.push(style); + palette.len() - 1 + }); + if previous != Some(index) { + changes.push(format!("{x}:{index}")); + previous = Some(index); + } + } + runs.push(format!("{y}: {}", changes.join(" "))); + } + let palette = palette + .iter() + .enumerate() + .map(|(index, style)| format!("{index}: {style}")) + .collect::>() + .join("\n"); + let snapshot = format!( + "{}x{}\nText:\n{}\nPalette (foreground|background|underline|modifiers):\n{palette}\nRows (column:palette):\n{}", + area.width, + area.height, + text.join("\n"), + runs.join("\n") + ); + insta::assert_snapshot!( + format!( + "analytics_{theme}_{}_{}", + if business { "business" } else { "consumer" }, + section as usize + 1 + ), + snapshot + ); + } + } + }); + } +} diff --git a/codex-rs/tui/src/analytics/summary.rs b/codex-rs/tui/src/analytics/summary.rs new file mode 100644 index 0000000000000000000000000000000000000000..b0f790a875fb91ef3e586d8af55503e8ded3ddcb --- /dev/null +++ b/codex-rs/tui/src/analytics/summary.rs @@ -0,0 +1,75 @@ +//! Account profile loading and the Summary's year-view interaction. +//! Aggregation changes reuse the loaded profile; account refresh replaces all profile data. + +use super::AnalyticsView; +use super::activity_chart::TokenActivityView; +use super::client::Live; +use super::data::Load; +use super::sections::Section; +use crate::keymap::ListAction; +use codex_backend_client::AccountProfile; + +pub(super) const VIEWS: [TokenActivityView; 3] = [ + TokenActivityView::Daily, + TokenActivityView::Weekly, + TokenActivityView::Cumulative, +]; + +impl Live { + pub(super) async fn profile(&self) -> Result, String> { + let session = self.session().await?; + let profile = session + .backend + .request(|client| async move { client.get_account_profile().await }) + .await; + session.backend.ensure_identity().await?; + profile.map(Some).map_err(super::client::request_error) + } +} + +impl AnalyticsView { + pub(crate) fn select_summary(&mut self, view: Option) { + self.section = Section::Summary; + self.zoomed = true; + self.scroll_offset = 0; + if let Some(view) = view { + self.sections[Section::Summary].group = VIEWS + .iter() + .position(|item| *item == view) + .unwrap_or_default(); + } + } + + pub(super) fn load_summary(&mut self) { + if let (Some((_, _, frame)), Some(live)) = (&self.connection, &self.live) { + let live = std::sync::Arc::clone(live); + self.profile = Load::start(async move { live.profile().await }, frame.clone()); + } + } + + pub(super) fn summary_action(&mut self, action: Option) { + let step = match action { + Some(ListAction::MoveUp) => Some(-1), + Some(ListAction::MoveDown) => Some(1), + Some(ListAction::PageUp) => Some(-(self.viewport_height as isize)), + Some(ListAction::PageDown) => Some(self.viewport_height as isize), + Some(ListAction::JumpTop) => { + self.scroll_offset = 0; + Some(0) + } + Some(ListAction::JumpBottom) => { + self.scroll_offset = usize::MAX; + Some(0) + } + Some(ListAction::Cancel) => { + self.is_done = true; + None + } + Some(ListAction::MoveLeft | ListAction::MoveRight | ListAction::Accept) | None => None, + }; + if let Some(step) = step { + self.scroll_offset = self.scroll_offset.saturating_add_signed(step); + self.follow_selection = false; + } + } +} diff --git a/codex-rs/tui/src/analytics/summary_panel.rs b/codex-rs/tui/src/analytics/summary_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..6bcebfd5a91d715c79b60dd9c90baa24e5722842 --- /dev/null +++ b/codex-rs/tui/src/analytics/summary_panel.rs @@ -0,0 +1,363 @@ +//! Responsive profile summary using the Analytics container's typography and spacing. + +use super::AnalyticsView; +use super::activity_chart; +use super::render::columns; +use super::render::join_columns; +use super::sections::Section; +use super::styles::secondary_style; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow as truncate; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; +use codex_backend_client::ProfileInvocationKind; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; + +impl AnalyticsView { + pub(super) fn summary_lines(&self, width: usize) -> Vec> { + let width = width.max(/*other*/ 1); + let inner_width = width.min(/*other*/ 110); + let mut lines = Vec::new(); + let Some(profile) = self.profile.ready() else { + lines.push( + self.profile + .message() + .unwrap_or("Profile statistics unavailable.") + .to_owned() + .into(), + ); + return lines; + }; + if let Some(identity) = &profile.profile { + if let Some(name) = identity + .display_name + .as_deref() + .filter(|name| !name.trim().is_empty()) + { + lines.push(center( + truncate(name.trim().to_owned().bold().into(), inner_width), + inner_width, + )); + } + if let Some(username) = identity + .username + .as_deref() + .filter(|name| !name.trim().is_empty()) + { + lines.push(center( + truncate( + format!("@{}", username.trim().trim_start_matches('@')) + .set_style(secondary_style()) + .into(), + inner_width, + ), + inner_width, + )); + } + } + let plan = self.account.ready().map(|plan| { + use codex_protocol::account::PlanType; + match plan { + PlanType::Free => "Free", + PlanType::Go => "Go", + PlanType::Plus => "Plus", + PlanType::Pro => "Pro", + PlanType::ProLite => "Pro Lite", + PlanType::Team + | PlanType::Business + | PlanType::SelfServeBusinessProLite + | PlanType::SelfServeBusinessUsageBased => "Business", + PlanType::Ent26 + | PlanType::EnterpriseCbpAutomation + | PlanType::EnterpriseCbpUsageBased + | PlanType::Enterprise => "Enterprise", + PlanType::Edu | PlanType::EduPlus | PlanType::EduPro => "Education", + PlanType::Unknown => "Account", + } + }); + if let Some(plan) = plan { + lines.push(center( + plan.set_style(secondary_style()).into(), + inner_width, + )); + } + lines.push(Line::default()); + let tokens = &profile.stats.tokens; + let fields = [ + ( + "Lifetime tokens", + tokens + .lifetime_tokens + .map(|value| super::data::compact_amount(value as f64)), + ), + ( + "Peak tokens", + tokens + .peak_daily_tokens + .map(|value| super::data::compact_amount(value as f64)), + ), + ( + "Longest chat", + tokens.longest_running_turn_sec.map(duration), + ), + ( + "Current streak", + tokens + .current_streak_days + .map(|days| format!("{days} days")), + ), + ( + "Longest streak", + tokens + .longest_streak_days + .map(|days| format!("{days} days")), + ), + ]; + if !self.zoomed { + lines.extend(fields.into_iter().map(|(label, value)| { + columns( + label.set_style(secondary_style()).into(), + value.unwrap_or_else(|| "—".into()).bold().into(), + inner_width, + ) + })); + return lines; + } + let count = if inner_width >= 90 { + 5 + } else if inner_width >= 54 { + 3 + } else if inner_width >= 36 { + 2 + } else { + 1 + }; + for chunk in fields.chunks(count) { + let cell_width = inner_width / chunk.len(); + let mut values = Line::default(); + let mut labels = Line::default(); + for (index, (label, value)) in chunk.iter().enumerate() { + for (row, cell) in [ + ( + &mut values, + value.clone().unwrap_or_else(|| "—".into()).bold(), + ), + (&mut labels, label.to_string().set_style(secondary_style())), + ] { + let cell = center(truncate(cell.into(), cell_width), cell_width); + let padding = cell_width.saturating_sub(cell.width()); + row.spans.extend(cell.spans); + if index + 1 < chunk.len() { + row.spans.push(" ".repeat(padding).into()); + } + } + } + lines.extend([values, labels, Line::default()]); + } + let selected = self.sections[Section::Summary].group; + let controls = Line::from(super::summary::VIEWS[selected].label()); + if inner_width >= 58 { + lines.push(columns( + "Token activity".bold().into(), + controls, + inner_width, + )); + } else { + lines.push("Token activity".bold().into()); + lines.extend(word_wrap_lines([controls], RtOptions::new(inner_width))); + } + lines.push("Last 12 months".set_style(secondary_style()).into()); + lines.push(Line::default()); + if let Some(buckets) = &tokens.daily_usage_buckets { + lines.extend(activity_chart::chart_lines( + super::summary::VIEWS[selected], + buckets, + self.end_date, + inner_width as u16, + )); + } else { + lines.push( + "Token activity history unavailable" + .set_style(secondary_style()) + .into(), + ); + } + lines.push(Line::default()); + let stats = &profile.stats; + let reasoning = stats + .most_used_reasoning_effort + .as_deref() + .map(|effort| { + let percentage = percent(stats.most_used_reasoning_effort_percentage.as_ref()); + format!("{effort} · {percentage}") + }) + .unwrap_or_else(|| "—".into()); + let metrics = [ + ( + "Fast Mode", + percent(stats.fast_mode_usage_percentage.as_ref()), + ), + ("Most used reasoning", reasoning), + ("Skills explored", count_value(stats.unique_skills_used)), + ("Total skills used", count_value(stats.total_skills_used)), + ("Total chats", count_value(stats.total_threads)), + ]; + let two_columns = inner_width >= 76; + let column_width = if two_columns { + (inner_width - 4) / 2 + } else { + inner_width + }; + let mut insights = vec!["Activity insights".bold().into(), Line::default()]; + for (label, value) in metrics { + insights.extend(word_wrap_lines( + [columns( + label.set_style(secondary_style()).into(), + value.into(), + column_width, + )], + RtOptions::new(column_width), + )); + } + let mut plugins = vec![ + "Most used plugins and skills".bold().into(), + Line::default(), + ]; + if let Some(invocations) = &stats.top_invocations { + let mut displayed = 0; + for invocation in invocations { + let (prefix, name) = match invocation.kind { + ProfileInvocationKind::Plugin => ("@", invocation.plugin_name.as_deref()), + ProfileInvocationKind::Skill => ("$", invocation.skill_name.as_deref()), + ProfileInvocationKind::Unknown => continue, + }; + let Some(name) = name.filter(|name| !name.trim().is_empty()) else { + continue; + }; + let value = invocation + .usage_count + .map(|count| { + format!( + "{} {}", + count_value(Some(count)), + if count == 1 { "run" } else { "runs" } + ) + }) + .unwrap_or_else(|| "—".into()); + plugins.extend(word_wrap_lines( + [columns( + truncate( + format!("{prefix}{}", name.trim()).into(), + column_width.saturating_sub(value.len() + 1), + ), + value.set_style(secondary_style()).into(), + column_width, + )], + RtOptions::new(column_width), + )); + displayed += 1; + if displayed == 5 { + break; + } + } + if displayed == 0 { + plugins.push( + "No reported plugins or skills." + .set_style(secondary_style()) + .into(), + ); + } + } else { + plugins.push( + "Plugin and skill usage unavailable." + .set_style(secondary_style()) + .into(), + ); + } + if two_columns { + lines.extend(join_columns(&insights, &plugins, column_width)); + } else { + lines.extend(insights); + lines.push(Line::default()); + lines.extend(plugins); + } + if let Some(metadata) = &profile.metadata { + lines.push(Line::default()); + if let Some(date) = metadata + .stats_as_of + .as_deref() + .and_then(|date| chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()) + { + lines.push( + format!("Statistics as of {}", date.format("%b %-d, %Y")) + .set_style(secondary_style()) + .into(), + ); + } + if metadata + .stats_error + .as_deref() + .is_some_and(|error| !error.trim().is_empty()) + { + lines.push( + "Some profile statistics are unavailable." + .set_style(secondary_style()) + .into(), + ); + } + } + let padding = " ".repeat(width.saturating_sub(inner_width) / 2); + word_wrap_lines(lines, RtOptions::new(inner_width)) + .into_iter() + .map(|mut line| { + line.spans.insert(/*index*/ 0, padding.clone().into()); + line + }) + .collect() + } +} + +fn center(mut line: Line<'static>, width: usize) -> Line<'static> { + line.spans.insert( + /*index*/ 0, + " ".repeat(width.saturating_sub(line.width()) / 2).into(), + ); + line +} + +fn percent(value: Option<&serde_json::Number>) -> String { + value + .and_then(serde_json::Number::as_f64) + .filter(|value| value.is_finite() && (0.0..=100.0).contains(value)) + .map(|value| format!("{value:.0}%")) + .unwrap_or_else(|| "—".into()) +} + +fn count_value(value: Option) -> String { + let Some(value) = value else { + return "—".into(); + }; + let digits = value.to_string(); + digits + .chars() + .enumerate() + .fold(String::new(), |mut result, (index, ch)| { + if index > 0 && (digits.len() - index).is_multiple_of(/*rhs*/ 3) { + result.push(','); + } + result.push(ch); + result + }) +} + +fn duration(seconds: i64) -> String { + let seconds = seconds.max(/*other*/ 0); + let (hours, minutes) = (seconds / 3600, seconds % 3600 / 60); + match (hours, minutes) { + (0, 0) => format!("{seconds}s"), + (0, minutes) => format!("{minutes}m"), + (hours, 0) => format!("{hours}h"), + (hours, minutes) => format!("{hours}h {minutes}m"), + } +} diff --git a/codex-rs/tui/src/analytics/summary_tests.rs b/codex-rs/tui/src/analytics/summary_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..faf84d765b7bc18c620f62f6c309b6161bd5df6b --- /dev/null +++ b/codex-rs/tui/src/analytics/summary_tests.rs @@ -0,0 +1,153 @@ +//! Summary reflow, aggregation selection, and independently unavailable profile sections. +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +fn profile() -> codex_backend_client::AccountProfile { + serde_json::from_value(json!({ + "profile":{"display_name":"Example User","username":"example.user"}, + "metadata":{"stats_as_of":"2026-09-09"}, + "stats":{"lifetime_tokens":142_300_000_000_i64,"peak_daily_tokens":7_000_000_000_i64, + "longest_running_turn_sec":158820,"current_streak_days":158,"longest_streak_days":160, + "daily_usage_buckets":[{"start_date":"2026-09-08","tokens":100},{"start_date":"2026-09-09","tokens":200}], + "fast_mode_usage_percentage":37.0,"most_used_reasoning_effort":"high","most_used_reasoning_effort_percentage":26.0, + "unique_skills_used":147,"total_skills_used":13190,"total_threads":21482, + "top_invocations":[{"type":"skill","skill_name":"review","usage_count":1142},{"type":"plugin","plugin_name":"GitHub","usage_count":1059},{"type":"future"}]} + })).unwrap() +} + +#[test] +fn summary_modes_reflow_and_keep_selection_across_tabs() { + let mut view = fixture::view(models::AccountKind::Consumer); + view.profile = Load::Ready(profile()); + view.end_date = "2026-09-09".parse().unwrap(); + view.select_summary(Some(crate::analytics::TokenActivityView::Daily)); + insta::assert_snapshot!( + "summary_daily_wide", + screen(&mut view, /*width*/ 140, /*height*/ 44) + ); + press(&mut view, KeyCode::Char('g')); + assert_eq!(view.sections[Section::Summary].group, 1); + assert!(view.profile.ready().is_some()); + insta::assert_snapshot!( + "summary_weekly", + screen(&mut view, /*width*/ 110, /*height*/ 44) + ); + press(&mut view, KeyCode::Tab); + press(&mut view, KeyCode::BackTab); + assert_eq!(view.sections[Section::Summary].group, 1); + press(&mut view, KeyCode::Char('g')); + insta::assert_snapshot!( + "summary_cumulative_narrow", + screen(&mut view, /*width*/ 64, /*height*/ 60) + ); + for width in [1, 4, 28, 64] { + let lines = view.summary_lines(width); + assert!(lines.iter().all(|line| line.width() <= width)); + } + screen(&mut view, /*width*/ 64, /*height*/ 18); + press(&mut view, KeyCode::End); + insta::assert_snapshot!( + "summary_scrolled", + screen(&mut view, /*width*/ 64, /*height*/ 18) + ); + press(&mut view, KeyCode::Home); + press(&mut view, KeyCode::Char('z')); + insta::assert_snapshot!( + "summary_overview", + screen(&mut view, /*width*/ 144, /*height*/ 64) + ); + press(&mut view, KeyCode::Esc); + assert!(view.is_done); +} + +#[test] +fn partial_summary_keeps_missing_values_and_zero() { + let mut view = fixture::view(models::AccountKind::Business); + view.select_summary(/*view*/ None); + view.profile = Load::Ready(serde_json::from_value(json!({ + "metadata":{"stats_error":"sensitive upstream error"}, + "stats":{"lifetime_tokens":0,"fast_mode_usage_percentage":0,"total_threads":0,"top_invocations":[]} + })).unwrap()); + let rendered = screen(&mut view, /*width*/ 100, /*height*/ 40); + assert!(!rendered.contains("sensitive upstream error")); + insta::assert_snapshot!("summary_partial", rendered); + view.refresh(); + assert!(view.profile.ready().is_none()); +} + +#[tokio::test] +async fn summary_loads_once_per_refresh_and_does_not_block_other_reports() { + use wiremock::Mock; + use wiremock::ResponseTemplate; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + let server = test_support::server().await; + let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(/*v*/ 0)); + let count = std::sync::Arc::clone(&requests); + Mock::given(method("GET")) + .and(path("/backend-api/wham/profiles/me")) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(move |_: &wiremock::Request| { + if count.fetch_add(/*val*/ 1, std::sync::atomic::Ordering::SeqCst) == 0 { + ResponseTemplate::new(/*s*/ 503) + } else { + ResponseTemplate::new(/*s*/ 200) + .set_body_json(json!({"stats":{"lifetime_tokens":123}})) + } + }) + .expect(/*r*/ 2) + .mount(&server) + .await; + let (_home, _app_server, mut view) = client::tests::connected_view(&server, "plus").await; + test_support::settle(&mut view).await; + assert_eq!(view.section, Section::Summary); + assert!(matches!(view.profile, Load::Error(_))); + assert!(view.sections[Section::Usage].history.ready().is_some()); + press(&mut view, KeyCode::Char('g')); + press(&mut view, KeyCode::Tab); + press(&mut view, KeyCode::BackTab); + test_support::settle(&mut view).await; + assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 1); + press(&mut view, KeyCode::Char('R')); + test_support::settle(&mut view).await; + assert_eq!( + view.profile.ready().unwrap().stats.tokens.lifetime_tokens, + Some(123) + ); + assert_eq!(view.sections[Section::Summary].group, 1); + view.cancel_loads(); + assert!(matches!(view.profile, Load::Unavailable)); +} + +#[tokio::test] +async fn closing_summary_aborts_its_pending_profile() { + let mut view = fixture::view(models::AccountKind::Consumer); + let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel::<()>(); + view.profile = Load::start( + async move { + let _cancelled = cancelled_tx; + std::future::pending().await + }, + crate::tui::FrameRequester::test_dummy(), + ); + view.cancel_loads(); + assert!(cancelled_rx.await.is_err()); +} + +#[test] +fn summary_scroll_hints_follow_overflow_and_grouping_wraps() { + let mut view = fixture::view(models::AccountKind::Consumer); + view.profile = Load::Ready(profile()); + view.select_summary(Some(crate::analytics::TokenActivityView::Daily)); + assert!(!screen(&mut view, /*width*/ 140, /*height*/ 60).contains("scroll")); + assert!(screen(&mut view, /*width*/ 80, /*height*/ 18).contains("scroll")); + let mut groups = Vec::new(); + for _ in 0..4 { + groups.push(view.sections[Section::Summary].group); + press(&mut view, KeyCode::Char('g')); + } + assert_eq!(groups, vec![0, 1, 2, 0]); + assert!(!screen(&mut view, /*width*/ 140, /*height*/ 60).contains("scroll")); +} diff --git a/codex-rs/tui/src/analytics/task_panel.rs b/codex-rs/tui/src/analytics/task_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..6f9c1826103a2f6024242e535e68fac8cc37a881 --- /dev/null +++ b/codex-rs/tui/src/analytics/task_panel.rs @@ -0,0 +1,344 @@ +//! Consumer chat metrics retain actual balance debits, current-limit comparisons, and partial states. + +use super::AnalyticsView; +use super::render::columns; +use super::sections::Section; +use super::styles::number; +use super::styles::secondary_style; +use super::tasks::Chat; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow as truncate; +use crate::style::accent_style; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; +use codex_backend_client::TaskUsageAmounts; +use codex_backend_client::TaskUsageStatus; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::cmp::Ordering; +use std::ops::Range; + +pub(super) const METRICS: [&str; 3] = ["Weekly %", "5-hour %", "Balance credits"]; + +fn compare(a: Option<&TaskUsageAmounts>, b: Option<&TaskUsageAmounts>, metric: usize) -> Ordering { + if metric == 2 { + a.and_then(|a| a.balance_usage_credits.as_ref()) + .cmp(&b.and_then(|b| b.balance_usage_credits.as_ref())) + } else { + let value = |amounts: &TaskUsageAmounts| { + if metric == 0 { + amounts.weekly_limit_percent + } else { + amounts.five_hour_limit_percent + } + }; + a.and_then(value) + .partial_cmp(&b.and_then(value)) + .unwrap_or(Ordering::Equal) + } +} + +pub(super) fn amount(amounts: Option<&TaskUsageAmounts>, metric: usize) -> String { + let Some(amounts) = amounts else { + return "—".into(); + }; + if metric == 2 { + amounts + .balance_usage_credits + .as_ref() + .map(|amount| amount.as_str().to_string()) + .unwrap_or_else(|| "—".into()) + } else { + (if metric == 0 { + amounts.weekly_limit_percent + } else { + amounts.five_hour_limit_percent + }) + .map(|value| format!("{value:.1}%")) + .unwrap_or_else(|| "—".into()) + } +} + +pub(super) fn available(chat: &Chat) -> Option<&TaskUsageAmounts> { + chat.task + .as_ref() + .filter(|task| task.data_status != TaskUsageStatus::Unavailable) + .map(|task| &task.amounts) +} + +impl AnalyticsView { + pub(super) fn task_metrics(&self) -> Vec { + (0..3) + .filter(|metric| { + *metric == 2 + || self.tasks.ready().is_some_and(|chats| { + chats.rows.iter().any(|chat| { + available(chat).is_some_and(|amounts| { + if *metric == 0 { + amounts.weekly_limit_percent.is_some() + } else { + amounts.five_hour_limit_percent.is_some() + } + }) + }) + }) + }) + .collect() + } + + pub(super) fn task_metric(&self) -> usize { + let metrics = self.task_metrics(); + if metrics.contains(&self.chat_metric) { + self.chat_metric + } else { + metrics[0] + } + } + + pub(super) fn task_rows(&self) -> Vec<&Chat> { + let mut rows = self + .tasks + .ready() + .map(|chats| chats.rows.iter().collect::>()) + .unwrap_or_default(); + rows.sort_by(|a, b| compare(available(b), available(a), self.task_metric())); + rows + } + + pub(super) fn task_lines(&self, width: usize) -> (Vec>, Range) { + let width = width.clamp(/*min*/ 1, /*max*/ 110); + let wrap = |lines| word_wrap_lines(lines, RtOptions::new(width)); + let Some(chats) = self.tasks.ready() else { + return ( + wrap(vec![ + self.tasks + .message() + .unwrap_or("No task usage reported.") + .to_string() + .into(), + ]), + 0..1, + ); + }; + let metric = self.task_metric(); + let mut lines = wrap(vec![ + format!( + "Usage estimates · active in past 30 days · sorted by {}", + METRICS[metric] + ) + .set_style(secondary_style()) + .into(), + if metric == 2 { + "Credits debited from balance · includes adjustments" + } else { + "Recorded usage / current full limit · may exceed 100%" + } + .set_style(secondary_style()) + .into(), + ]); + if chats.rows.is_empty() { + lines.push("No recent local chats.".into()); + return (lines, 0..1); + } + let missing = chats + .rows + .iter() + .filter(|chat| amount(available(chat), metric) == "—") + .count(); + let partial = chats + .rows + .iter() + .filter(|chat| { + chat.task + .as_ref() + .is_some_and(|task| task.data_status == TaskUsageStatus::Partial) + }) + .count(); + if missing + partial > 0 { + lines.extend(wrap(vec![ + format!("Partial ranking · {partial} partial · {missing} unavailable") + .set_style(secondary_style()) + .into(), + ])); + } + let metrics = self.task_metrics(); + let widths = metrics + .iter() + .map(|index| { + chats + .rows + .iter() + .map(|chat| amount(available(chat), *index).len()) + .max() + .unwrap_or_default() + .max(METRICS[*index].len()) + }) + .collect::>(); + let show_all = widths.iter().sum::() + widths.len() * 2 + 24 <= width; + let metric_text = |chat: Option<&Chat>| { + if !show_all { + return chat.map_or_else( + || METRICS[metric].to_string(), + |chat| amount(available(chat), metric), + ); + } + metrics + .iter() + .zip(&widths) + .map(|(index, column_width)| { + let value = chat.map_or_else( + || METRICS[*index].to_string(), + |chat| amount(available(chat), *index), + ); + format!("{value:>column_width$}") + }) + .collect::>() + .join(" ") + }; + lines.push(Line::default()); + lines.push(columns( + " Chat".bold().into(), + metric_text(/*chat*/ None).bold().into(), + width, + )); + let rows = self + .task_rows() + .into_iter() + .enumerate() + .map(|(index, chat)| { + let selected = + self.section == Section::Chats && index == self.sections[Section::Chats].cursor; + let value = metric_text(Some(chat)); + let status = if chat + .task + .as_ref() + .is_some_and(|task| task.data_status == TaskUsageStatus::Partial) + { + " · partial" + } else { + "" + }; + let label = format!( + "{} {}{status}", + if selected { "›" } else { " " }, + chat.display_title() + ); + let label: Line<'static> = if selected { + label.set_style(accent_style()).into() + } else { + label.into() + }; + let mut row = vec![columns( + truncate(label, width.saturating_sub(value.len() + 1)), + if value == "—" { + value.set_style(secondary_style()).into() + } else if selected { + number(value).into() + } else { + value.into() + }, + width, + )]; + if self.zoomed && self.sections[Section::Chats].detail == Some(index) { + row.push("─".repeat(width).dim().into()); + row.extend(word_wrap_lines( + [chat.display_title().to_owned().bold()], + RtOptions::new(width) + .initial_indent(" ".into()) + .subsequent_indent(" ".into()), + )); + for index in self.task_metrics() { + let label = METRICS[index]; + row.push(columns( + format!(" {label}").into(), + amount(available(chat), index).into(), + width, + )); + } + if let Some(task) = &chat.task { + if task.data_status != TaskUsageStatus::Unavailable + && !task.groups.is_empty() + { + let mut groups = task.groups.iter().collect::>(); + groups.sort_by(|a, b| { + compare(Some(&b.amounts), Some(&a.amounts), metric) + }); + row.push(Line::default()); + row.push(columns( + " Model / effort / speed".bold().into(), + METRICS[metric].bold().into(), + width, + )); + for group in groups { + let model = self + .model_name(group.model.as_deref().unwrap_or("Not reported")); + let value = amount(Some(&group.amounts), metric); + row.push(columns( + truncate( + format!(" {model}").bold().into(), + width.saturating_sub(value.len() + 1), + ), + value.into(), + width, + )); + row.push( + format!( + " {} · {} · {}", + group.reasoning_effort.as_deref().unwrap_or("Not reported"), + group.speed.as_deref().unwrap_or("Not reported"), + group + .product_experience + .as_deref() + .unwrap_or("Not reported") + ) + .set_style(secondary_style()) + .into(), + ); + } + } + } else { + row.push( + " Task usage unavailable." + .set_style(secondary_style()) + .into(), + ); + } + row.push(Line::default()); + } + wrap(row) + }) + .collect::>(); + let mut coverage = vec![ + "Local chats · includes discovered descendants · excludes archived roots" + .set_style(secondary_style()) + .into(), + ]; + if chats.truncated { + coverage.push( + "Partial ranking · 100 most recently active chats" + .set_style(secondary_style()) + .into(), + ); + } + if let Some(time) = chats.updated_at { + coverage.push( + format!( + "Updated {} UTC · recent activity may be delayed", + time.format("%b %-d %H:%M") + ) + .set_style(secondary_style()) + .into(), + ); + } + if !self.zoomed { + let count = rows.len(); + lines.extend(rows.into_iter().take(/*n*/ 5).flatten()); + lines.push(Line::default()); + lines.push(format!("Showing top {} of {count} chats", count.min(/*other*/ 5)).into()); + lines.extend(wrap(coverage)); + (lines, 0..1) + } else { + self.chat_window(lines, rows, wrap(coverage), width) + } + } +} diff --git a/codex-rs/tui/src/analytics/tasks.rs b/codex-rs/tui/src/analytics/tasks.rs new file mode 100644 index 0000000000000000000000000000000000000000..5888039f550b2da5869490001dc94bb0e17673ae --- /dev/null +++ b/codex-rs/tui/src/analytics/tasks.rs @@ -0,0 +1,244 @@ +//! Consumer task queries preserve missing amounts and require complete descendant groups. +use super::client::Live; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_backend_client::TaskUsage; +use codex_backend_client::TaskUsageThread; +use futures::StreamExt; +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; + +pub(super) struct Chat { + pub title: String, + pub task: Option, +} +impl Chat { + /// Local history can span accounts; unavailable task data does not verify ownership. + pub(super) fn display_title(&self) -> &str { + if self.task.as_ref().is_some_and(|task| { + task.data_status != codex_backend_client::TaskUsageStatus::Unavailable + }) { + &self.title + } else { + "Chat usage unavailable" + } + } +} + +#[derive(Default)] +pub(super) struct Chats { + pub rows: Vec, + pub truncated: bool, + pub updated_at: Option>, +} + +pub(super) async fn read( + handle: AppServerRequestHandle, + live: Arc, +) -> Result, String> { + let session = live.session().await?; + if session.kind != super::models::AccountKind::Consumer { + return Ok(None); + } + session.backend.ensure_identity().await?; + let mut roots = tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 60), + super::chats::roots(&handle), + ) + .await + .map_err(|_| "Chat listing timed out. Press R to retry.".to_string())??; + session.backend.ensure_identity().await?; + roots.sort_by(|a, b| { + b.updated_at + .cmp(&a.updated_at) + .then_with(|| a.id.cmp(&b.id)) + }); + let truncated = roots.len() > 100; + roots.truncate(/*len*/ 100); + let discoveries = roots + .iter() + .map(|root| descendants(&handle, root)) + .collect::>(); + let mut pending = futures::stream::iter(discoveries).buffer_unordered(/*n*/ 2); + let mut queries = Vec::new(); + // Keep complete groups collected before the deadline; never query partial descendants. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(/*secs*/ 25); + while let Ok(Some(result)) = tokio::time::timeout_at(deadline, pending.next()).await { + if let Ok(query) = result { + queries.push(query); + } + } + drop(pending); + let mut seen = HashSet::new(); + let mut batches = Vec::new(); + let mut batch = Vec::new(); + let mut count = 0; + for query in queries { + let ids = std::iter::once(&query.thread_id).chain(&query.descendant_thread_ids); + if ids.clone().any(|id| !seen.insert(id.clone())) { + return Err("Task descendant groups overlap.".into()); + } + let size = 1 + query.descendant_thread_ids.len(); + if count + size > 1_000 || batch.len() == 100 { + batches.push(std::mem::take(&mut batch)); + count = 0; + } + count += size; + batch.push(query); + } + if !batch.is_empty() { + batches.push(batch); + } + let requests = batches + .into_iter() + .map(|threads| async move { + session + .backend + .request(|client| { + let threads = threads.clone(); + async move { client.get_task_usage(&threads).await } + }) + .await + }) + .collect::>(); + let expected_batches = requests.len(); + let mut completed_batches = 0; + let mut pending = futures::stream::iter(requests).buffer_unordered(/*n*/ 2); + let mut usage = HashMap::new(); + let mut freshness = Vec::new(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(/*secs*/ 25); + while let Ok(Some(result)) = tokio::time::timeout_at(deadline, pending.next()).await { + completed_batches += 1; + match result { + Ok(response) => { + freshness.push( + response + .data_as_of + .as_deref() + .map(chrono::DateTime::parse_from_rfc3339) + .transpose() + .map_err(|_| "Invalid task usage timestamp.")? + .map(|time| time.with_timezone(&chrono::Utc)), + ); + usage.extend( + response + .threads + .into_iter() + .map(|task| (task.thread_id.clone(), task)), + ); + } + Err(error) + if error + .status() + .is_some_and(|status| matches!(status.as_u16(), 404 | 503)) => + { + freshness.push(/*value*/ None); + } + Err(error) => return Err(super::client::request_error(error)), + } + } + if completed_batches < expected_batches { + freshness.push(/*value*/ None); + } + drop(pending); + session.backend.ensure_identity().await?; + Ok(Some(Chats { + truncated, + updated_at: if freshness.iter().all(Option::is_some) { + freshness.into_iter().flatten().min() + } else { + None + }, + rows: roots + .into_iter() + .map(|thread| Chat { + task: usage.remove(&thread.id), + title: thread + .name + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| { + if thread.preview.trim().is_empty() { + "Untitled chat".into() + } else { + thread.preview + } + }), + }) + .collect(), + })) +} + +async fn descendants( + handle: &AppServerRequestHandle, + root: &Thread, +) -> Result { + let mut ids = HashSet::new(); + let mut parents = HashMap::new(); + for archived in [false, true] { + let mut cursor = None; + let mut seen = HashSet::new(); + loop { + let page: ThreadListResponse = handle + .request_typed(ClientRequest::ThreadList { + request_id: RequestId::String(uuid::Uuid::new_v4().to_string()), + params: ThreadListParams { + ancestor_thread_id: Some(root.id.clone()), + archived: Some(archived), + cursor, + limit: Some(100), + ..super::chats::list_params() + }, + }) + .await + .map_err(super::data::error)?; + for thread in page.data { + let parent = thread + .parent_thread_id + .filter(|_| thread.id != root.id) + .ok_or("Task descendant discovery is unsupported.")?; + parents.insert(thread.id.clone(), parent); + ids.insert(thread.id); + } + if ids.len() >= 1_000 { + return Err("Task exceeds the reporting limit.".into()); + } + match page.next_cursor { + Some(next) if seen.insert(next.clone()) => cursor = Some(next), + Some(_) => return Err("Task listing repeated a cursor.".into()), + None => break, + } + } + } + // Older servers can ignore the ancestor filter. Prove every returned chain reaches this root. + for id in &ids { + let mut current = id; + let mut chain = HashSet::new(); + while current != &root.id { + if !chain.insert(current) { + return Err("Task descendant cycle.".into()); + } + current = parents + .get(current) + .ok_or("Task descendant discovery is incomplete.")?; + } + } + let mut descendant_thread_ids = ids.into_iter().collect::>(); + descendant_thread_ids.sort(); + Ok(TaskUsageThread { + thread_id: root.id.clone(), + created_at: (root.created_at > 0) + .then(|| chrono::DateTime::from_timestamp(root.created_at, /*nsecs*/ 0)) + .flatten() + .map(|time| time.to_rfc3339()), + descendant_thread_ids, + }) +} + +#[cfg(test)] +#[path = "tasks_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/tasks_tests.rs b/codex-rs/tui/src/analytics/tasks_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..526ebf226f23d248c78f952929f8a25f5afe2b58 --- /dev/null +++ b/codex-rs/tui/src/analytics/tasks_tests.rs @@ -0,0 +1,132 @@ +//! Consumer HTTP contract, descendant discovery, and partial-result regression coverage. + +use super::super::client::tests::live; +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test] +async fn consumer_roots_include_paginated_archived_descendants_and_keep_missing_usage() { + for (unsupported, status) in [(false, 200), (true, 200), (false, 404), (false, 503)] { + use codex_app_server_client::RemoteAppServerClient; + use codex_app_server_client::RemoteAppServerConnectArgs; + use codex_app_server_client::RemoteAppServerEndpoint; + use futures::SinkExt; + use tokio_tungstenite::tungstenite::Message; + + let server = MockServer::start().await; + let (_home, live) = live(&server, "plus").await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let websocket_url = format!("ws://{}", listener.local_addr().unwrap()); + let rpc = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let mut pages = 0; + while let Some(Ok(Message::Text(text))) = socket.next().await { + let request: serde_json::Value = serde_json::from_str(&text).unwrap(); + let result = match request["method"].as_str() { + Some("initialize") => { + json!({"userAgent":"analytics-test", "codexHome":"/unused"}) + } + Some("thread/list") => { + pages += 1; + if pages == 1 { + tokio::time::pause(); + tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 31)).await; + tokio::time::resume(); + } + let params = &request["params"]; + assert_eq!(params["modelProviders"], json!([])); + assert_ne!(params["useStateDbOnly"], json!(true)); + let ancestor = params["ancestorThreadId"].as_str(); + let archived = params["archived"] == true; + let cursor = params["cursor"].as_str(); + let ids = match (ancestor, archived, cursor) { + (None, false, None) => vec!["root", "missing"], + (Some("root"), false, None) if unsupported => vec!["unrelated"], + (Some("root"), false, None) => vec!["child"], + (Some("root"), false, Some("next")) => vec!["grandchild"], + (Some("root"), true, None) => vec!["archived-child"], + (Some("missing"), _, None) => Vec::new(), + _ => panic!("Unexpected thread listing: {params}"), + }; + let data = ids.into_iter().map(|id| json!({ + "id":id, "sessionId":id, "parentThreadId":if id == "unrelated" { None } else if id == "grandchild" { Some("child") } else { ancestor }, "preview":id, "ephemeral":false, + "modelProvider":"openai", "createdAt":1788220800_i64, + "updatedAt":chrono::Utc::now().timestamp(), "status":{"type":"idle"}, + "cwd":std::env::temp_dir(), "cliVersion":"test", "source":"cli", "turns":[] + })).collect::>(); + json!({"data":data, "nextCursor":if ancestor == Some("root") && !archived && cursor.is_none() { Some("next") } else { None }}) + } + _ => continue, + }; + socket + .send(Message::Text( + json!({"jsonrpc":"2.0", "id":request["id"], "result":result}) + .to_string() + .into(), + )) + .await + .unwrap(); + if pages == if unsupported { 4 } else { 6 } { + break; + } + } + pages + }); + let remote = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token: None, + }, + client_name: "analytics-test".into(), + client_version: "test".into(), + experimental_api: false, + mcp_server_openai_form_elicitation: false, + opt_out_notification_methods: Vec::new(), + channel_capacity: 16, + }) + .await + .unwrap(); + Mock::given(method("POST")).and(path("/backend-api/wham/usage/thread_usage/query_v2")) + .respond_with(move |request:&wiremock::Request| { + let mut body:serde_json::Value = request.body_json().unwrap(); + body["threads"].as_array_mut().unwrap().sort_by_key(|thread| thread["thread_id"].as_str().unwrap().to_string()); + let mut expected=json!({"threads":[ + {"thread_id":"missing", "created_at":"2026-09-01T00:00:00+00:00", "descendant_thread_ids":[]}, + {"thread_id":"root", "created_at":"2026-09-01T00:00:00+00:00", "descendant_thread_ids":["archived-child", "child", "grandchild"]} + ]}); + if unsupported { expected["threads"].as_array_mut().unwrap().retain(|thread| thread["thread_id"] == "missing"); } + assert_eq!(body, expected); + let mut response=json!({"data_as_of":null,"threads":[]}); + if !unsupported { response["threads"] = json!([{ "thread_id":"root", "data_status":"partial", "usage_source":"included_plan", "weekly_limit_percent":125, "five_hour_limit_percent":null, "balance_usage_credits":null, "groups":[] }]); } + ResponseTemplate::new(status).set_body_json(response) + }).expect(/*r*/ 1).mount(&server).await; + let handle = AppServerRequestHandle::Remote(remote.request_handle()); + let chats = read(handle, Arc::new(live)).await.unwrap().unwrap(); + assert_eq!( + ( + chats.rows.len(), + chats + .rows + .iter() + .filter_map(|chat| chat.task.as_ref().map(|task| task.data_status)) + .collect::>() + ), + ( + 2, + if unsupported || status != 200 { + vec![] + } else { + vec![codex_backend_client::TaskUsageStatus::Partial] + } + ) + ); + assert_eq!(rpc.await.unwrap(), if unsupported { 4 } else { 6 }); + } +} diff --git a/codex-rs/tui/src/analytics/test_fixtures.rs b/codex-rs/tui/src/analytics/test_fixtures.rs new file mode 100644 index 0000000000000000000000000000000000000000..07ff4eab170e632529612907744854f93b2d1761 --- /dev/null +++ b/codex-rs/tui/src/analytics/test_fixtures.rs @@ -0,0 +1,239 @@ +//! Bounded fictional dashboard data with raw daily units. +//! Charts and their totals share the same daily series; no account activity is read. + +use crate::analytics::sections::Section; +pub(super) const END_DATE: chrono::NaiveDate = + match chrono::NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 9, /*day*/ 2) { + Some(date) => date, + None => panic!("invalid test date"), + }; + +pub(super) const TOOL_DAILY: [[u32; 7]; 2] = [[34, 23, 29, 26, 22, 23, 29], [8, 6, 9, 8, 7, 5, 11]]; + +pub(super) const GROUPS: &[(&str, [&str; 4], [u32; 4])] = &[ + ( + "Surface", + ["Desktop app", "CLI", "IDE extension", "Web"], + [35, 36, 17, 12], + ), + ( + "Feature", + ["Tasks", "Code review", "Images", "Other"], + [65, 15, 12, 8], + ), + ( + "Model", + ["GPT-5.6-Sol", "GPT-5.6-Terra", "GPT-5.5", "Other"], + [60, 25, 10, 5], + ), + ( + "Task start", + ["User messages", "Goals", "Automations", "Other"], + [29, 10, 5, 5], + ), + ( + "Speed", + ["Standard", "Fast", "Priority", "Unknown"], + [65, 30, 5, 0], + ), + ( + "Reasoning", + ["Low", "Medium", "High", "Other"], + [10, 30, 50, 10], + ), + ( + "Token type", + ["Uncached input", "Cached input", "Output", "Other"], + [20, 70, 10, 0], + ), +]; + +pub(super) fn daily(section: Section, range: usize) -> Vec { + let (earlier, recent) = match section { + Section::Usage => (40, [20, 73, 64, 91, 81, 100, 86]), + Section::Credits => (35, [0, 41, 57, 85, 75, 109, 64]), + Section::Activity => (34, [62, 83, 107, 75, 59, 85, 103]), + _ => return Vec::new(), + }; + let mut values = vec![earlier; if range == 0 { 0 } else { 23 }]; + values.extend(recent); + values +} + +pub(super) fn breakdown(total: u32, group: usize) -> [u32; 4] { + let mut values = GROUPS[group].2.map(|weight| total * weight / 100); + if group != 3 { + values[3] = total - values[..3].iter().sum::(); + } + values +} + +/// Fixtures use the private report types and the same presentation path as live reports. +pub(super) fn history( + report: usize, + range: usize, + group: usize, +) -> crate::analytics::models::AccountAnalyticsHistory { + use crate::analytics::models::AccountAnalyticsDay; + use crate::analytics::models::AccountAnalyticsHistory; + use crate::analytics::models::AccountAnalyticsUnit; + use crate::analytics::models::AccountAnalyticsValue; + let section = [ + Section::Usage, + Section::Credits, + Section::Activity, + Section::Plugins, + Section::Skills, + ][report]; + let series = if report < 3 { + daily(section, range) + } else { + let mut series = vec![if report == 3 { 20 } else { 5 }; if range == 0 { 0 } else { 23 }]; + series.extend(TOOL_DAILY[report - 3]); + series + }; + let start = END_DATE - chrono::Days::new(if range == 0 { 6 } else { 29 }); + AccountAnalyticsHistory { + unit: if report == 0 { + AccountAnalyticsUnit::RelativeUsage + } else if report == 1 { + AccountAnalyticsUnit::Credits + } else { + AccountAnalyticsUnit::Count + }, + updated_at: None, + data: series + .into_iter() + .enumerate() + .map(|(day, total)| { + let labels = if report < 3 { + GROUPS[group].1 + } else if report == 3 { + ["Figma", "Slack", "Notion", "GitHub"] + } else { + ["Code review", "Planning", "Debugging", "Testing"] + }; + let values = breakdown(total, if report < 3 { group } else { 0 }); + let pairs = if report == 0 && group == 0 { + vec![ + ("Codex", total * 65 / 100), + ("Work", total - total * 65 / 100), + ] + } else { + labels.into_iter().zip(values).collect() + }; + AccountAnalyticsDay { + date: start + chrono::Days::new(day as u64), + total: f64::from(total), + values: pairs + .into_iter() + .map(|(label, value)| AccountAnalyticsValue { + key: label.into(), + label: label.into(), + value: f64::from(value), + }) + .collect(), + } + }) + .collect(), + } +} + +pub(super) fn chats() -> super::chats::Chats { + use codex_backend_client::ThreadUsage; + use codex_backend_client::ThreadUsageBreakdownGroup; + super::chats::Chats { + rows: [ + ("Q3 planning analysis", 120), + ("Refactor billing usage", 113), + ("Other usage", 109), + ("Launch readiness review", 85), + ("Plan the week", 0), + ] + .into_iter() + .enumerate() + .map(|(index, (title, credits))| super::chats::Chat { + title: title.into(), + usage: Some(ThreadUsage { + thread_id: format!("test-{index}"), + estimated_usage_credits_micros: credits * 1_000_000, + estimated_usage_usd_micros: None, + groups: vec![ThreadUsageBreakdownGroup { + model: Some("GPT-5.6-Sol".into()), + reasoning_effort: Some("high".into()), + speed: Some("standard".into()), + estimated_usage_credits_micros: credits * 1_000_000, + net_new_input_tokens: None, + cached_input_tokens: None, + input_tokens: None, + output_tokens: None, + total_tokens: None, + }], + }), + }) + .collect(), + } +} + +pub(super) fn tokens(range: usize, grouping: usize) -> super::models::AccountAnalyticsHistory { + let response = serde_json::json!({"data_freshness_ts":"2026-09-02T16:00:00Z", "data": + daily(Section::Usage, range).into_iter().enumerate().map(|(index, total)| serde_json::json!({ + "date": (END_DATE - chrono::Days::new(if range == 0 { 6 } else { 29 }) + chrono::Days::new(index as u64)).to_string(), + "groups": [{"dimensions":{"model":"GPT-5.5"}, "uncached_text_input_tokens": total * 1000, "cached_text_input_tokens":total * 7000, "text_output_tokens":total * 500}] + })).collect::>() + }); + match super::tokens::history( + match serde_json::from_value(response) { + Ok(response) => response, + Err(_) => panic!("invalid test token response"), + }, + super::data::GROUPINGS[grouping], + END_DATE - chrono::Days::new(if range == 0 { 6 } else { 29 }), + END_DATE, + ) { + Ok(history) => history, + Err(_) => panic!("invalid test token history"), + } +} + +/// Build a fixed account state for rendering tests; never opens a connection. +pub(super) fn view(kind: super::models::AccountKind) -> super::AnalyticsView { + let mut view = super::AnalyticsView::new(crate::keymap::RuntimeKeymap::defaults().list); + view.end_date = END_DATE; + view.account = super::data::Load::Ready(match kind { + super::models::AccountKind::Consumer => codex_protocol::account::PlanType::Plus, + super::models::AccountKind::Business => codex_protocol::account::PlanType::Team, + super::models::AccountKind::Enterprise => { + codex_protocol::account::PlanType::EnterpriseCbpUsageBased + } + super::models::AccountKind::Unknown => codex_protocol::account::PlanType::Unknown, + }); + view.section = if view.business() { + super::sections::Section::Credits + } else { + super::sections::Section::Usage + }; + view.start_reports(); + seed_reports(&mut view); + view +} + +/// Populate explicit report state after a test chooses its account, period, and grouping. +pub(super) fn seed_reports(view: &mut super::AnalyticsView) { + use super::data::Load; + if view.business() { + view.chats = Load::Ready(chats()); + } + for section in view.visible_sections() { + if let Some(report) = section.report() { + let range = view.ranges[view.range_group(*section) as usize]; + let group = view.sections[*section].group; + view.sections[*section].history = + Load::Ready(if *section == Section::Usage && view.business() { + tokens(range, group) + } else { + history(report as usize, range, group) + }); + } + } +} diff --git a/codex-rs/tui/src/analytics/test_support.rs b/codex-rs/tui/src/analytics/test_support.rs new file mode 100644 index 0000000000000000000000000000000000000000..82b605a1a3f6a746122678956822a3cf82fbe1fa --- /dev/null +++ b/codex-rs/tui/src/analytics/test_support.rs @@ -0,0 +1,76 @@ +//! Mock HTTP reports for tests that exercise the authenticated dashboard loading path. +use super::data::Load; +use serde_json::json; + +pub(super) async fn server() -> wiremock::MockServer { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path_regex("^/backend-api/wham/(usage|analytics)/")) + .respond_with(|request: &wiremock::Request| { + let params = request.url.query_pairs().collect::>(); + let today = chrono::Utc::now().date_naive(); + let start = params.get("start_date").map_or(today, |date| date.parse().unwrap()); + let end = params.get("end_date").map_or(today, |date| date.parse().unwrap()); + let route = request.url.path().rsplit('/').next().unwrap(); + let data = start.iter_days().take_while(|date| *date <= end).map(|date| { + let date = date.to_string(); + match route { + "daily-token-usage-breakdown" | "daily-workspace-user-token-usage-breakdown" => json!({ + "date":date, "product_surface_usage_values":{"codex_cli":10.0}, + "premium_usage_values":{ + "credit_usage_credits":{"codex_cli":10.0}, "total_usage_credits":{}, + "uncached_text_input_tokens_by_surface":{}, "cached_text_input_tokens_by_surface":{}, + "text_output_tokens_by_surface":{}, "text_total_tokens_by_surface":{} + }, + "models":[{"model":"GPT-5.5","credits":10.0,"on_demand_credits":10.0,"turns":10,"speed":"standard"}], + "groups":[{"dimensions":{"model":"GPT-5.5"},"credits":10.0,"uncached_text_input_tokens":10,"cached_text_input_tokens":20,"text_output_tokens":30}] + }), + "credit-usage-events" => json!({"date":date,"product_surface":"cli","credit_amount":10.0}), + "daily-workspace-user-credit-usage" => json!({"date":date,"values":{"codex":10.0}}), + "daily-workspace-usage-counts" => json!({ + "date":date,"totals":{"users":1,"threads":1,"turns":10,"credits":10.0},"clients":[], + "models":[{"model":"GPT-5.5","credits":10.0,"turns":10}] + }), + "daily-plugin-usage-metrics" => json!({"date":date,"plugin_usage_overviews":[{ + "plugin_id":null,"plugin_name":"test","marketplace":null,"display_name":"Test plugin","invocation_counts":10 + }]}), + "daily-skill-usage-metrics" => json!({"date":date,"skill_usage_overviews":[{ + "skill_name":"test","skill_ids":[],"display_name":"Test skill","invocation_counts":10 + }]}), + _ => panic!("Unexpected mock analytics route: {route}"), + } + }).collect::>(); + let response = if route == "daily-workspace-user-credit-usage" { + json!({"breakdown":params["breakdown"],"series":[{"key":"codex","label":"Codex","total":data.len() as f64 * 10.0}],"data":data}) + } else { + json!({"data":data}) + }; + wiremock::ResponseTemplate::new(/*s*/ 200).set_body_json(response) + }) + .mount(&server).await; + server +} + +pub(super) async fn settle(view: &mut super::AnalyticsView) { + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 10), async { + loop { + view.poll_reports(); + if !matches!(view.account, Load::Loading(_)) + && !view + .sections + .0 + .iter() + .any(|state| matches!(state.history, Load::Loading(_))) + && !matches!(view.chats, Load::Loading(_)) + && !matches!(view.tasks, Load::Loading(_)) + && !matches!(view.profile, Load::Loading(_)) + && !matches!(view.plan.report, Load::Loading(_)) + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(/*millis*/ 5)).await; + } + }) + .await + .unwrap(); +} diff --git a/codex-rs/tui/src/analytics/tokens.rs b/codex-rs/tui/src/analytics/tokens.rs new file mode 100644 index 0000000000000000000000000000000000000000..7e8e1cae1415177b83fcf1f9d5bcf5651d8c35a6 --- /dev/null +++ b/codex-rs/tui/src/analytics/tokens.rs @@ -0,0 +1,135 @@ +//! Enterprise text token history, independent of credit and allowance accounting. + +use super::models::*; +use super::report_data::AnalyticsData; +use chrono::NaiveDate; +use std::collections::BTreeMap; + +pub(super) fn history( + response: AnalyticsData, + grouping: AccountAnalyticsGrouping, + start: NaiveDate, + end: NaiveDate, +) -> Result { + filtered_history(response, grouping, start, end, /*model_filter*/ None) +} + +pub(super) fn filtered_history( + response: AnalyticsData, + grouping: AccountAnalyticsGrouping, + start: NaiveDate, + end: NaiveDate, + model_filter: Option<&str>, +) -> Result { + let mut days: BTreeMap> = BTreeMap::new(); + for record in response.data { + let date = record + .date + .parse::() + .map_err(|_| "Invalid token report date.")?; + if date < start || date > end { + continue; + } + let groups = if let Some(groups) = record.groups { + groups + .into_iter() + .map(|group| { + ( + if group.is_other { + "Other".into() + } else { + group + .dimensions + .get("model") + .cloned() + .unwrap_or_else(|| "Unknown".into()) + }, + [ + group.uncached_text_input_tokens, + group.cached_text_input_tokens, + group.text_output_tokens, + ], + None, + ) + }) + .collect::>() + } else if let Some(models) = record.models { + models + .into_iter() + .map(|model| { + ( + model.model, + [ + model.uncached_text_input_tokens, + model.cached_text_input_tokens, + model.text_output_tokens, + ], + model.text_total_tokens, + ) + }) + .collect() + } else { + return Err("Token breakdown is not available in this report.".into()); + }; + let values = days.entry(date).or_default(); + for (model, counts, total) in groups { + if model_filter.is_some_and(|selected| selected != model) { + continue; + } + let missing_components = counts.iter().all(Option::is_none); + if grouping == AccountAnalyticsGrouping::Model + && let Some(total) = total.filter(|total| *total != 0.0 || missing_components) + { + if !total.is_finite() || total < 0.0 || total.fract() != 0.0 { + return Err("Invalid token count.".into()); + } + *values.entry(model).or_default() += total; + continue; + } + if missing_components { + return Err("Token counts were not reported.".into()); + } + for (label, count) in ["Uncached input", "Cached input", "Output"] + .into_iter() + .zip(counts) + { + let count = count.unwrap_or(/*default*/ 0.0); + if !count.is_finite() || count < 0.0 || count.fract() != 0.0 { + return Err("Invalid token count.".into()); + } + let key = if grouping == AccountAnalyticsGrouping::Model { + &model + } else { + label + }; + *values.entry(key.to_string()).or_default() += count; + } + } + } + Ok(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Tokens, + updated_at: response + .data_freshness_ts + .and_then(|time| chrono::DateTime::parse_from_rfc3339(&time).ok()) + .map(|time| time.timestamp()), + data: days + .into_iter() + .map(|(date, values)| AccountAnalyticsDay { + date, + total: values.values().sum(), + values: values + .into_iter() + .map(|(key, value)| AccountAnalyticsValue { + label: key.clone(), + key, + value, + }) + .collect(), + }) + .collect(), + }) +} + +#[cfg(test)] +#[path = "tokens_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/tokens_tests.rs b/codex-rs/tui/src/analytics/tokens_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..3501317b1963edb9ec01e6164f533e1585cba0d5 --- /dev/null +++ b/codex-rs/tui/src/analytics/tokens_tests.rs @@ -0,0 +1,131 @@ +//! Enterprise token normalization across supported groupings. + +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn tokens_use_text_components_and_keep_daily_totals_across_groupings() { + let response: AnalyticsData = serde_json::from_value(json!({"units":"credits", "data":[{ + "date":"2026-09-01", "groups":[{"dimensions":{"model":"gpt-5"}, "uncached_text_input_tokens":10, "cached_text_input_tokens":80, "text_output_tokens":10}] + }]})).unwrap(); + let date = "2026-09-01".parse().unwrap(); + let by_type = history( + response.clone(), + AccountAnalyticsGrouping::TokenType, + date, + date, + ) + .unwrap(); + let by_model = history(response, AccountAnalyticsGrouping::Model, date, date).unwrap(); + assert_eq!( + (by_type.unit, by_type.data[0].total, by_model.data[0].total), + (AccountAnalyticsUnit::Tokens, 100.0, 100.0) + ); + assert_eq!( + by_type.data[0] + .values + .iter() + .map(|v| (v.label.as_str(), v.value)) + .collect::>(), + vec![ + ("Cached input", 80.0), + ("Output", 10.0), + ("Uncached input", 10.0) + ] + ); +} + +#[test] +fn token_model_filter_retains_components_and_freshness() { + let date = "2026-09-01".parse().unwrap(); + let response: AnalyticsData = serde_json::from_value(json!({"data_freshness_ts":"2026-09-02T00:00:00Z", "data":[{ + "date":"2026-09-01", "groups":[ + {"dimensions":{"model":"alpha"}, "uncached_text_input_tokens":10,"cached_text_input_tokens":20,"text_output_tokens":30}, + {"dimensions":{"model":"beta"}, "uncached_text_input_tokens":100,"cached_text_input_tokens":200,"text_output_tokens":300} + ], "models":[{"model":"ignored", "text_total_tokens":1000}] + }]})).unwrap(); + let selected = filtered_history( + response.clone(), + AccountAnalyticsGrouping::TokenType, + date, + date, + Some("alpha"), + ) + .unwrap(); + let expected = history(serde_json::from_value(json!({"data_freshness_ts":"2026-09-02T00:00:00Z", "data":[{ + "date":"2026-09-01", "models":[{"model":"alpha", "uncached_text_input_tokens":10,"cached_text_input_tokens":20,"text_output_tokens":30}] + }]})).unwrap(), AccountAnalyticsGrouping::TokenType, date, date).unwrap(); + assert_eq!(selected, expected); + assert_eq!( + history(response, AccountAnalyticsGrouping::TokenType, date, date) + .unwrap() + .data[0] + .total, + 660.0 + ); +} + +#[test] +fn model_totals_use_reported_total_and_missing_components_remain_unavailable() { + let date = "2026-09-01".parse().unwrap(); + let response: AnalyticsData = serde_json::from_value(json!({"data":[{"date":"2026-09-01", "models":[{"model":"alpha", "text_total_tokens":123}]}]})).unwrap(); + assert_eq!( + history( + response.clone(), + AccountAnalyticsGrouping::Model, + date, + date + ) + .unwrap() + .data, + vec![AccountAnalyticsDay { + date: "2026-09-01".parse().unwrap(), + total: 123.0, + values: vec![AccountAnalyticsValue { + key: "alpha".into(), + label: "alpha".into(), + value: 123.0 + }] + }] + ); + assert_eq!( + history(response, AccountAnalyticsGrouping::TokenType, date, date).unwrap_err(), + "Token counts were not reported." + ); +} + +#[test] +fn explicit_zero_model_total_is_reported_without_components() { + let date = "2026-09-01".parse().unwrap(); + let response: AnalyticsData = serde_json::from_value(json!({ + "data": [{"date": "2026-09-01", "models": [ + {"model": "idle", "text_total_tokens": 0}, + {"model": "active", "text_total_tokens": 0, "text_output_tokens": 5} + ]}] + })) + .unwrap(); + assert_eq!( + history(response, AccountAnalyticsGrouping::Model, date, date).unwrap(), + AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Tokens, + updated_at: None, + data: vec![AccountAnalyticsDay { + date: "2026-09-01".parse().unwrap(), + total: 5.0, + values: vec![ + AccountAnalyticsValue { + key: "active".into(), + label: "active".into(), + value: 5.0, + }, + AccountAnalyticsValue { + key: "idle".into(), + label: "idle".into(), + value: 0.0, + }, + ], + }], + } + ); +} diff --git a/codex-rs/tui/src/analytics/tool_panel.rs b/codex-rs/tui/src/analytics/tool_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..c0a4d4472d95240d88f8fb6de5bb48720bd11bca --- /dev/null +++ b/codex-rs/tui/src/analytics/tool_panel.rs @@ -0,0 +1,26 @@ +//! Human-readable plugin and skill labels. + +pub(super) fn tool_name(label: &str) -> String { + let label = match label.split_once(':') { + Some((namespace, name)) if namespace.trim().eq_ignore_ascii_case(name.trim()) => { + name.trim() + } + _ => label, + }; + label + .split_whitespace() + .map(|word| match word.to_ascii_lowercase().as_str() { + "pr" => "PR", + "cli" => "CLI", + "api" => "API", + "ide" => "IDE", + "mcp" => "MCP", + "sdk" => "SDK", + "ui" => "UI", + "tui" => "TUI", + "openai" => "OpenAI", + _ => word, + }) + .collect::>() + .join(" ") +} diff --git a/codex-rs/tui/src/analytics/tool_panel_tests.rs b/codex-rs/tui/src/analytics/tool_panel_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..87a5f0cc7cc554605ca174bbdd3ec5cd53603309 --- /dev/null +++ b/codex-rs/tui/src/analytics/tool_panel_tests.rs @@ -0,0 +1,101 @@ +//! Tool chart layout, independent selection, naming, and partial report states. + +use super::*; +use crate::analytics::sections::Section; +use pretty_assertions::assert_eq; + +#[test] +fn analytics_tools_reflow_and_keep_independent_days() { + let mut view = fixture::view(models::AccountKind::Consumer); + press(&mut view, KeyCode::Char('4')); + let wide = screen(&mut view, /*width*/ 140, /*height*/ 48); + assert!(wide.contains("186 calls")); + press(&mut view, KeyCode::Char('5')); + press(&mut view, KeyCode::Left); + press(&mut view, KeyCode::Enter); + assert_eq!( + ( + view.sections[Section::Plugins].cursor, + view.sections[Section::Skills].cursor, + view.sections[Section::Skills].detail + ), + (6, 5, Some(5)) + ); + let narrow = screen(&mut view, /*width*/ 58, /*height*/ 32); + assert!(narrow.contains("Sep 1 · 5 uses")); + press(&mut view, KeyCode::Char('r')); + assert_eq!( + ( + view.sections[Section::Plugins].cursor, + view.sections[Section::Skills].cursor, + view.ranges + ), + (29, 28, [0, 0, 1]) + ); + press(&mut view, KeyCode::Char('r')); + press(&mut view, KeyCode::Char('z')); + press(&mut view, KeyCode::Char('z')); + assert_eq!( + ( + view.sections[Section::Plugins].cursor, + view.sections[Section::Skills].cursor, + view.sections[Section::Skills].detail + ), + (6, 5, Some(5)) + ); + insta::assert_snapshot!(format!("{wide}\n{narrow}")); +} + +#[test] +fn analytics_tools_handle_empty_days_and_independent_failures() { + let mut view = fixture::view(models::AccountKind::Consumer); + let mut history = fixture::history(/*report*/ 4, /*range*/ 0, /*group*/ 0); + history.data[6].total = 0.0; + for value in &mut history.data[6].values { + value.value = 0.0; + } + history.data.remove(/*index*/ 5); + view.sections[Section::Skills].history = Load::Ready(history); + press(&mut view, KeyCode::Char('5')); + let empty = screen(&mut view, /*width*/ 140, /*height*/ 44); + assert!(empty.contains("No uses reported for Sep 2.")); + assert!(!empty.contains("details")); + press(&mut view, KeyCode::Enter); + assert_eq!(view.sections[Section::Skills].detail, None); + press(&mut view, KeyCode::Left); + let missing = screen(&mut view, /*width*/ 140, /*height*/ 44); + assert!(missing.contains("Data not reported for Sep 1.")); + assert_eq!( + empty.lines().position(|line| line.contains('▲')), + missing.lines().position(|line| line.contains('▲')), + ); + view.sections[Section::Skills].history = + Load::Error("Skills could not be loaded. Press R to retry.".into()); + let partial = screen(&mut view, /*width*/ 140, /*height*/ 44); + assert!(partial.contains("Skills could not be loaded")); + press(&mut view, KeyCode::Char('4')); + assert!(screen(&mut view, /*width*/ 140, /*height*/ 44).contains("186 calls")); + insta::assert_snapshot!(format!("{empty}\n{missing}\n{partial}")); +} + +#[test] +fn analytics_tool_names_preserve_acronyms_and_distinct_namespaces() { + let labels = [ + "Pr Reviewer", + "Slack Cli", + "Squatch: Squatch", + "Openai Docs", + "User Writing: Writing Style", + ]; + assert_eq!( + labels.map(tool_panel::tool_name), + [ + "PR Reviewer", + "Slack CLI", + "Squatch", + "OpenAI Docs", + "User Writing: Writing Style" + ] + .map(str::to_owned), + ); +} diff --git a/codex-rs/tui/src/app_backtrack/legacy_input.rs b/codex-rs/tui/src/app_backtrack/legacy_input.rs new file mode 100644 index 0000000000000000000000000000000000000000..c36e83bd768785d26d8dd74ccdba446a1347df70 --- /dev/null +++ b/codex-rs/tui/src/app_backtrack/legacy_input.rs @@ -0,0 +1,74 @@ +//! Input routing for the read-only transcript viewer. +//! +//! Preview selection is shared with the interactive transcript path so both +//! modes retain the same prompt navigation and confirmation behavior. + +use super::*; + +impl App { + pub(super) fn handle_legacy_transcript_event( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + event: TuiEvent, + ) -> Result { + if let TuiEvent::Key(key_event) = &event + && let Some(Overlay::Transcript(overlay)) = self.overlay.as_ref() + && (overlay.should_load_older(*key_event) + || (self.backtrack.overlay_preview_active + && self.backtrack.nth_user_message == 0 + && matches!(key_event.code, KeyCode::Esc | KeyCode::Left) + && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat))) + && let Some(thread_id) = self.chat_widget.thread_id() + && app_server.has_older_history(thread_id) + && self.request_older_history_page(app_server, thread_id) + { + if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { + overlay.set_history_state(if overlay.should_load_from_start(*key_event) { + TranscriptHistoryState::LoadingBeginning + } else { + TranscriptHistoryState::LoadingOlder + }); + } + tui.frame_requester().schedule_frame(); + } + if self.backtrack.overlay_preview_active { + return self.handle_backtrack_preview_event(tui, event); + } + match event { + TuiEvent::Key(KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => self.begin_overlay_backtrack_preview(tui), + event => self.overlay_forward_event(tui, event)?, + } + Ok(true) + } + + pub(super) fn handle_backtrack_preview_event( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result { + match event { + TuiEvent::Key(KeyEvent { + code: KeyCode::Esc | KeyCode::Left, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => self.overlay_step_backtrack(tui, event)?, + TuiEvent::Key(KeyEvent { + code: KeyCode::Right, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => self.overlay_step_backtrack_forward(tui, event)?, + TuiEvent::Key(KeyEvent { + code: KeyCode::Enter, + kind: KeyEventKind::Press, + .. + }) => self.overlay_confirm_backtrack(tui), + event => self.overlay_forward_event(tui, event)?, + } + Ok(true) + } +} diff --git a/codex-rs/tui/src/app_server_session/collaboration_catalog_tests.rs b/codex-rs/tui/src/app_server_session/collaboration_catalog_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..3312ae7324b2255a3d7bcff04b0b5d2b133ad0e8 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/collaboration_catalog_tests.rs @@ -0,0 +1,92 @@ +//! Optional catalog discovery uses the public RPC and never prevents ordinary bootstrap. + +use super::*; +use crate::legacy_core::config::ConfigBuilder; +use codex_app_server_protocol::JSONRPCMessage; +use codex_protocol::config_types::CollaborationModeMask; +use codex_protocol::config_types::ModeKind; +use codex_protocol::openai_models::ReasoningEffort; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::json; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test] +async fn collaboration_catalog_is_optional_and_refetched_on_bootstrap() -> Result<()> { + for initial_reply in [ + Some(json!({"error": {"code": -32601, "message": "Method not found"}})), + Some(json!({"error": {"code": -32600, "message": "Experimental API unavailable"}})), + Some(json!({"error": {"code": -32603, "message": "Discovery failed"}})), + Some(json!({"result": {"data": []}})), + Some(json!({"result": {"data": "invalid"}})), + None, + ] { + let codex_home = tempfile::tempdir()?; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + config.model = Some("task-model".into()); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + let mut socket = tokio_tungstenite::accept_async(stream).await?; + let mut catalog_requests = 0; + while let Some(Ok(Message::Text(text))) = socket.next().await { + let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else { + continue; + }; + let mut reply = match request.method.as_str() { + "initialize" => json!({"result": {"userAgent": "catalog-test/1.0.0"}}), + "account/read" => { + json!({"result": {"account": null, "requiresOpenaiAuth": false}}) + } + "model/list" => json!({"result": {"data": [], "nextCursor": null}}), + "configRequirements/read" => json!({"result": {"requirements": null}}), + "collaborationMode/list" => { + assert_eq!(request.params, Some(json!({}))); + catalog_requests += 1; + if catalog_requests == 1 { + let Some(reply) = initial_reply.clone() else { + continue; + }; + reply + } else { + json!({"result": {"data": [{"name": "Server plan", "mode": "plan", "model": "server-model", "reasoning_effort": "high"}]}}) + } + } + method => panic!("unexpected request: {method}"), + }; + reply["id"] = json!(request.id); + socket.send(Message::Text(reply.to_string().into())).await?; + } + Ok::<_, color_eyre::Report>(catalog_requests) + }); + let mut session = AppServerSession::new( + crate::connect_remote_app_server(endpoint).await?, + ThreadParamsMode::Remote, + ); + let initial = session.bootstrap(&config).await?; + assert_eq!( + (initial.default_model, initial.collaboration_modes), + ("task-model".into(), vec![]) + ); + let refreshed = session.bootstrap(&config).await?; + assert_eq!( + refreshed.collaboration_modes, + vec![CollaborationModeMask { + name: "Server plan".into(), + mode: Some(ModeKind::Plan), + model: Some("server-model".into()), + reasoning_effort: Some(Some(ReasoningEffort::High)), + developer_instructions: Some(None), + }] + ); + session.shutdown().await?; + assert_eq!(server.await??, 2); + } + Ok(()) +} diff --git a/codex-rs/tui/src/app_server_session/fs.rs b/codex-rs/tui/src/app_server_session/fs.rs new file mode 100644 index 0000000000000000000000000000000000000000..74d0e075efda2e5fcb1830e4aea802255a971973 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/fs.rs @@ -0,0 +1,135 @@ +use super::AppServerSession; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_client::AppServerPath; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::FsCreateDirectoryParams; +use codex_app_server_protocol::FsCreateDirectoryResponse; +use codex_app_server_protocol::FsReadFileParams; +use codex_app_server_protocol::FsReadFileResponse; +use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsRemoveResponse; +use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::FsWriteFileResponse; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::RequestId; +use codex_utils_absolute_path::AbsolutePathBuf; +use color_eyre::eyre::Result; +use color_eyre::eyre::WrapErr; +use serde::de::DeserializeOwned; +use serde_json::json; + +impl AppServerSession { + pub(crate) async fn fs_create_directory_all_path( + &mut self, + path: &AppServerPath, + ) -> Result<()> { + self.request_fs_path::( + "fs/createDirectory", + path, + |request_id, path| ClientRequest::FsCreateDirectory { + request_id, + params: FsCreateDirectoryParams { + path, + recursive: Some(true), + }, + }, + || json!({ "path": path.as_str(), "recursive": true }), + ) + .await + .map(drop) + } + + pub(crate) async fn fs_write_file_path( + &mut self, + path: &AppServerPath, + bytes: Vec, + ) -> Result<()> { + let data_base64 = STANDARD.encode(bytes); + self.request_fs_path::( + "fs/writeFile", + path, + |request_id, path| ClientRequest::FsWriteFile { + request_id, + params: FsWriteFileParams { + path, + data_base64: data_base64.clone(), + }, + }, + || json!({ "path": path.as_str(), "dataBase64": data_base64 }), + ) + .await + .map(drop) + } + + pub(crate) async fn fs_read_file_path(&mut self, path: &AppServerPath) -> Result> { + let response: FsReadFileResponse = self + .request_fs_path( + "fs/readFile", + path, + |request_id, path| ClientRequest::FsReadFile { + request_id, + params: FsReadFileParams { path }, + }, + || json!({ "path": path.as_str() }), + ) + .await?; + STANDARD + .decode(response.data_base64) + .wrap_err("fs/readFile returned invalid base64 data") + } + + pub(crate) async fn fs_remove_path(&mut self, path: &AppServerPath) -> Result<()> { + self.request_fs_path::( + "fs/remove", + path, + |request_id, path| ClientRequest::FsRemove { + request_id, + params: FsRemoveParams { + path, + recursive: None, + force: None, + }, + }, + || json!({ "path": path.as_str() }), + ) + .await + .map(drop) + } + + async fn request_fs_path( + &mut self, + method: &str, + path: &AppServerPath, + local_request: impl FnOnce(RequestId, AbsolutePathBuf) -> ClientRequest, + remote_params: impl FnOnce() -> serde_json::Value, + ) -> Result { + let request_id = self.next_request_id(); + match self.request_handle() { + AppServerRequestHandle::Remote(handle) => { + let response = handle + .request_json_rpc(JSONRPCRequest { + id: request_id, + method: method.to_string(), + params: Some(remote_params()), + trace: None, + }) + .await + .wrap_err_with(|| format!("{method} failed in TUI"))?; + serde_json::from_value(response.map_err(|source| { + color_eyre::eyre::eyre!("{method} failed in TUI: {}", source.message) + })?) + .wrap_err_with(|| format!("{method} returned invalid data")) + } + AppServerRequestHandle::InProcess(_) => { + let path = AbsolutePathBuf::from_absolute_path_checked(path.as_str()) + .wrap_err_with(|| format!("invalid local app-server fs path {path}"))?; + self.client + .request_typed(local_request(request_id, path)) + .await + .wrap_err_with(|| format!("{method} failed in TUI")) + } + } + } +} diff --git a/codex-rs/tui/src/app_server_session/history.rs b/codex-rs/tui/src/app_server_session/history.rs new file mode 100644 index 0000000000000000000000000000000000000000..f6fe4d2c3cfdcc8a26c5c0a751e23c1f9903420c --- /dev/null +++ b/codex-rs/tui/src/app_server_session/history.rs @@ -0,0 +1,380 @@ +//! Bounded app-server transcript loading for resume, fork, and transcript views. + +use std::collections::HashSet; + +use super::AppServerSession; +use crate::history_cell::HistoryRenderMode; +use crate::legacy_core::config::Config; +use crate::resize_reflow_cap::resize_reflow_max_rows; +use crate::thread_transcript::RawReasoningVisibility; +use crate::thread_transcript::thread_items_to_transcript_cells; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadHistoryMode; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemsListParams; +use codex_app_server_protocol::ThreadItemsListResponse; +use codex_app_server_protocol::ThreadRevertParams; +use codex_app_server_protocol::ThreadRevertResponse; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadTurnsListResponse; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnItemsView; +use codex_protocol::ThreadId; +use color_eyre::eyre::Result; +use color_eyre::eyre::WrapErr; + +pub(crate) const INITIAL_HISTORY_TURN_LIMIT: u32 = 5; +pub(crate) const HISTORY_ITEM_PAGE_LIMIT: u32 = 100; +pub(crate) const HISTORY_ITEM_SCAN_LIMIT: usize = 4 * HISTORY_ITEM_PAGE_LIMIT as usize; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HistoryHydrationScope<'a> { + Initial, + Complete, + ThroughTurn(&'a str), +} + +pub(crate) fn thread_items_page_params( + thread_id: ThreadId, + turn_id: Option<&str>, + cursor: Option, + limit: u32, +) -> ThreadItemsListParams { + ThreadItemsListParams { + thread_id: thread_id.to_string(), + turn_id: turn_id.map(str::to_string), + cursor, + limit: Some(limit), + sort_direction: Some(SortDirection::Desc), + } +} + +fn advancing_cursor( + current: Option<&str>, + next: Option, + seen_cursors: &mut HashSet, +) -> Option { + if let Some(current) = current { + seen_cursors.insert(current.to_string()); + } + next.filter(|next| seen_cursors.insert(next.clone())) +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ThreadHistoryPagination { + pub(super) history_mode: ThreadHistoryMode, + next_turn_cursor: Option, + next_item_cursor: Option, + seen_turn_cursors: HashSet, + seen_item_cursors: HashSet, + loading_older: bool, +} + +impl AppServerSession { + pub(crate) async fn revert_thread( + &mut self, + thread_id: ThreadId, + before_turn_id: String, + retained_turns: &[Turn], + ) -> std::result::Result { + let request_id = self.next_request_id(); + let response: ThreadRevertResponse = self + .client + .request_typed(ClientRequest::ThreadRevert { + request_id, + params: ThreadRevertParams { + thread_id: thread_id.to_string(), + before_turn_id, + }, + }) + .await?; + // Older cursors in the retained prefix remain valid. If the entire displayed window + // disappeared, resume paging at the replacement history's end instead. + if retained_turns.iter().all(|turn| turn.items.is_empty()) { + self.history_pagination.insert( + thread_id, + ThreadHistoryPagination { + history_mode: ThreadHistoryMode::Paginated, + next_turn_cursor: response.turns_backwards_cursor.clone(), + next_item_cursor: response.items_backwards_cursor.clone(), + ..ThreadHistoryPagination::default() + }, + ); + } else { + self.cancel_older_history_page(thread_id); + } + Ok(response) + } + + pub(crate) fn has_older_history(&self, thread_id: ThreadId) -> bool { + self.history_pagination + .get(&thread_id) + .is_some_and(|page| page.next_item_cursor.is_some()) + } + + pub(crate) fn begin_older_history_page(&mut self, thread_id: ThreadId) -> Option { + let page = self.history_pagination.get_mut(&thread_id)?; + if page.loading_older { + return None; + } + let cursor = page.next_item_cursor.clone()?; + page.loading_older = true; + Some(cursor) + } + + pub(crate) fn cancel_older_history_page(&mut self, thread_id: ThreadId) { + if let Some(page) = self.history_pagination.get_mut(&thread_id) { + page.loading_older = false; + } + } + + pub(crate) async fn apply_older_history_page( + &mut self, + thread_id: ThreadId, + cursor: &str, + page: ThreadItemsListResponse, + turns: &mut Vec, + ) -> Result> { + let Some(mut state) = self.history_pagination.get(&thread_id).cloned() else { + return Ok(Vec::new()); + }; + if !state.loading_older || state.next_item_cursor.as_deref() != Some(cursor) { + return Ok(Vec::new()); + } + let items = self + .merge_thread_item_page(thread_id, page, &mut state, turns) + .await?; + state.loading_older = false; + self.history_pagination.insert(thread_id, state); + Ok(items) + } + + pub(crate) async fn thread_items_page( + &mut self, + thread_id: ThreadId, + turn_id: Option<&str>, + cursor: Option, + limit: u32, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadItemsList { + request_id, + params: thread_items_page_params(thread_id, turn_id, cursor, limit), + }) + .await + .wrap_err("failed to load a bounded thread item page") + } + + pub(crate) async fn thread_turns_page( + &mut self, + thread_id: ThreadId, + cursor: Option, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadTurnsList { + request_id, + params: ThreadTurnsListParams { + thread_id: thread_id.to_string(), + cursor, + limit: Some(INITIAL_HISTORY_TURN_LIMIT), + sort_direction: Some(SortDirection::Desc), + items_view: Some(TurnItemsView::NotLoaded), + }, + }) + .await + .wrap_err("failed to load a bounded thread history page") + } + + async fn merge_thread_item_page( + &mut self, + thread_id: ThreadId, + page: ThreadItemsListResponse, + state: &mut ThreadHistoryPagination, + turns: &mut Vec, + ) -> Result> { + state.next_item_cursor = advancing_cursor( + state.next_item_cursor.as_deref(), + page.next_cursor, + &mut state.seen_item_cursors, + ); + let mut items = Vec::new(); + for entry in page.data { + while !turns.iter().any(|turn| turn.id == entry.turn_id) { + let Some(cursor) = state.next_turn_cursor.take() else { + break; + }; + let page = self + .thread_turns_page(thread_id, Some(cursor.clone())) + .await?; + state.next_turn_cursor = advancing_cursor( + Some(&cursor), + page.next_cursor, + &mut state.seen_turn_cursors, + ); + turns.splice(0..0, page.data.into_iter().rev()); + } + if let Some(turn) = turns.iter_mut().find(|turn| turn.id == entry.turn_id) + && !turn.items.iter().any(|item| item.id() == entry.item.id()) + { + items.push(entry.item.clone()); + turn.items.insert(/*index*/ 0, entry.item); + turn.items_view = TurnItemsView::Summary; + } + } + items.reverse(); + Ok(items) + } + + /// Hydrates paginated threads through bounded turn and item pages. + /// + /// Legacy servers expose neither paging method, so only legacy threads may + /// use `thread/read(includeTurns: true)` to preserve their existing history. + pub(crate) async fn hydrate_initial_thread_history( + &mut self, + thread: &mut Thread, + turn_cursor: Option, + item_cursor: Option, + config: Option<&Config>, + local_settings: Option<&crate::local_settings::LocalSettings>, + scope: HistoryHydrationScope<'_>, + ) -> Result<()> { + let thread_id = ThreadId::from_string(&thread.id) + .wrap_err("invalid thread id in bounded history response")?; + if thread.history_mode == ThreadHistoryMode::Legacy { + if thread.turns.is_empty() { + thread.turns = Box::pin(self.thread_read(thread_id, /*include_turns*/ true)) + .await? + .turns; + } + self.history_pagination.entry(thread_id).or_default(); + return Ok(()); + } + + let page = self.thread_turns_page(thread_id, turn_cursor).await?; + thread.turns = page.data.into_iter().rev().collect(); + let mut state = ThreadHistoryPagination { + history_mode: ThreadHistoryMode::Paginated, + next_turn_cursor: page.next_cursor, + next_item_cursor: item_cursor, + ..ThreadHistoryPagination::default() + }; + let width = crossterm::terminal::size() + .map(|(width, _)| width.max(/*other*/ 1)) + .unwrap_or(/*default*/ 80); + let row_budget = local_settings + .and_then(|settings| resize_reflow_max_rows(settings.terminal_resize_reflow())); + let item_budget = match (scope, config, row_budget) { + (HistoryHydrationScope::Complete, _, _) + | (HistoryHydrationScope::ThroughTurn(_), _, _) + | (HistoryHydrationScope::Initial, Some(_), None) => None, + (HistoryHydrationScope::Initial, Some(_), Some(max_rows)) => { + Some(max_rows.saturating_add(HISTORY_ITEM_SCAN_LIMIT)) + } + (HistoryHydrationScope::Initial, None, _) => Some(HISTORY_ITEM_PAGE_LIMIT as usize), + }; + let mut scanned_items = 0; + let mut rendered_rows = 0; + loop { + let remaining_rows = row_budget.map(|budget| budget.saturating_sub(rendered_rows)); + let remaining_items = item_budget.map(|budget| budget.saturating_sub(scanned_items)); + if remaining_rows == Some(0) || remaining_items == Some(0) { + break; + } + let limit = remaining_items + .unwrap_or(HISTORY_ITEM_PAGE_LIMIT as usize) + .min(HISTORY_ITEM_PAGE_LIMIT as usize); + let limit = if rendered_rows == 0 && scanned_items != 0 { + limit + } else { + limit.min(remaining_rows.unwrap_or(limit)) + } as u32; + let page = self + .thread_items_page( + thread_id, + /*turn_id*/ None, + state.next_item_cursor.clone(), + limit, + ) + .await?; + if page.data.is_empty() { + state.next_item_cursor = None; + break; + } + scanned_items = scanned_items.saturating_add(page.data.len()); + let items = self + .merge_thread_item_page(thread_id, page, &mut state, &mut thread.turns) + .await?; + // Finish the anchor turn so prompt editing can detect earlier steers, + // but do not scan history older than the displayed transcript. + if let HistoryHydrationScope::ThroughTurn(anchor) = scope + && let Some(index) = thread.turns.iter().position(|turn| turn.id == anchor) + && thread.turns[..index] + .iter() + .any(|turn| !turn.items.is_empty()) + { + break; + } + if let Some((config, local_settings)) = config.zip(local_settings) { + rendered_rows = rendered_history_rows( + thread_id, + thread, + items, + config, + local_settings, + width, + rendered_rows, + ); + } else { + rendered_rows = rendered_rows.saturating_add(items.len()); + } + if state.next_item_cursor.is_none() { + break; + } + } + if config.is_some() { + self.history_pagination.insert(thread_id, state); + } + Ok(()) + } +} + +fn rendered_history_rows( + thread_id: ThreadId, + thread: &Thread, + items: Vec, + config: &Config, + local_settings: &crate::local_settings::LocalSettings, + width: u16, + rendered_rows: usize, +) -> usize { + let visibility = if config.show_raw_agent_reasoning { + RawReasoningVisibility::Visible + } else { + RawReasoningVisibility::Hidden + }; + let mode = if local_settings.tui.raw_output_mode { + HistoryRenderMode::Raw + } else { + HistoryRenderMode::Rich + }; + thread_items_to_transcript_cells( + Some(thread_id), + &thread.cwd, + items, + visibility, + Some(config), + ) + .into_iter() + .fold(rendered_rows, |rows, cell| { + let height = usize::from(cell.desired_height_for_mode(width, mode)); + rows + height + usize::from(height != 0 && rows != 0 && !cell.is_stream_continuation()) + }) +} + +#[cfg(test)] +#[path = "history_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app_server_session/history_tests.rs b/codex-rs/tui/src/app_server_session/history_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..c498db3e8a16363eed42740fcb401619c4f4fdb4 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/history_tests.rs @@ -0,0 +1,28 @@ +use super::advancing_cursor; +use pretty_assertions::assert_eq; +use std::collections::HashSet; + +#[test] +fn advancing_cursor_rejects_repeated_cursors() { + let mut seen_cursors = HashSet::new(); + assert_eq!( + advancing_cursor( + /*current*/ None, + Some("first".to_string()), + &mut seen_cursors, + ), + Some("first".to_string()) + ); + assert_eq!( + advancing_cursor(Some("first"), Some("second".to_string()), &mut seen_cursors,), + Some("second".to_string()) + ); + assert_eq!( + advancing_cursor(Some("second"), Some("first".to_string()), &mut seen_cursors,), + None + ); + assert_eq!( + advancing_cursor(Some("second"), /*next*/ None, &mut seen_cursors), + None + ); +} diff --git a/codex-rs/tui/src/app_server_session/models.rs b/codex-rs/tui/src/app_server_session/models.rs new file mode 100644 index 0000000000000000000000000000000000000000..ca945f854f05f2007653425fc7c0fa6ff8aab7dc --- /dev/null +++ b/codex-rs/tui/src/app_server_session/models.rs @@ -0,0 +1,50 @@ +//! Fetch picker models without blocking the event loop and keep new-thread defaults in sync. + +use super::AppServerSession; +use super::model_preset_from_api_model; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::RequestId; +use codex_protocol::openai_models::ModelPreset; +use uuid::Uuid; + +impl AppServerSession { + pub(crate) fn set_available_models(&mut self, models: Vec) { + if let Some(default) = models + .iter() + .find(|model| model.is_default) + .or(models.first()) + { + self.default_model = Some(default.model.clone()); + } + self.available_models = models; + } + + pub(crate) fn fetch_models(&self, request_id: Uuid, app_event_tx: AppEventSender) { + let request_handle = self.request_handle(); + tokio::spawn(async move { + let result = request_handle + .request_typed::(ClientRequest::ModelList { + request_id: RequestId::String(format!("model-list-{request_id}")), + params: ModelListParams { + cursor: None, + limit: None, + include_hidden: Some(true), + }, + }) + .await + .map(|response| { + response + .data + .into_iter() + .map(model_preset_from_api_model) + .collect() + }) + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::ModelsLoaded { request_id, result }); + }); + } +} diff --git a/codex-rs/tui/src/app_server_session/prompt_history_tests.rs b/codex-rs/tui/src/app_server_session/prompt_history_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..3969bc2ab87d0245de51caa3174603503008a6ba --- /dev/null +++ b/codex-rs/tui/src/app_server_session/prompt_history_tests.rs @@ -0,0 +1,90 @@ +//! Local prompt history stays independent of server settings across thread lifecycle requests. + +use super::*; +use crate::legacy_core::config::ConfigBuilder; +use app_test_support::create_fake_rollout; +use codex_config::types::HistoryPersistence; +use codex_message_history::HistoryConfig; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn lifecycle_metadata_uses_local_prompt_history() -> Result<()> { + let local_home = tempfile::tempdir()?; + let local_config = ConfigBuilder::default() + .codex_home(local_home.path().to_path_buf()) + .build() + .await?; + let local_settings = LocalSettings::from(&local_config); + let history_config = + HistoryConfig::new(local_settings.codex_home.clone(), &local_settings.history); + codex_message_history::append_entry("local prompt", ThreadId::new(), &history_config).await?; + let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await; + let expected = Some(MessageHistoryMetadata { + log_id, + entry_count, + }); + assert_eq!(entry_count, 1); + + for mode in [ThreadParamsMode::Embedded, ThreadParamsMode::Remote] { + let server_home = tempfile::tempdir()?; + let mut config = ConfigBuilder::default() + .codex_home(server_home.path().to_path_buf()) + .build() + .await?; + config.history.persistence = HistoryPersistence::None; + config.history.max_bytes = Some(1); + let thread_id = ThreadId::from_string( + &create_fake_rollout( + server_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create source rollout"), + )?; + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + app_server.thread_params_mode = mode; + + let started = app_server + .start_thread_with_session_start_source( + &local_settings, + &config, + /*session_start_source*/ None, + /*remote_cwd_override*/ None, + /*selected_profile*/ None, + ) + .await?; + assert_eq!(started.session.message_history, expected); + let startup = start_thread_with_request_handle( + app_server.request_handle(), + &local_settings, + config.clone(), + mode, + /*remote_cwd_override*/ None, + app_server.thread_tool_transport(), + ) + .await?; + assert_eq!(startup.session.message_history, expected); + let resumed = app_server + .resume_thread( + &local_settings, + config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!(resumed.session.message_history, expected); + assert_eq!(resumed.session.thread_id, thread_id); + assert!(!resumed.turns.is_empty()); + let forked = app_server + .fork_thread(&local_settings, config, thread_id) + .await?; + assert_eq!(forked.session.message_history, expected); + assert_eq!(forked.session.forked_from_id, Some(thread_id)); + assert!(!forked.turns.is_empty()); + app_server.shutdown().await?; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app_server_session/realtime.rs b/codex-rs/tui/src/app_server_session/realtime.rs new file mode 100644 index 0000000000000000000000000000000000000000..3614301e89a482796b2cae2c26e19c4f5a8e9726 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/realtime.rs @@ -0,0 +1,93 @@ +//! App-server signaling for TUI-owned realtime WebRTC sessions. + +use super::AppServerSession; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ThreadRealtimeAppendSpeechParams; +use codex_app_server_protocol::ThreadRealtimeAppendSpeechResponse; +use codex_app_server_protocol::ThreadRealtimeStartParams; +use codex_app_server_protocol::ThreadRealtimeStartResponse; +use codex_app_server_protocol::ThreadRealtimeStartTransport; +use codex_app_server_protocol::ThreadRealtimeStopParams; +use codex_app_server_protocol::ThreadRealtimeStopResponse; +use codex_protocol::ThreadId; +use codex_protocol::protocol::RealtimeConversationVersion; +use codex_protocol::protocol::RealtimeOutputModality; +use codex_protocol::protocol::RealtimeVoice; +use color_eyre::eyre::Result; +use color_eyre::eyre::WrapErr; + +impl AppServerSession { + pub(crate) async fn thread_realtime_start( + &mut self, + thread_id: ThreadId, + offer_sdp: String, + model: Option, + voice: Option, + ) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadRealtimeStartResponse = self + .client + .request_typed(ClientRequest::ThreadRealtimeStart { + request_id, + params: ThreadRealtimeStartParams { + thread_id: thread_id.to_string(), + client_managed_handoffs: Some(true), + delegation_ack_filler: None, + flush_transcript_tail_on_session_end: None, + codex_responses_as_items: None, + codex_response_item_prefix: None, + codex_response_handoff_mode: None, + codex_response_handoff_channel_prefixes: None, + model, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: Some(false), + initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, + prompt: None, + realtime_session_id: None, + transport: Some(ThreadRealtimeStartTransport::Webrtc { sdp: offer_sdp }), + version: Some(RealtimeConversationVersion::V3), + voice, + }, + }) + .await + .wrap_err("thread/realtime/start failed in TUI")?; + Ok(()) + } + + pub(crate) async fn thread_realtime_stop(&mut self, thread_id: ThreadId) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadRealtimeStopResponse = self + .client + .request_typed(ClientRequest::ThreadRealtimeStop { + request_id, + params: ThreadRealtimeStopParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .wrap_err("thread/realtime/stop failed in TUI")?; + Ok(()) + } + + pub(crate) async fn thread_realtime_append_speech( + &mut self, + thread_id: ThreadId, + text: String, + ) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadRealtimeAppendSpeechResponse = self + .client + .request_typed(ClientRequest::ThreadRealtimeAppendSpeech { + request_id, + params: ThreadRealtimeAppendSpeechParams { + thread_id: thread_id.to_string(), + text, + }, + }) + .await + .wrap_err("thread/realtime/appendSpeech failed in TUI")?; + Ok(()) + } +} diff --git a/codex-rs/tui/src/app_server_session/reasoning_defaults_tests.rs b/codex-rs/tui/src/app_server_session/reasoning_defaults_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..40c65d9c4e0a4480df59d3a1dfdf08f67837a073 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/reasoning_defaults_tests.rs @@ -0,0 +1,174 @@ +//! Verify embedded TUI reasoning configuration and its outbound model request. + +use super::*; +use crate::legacy_core::config::ConfigBuilder; +use codex_app_server_protocol::ServerNotification; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[tokio::test] +async fn embedded_reasoning_defaults_reach_responses() -> Result<()> { + for (settings, summary, stream_options) in [ + ("", json!("detailed"), json!(null)), + ( + "[features]\nconcurrent_reasoning_summaries = false", + json!("detailed"), + json!(null), + ), + ( + "[features]\nconcurrent_reasoning_summaries = true", + json!("detailed"), + json!({"reasoning_summary_delivery": "sequential_cutoff"}), + ), + ( + "model_reasoning_summary = 'none'\n[features]\nconcurrent_reasoning_summaries = true", + json!(null), + json!(null), + ), + ] { + let server = responses::start_mock_server().await; + let response = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("response"), + responses::ev_completed("response"), + ]), + ) + .await; + let home = tempfile::tempdir()?; + let base_url = server.uri(); + std::fs::write( + home.path().join("config.toml"), + format!( + r#" +model = "gpt-5.2" +model_provider = "reasoning-test" +{settings} +[model_providers.reasoning-test] +name = "OpenAI" +base_url = "{base_url}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + )?; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + let started = app_server.start_thread(&config).await?; + let request_id = app_server.next_request_id(); + let _: TurnStartResponse = app_server + .client + .request_typed(ClientRequest::TurnStart { + request_id, + params: TurnStartParams { + thread_id: started.session.thread_id.to_string(), + input: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await?; + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), async { + while let Some(event) = app_server.next_event().await { + if let AppServerEvent::ServerNotification(notification) = event + && let ServerNotification::TurnCompleted(completed) = *notification + { + assert_eq!( + completed.turn.status, + codex_app_server_protocol::TurnStatus::Completed + ); + return; + } + } + panic!("app-server disconnected before completing the turn"); + }) + .await?; + let body = response.single_request().body_json(); + assert_eq!( + json!({"summary": body["reasoning"]["summary"], "stream_options": body["stream_options"]}), + json!({"summary": summary, "stream_options": stream_options}), + "settings: {settings}" + ); + app_server.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn new_tui_threads_request_summaries_unless_explicitly_disabled() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + for (config_text, expected_summary, expected_concurrent) in [ + ("", "detailed", false), + ( + "[features]\nconcurrent_reasoning_summaries = true", + "detailed", + true, + ), + ("model_reasoning_summary = 'none'", "none", false), + ( + "model_reasoning_summary = 'none'\n[features]\nconcurrent_reasoning_summaries = true", + "none", + false, + ), + ( + "model_reasoning_summary = 'concise'\n[features]\nconcurrent_reasoning_summaries = false", + "concise", + false, + ), + ] { + std::fs::write(temp_dir.path().join("config.toml"), config_text).expect("config"); + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await + .expect("config should build"); + let start = thread_start_params_from_config( + &config, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + /*session_start_source*/ None, + ); + let overrides = start.config.expect("thread config"); + assert_eq!( + overrides.get("model_reasoning_summary"), + Some(&serde_json::json!(expected_summary)), + ); + assert_eq!( + overrides["features"]["concurrent_reasoning_summaries"], + expected_concurrent, + ); + } + std::fs::write( + temp_dir.path().join("config.toml"), + "model_reasoning_summary = 'detailed'", + ) + .expect("config"); + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .cli_overrides(vec![( + "model_reasoning_summary".to_string(), + toml::Value::String("none".to_string()), + )]) + .build() + .await + .expect("override config"); + let start = thread_start_params_from_config( + &config, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + /*session_start_source*/ None, + ); + let overrides = start.config.expect("thread config"); + assert_eq!(overrides["model_reasoning_summary"], "none"); + assert_eq!( + overrides["features"]["concurrent_reasoning_summaries"], + false + ); +} diff --git a/codex-rs/tui/src/app_server_session/rollout_history.rs b/codex-rs/tui/src/app_server_session/rollout_history.rs new file mode 100644 index 0000000000000000000000000000000000000000..4d432cd8df2b088de6415e9993a67fbc65952531 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -0,0 +1,208 @@ +//! Resume history policy for local rollouts that may migrate between formats. + +use super::AppServerSession; +use super::AppServerStartedThread; +use super::HistoryHydrationScope; +use super::ResumeModelSettings; +use super::ThreadHistorySupport; +use super::bootstrap_request_error; +use super::is_history_pagination_unsupported; +use super::started_thread_from_resume_response; +use super::thread_resume_params_from_config; +use super::thread_session_state_from_thread_response; +use crate::legacy_core::config::Config; +use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ThreadHistoryMode; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_protocol::ThreadId; +use codex_utils_absolute_path::AbsolutePathBuf; +use color_eyre::eyre::Result; + +impl AppServerSession { + /// Read a conflicting thread without taking its writer lease. This is a snapshot, not an + /// attachment: the caller must not route turns through this session. + pub(crate) async fn read_thread_for_viewing( + &mut self, + config: &Config, + local_settings: &crate::local_settings::LocalSettings, + thread_id: ThreadId, + ) -> Result { + let mut thread = self.thread_read(thread_id, /*include_turns*/ false).await?; + self.hydrate_initial_thread_history( + &mut thread, + /*turn_cursor*/ None, + /*item_cursor*/ None, + Some(config), + Some(local_settings), + HistoryHydrationScope::Initial, + ) + .await?; + let session = thread_session_state_from_thread_response( + &thread.id, + crate::windows_sandbox::host_from_environments(thread.environments.as_deref()), + thread.forked_from_id.clone(), + thread.name.clone(), + thread.path.clone(), + thread.model.clone().unwrap_or_default(), + thread.model_provider.clone(), + config.service_tier.clone(), + AskForApproval::from(config.permissions.approval_policy.value()), + config.approvals_reviewer, + config.permissions.permission_profile().clone(), + config.permissions.active_permission_profile(), + thread.cwd.clone(), + config.workspace_roots.clone(), + Vec::new(), + thread.reasoning_effort, + config.personality, + local_settings, + ) + .await + .map_err(color_eyre::eyre::Report::msg)?; + Ok(AppServerStartedThread { + session, + turns: thread.turns, + blocks_direct_input: false, + task_tools_available: false, + }) + } + + pub(crate) fn with_local_codex_home(mut self, codex_home: &AbsolutePathBuf) -> Self { + self.task_tool_capabilities_dir = (!self.uses_embedded_app_server()) + .then(|| codex_home.join("tui-thread-reference-capabilities")); + self + } + + /// Retains trusted embedded-server history metadata for a later resume. + /// + /// Remote hints are ignored because another process can migrate the thread before resume. + pub(crate) fn remember_thread_history_mode( + &mut self, + thread_id: ThreadId, + history_mode: ThreadHistoryMode, + ) { + if !self.uses_embedded_app_server() { + return; + } + self.history_pagination + .entry(thread_id) + .or_default() + .history_mode = history_mode; + } + + pub(crate) async fn resume_thread( + &mut self, + local_settings: &crate::local_settings::LocalSettings, + config: Config, + thread_id: ThreadId, + model_settings: ResumeModelSettings, + ) -> Result { + let session_config = if matches!( + model_settings, + ResumeModelSettings::RestoreFromThread | ResumeModelSettings::PreserveExistingThread + ) { + config.clone() + } else { + self.session_config_with_effective_service_tier(&config) + }; + let mut params = thread_resume_params_from_config( + session_config, + thread_id, + self.thread_params_mode(), + self.remote_cwd_override.as_deref(), + model_settings, + ); + self.thread_tool_transport() + .configure_mcp(&mut params.config); + let mut rollout_maintenance_guard = None; + params.exclude_turns = if self.history_support == ThreadHistorySupport::Paginated { + let known_legacy_history = self + .history_pagination + .get(&thread_id) + .is_some_and(|state| state.history_mode == ThreadHistoryMode::Legacy) + && (!self.uses_embedded_app_server() + || ({ + // The guard prevents migration through the full resume, + // regardless of the server's migration feature settings. + rollout_maintenance_guard = + codex_rollout::try_acquire_rollout_maintenance_lock( + config.codex_home.as_path(), + ) + .ok() + .flatten(); + rollout_maintenance_guard.is_some() + } && self + .thread_read(thread_id, /*include_turns*/ false) + .await + .is_ok_and(|thread| thread.history_mode == ThreadHistoryMode::Legacy))); + !known_legacy_history + } else { + false + }; + if params.exclude_turns { + rollout_maintenance_guard = None; + } + let request_id = self.next_request_id(); + let resume_response = self + .client + .request_typed(ClientRequest::ThreadResume { + request_id, + params: params.clone(), + }) + .await; + drop(rollout_maintenance_guard); + let mut response: ThreadResumeResponse = match resume_response { + Ok(response) => response, + Err(TypedRequestError::Server { source, .. }) + if params.exclude_turns && is_history_pagination_unsupported(&source) => + { + self.history_support = ThreadHistorySupport::LegacyOnly; + params.exclude_turns = false; + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadResume { request_id, params }) + .await + .map_err(|err| { + bootstrap_request_error("thread/resume failed during TUI bootstrap", err) + })? + } + Err(err) => { + return Err(bootstrap_request_error( + "thread/resume failed during TUI bootstrap", + err, + )); + } + }; + self.hydrate_initial_thread_history( + &mut response.thread, + response.turns_backwards_cursor.clone(), + response.items_backwards_cursor.clone(), + Some(&config), + Some(local_settings), + HistoryHydrationScope::Initial, + ) + .await?; + let fork_parent_title = self + .fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref()) + .await; + let mut started = started_thread_from_resume_response( + response, + local_settings, + &config, + self.thread_params_mode(), + ) + .await?; + started.session.fork_parent_title = fork_parent_title; + if self.task_tools_available(thread_id) { + self.remember_task_tool_thread(thread_id); + started.task_tools_available = true; + } + Ok(started) + } +} + +#[cfg(test)] +#[path = "rollout_history_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..64db875be3b4a0730ed2b458870edda223b904dc --- /dev/null +++ b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs @@ -0,0 +1,447 @@ +use super::super::ForkGoalContinuation; +use super::super::ForkPermissionMode; +use super::super::ResumeModelSettings; +use super::super::ThreadParamsMode; +use crate::legacy_core::config::Config; +use crate::legacy_core::config::ConfigBuilder; +use app_test_support::create_fake_paginated_rollout; +use app_test_support::create_fake_rollout; +use codex_app_server_protocol::ThreadHistoryMode; +use codex_features::Feature; +use codex_protocol::ThreadId; +use color_eyre::eyre::Result; +use futures::FutureExt; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +async fn build_config(temp_dir: &TempDir) -> Config { + ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await + .expect("config should build") +} + +#[tokio::test] +async fn remote_resume_restores_saved_server_profile_without_permission_overrides() -> Result<()> { + let home = tempfile::tempdir()?; + std::fs::write( + home.path().join("config.toml"), + "default_permissions = \":workspace\"\n[permissions.server-only]\nextends = \":read-only\"\n", + )?; + let config = build_config(&home).await; + let client_config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .cli_overrides(vec![( + "default_permissions".to_string(), + ":workspace".into(), + )]) + .harness_overrides(crate::legacy_core::config::ConfigOverrides { + default_permissions: Some(":read-only".into()), + ..Default::default() + }) + .build() + .await?; + let thread_id = ThreadId::from_string( + &create_fake_rollout( + home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create source rollout"), + )?; + let mut server = crate::start_embedded_app_server_for_picker(&config).await?; + server.thread_params_mode = ThreadParamsMode::Remote; + let local_settings = crate::local_settings::LocalSettings::from(&client_config); + let initial = server + .resume_thread( + &local_settings, + client_config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!( + initial + .session + .active_permission_profile + .map(|profile| profile.id), + Some(":workspace".to_string()) + ); + server + .thread_settings_update(codex_app_server_protocol::ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + permissions: Some("server-only".into()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), + approvals_reviewer: Some(codex_app_server_protocol::ApprovalsReviewer::AutoReview), + ..Default::default() + }) + .await?; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let Some(codex_app_server_client::AppServerEvent::ServerNotification(notification)) = + server.next_event().await + && let codex_app_server_protocol::ServerNotification::ThreadSettingsUpdated( + settings, + ) = *notification + && settings.thread_id == thread_id.to_string() + && settings + .thread_settings + .active_permission_profile + .as_ref() + .is_some_and(|profile| profile.id == "server-only") + { + break; + } + } + }) + .await?; + server.shutdown().await?; + + let mut server = crate::start_embedded_app_server_for_picker(&config).await?; + server.thread_params_mode = ThreadParamsMode::Remote; + let resumed = server + .resume_thread( + &local_settings, + client_config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!( + resumed + .session + .active_permission_profile + .as_ref() + .map(|profile| profile.id.as_str()), + Some("server-only") + ); + assert_eq!( + resumed.session.approval_policy, + codex_app_server_protocol::AskForApproval::Never + ); + assert_eq!( + resumed.session.approvals_reviewer, + codex_protocol::config_types::ApprovalsReviewer::AutoReview + ); + // A locally remembered profile may have been removed since selection. + let stale_selection = crate::app_event::PermissionProfileSelection { + profile_id: "removed-profile".into(), + approval_policy: None, + approvals_reviewer: None, + display_label: "removed-profile".into(), + }; + let forked = server + .fork_thread_at( + &local_settings, + client_config.clone(), + thread_id, + /*last_turn_id*/ None, + /*before_turn_id*/ None, + ForkGoalContinuation::StartIfIdle, + Some(&stale_selection), + ) + .await?; + assert_eq!( + forked + .session + .active_permission_profile + .as_ref() + .map(|profile| profile.id.as_str()), + Some("server-only") + ); + assert_eq!( + forked.session.approval_policy, + codex_app_server_protocol::AskForApproval::Never + ); + assert_eq!( + forked.session.approvals_reviewer, + codex_protocol::config_types::ApprovalsReviewer::AutoReview + ); + let side = server + .fork_side_thread(&local_settings, client_config, thread_id) + .await?; + assert_eq!( + side.session.active_permission_profile.unwrap().id, + "server-only" + ); + let explicit_config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(crate::legacy_core::config::ConfigOverrides { + sandbox_mode: Some(codex_protocol::config_types::SandboxMode::ReadOnly), + approval_policy: Some(codex_protocol::protocol::AskForApproval::OnRequest), + ..Default::default() + }) + .build() + .await?; + let explicit_fork = server + .fork_thread_with_permission_mode( + &local_settings, + explicit_config, + thread_id, + ForkPermissionMode::OverrideFromCurrentConfig, + ) + .await?; + assert_eq!( + explicit_fork.session.approval_policy, + codex_app_server_protocol::AskForApproval::OnRequest + ); + assert_eq!( + explicit_fork.session.permission_profile, + codex_protocol::models::PermissionProfile::read_only() + ); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn viewing_thread_reads_history_without_resuming_it() -> Result<()> { + let codex_home = tempfile::tempdir()?; + let config = build_config(&codex_home).await; + let thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create source rollout"), + )?; + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + let next_request_id = app_server.next_request_id; + let viewed = app_server + .read_thread_for_viewing( + &config, + &crate::local_settings::LocalSettings::from(&config), + thread_id, + ) + .await?; + + assert_eq!(viewed.session.thread_id, thread_id); + assert!(!viewed.turns.is_empty()); + assert!(!viewed.blocks_direct_input); + assert!(app_server.next_request_id > next_request_id); + app_server.shutdown().await?; + Ok(()) +} + +#[test] +fn only_active_writer_failures_offer_read_only_view() { + let conflict = color_eyre::eyre::eyre!("thread already has an active writer (code -32600)"); + let unrelated = color_eyre::eyre::eyre!("thread not found (code -32600)"); + assert!(crate::app_server_session::is_active_writer_error(&conflict)); + assert!(!crate::app_server_session::is_active_writer_error( + &unrelated + )); +} + +#[tokio::test] +async fn legacy_resume_preserves_history_mode_after_picker_server_replacement() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let config = build_config(&codex_home).await; + let thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create source rollout"), + )?; + let mut picker_app_server = crate::start_embedded_app_server_for_picker(&config).await?; + let history_mode = picker_app_server + .thread_read(thread_id, /*include_turns*/ false) + .await? + .history_mode; + picker_app_server.shutdown().await?; + + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + app_server.remember_thread_history_mode(thread_id, history_mode); + let next_request_id = app_server.next_request_id; + let resumed = app_server + .resume_thread( + &crate::local_settings::LocalSettings::from(&config), + config, + thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; + + assert_eq!(app_server.next_request_id, next_request_id + 2); + assert!(!resumed.turns.is_empty()); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn cached_legacy_resume_revalidates_history_across_migration_settings() -> Result<()> { + for (startup_enabled, workspace_enabled) in + [(false, false), (false, true), (true, false), (true, true)] + { + let codex_home = tempfile::tempdir().expect("tempdir"); + // Keep the large setup futures off the test thread's stack. + let config = Box::pin(build_config(&codex_home)).await; + let legacy_thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved legacy user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create legacy rollout"), + )?; + let mut startup_config = config.clone(); + if startup_enabled { + startup_config + .features + .enable(Feature::BackgroundPaginatedRolloutMigration)?; + } + let mut resume_config = config; + if workspace_enabled { + resume_config + .features + .enable(Feature::BackgroundPaginatedRolloutMigration)?; + } + // Keep the real startup worker from migrating the legacy fixture before selection. + let maintenance_guard = + codex_rollout::try_acquire_rollout_maintenance_lock(codex_home.path())? + .expect("acquire rollout maintenance lock"); + let mut app_server = + Box::pin(crate::start_embedded_app_server_for_picker(&startup_config)).await?; + app_server.remember_thread_history_mode(legacy_thread_id, ThreadHistoryMode::Legacy); + let local_settings = crate::local_settings::LocalSettings::from(&resume_config); + let next_request_id = app_server.next_request_id; + let legacy = { + // Keep the large resume future off the Windows test thread's stack. + let mut resume = Box::pin(app_server.resume_thread( + &local_settings, + resume_config.clone(), + legacy_thread_id, + ResumeModelSettings::RestoreFromThread, + )); + drop(maintenance_guard); + // This current-thread test polls resume before yielding to the startup worker. + // Resume must acquire its guard before waiting for metadata revalidation. + assert!(resume.as_mut().now_or_never().is_none()); + assert!( + codex_rollout::try_acquire_rollout_maintenance_lock(codex_home.path())?.is_none() + ); + resume.await? + }; + assert_eq!(app_server.next_request_id, next_request_id + 2); + assert!(!legacy.turns.is_empty()); + app_server.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn rollout_maintenance_contention_disables_cached_legacy_resume_shortcut() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let config = build_config(&codex_home).await; + let thread_id = ThreadId::from_string( + &create_fake_paginated_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved paginated user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create paginated rollout"), + )?; + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + app_server.remember_thread_history_mode(thread_id, ThreadHistoryMode::Legacy); + let _maintenance_guard = + codex_rollout::try_acquire_rollout_maintenance_lock(codex_home.path())? + .expect("acquire rollout maintenance lock"); + let next_request_id = app_server.next_request_id; + + let resumed = app_server + .resume_thread( + &crate::local_settings::LocalSettings::from(&config), + config, + thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; + + assert_eq!(app_server.next_request_id, next_request_id + 3); + assert_eq!(resumed.session.thread_id, thread_id); + assert_eq!( + app_server + .history_pagination + .get(&thread_id) + .map(|state| state.history_mode), + Some(ThreadHistoryMode::Paginated) + ); + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn stale_legacy_history_mode_is_revalidated_before_resume() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let config = build_config(&codex_home).await; + let thread_id = ThreadId::from_string( + &create_fake_paginated_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved paginated user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create paginated rollout"), + )?; + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + app_server.remember_thread_history_mode(thread_id, ThreadHistoryMode::Legacy); + let next_request_id = app_server.next_request_id; + + let resumed = app_server + .resume_thread( + &crate::local_settings::LocalSettings::from(&config), + config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!(resumed.session.thread_id, thread_id); + assert!(app_server.next_request_id >= next_request_id + 4); + assert_eq!( + app_server + .history_pagination + .get(&thread_id) + .map(|state| state.history_mode), + Some(ThreadHistoryMode::Paginated) + ); + + let missing_thread_id = ThreadId::new(); + app_server.remember_thread_history_mode(missing_thread_id, ThreadHistoryMode::Legacy); + let next_request_id = app_server.next_request_id; + assert!( + app_server + .resume_thread( + &crate::local_settings::LocalSettings::from(&config), + config, + missing_thread_id, + ResumeModelSettings::RestoreFromThread + ) + .await + .is_err() + ); + assert_eq!(app_server.next_request_id, next_request_id + 2); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app_server_session/thread_list.rs b/codex-rs/tui/src/app_server_session/thread_list.rs new file mode 100644 index 0000000000000000000000000000000000000000..17a45f0280a467c74b968a3b619c71b34bd39722 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/thread_list.rs @@ -0,0 +1,41 @@ +//! Session listing preserves grouped filters while tolerating older single-CWD daemons. + +use super::*; +use codex_app_server_protocol::ThreadListCwdFilter; + +impl AppServerSession { + pub(crate) async fn thread_list( + &mut self, + mut params: ThreadListParams, + ) -> Result { + loop { + let request_id = self.next_request_id(); + let response = self + .client + .request_typed(ClientRequest::ThreadList { + request_id, + params: params.clone(), + }) + .await; + if let Err(TypedRequestError::Server { source, .. }) = &response + && matches!( + source.code, + JSONRPC_INVALID_REQUEST | JSONRPC_INVALID_PARAMS + ) + && source.message.contains("invalid type: sequence") + && source.message.contains("expected a string") + && let Some(ThreadListCwdFilter::Many(cwds)) = ¶ms.cwd + && let Some(cwd) = cwds.first() + { + // Repository discovery puts the originally requested CWD first. + params.cwd = Some(ThreadListCwdFilter::One(cwd.clone())); + continue; + } + return response.wrap_err("thread/list failed during TUI session lookup"); + } + } +} + +#[cfg(test)] +#[path = "thread_list_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app_server_session/thread_list_tests.rs b/codex-rs/tui/src/app_server_session/thread_list_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..7ce2c5509a69c43a83bc5d41737aaad483677551 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/thread_list_tests.rs @@ -0,0 +1,77 @@ +//! Public-RPC compatibility keeps modern grouping and narrows only legacy array errors. + +use super::*; +use codex_app_server_protocol::JSONRPCMessage; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::json; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test] +async fn thread_list_preserves_filters_and_retries_only_legacy_cwd_errors() -> Result<()> { + let legacy_message = "invalid type: sequence, expected a string"; + for (code, message, retry) in [ + (0, "", false), + (-32600, legacy_message, true), + (-32602, legacy_message, true), + (-32603, legacy_message, false), + (-32602, "invalid cursor", false), + ] { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + let mut socket = tokio_tungstenite::accept_async(stream).await?; + let mut requests = Vec::new(); + while let Some(Ok(Message::Text(text))) = socket.next().await { + let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else { + continue; + }; + let mut reply = match request.method.as_str() { + "initialize" => json!({"result": {"userAgent": "thread-list-test/1.0"}}), + "thread/list" => { + requests.push(request.params.unwrap()); + if code != 0 && requests.len() == 1 { + json!({"error": {"code": code, "message": message}}) + } else { + json!({"result": {"data": [], "nextCursor": "next"}}) + } + } + method => panic!("unexpected request: {method}"), + }; + reply["id"] = json!(request.id); + socket.send(Message::Text(reply.to_string().into())).await?; + } + Ok::<_, color_eyre::Report>(requests) + }); + let params: ThreadListParams = serde_json::from_value(json!({ + "cwd": ["/requested/nested", "/linked/nested"], "cursor": "cursor", "limit": 3, + "sortKey": "created_at", "modelProviders": ["provider"], "archived": true, + "searchTerm": "needle" + }))?; + let mut expected = vec![serde_json::to_value(¶ms)?]; + if retry { + let mut fallback = expected[0].clone(); + fallback["cwd"] = json!("/requested/nested"); + expected.push(fallback); + } + let mut session = AppServerSession::new( + crate::connect_remote_app_server(endpoint).await?, + ThreadParamsMode::Embedded, + ); + let result = session.thread_list(params).await; + if code == 0 || retry { + assert_eq!( + serde_json::to_value(result?)?, + json!({"data": [], "nextCursor": "next", "backwardsCursor": null}) + ); + } else { + assert!(format!("{:#}", result.unwrap_err()).contains(message)); + } + session.shutdown().await?; + assert_eq!(server.await??, expected); + } + Ok(()) +} diff --git a/codex-rs/tui/src/backend_banners/actions.rs b/codex-rs/tui/src/backend_banners/actions.rs new file mode 100644 index 0000000000000000000000000000000000000000..0b3d99968ea279af78e0d087ab90fa84bc783044 --- /dev/null +++ b/codex-rs/tui/src/backend_banners/actions.rs @@ -0,0 +1,87 @@ +//! Map backend CTA names to existing CLI actions and desktop-equivalent browser destinations. + +use super::BackendBanner; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_protocol::account::PlanType; + +const USAGE_URL: &str = "https://chatgpt.com/codex/settings/usage"; +const WORKSPACE_USAGE_URL: &str = "https://chatgpt.com/admin/usage-limits/workspace"; + +pub(super) enum BannerAction { + OpenUrl(String), + NotifyOwner(AddCreditsNudgeCreditType), + ResetUsage, +} + +impl BackendBanner { + pub(super) fn resolve_action(&self, action: &str) -> Option { + match action { + "notify_owner" | "contact_owner" => { + return Some(BannerAction::NotifyOwner( + AddCreditsNudgeCreditType::Credits, + )); + } + "request_increase" => { + let Some(destination) = self.request_url.as_deref() else { + return Some(BannerAction::NotifyOwner( + AddCreditsNudgeCreditType::UsageLimit, + )); + }; + let url = url::Url::parse(destination).ok()?; + if !matches!(url.scheme(), "https" | "http") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + { + return None; + } + return Some(BannerAction::OpenUrl(url.to_string())); + } + // Keep the existing picker and explicit confirmation before consuming a reset. + "reset_usage" => return Some(BannerAction::ResetUsage), + _ => {} + } + let destination = match action { + "add_credits" | "buy_credits" + if self.plan_type.is_some_and(PlanType::is_workspace_account) => + { + "https://chatgpt.com/admin/billing?codex_credit_action=add_credits".to_string() + } + "add_credits" | "buy_credits" => format!("{USAGE_URL}?credits_modal=true"), + "buy_reset" => "https://chatgpt.com/codex/purchase/reset".to_string(), + "view_usage" | "request_increase_usage_settings" => USAGE_URL.to_string(), + "view_workspace_usage" | "increase_spend_cap" => WORKSPACE_USAGE_URL.to_string(), + "open_plus_pricing_web" => "https://chatgpt.com/explore/plus".to_string(), + "open_pro_pricing_web" => "https://chatgpt.com/explore/pro".to_string(), + "open_pricing_dialog" => { + let mut url = url::Url::parse("https://chatgpt.com/").ok()?; + let target = if matches!(self.plan_type, Some(PlanType::Plus | PlanType::ProLite)) { + "pro" + } else { + "plus" + }; + url.query_pairs_mut() + .append_pair("cta_tab", "personal") + .append_pair("highlight_plan", target); + if self.plan_type == Some(PlanType::ProLite) { + url.query_pairs_mut().append_pair("pro_variant", "2x"); + } + url.set_fragment(Some("pricing")); + url.to_string() + } + // Desktop-only referral and Premium dialogs need their own CLI flow. + _ => return None, + }; + let mut destination = url::Url::parse(&destination).ok()?; + if destination.path().starts_with("/admin/") { + if self.account_id.is_empty() { + return None; + } + // The existing admin route selects the requested workspace before opening the modal. + destination + .query_pairs_mut() + .append_pair("account_id", &self.account_id); + } + Some(BannerAction::OpenUrl(destination.to_string())) + } +} diff --git a/codex-rs/tui/src/backend_banners/render.rs b/codex-rs/tui/src/backend_banners/render.rs new file mode 100644 index 0000000000000000000000000000000000000000..e4eeeb3e509163a500230e5c44d255d20db32b12 --- /dev/null +++ b/codex-rs/tui/src/backend_banners/render.rs @@ -0,0 +1,74 @@ +//! Render backend-owned copy and ordered CTA labels with the shared inline banner UI. + +use super::BackendBanner; +use super::BannerPresentation; +use super::actions::BannerAction; +use crate::app_event::AppEvent; +use crate::bottom_pane::ActionableBanner; +use crate::bottom_pane::BannerDismissal; +use crate::bottom_pane::SelectionItem; +use chrono::DateTime; +use chrono::Local; + +impl BackendBanner { + pub(crate) fn actionable_banner(&self) -> ActionableBanner { + let reset_time = self + .reset_at + .and_then(|timestamp| DateTime::from_timestamp(timestamp, /*nsecs*/ 0)) + .map(|time| { + crate::status::format_reset_timestamp(time.with_timezone(&Local), Local::now()) + }); + let copy = |text: &str| { + let text: String = text + .chars() + .filter(|c| !c.is_control() || *c == '\n') + .collect(); + match reset_time.as_deref() { + Some(reset_time) => text.replace("{time}", reset_time), + None => text, + } + }; + let actions = self + .ctas + .iter() + .filter_map(|cta| { + if cta.label.trim().is_empty() + || cta.label.len() > 256 + || cta.label.chars().any(char::is_control) + { + return None; + } + let action = self.resolve_action(&cta.action)?; + Some(SelectionItem { + name: cta.label.clone(), + actions: vec![Box::new(move |tx| { + tx.send(match &action { + BannerAction::OpenUrl(url) => { + AppEvent::OpenUrlInBrowser { url: url.clone() } + } + BannerAction::NotifyOwner(credit_type) => { + AppEvent::SendAddCreditsNudgeEmail { + credit_type: *credit_type, + } + } + BannerAction::ResetUsage => AppEvent::OpenRateLimitResetCredits, + }) + })], + dismiss_on_select: false, + ..Default::default() + }) + }) + .collect::>(); + ActionableBanner { + title: copy(&self.title), + description: copy(&self.description), + actions, + dismissal: if self.presentation == BannerPresentation::Dismissible { + BannerDismissal::Dismissible + } else { + BannerDismissal::Persistent + }, + ..Default::default() + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/AGENTS.md b/codex-rs/tui/src/bottom_pane/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..b07fa2eca8d566939def6860774eb3237f6fdc1a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/AGENTS.md @@ -0,0 +1,12 @@ +# TUI bottom pane (state machines) + +When changing the paste-burst or chat-composer state machines in this folder, keep the docs in sync: + +- Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a + readable, top-down explanation of the current behavior. +- Keep implementations/docstrings aligned unless a divergence is intentional and documented. + +Practical check: + +- After edits, sanity-check that docs mention only APIs/behavior that exist in code (especially the + Enter/newline paths and `disable_paste_burst` semantics). diff --git a/codex-rs/tui/src/bottom_pane/action_required_title.rs b/codex-rs/tui/src/bottom_pane/action_required_title.rs new file mode 100644 index 0000000000000000000000000000000000000000..c4b6e9418e46689e6c7c40c496de4c48b51c1dc2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/action_required_title.rs @@ -0,0 +1,25 @@ +use super::TerminalTitleItem; + +pub(crate) const ACTION_REQUIRED_PREVIEW_PREFIX: &str = "[ ! ] Action Required"; + +pub(crate) fn build_action_required_title_text( + prefix: &str, + items: I, + excluded_items: &[TerminalTitleItem], + mut value_for: F, +) -> String +where + I: IntoIterator, + F: FnMut(TerminalTitleItem) -> Option, +{ + let mut parts = vec![prefix.to_string()]; + for item in items { + if item == TerminalTitleItem::Spinner || excluded_items.contains(&item) { + continue; + } + if let Some(value) = value_for(item) { + parts.push(value); + } + } + parts.join(" | ") +} diff --git a/codex-rs/tui/src/bottom_pane/actionable_banner.rs b/codex-rs/tui/src/bottom_pane/actionable_banner.rs new file mode 100644 index 0000000000000000000000000000000000000000..65750afcfaeb85009057f37e7fec220d7c5c2768 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/actionable_banner.rs @@ -0,0 +1,265 @@ +//! Shared title, description, and CTA content for banners in the TUI bottom pane. +//! +//! Workspace notices and backend banners share the existing selection list for CTA dispatch. +//! The inline wrapper owns presentation and dismissal while preserving normal composer input. + +use super::BottomPane; +use super::BottomPaneView; +use super::SelectionItem; +use super::SelectionViewParams; +use super::list_selection_view::ListSelectionView; +use crate::render::Insets; +use crate::render::RectExt; +use crate::render::renderable::Renderable; +use crate::terminal_palette::best_color; +use crate::terminal_palette::default_fg; +use crate::wrapping::word_wrap_lines; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use std::cell::Cell; + +#[derive(Default)] +pub(crate) struct ActionableBanner { + pub(crate) title: String, + pub(crate) description: String, + pub(crate) actions: Vec, + pub(crate) initial_selected_idx: Option, + pub(crate) dismissal: BannerDismissal, + pub(crate) view_id: Option<&'static str>, +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum BannerDismissal { + #[default] + Dismissible, + Persistent, +} + +pub(super) struct InlineBanner { + content: InlineBannerContent, + dismissal: BannerDismissal, + shown: Cell, + // Current visibility is separate from whether this banner has ever been shown. + pub(super) visible: Cell, + dismissed: bool, + visible_action_count: Cell, +} + +enum InlineBannerContent { + Actions(Box), + Information { + header: Box, + hint: Line<'static>, + }, +} + +impl Renderable for InlineBanner { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.visible_action_count.set(0); + self.visible.set(!self.dismissed && !area.is_empty()); + if !self.visible.get() { + return; + } + self.shown.set(true); + match &self.content { + InlineBannerContent::Actions(view) => { + view.render(area, buf); + self.visible_action_count.set(view.rendered_item_count()); + } + InlineBannerContent::Information { header, hint } => { + let content = area.inset(Insets::tlbr( + /*top*/ 1, /*left*/ 2, /*bottom*/ 1, /*right*/ 2, + )); + header.render(content, buf); + let y = content + .y + .saturating_add(header.desired_height(content.width) + 1); + if y < area.bottom() { + Renderable::render( + hint, + Rect::new(content.x, y, content.width, /*height*/ 1), + buf, + ); + } + } + } + } + + fn desired_height(&self, width: u16) -> u16 { + if self.dismissed { + return 0; + } + match &self.content { + InlineBannerContent::Actions(view) => view.desired_height(width), + InlineBannerContent::Information { header, hint } => { + header.desired_height(width.saturating_sub(4)) + + if hint.width() == 0 { 2 } else { 4 } + } + } + } +} + +/// Multiline banner copy uses the same word wrapping for measurement and rendering. +struct BannerContent(Vec>); + +impl BannerContent { + fn wrapped_lines(&self, width: u16) -> Vec> { + let mut lines = word_wrap_lines(&self.0, width as usize); + if lines.len() > 8 { + lines.truncate(7); + lines.push("…".dim().into()); + } + lines + } +} + +impl Renderable for BannerContent { + fn render(&self, area: Rect, buf: &mut Buffer) { + let lines = self.wrapped_lines(area.width); + // Explicit foreground avoids a terminal's separate bold color washing out the title. + let foreground = default_fg().map(best_color).unwrap_or(Color::Reset); + Widget::render(Paragraph::new(lines).fg(foreground), area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.wrapped_lines(width).len() as u16 + } +} + +impl From for SelectionViewParams { + fn from(banner: ActionableBanner) -> Self { + let lines = banner + .title + .lines() + .map(|line| Line::from(line.to_owned().bold())) + .chain( + banner + .description + .lines() + .map(|line| Line::from(line.to_owned())), + ) + .collect(); + Self { + view_id: banner.view_id, + header: Box::new(BannerContent(lines)), + items: banner.actions, + initial_selected_idx: banner.initial_selected_idx, + ..Default::default() + } + } +} + +impl BottomPane { + pub(crate) fn show_actionable_banner(&mut self, banner: ActionableBanner) { + self.show_selection_view(banner.into()); + } + + /// Display a banner above the composer without interrupting active dialogs or losing a draft. + pub(crate) fn set_inline_banner(&mut self, banner: Option) { + self.inline_banner = banner.map(|banner| { + let dismissal = banner.dismissal; + let has_actions = !banner.actions.is_empty(); + let mut params: SelectionViewParams = banner.into(); + params.header_gap = 0; + params.allow_cancel = dismissal == BannerDismissal::Dismissible; + for item in &mut params.items { + item.dismiss_on_select = false; + } + let hint = match (dismissal, has_actions) { + (BannerDismissal::Persistent, true) => "Press a number to choose", + (BannerDismissal::Persistent, false) => "", + (BannerDismissal::Dismissible, true) => { + "Press a number to choose · esc to dismiss · type to continue" + } + (BannerDismissal::Dismissible, false) => "esc to dismiss · type to continue", + }; + let hint: Line<'static> = hint.dim().into(); + params.footer_hint = Some(hint.clone()); + InlineBanner { + content: if has_actions { + InlineBannerContent::Actions(Box::new(ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ))) + } else { + InlineBannerContent::Information { + header: params.header, + hint, + } + }, + dismissal, + shown: Cell::new(false), + visible: Cell::new(false), + dismissed: false, + visible_action_count: Cell::new(0), + } + }); + self.request_redraw(); + } + + /// Observation for the owning controller; model hiding is not a dismissal. + pub(crate) fn inline_banner_lifecycle(&self) -> (bool, bool) { + self.inline_banner + .as_ref() + .map_or((false, false), |banner| { + (banner.shown.get(), banner.dismissed) + }) + } + + pub(super) fn inline_banner_accepts_dismissal(&self) -> bool { + self.inline_banner.as_ref().is_some_and(|banner| { + banner.visible.get() + && !banner.dismissed + && banner.dismissal == BannerDismissal::Dismissible + }) + } + + pub(super) fn handle_inline_banner_key(&mut self, key: KeyEvent) -> bool { + // Draft input, completion menus, and paste bursts retain all of their normal keys. + if !self.composer_is_empty() + || self.composer.popup_active() + || self.composer.is_in_paste_burst() + || self.composer_should_handle_vim_insert_escape(key) + || self.is_task_running + || key.kind != KeyEventKind::Press + || key.modifiers != KeyModifiers::NONE + { + return false; + } + let Some(banner) = self.inline_banner.as_mut() else { + return false; + }; + if !banner.visible.get() || banner.dismissed { + return false; + } + match key.code { + KeyCode::Esc if banner.dismissal == BannerDismissal::Persistent => return false, + KeyCode::Esc => { + banner.dismissed = true; + } + KeyCode::Char(digit @ '1'..='9') => { + if digit as usize - '1' as usize >= banner.visible_action_count.get() { + return false; + } + let InlineBannerContent::Actions(view) = &mut banner.content else { + return false; + }; + view.handle_key_event(key); + let _ = view.take_last_selected_index(); + } + _ => return false, + } + self.request_redraw(); + true + } +} diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..fab39e250236b2a0113878b419706f974e699bb3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -0,0 +1,1728 @@ +#[cfg(test)] +use crate::app_command::AppCommand as Op; +use codex_app_server_protocol::McpServerElicitationAction; +use codex_app_server_protocol::RequestId as AppServerRequestId; +use codex_protocol::ThreadId; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::Wrap; +use textwrap::wrap; +use url::Url; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; +use crate::app::app_server_requests::ResolvedAppServerRequest; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::key_hint; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::style::user_message_style; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; + +const MCP_CODEX_APPS_SERVER_NAME: &str = "codex_apps"; +const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure"; +const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure"; +const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id"; +const CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY: &str = "connector_name"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AppLinkScreen { + Link, + InstallConfirmation, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AppLinkSuggestionType { + Install, + Enable, + Auth, + ExternalAction, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AppLinkElicitationTarget { + pub(crate) thread_id: ThreadId, + pub(crate) server_name: String, + pub(crate) request_id: AppServerRequestId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AppLinkViewParams { + pub(crate) app_id: String, + pub(crate) title: String, + pub(crate) description: Option, + pub(crate) instructions: String, + pub(crate) url: String, + pub(crate) is_installed: bool, + pub(crate) is_enabled: bool, + pub(crate) suggest_reason: Option, + pub(crate) suggestion_type: Option, + pub(crate) elicitation_target: Option, +} + +impl AppLinkViewParams { + pub(crate) fn from_url_app_server_request( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + request: &codex_app_server_protocol::McpServerElicitationRequest, + ) -> Option { + let codex_app_server_protocol::McpServerElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } = request + else { + return None; + }; + if server_name == MCP_CODEX_APPS_SERVER_NAME { + let url = validate_external_url(url, /*require_chatgpt_host*/ true)?; + return Self::from_codex_apps_auth_url_parts( + thread_id, + server_name, + request_id, + meta.as_ref(), + message, + url.as_str(), + elicitation_id, + ); + } + + let url = validate_external_url(url, /*require_chatgpt_host*/ false)?; + Some(Self::from_generic_url_parts( + thread_id, + server_name, + request_id, + message, + url.as_str(), + elicitation_id, + )) + } + + fn from_codex_apps_auth_url_parts( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + meta: Option<&serde_json::Value>, + message: &str, + url: &str, + elicitation_id: &str, + ) -> Option { + let auth_failure = meta? + .as_object()? + .get(MCP_TOOL_CODEX_APPS_META_KEY)? + .as_object()? + .get(CONNECTOR_AUTH_FAILURE_META_KEY)? + .as_object()?; + if auth_failure + .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY) + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + let app_id = auth_failure + .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(elicitation_id) + .to_string(); + let title = auth_failure + .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(app_id.as_str()) + .to_string(); + + Some(Self { + app_id, + title, + description: None, + instructions: "Sign in to this app in your browser, then return here.".to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some(message.to_string()), + suggestion_type: Some(AppLinkSuggestionType::Auth), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id, + server_name: server_name.to_string(), + request_id, + }), + }) + } + + fn from_generic_url_parts( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + message: &str, + url: &str, + elicitation_id: &str, + ) -> Self { + Self { + app_id: elicitation_id.to_string(), + title: "Action required".to_string(), + description: Some(format!("Server: {server_name}")), + instructions: "Complete the requested action in your browser, then return here." + .to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some(message.to_string()), + suggestion_type: Some(AppLinkSuggestionType::ExternalAction), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id, + server_name: server_name.to_string(), + request_id, + }), + } + } +} + +fn validate_external_url(url: &str, require_chatgpt_host: bool) -> Option { + let parsed = Url::parse(url).ok()?; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + return None; + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return None; + } + if require_chatgpt_host && !is_allowed_chatgpt_auth_host(parsed.host_str()?) { + return None; + } + Some(parsed) +} + +fn is_allowed_chatgpt_auth_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + host == "chatgpt.com" + || host == "chatgpt-staging.com" + || host.ends_with(".chatgpt.com") + || host.ends_with(".chatgpt-staging.com") +} + +pub(crate) struct AppLinkView { + app_id: String, + title: String, + description: Option, + instructions: String, + url: String, + is_installed: bool, + is_enabled: bool, + suggest_reason: Option, + suggestion_type: Option, + elicitation_target: Option, + app_event_tx: AppEventSender, + screen: AppLinkScreen, + selected_action: usize, + complete: bool, + list_keymap: ListKeymap, +} + +impl AppLinkView { + #[cfg(test)] + pub(crate) fn new(params: AppLinkViewParams, app_event_tx: AppEventSender) -> Self { + Self::new_with_keymap( + params, + app_event_tx, + crate::keymap::RuntimeKeymap::defaults().list, + ) + } + + pub(crate) fn new_with_keymap( + params: AppLinkViewParams, + app_event_tx: AppEventSender, + list_keymap: ListKeymap, + ) -> Self { + let AppLinkViewParams { + app_id, + title, + description, + instructions, + url, + is_installed, + is_enabled, + suggest_reason, + suggestion_type, + elicitation_target, + } = params; + Self { + app_id, + title, + description, + instructions, + url, + is_installed, + is_enabled, + suggest_reason, + suggestion_type, + elicitation_target, + app_event_tx, + screen: AppLinkScreen::Link, + selected_action: 0, + complete: false, + list_keymap, + } + } + + fn action_labels(&self) -> Vec<&'static str> { + if self.is_auth_suggestion() { + return match self.screen { + AppLinkScreen::Link => vec!["Open sign-in URL", "Back"], + AppLinkScreen::InstallConfirmation => vec!["I already signed in", "Back"], + }; + } + if self.is_external_action_suggestion() { + return match self.screen { + AppLinkScreen::Link => vec!["Open link", "Back"], + AppLinkScreen::InstallConfirmation => vec!["I finished", "Back"], + }; + } + + match self.screen { + AppLinkScreen::Link => { + if self.is_installed { + vec![ + "Manage on ChatGPT", + if self.is_enabled { + "Disable app" + } else { + "Enable app" + }, + "Back", + ] + } else { + vec!["Install on ChatGPT", "Back"] + } + } + AppLinkScreen::InstallConfirmation => vec!["I already Installed it", "Back"], + } + } + + fn move_selection_prev(&mut self) { + self.selected_action = self.selected_action.saturating_sub(1); + } + + fn move_selection_next(&mut self) { + self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1); + } + + fn is_tool_suggestion(&self) -> bool { + self.elicitation_target.is_some() + } + + fn is_auth_suggestion(&self) -> bool { + self.is_tool_suggestion() && self.suggestion_type == Some(AppLinkSuggestionType::Auth) + } + + fn is_external_action_suggestion(&self) -> bool { + self.is_tool_suggestion() + && self.suggestion_type == Some(AppLinkSuggestionType::ExternalAction) + } + + fn is_browser_action_suggestion(&self) -> bool { + self.is_auth_suggestion() || self.is_external_action_suggestion() + } + + fn resolve_elicitation(&self, decision: McpServerElicitationAction) { + let Some(target) = self.elicitation_target.as_ref() else { + return; + }; + self.app_event_tx.resolve_elicitation( + target.thread_id, + target.server_name.clone(), + target.request_id.clone(), + decision, + /*content*/ None, + /*meta*/ None, + ); + } + + fn decline_tool_suggestion(&mut self) { + self.resolve_elicitation(McpServerElicitationAction::Decline); + self.complete = true; + } + + fn open_external_url(&mut self) { + self.app_event_tx.send(AppEvent::OpenUrlInBrowser { + url: self.url.clone(), + }); + if !self.is_installed || self.is_browser_action_suggestion() { + self.screen = AppLinkScreen::InstallConfirmation; + self.selected_action = 0; + } + } + + fn complete_external_flow_and_close(&mut self) { + let should_refresh_connectors = self + .elicitation_target + .as_ref() + .is_none_or(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME); + if should_refresh_connectors { + self.app_event_tx.send(AppEvent::RefreshConnectors { + force_refetch: true, + }); + } + if self.is_tool_suggestion() { + self.resolve_elicitation(McpServerElicitationAction::Accept); + } + self.complete = true; + } + + fn back_to_link_screen(&mut self) { + self.screen = AppLinkScreen::Link; + self.selected_action = 0; + } + + fn toggle_enabled(&mut self) { + self.is_enabled = !self.is_enabled; + self.app_event_tx.send(AppEvent::SetAppEnabled { + id: self.app_id.clone(), + enabled: self.is_enabled, + }); + if self.is_tool_suggestion() { + self.resolve_elicitation(McpServerElicitationAction::Accept); + self.complete = true; + } + } + + fn activate_selected_action(&mut self) { + if self.is_tool_suggestion() { + match self.suggestion_type { + Some(AppLinkSuggestionType::Enable) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + 1 if self.is_installed => self.toggle_enabled(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::Auth) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::ExternalAction) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::Install) | None => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + } + return; + } + + match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + 1 if self.is_installed => self.toggle_enabled(), + _ => self.complete = true, + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), + _ => self.back_to_link_screen(), + }, + } + } + + fn content_lines(&self, width: u16) -> Vec> { + match self.screen { + AppLinkScreen::Link => self.link_content_lines(width), + AppLinkScreen::InstallConfirmation => self.install_confirmation_lines(width), + } + } + + fn link_content_lines(&self, width: u16) -> Vec> { + let usable_width = width.max(1) as usize; + let mut lines: Vec> = Vec::new(); + + lines.push(Line::from(self.title.clone().bold())); + if let Some(description) = self + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + { + for line in wrap(description, usable_width) { + lines.push(Line::from(line.into_owned().dim())); + } + } + + lines.push(Line::from("")); + if let Some(suggest_reason) = self + .suggest_reason + .as_deref() + .map(str::trim) + .filter(|suggest_reason| !suggest_reason.is_empty()) + { + for line in wrap(suggest_reason, usable_width) { + lines.push(Line::from(line.into_owned().italic())); + } + lines.push(Line::from("")); + } + let is_browser_action_suggestion = self.is_browser_action_suggestion(); + if self.is_installed && !is_browser_action_suggestion { + for line in wrap("Use $ to insert this app into the prompt.", usable_width) { + lines.push(Line::from(line.into_owned())); + } + lines.push(Line::from("")); + } + + if is_browser_action_suggestion { + lines.push(Line::from("URL".dim())); + for line in wrap(&self.url, usable_width) { + lines.push(Line::from(line.into_owned())); + } + lines.push(Line::from("")); + } + + let instructions = self.instructions.trim(); + if !instructions.is_empty() { + for line in wrap(instructions, usable_width) { + lines.push(Line::from(line.into_owned())); + } + if !is_browser_action_suggestion { + for line in wrap( + "Newly installed apps can take a few minutes to appear in /apps.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + if !self.is_installed { + for line in wrap( + "After installed, use $ to insert this app into the prompt.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } + } + lines.push(Line::from("")); + } + + lines + } + + fn install_confirmation_lines(&self, width: u16) -> Vec> { + let usable_width = width.max(1) as usize; + let mut lines: Vec> = Vec::new(); + + let is_auth_suggestion = self.is_auth_suggestion(); + let is_external_action_suggestion = self.is_external_action_suggestion(); + let is_codex_apps_auth = is_auth_suggestion + && self + .elicitation_target + .as_ref() + .is_some_and(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME); + lines.push(Line::from( + if is_auth_suggestion { + if is_codex_apps_auth { + "Finish App Sign In" + } else { + "Finish Authentication" + } + } else if is_external_action_suggestion { + "Finish in Browser" + } else { + "Finish App Setup" + } + .bold(), + )); + lines.push(Line::from("")); + + if is_auth_suggestion { + for line in wrap( + if is_codex_apps_auth { + "Sign in to the app on ChatGPT in the browser window that just opened." + } else { + "Complete authentication in the browser window that just opened." + }, + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Then return here and select \"I already signed in\".", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } else if is_external_action_suggestion { + for line in wrap( + "Complete the requested action in the browser window that just opened.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap("Then return here and select \"I finished\".", usable_width) { + lines.push(Line::from(line.into_owned())); + } + } else { + for line in wrap( + "Complete app setup on ChatGPT in the browser window that just opened.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Sign in there if needed, then return here and select \"I already Installed it\".", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + if is_auth_suggestion { + "Sign-in URL:" + } else if is_external_action_suggestion { + "Link:" + } else { + "Setup URL:" + } + .dim(), + ])); + let url_line = Line::from(vec![self.url.clone().cyan().underlined()]); + lines.extend(adaptive_wrap_lines( + vec![url_line], + RtOptions::new(usable_width), + )); + + lines + } + + fn action_rows(&self) -> Vec { + self.action_labels() + .into_iter() + .enumerate() + .map(|(index, label)| { + let prefix = if self.selected_action == index { + '›' + } else { + ' ' + }; + GenericDisplayRow { + name: format!("{prefix} {}. {label}", index + 1), + ..Default::default() + } + }) + .collect() + } + + fn action_state(&self) -> ScrollState { + let mut state = ScrollState::new(); + state.selected_idx = Some(self.selected_action); + state + } + + fn action_rows_height(&self, width: u16) -> u16 { + let rows = self.action_rows(); + let state = self.action_state(); + measure_rows_height(&rows, &state, rows.len().max(1), width.max(1)) + } + + fn hint_line(&self) -> Line<'static> { + let mut spans = vec!["Use ".into(), key_hint::plain(KeyCode::Tab).into()]; + if let Some(move_up) = self.list_keymap.primary_hint(ListAction::MoveUp) { + spans.extend([" / ".into(), move_up.into()]); + } + if let Some(move_down) = self.list_keymap.primary_hint(ListAction::MoveDown) { + spans.extend([" ".into(), move_down.into()]); + } + spans.push(" to move".into()); + if let Some(accept) = self.list_keymap.primary_hint(ListAction::Accept) { + spans.extend([", ".into(), accept.into(), " to select".into()]); + } + if let Some(cancel) = self.list_keymap.primary_hint(ListAction::Cancel) { + spans.extend([", ".into(), cancel.into(), " to close".into()]); + } + Line::from(spans) + } +} + +impl BottomPaneView for AppLinkView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + _ if self.list_keymap.action_for(key_event) == Some(ListAction::Cancel) => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::BackTab, + .. + } + | KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_selection_prev(), + _ if matches!( + self.list_keymap.action_for(key_event), + Some(ListAction::MoveUp | ListAction::MoveLeft) + ) => + { + self.move_selection_prev() + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_selection_next(), + _ if matches!( + self.list_keymap.action_for(key_event), + Some(ListAction::MoveDown | ListAction::MoveRight) + ) => + { + self.move_selection_next() + } + _ if self.list_keymap.action_for(key_event) == Some(ListAction::Accept) => { + self.activate_selected_action(); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::NONE, + .. + } => { + if let Some(index) = c + .to_digit(10) + .and_then(|digit| digit.checked_sub(1)) + .map(|index| index as usize) + && index < self.action_labels().len() + { + self.selected_action = index; + self.activate_selected_action(); + } + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => self.activate_selected_action(), + _ => {} + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + if self.is_tool_suggestion() { + self.resolve_elicitation(McpServerElicitationAction::Decline); + } + self.complete = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool { + let ResolvedAppServerRequest::McpElicitation { + server_name, + request_id, + } = request + else { + return false; + }; + let Some(target) = self.elicitation_target.as_ref() else { + return false; + }; + if target.server_name != *server_name || target.request_id != *request_id { + return false; + } + + self.complete = true; + true + } + + fn terminal_title_requires_action(&self) -> bool { + self.is_tool_suggestion() + } +} + +impl crate::render::renderable::Renderable for AppLinkView { + fn desired_height(&self, width: u16) -> u16 { + let content_width = width.saturating_sub(4).max(1); + let content_lines = self.content_lines(content_width); + let content_rows = Paragraph::new(content_lines) + .wrap(Wrap { trim: false }) + .line_count(content_width) + .max(1) as u16; + let action_rows_height = self.action_rows_height(content_width); + content_rows + action_rows_height + 3 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + Block::default() + .style(user_message_style()) + .render(area, buf); + + let actions_height = self.action_rows_height(area.width.saturating_sub(4)); + let [content_area, actions_area, hint_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(actions_height), + Constraint::Length(1), + ]) + .areas(area); + + let inner = content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2)); + let content_width = inner.width.max(1); + let lines = self.content_lines(content_width); + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(inner, buf); + crate::terminal_hyperlinks::mark_url_hyperlink(buf, inner, &self.url); + + if actions_area.height > 0 { + let actions_area = Rect { + x: actions_area.x.saturating_add(2), + y: actions_area.y, + width: actions_area.width.saturating_sub(2), + height: actions_area.height, + }; + let action_rows = self.action_rows(); + let action_state = self.action_state(); + render_rows( + actions_area, + buf, + &action_rows, + &action_state, + action_rows.len().max(1), + "No actions", + ); + } + + if hint_area.height > 0 { + let hint_area = Rect { + x: hint_area.x.saturating_add(2), + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + self.hint_line().dim().render(hint_area, buf); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::app_server_requests::ResolvedAppServerRequest; + use crate::app_event::AppEvent; + use crate::render::renderable::Renderable; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn suggestion_target() -> AppLinkElicitationTarget { + AppLinkElicitationTarget { + thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001") + .expect("valid thread id"), + server_name: "codex_apps".to_string(), + request_id: AppServerRequestId::String("request-1".to_string()), + } + } + + fn generic_url_target() -> AppLinkElicitationTarget { + AppLinkElicitationTarget { + thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") + .expect("valid thread id"), + server_name: "payments".to_string(), + request_id: AppServerRequestId::String("request-2".to_string()), + } + } + + fn auth_url_request(url: &str) -> codex_app_server_protocol::McpServerElicitationRequest { + codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: Some(serde_json::json!({ + "_codex_apps": { + "connector_auth_failure": { + "is_auth_failure": true, + "connector_id": "connector_calendar", + "connector_name": "Google Calendar", + }, + }, + })), + message: "Reconnect Google Calendar on ChatGPT.".to_string(), + url: url.to_string(), + elicitation_id: "codex_apps_auth_call_123".to_string(), + } + } + + #[test] + fn codex_apps_auth_url_elicitation_builds_auth_app_link_params() { + let target = suggestion_target(); + let request = + auth_url_request("https://chatgpt.com/apps/google-calendar/connector_calendar"); + + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected auth app link params"); + + assert_eq!(params.app_id, "connector_calendar"); + assert_eq!(params.title, "Google Calendar"); + assert_eq!( + params.url, + "https://chatgpt.com/apps/google-calendar/connector_calendar" + ); + assert_eq!(params.suggestion_type, Some(AppLinkSuggestionType::Auth)); + assert_eq!(params.elicitation_target, Some(target)); + } + + #[test] + fn non_codex_apps_url_elicitation_builds_generic_app_link_params() { + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + + assert_eq!( + params, + AppLinkViewParams { + app_id: "payment-123".to_string(), + title: "Action required".to_string(), + description: Some("Server: payments".to_string()), + instructions: "Complete the requested action in your browser, then return here." + .to_string(), + url: "https://payments.example/checkout/123".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some("Review the payment details to continue.".to_string()), + suggestion_type: Some(AppLinkSuggestionType::ExternalAction), + elicitation_target: Some(target), + } + ); + } + + #[test] + fn codex_apps_auth_url_elicitation_rejects_untrusted_urls() { + let target = suggestion_target(); + for url in [ + "http://chatgpt.com/apps/google-calendar/connector_calendar", + "https://user:pass@chatgpt.com/apps/google-calendar/connector_calendar", + "https://chatgpt.com.evil.example/apps/google-calendar/connector_calendar", + "https://evilchatgpt.com/apps/google-calendar/connector_calendar", + ] { + let request = auth_url_request(url); + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ); + assert!(params.is_none(), "expected {url} to be rejected"); + } + } + + #[test] + fn generic_url_elicitation_rejects_untrusted_urls() { + let target = generic_url_target(); + for url in [ + "http://payments.example/checkout/123", + "https://user:pass@payments.example/checkout/123", + ] { + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: url.to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ); + assert!(params.is_none(), "expected {url} to be rejected"); + } + } + + fn render_snapshot(view: &AppLinkView, area: Rect) -> String { + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| { + let symbol = buf[(x, y)].symbol(); + if symbol.is_empty() { + ' ' + } else { + crate::terminal_hyperlinks::strip_osc8(symbol) + .chars() + .next() + .unwrap_or(' ') + } + }) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") + } + + #[test] + fn installed_app_has_toggle_action() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: "https://example.test/notion".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, + }, + tx, + ); + + assert_eq!( + view.action_labels(), + vec!["Manage on ChatGPT", "Disable app", "Back"] + ); + } + + #[test] + fn regular_app_link_does_not_require_terminal_title_action() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: "https://example.test/notion".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, + }, + tx, + ); + + assert!(!view.terminal_title_requires_action()); + } + + #[test] + fn tool_suggestion_requires_terminal_title_action() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Enable this app to use it for the current request.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: true, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Enable), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert!(view.terminal_title_requires_action()); + } + + #[test] + fn horizontal_list_keys_move_action_selection() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: "https://example.test/notion".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, + }, + tx, + ); + + assert_eq!(view.selected_action, 0); + view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_action, 1); + view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_action, 0); + } + + #[test] + fn remapped_horizontal_list_keys_control_action_selection() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut list_keymap = crate::keymap::RuntimeKeymap::defaults().list; + list_keymap.move_left = vec![key_hint::plain(KeyCode::Char('x'))]; + list_keymap.move_right = vec![key_hint::plain(KeyCode::Char('z'))]; + let mut view = AppLinkView::new_with_keymap( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: "https://example.test/notion".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, + }, + tx, + list_keymap, + ); + + assert_eq!(view.selected_action, 0); + view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)); + assert_eq!(view.selected_action, 0); + view.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(view.selected_action, 0); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); + assert_eq!(view.selected_action, 1); + view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + assert_eq!(view.selected_action, 1); + view.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(view.selected_action, 1); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!(view.selected_action, 0); + } + + #[test] + fn toggle_action_sends_set_app_enabled_and_updates_label() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: "https://example.test/notion".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::SetAppEnabled { id, enabled }) => { + assert_eq!(id, "connector_1"); + assert!(!enabled); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + + assert_eq!( + view.action_labels(), + vec!["Manage on ChatGPT", "Enable app", "Back"] + ); + } + + #[test] + fn generic_url_elicitation_resolves_without_connector_refresh() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let mut view = AppLinkView::new(params, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::OpenUrlInBrowser { url }) => { + assert_eq!(url, "https://payments.example/checkout/123"); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert_eq!(view.screen, AppLinkScreen::InstallConfirmation); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, target.thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "payments".to_string(), + request_id: AppServerRequestId::String("request-2".to_string()), + decision: McpServerElicitationAction::Accept, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(rx.try_recv().is_err()); + assert!(view.is_complete()); + } + + #[test] + fn install_confirmation_does_not_split_long_url_like_token_without_scheme() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let url_like = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: url_like.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, + }, + tx, + ); + view.screen = AppLinkScreen::InstallConfirmation; + + let rendered: Vec = view + .content_lines(/*width*/ 40) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::() + }) + .collect(); + + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(url_like)) + .count(), + 1, + "expected full URL-like token in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn install_confirmation_render_keeps_url_tail_visible_when_narrow() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/tail42"; + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, + }, + tx, + ); + view.screen = AppLinkScreen::InstallConfirmation; + + let width: u16 = 36; + 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 rendered_blob = (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| { + let symbol = buf[(x, y)].symbol(); + if symbol.is_empty() { + ' ' + } else { + crate::terminal_hyperlinks::strip_osc8(symbol) + .chars() + .next() + .unwrap_or(' ') + } + }) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + rendered_blob.contains("tail42"), + "expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}" + ); + } + + #[test] + fn install_tool_suggestion_resolves_elicitation_after_confirmation() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Install this app in your browser, then return here.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: false, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Install), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::OpenUrlInBrowser { url }) => { + assert_eq!(url, "https://example.test/google-calendar".to_string()); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert_eq!(view.screen, AppLinkScreen::InstallConfirmation); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::RefreshConnectors { force_refetch }) => { + assert!(force_refetch); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, suggestion_target().thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "codex_apps".to_string(), + request_id: AppServerRequestId::String("request-1".to_string()), + decision: McpServerElicitationAction::Accept, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(view.is_complete()); + } + + #[test] + fn declined_tool_suggestion_resolves_elicitation_decline() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: None, + instructions: "Install this app in your browser, then return here.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: false, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Install), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, suggestion_target().thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "codex_apps".to_string(), + request_id: AppServerRequestId::String("request-1".to_string()), + decision: McpServerElicitationAction::Decline, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(view.is_complete()); + } + + #[test] + fn enable_tool_suggestion_resolves_elicitation_after_enable() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Enable this app to use it for the current request.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: true, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Enable), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::SetAppEnabled { id, enabled }) => { + assert_eq!(id, "connector_google_calendar"); + assert!(enabled); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, suggestion_target().thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "codex_apps".to_string(), + request_id: AppServerRequestId::String("request-1".to_string()), + decision: McpServerElicitationAction::Accept, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(view.is_complete()); + } + + #[test] + fn resolved_tool_suggestion_dismisses_matching_view() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Enable this app to use it for the current request.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: true, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Enable), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert!( + view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { + server_name: "codex_apps".to_string(), + request_id: AppServerRequestId::String("request-1".to_string()), + }) + ); + assert!(view.is_complete()); + } + + #[test] + fn resolved_tool_suggestion_ignores_non_matching_request() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Enable this app to use it for the current request.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: true, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Enable), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert!( + !view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { + server_name: "other_server".to_string(), + request_id: AppServerRequestId::String("request-1".to_string()), + }) + ); + assert!(!view.is_complete()); + } + + #[test] + fn install_suggestion_with_reason_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Install this app in your browser, then return here.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: false, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Install), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert_snapshot!( + "app_link_view_install_suggestion_with_reason", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn enable_suggestion_with_reason_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Enable this app to use it for the current request.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: true, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Enable), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert_snapshot!( + "app_link_view_enable_suggestion_with_reason", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn auth_suggestion_with_reason_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: None, + instructions: "Sign in to this app in your browser, then return here.".to_string(), + url: "https://chatgpt.com/apps/google-calendar/connector_google_calendar" + .to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some("Reconnect Google Calendar on ChatGPT.".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Auth), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert_snapshot!( + "app_link_view_auth_suggestion_with_reason", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn generic_url_elicitation_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let view = AppLinkView::new(params, tx); + + assert_snapshot!( + "app_link_view_generic_url_elicitation", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn generic_url_elicitation_confirmation_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let mut view = AppLinkView::new(params, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_snapshot!( + "app_link_view_generic_url_elicitation_confirmation", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/apply_patch_header.rs b/codex-rs/tui/src/bottom_pane/apply_patch_header.rs new file mode 100644 index 0000000000000000000000000000000000000000..33dc4ffb8781f368d836f1146ad0029d524b383a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/apply_patch_header.rs @@ -0,0 +1,74 @@ +//! Render file-change approval descriptions and cross-platform destinations. + +use super::approval_overlay::ApplyPatchApprovalRequest; +use crate::diff_model::FileChange; +use crate::render::renderable::Renderable; +use codex_utils_path_uri::LegacyAppPathString; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; + +pub(super) fn build_header(request: &ApplyPatchApprovalRequest) -> Box { + let mut header = Vec::new(); + if let Some(thread_label) = &request.thread_label { + header.push(Line::from(vec![ + "Thread: ".into(), + thread_label.clone().bold(), + ])); + } + + if !header.is_empty() { + header.push(Line::from("")); + } + + let description = request + .reason + .as_deref() + .filter(|reason| !reason.is_empty()) + .unwrap_or("Apply proposed file edits"); + header.push(Line::from_iter([ + "Description: ".into(), + description.to_string().italic(), + ])); + + let mut destinations: Vec<_> = request + .changes + .iter() + .flat_map(|(path, change)| { + let move_destination = match change { + FileChange::Update { + move_path: Some(move_path), + .. + } => Some(move_path), + FileChange::Add { .. } + | FileChange::Delete { .. } + | FileChange::Update { + move_path: None, .. + } => None, + }; + std::iter::once(path).chain(move_destination) + }) + .map(|path| { + LegacyAppPathString::from_path(path) + .to_inferred_path_uri() + .map(|path| path.inferred_native_path_string()) + .unwrap_or_else(|| request.cwd.join(path).display().to_string()) + }) + .collect(); + destinations.sort(); + destinations.dedup(); + if destinations.is_empty() { + header.push(Line::from(vec![ + "Destination: ".into(), + "unavailable".bold(), + ])); + } + for destination in destinations { + header.push(Line::from_iter([ + "Destination: ".into(), + destination.bold(), + ])); + } + Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) +} diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs new file mode 100644 index 0000000000000000000000000000000000000000..836cd1dba5b53e4749378cb349e1e858bbe131f1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -0,0 +1,2510 @@ +//! Approval modal rendering and decision routing for high-risk operations. +//! +//! This module converts agent approval requests (exec/apply-patch/MCP +//! elicitation) into a list-selection view with action-specific options and +//! shortcuts. It owns two important contracts: +//! +//! 1. Selection always emits an explicit decision event back to the app. +//! 2. MCP elicitation keeps `Esc` mapped to `Cancel`, even with custom +//! keybindings, so dismissal never silently becomes "continue without info". +//! +//! This module does not evaluate whether an action is safe to run; it only +//! presents choices and routes user decisions. + +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::app::app_server_requests::ResolvedAppServerRequest; +#[cfg(test)] +use crate::app_command::AppCommand as Op; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::list_selection_view::ListSelectionView; +use crate::bottom_pane::list_selection_view::SelectionItem; +use crate::bottom_pane::list_selection_view::SelectionViewParams; +use crate::bottom_pane::popup_consts::accept_cancel_hint_line; +use crate::diff_model::FileChange; +use crate::exec_command::strip_bash_lc_and_escape; +use crate::history_cell; +use crate::history_cell::ReviewDecision; +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ApprovalKeymap; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::render::highlight::highlight_bash_to_lines; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use codex_app_server_protocol::AdditionalPermissionProfile; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionApprovalKind; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; +use codex_app_server_protocol::McpServerElicitationAction; +use codex_app_server_protocol::NetworkApprovalContext; +use codex_app_server_protocol::NetworkApprovalProtocol; +use codex_app_server_protocol::NetworkPolicyRuleAction; +use codex_app_server_protocol::RequestId; +use codex_features::Features; +use codex_protocol::ThreadId; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; + +/// Request coming from the agent that needs user approval. +#[derive(Clone, Debug)] +pub(crate) enum ApprovalRequest { + Exec(ExecApprovalRequest), + Permissions(PermissionsApprovalRequest), + ApplyPatch(ApplyPatchApprovalRequest), + McpElicitation(McpElicitationApprovalRequest), +} + +#[derive(Clone, Debug)] +pub(crate) struct ExecApprovalRequest { + pub kind: CommandExecutionApprovalKind, + pub thread_id: ThreadId, + pub thread_label: Option, + pub id: String, + pub environment_id: Option, + pub command: Vec, + pub reason: Option, + pub available_decisions: Vec, + pub network_approval_context: Option, + pub additional_permissions: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct PermissionsApprovalRequest { + pub thread_id: ThreadId, + pub thread_label: Option, + pub call_id: String, + pub environment_id: Option, + pub reason: Option, + pub permissions: RequestPermissionProfile, +} + +#[derive(Clone, Debug)] +pub(crate) struct ApplyPatchApprovalRequest { + pub thread_id: ThreadId, + pub thread_label: Option, + pub id: String, + pub reason: Option, + pub cwd: AbsolutePathBuf, + pub changes: HashMap, +} + +#[derive(Clone, Debug)] +pub(crate) struct McpElicitationApprovalRequest { + pub thread_id: ThreadId, + pub thread_label: Option, + pub server_name: String, + pub request_id: RequestId, + pub message: String, +} + +impl ApprovalRequest { + pub(super) fn thread_id(&self) -> ThreadId { + match self { + ApprovalRequest::Exec(request) => request.thread_id, + ApprovalRequest::Permissions(request) => request.thread_id, + ApprovalRequest::ApplyPatch(request) => request.thread_id, + ApprovalRequest::McpElicitation(request) => request.thread_id, + } + } + + fn thread_label(&self) -> Option<&str> { + match self { + ApprovalRequest::Exec(request) => request.thread_label.as_deref(), + ApprovalRequest::Permissions(request) => request.thread_label.as_deref(), + ApprovalRequest::ApplyPatch(request) => request.thread_label.as_deref(), + ApprovalRequest::McpElicitation(request) => request.thread_label.as_deref(), + } + } + + pub(super) fn matches_resolved_request(&self, request: &ResolvedAppServerRequest) -> bool { + match (self, request) { + ( + ApprovalRequest::Exec(request), + ResolvedAppServerRequest::ExecApproval { thread_id, id }, + ) => request.thread_id.to_string() == *thread_id && request.id == *id, + ( + ApprovalRequest::Permissions(request), + ResolvedAppServerRequest::PermissionsApproval { thread_id, id }, + ) => request.thread_id.to_string() == *thread_id && request.call_id == *id, + ( + ApprovalRequest::ApplyPatch(request), + ResolvedAppServerRequest::FileChangeApproval { thread_id, id }, + ) => request.thread_id.to_string() == *thread_id && request.id == *id, + ( + ApprovalRequest::McpElicitation(request), + ResolvedAppServerRequest::McpElicitation { + server_name: resolved_server_name, + request_id: resolved_request_id, + }, + ) => { + request.server_name == *resolved_server_name + && request.request_id == *resolved_request_id + } + _ => false, + } + } +} + +/// Modal overlay asking the user to approve or deny one or more requests. +pub(crate) struct ApprovalOverlay { + current_request: Option, + queue: Vec, + app_event_tx: AppEventSender, + list: ListSelectionView, + options: Vec, + current_complete: bool, + done: bool, + features: Features, + approval_keymap: ApprovalKeymap, + list_keymap: ListKeymap, +} + +impl ApprovalOverlay { + pub fn new( + request: ApprovalRequest, + app_event_tx: AppEventSender, + features: Features, + approval_keymap: ApprovalKeymap, + list_keymap: ListKeymap, + ) -> Self { + let mut view = Self { + current_request: None, + queue: Vec::new(), + app_event_tx: app_event_tx.clone(), + list: ListSelectionView::new(Default::default(), app_event_tx, list_keymap.clone()), + options: Vec::new(), + current_complete: false, + done: false, + features, + approval_keymap, + list_keymap, + }; + view.set_current(request); + view + } + + pub fn enqueue_request(&mut self, req: ApprovalRequest) { + self.queue.push(req); + } + + fn dismiss_resolved_request(&mut self, request: &ResolvedAppServerRequest) -> bool { + let queue_len = self.queue.len(); + self.queue + .retain(|queued_request| !queued_request.matches_resolved_request(request)); + if self + .current_request + .as_ref() + .is_some_and(|current_request| current_request.matches_resolved_request(request)) + { + self.current_complete = true; + self.advance_queue(); + return true; + } + + self.queue.len() != queue_len + } + + fn set_current(&mut self, request: ApprovalRequest) { + self.current_complete = false; + let header = build_header(&request); + let (options, params) = Self::build_options( + &request, + header, + &self.features, + &self.approval_keymap, + &self.list_keymap, + ); + self.current_request = Some(request); + self.options = options; + self.list = + ListSelectionView::new(params, self.app_event_tx.clone(), self.list_keymap.clone()); + } + + fn build_options( + request: &ApprovalRequest, + header: Box, + _features: &Features, + approval_keymap: &ApprovalKeymap, + list_keymap: &ListKeymap, + ) -> (Vec, SelectionViewParams) { + let (options, title) = match request { + ApprovalRequest::Exec(request) => { + let title = if request.kind == CommandExecutionApprovalKind::WriteStdin { + request.command.get(2).map_or_else( + || "Would you like to send input to the existing terminal?".to_string(), + |process_id| { + format!("Would you like to send input to terminal {process_id}?") + }, + ) + } else { + request.network_approval_context.as_ref().map_or_else( + || "Would you like to run the following command?".to_string(), + |network_approval_context| { + format!( + "Do you want to approve network access to \"{}\"?", + network_approval_context.host + ) + }, + ) + }; + ( + exec_options( + &request.available_decisions, + request.network_approval_context.as_ref(), + request.additional_permissions.as_ref(), + approval_keymap, + ), + title, + ) + } + ApprovalRequest::Permissions(_) => ( + permissions_options(approval_keymap), + "Would you like to grant these permissions?".to_string(), + ), + ApprovalRequest::ApplyPatch(_) => ( + patch_options(approval_keymap), + "Would you like to make the following edits?".to_string(), + ), + ApprovalRequest::McpElicitation(request) => ( + elicitation_options(approval_keymap), + format!("{} needs your approval.", request.server_name), + ), + }; + + let header = Box::new(ColumnRenderable::with([ + Line::from(title.bold()).into(), + Line::from("").into(), + header, + ])); + + let items = options + .iter() + .map(|opt| SelectionItem { + name: opt.label.clone(), + display_shortcut: approval_keymap.hint_for_bindings(&opt.shortcuts), + dismiss_on_select: false, + ..Default::default() + }) + .collect(); + + let params = SelectionViewParams { + footer_hint: Some(approval_footer_hint(request, approval_keymap, list_keymap)), + items, + header, + ..Default::default() + }; + + (options, params) + } + + fn apply_selection(&mut self, actual_idx: usize) { + if self.current_complete { + return; + } + let Some(option) = self.options.get(actual_idx) else { + return; + }; + if let Some(request) = self.current_request.as_ref() { + match (request, &option.decision) { + (ApprovalRequest::Exec(request), ApprovalDecision::Command(decision)) => { + self.handle_exec_decision(&request.id, &request.command, decision.clone()); + } + ( + ApprovalRequest::Permissions(request), + ApprovalDecision::Permissions(decision), + ) => { + self.handle_permissions_decision( + &request.call_id, + &request.permissions, + *decision, + ); + } + (ApprovalRequest::ApplyPatch(request), ApprovalDecision::FileChange(decision)) => { + self.handle_patch_decision(&request.id, decision.clone()); + } + ( + ApprovalRequest::McpElicitation(request), + ApprovalDecision::McpElicitation(decision), + ) => { + self.handle_elicitation_decision( + &request.server_name, + &request.request_id, + *decision, + ); + } + _ => {} + } + } + + self.current_complete = true; + self.advance_queue(); + } + + fn handle_exec_decision( + &self, + id: &str, + command: &[String], + decision: CommandExecutionApprovalDecision, + ) { + let Some(request) = self.current_request.as_ref() else { + return; + }; + if request.thread_label().is_none() { + let network_approval_context = match request { + ApprovalRequest::Exec(request) => request.network_approval_context.as_ref(), + ApprovalRequest::Permissions(_) + | ApprovalRequest::ApplyPatch(_) + | ApprovalRequest::McpElicitation(_) => None, + }; + let subject = if let Some(network_approval_context) = network_approval_context { + history_cell::ApprovalDecisionSubject::NetworkAccess { + target: network_approval_target(network_approval_context, command), + } + } else if let Some(target) = network_approval_command_target(command) { + history_cell::ApprovalDecisionSubject::NetworkAccess { + target: target.to_string(), + } + } else { + history_cell::ApprovalDecisionSubject::Command(command.to_vec()) + }; + let cell = history_cell::new_approval_decision_cell( + subject, + command_decision_to_review_decision(&decision), + history_cell::ApprovalDecisionActor::User, + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + } + let thread_id = request.thread_id(); + self.app_event_tx + .exec_approval(thread_id, id.to_string(), decision); + } + + fn handle_permissions_decision( + &self, + call_id: &str, + permissions: &RequestPermissionProfile, + decision: PermissionsDecision, + ) { + let Some(request) = self.current_request.as_ref() else { + return; + }; + let granted_permissions = match decision { + PermissionsDecision::GrantForTurn + | PermissionsDecision::GrantForTurnWithStrictAutoReview + | PermissionsDecision::GrantForSession => permissions.clone(), + PermissionsDecision::Deny => Default::default(), + }; + let scope = if matches!(decision, PermissionsDecision::GrantForSession) { + PermissionGrantScope::Session + } else { + PermissionGrantScope::Turn + }; + let strict_auto_review = matches!( + decision, + PermissionsDecision::GrantForTurnWithStrictAutoReview + ); + if request.thread_label().is_none() { + let message = if granted_permissions.is_empty() { + "You did not grant additional permissions" + } else if strict_auto_review { + "You granted additional permissions with strict auto review" + } else if matches!(scope, PermissionGrantScope::Session) { + "You granted additional permissions for this session" + } else { + "You granted additional permissions" + }; + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + crate::history_cell::PlainHistoryCell::new(vec![message.into()]), + ))); + } + let thread_id = request.thread_id(); + self.app_event_tx.request_permissions_response( + thread_id, + call_id.to_string(), + codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: granted_permissions, + scope, + strict_auto_review, + }, + ); + } + + fn handle_patch_decision(&self, id: &str, decision: FileChangeApprovalDecision) { + let Some(thread_id) = self + .current_request + .as_ref() + .map(ApprovalRequest::thread_id) + else { + return; + }; + self.app_event_tx + .patch_approval(thread_id, id.to_string(), decision); + } + + fn handle_elicitation_decision( + &self, + server_name: &str, + request_id: &RequestId, + decision: McpServerElicitationAction, + ) { + let Some(thread_id) = self + .current_request + .as_ref() + .map(ApprovalRequest::thread_id) + else { + return; + }; + self.app_event_tx.resolve_elicitation( + thread_id, + server_name.to_string(), + request_id.clone(), + decision, + /*content*/ None, + /*meta*/ None, + ); + } + + fn advance_queue(&mut self) { + if let Some(next) = self.queue.pop() { + self.set_current(next); + } else { + self.done = true; + } + } + + fn cancel_current_request(&mut self) { + if self.done { + return; + } + if !self.current_complete + && let Some(request) = self.current_request.as_ref() + { + match request { + ApprovalRequest::Exec(request) => { + self.handle_exec_decision( + &request.id, + &request.command, + CommandExecutionApprovalDecision::Cancel, + ); + } + ApprovalRequest::Permissions(request) => { + self.handle_permissions_decision( + &request.call_id, + &request.permissions, + PermissionsDecision::Deny, + ); + } + ApprovalRequest::ApplyPatch(request) => { + self.handle_patch_decision(&request.id, FileChangeApprovalDecision::Cancel); + } + ApprovalRequest::McpElicitation(request) => { + self.handle_elicitation_decision( + &request.server_name, + &request.request_id, + McpServerElicitationAction::Cancel, + ); + } + } + } + self.queue.clear(); + self.done = true; + } + + /// Apply approval-specific shortcuts before delegating to list navigation. + /// + /// `open_fullscreen` is handled here because it is orthogonal to list item + /// selection and should work regardless of current highlighted row. + fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool { + if key_event.kind == KeyEventKind::Press + && self.approval_keymap.open_fullscreen.is_pressed(*key_event) + && let Some(request) = self.current_request.as_ref() + { + self.app_event_tx + .send(AppEvent::FullScreenApprovalRequest(request.clone())); + return true; + } + + if key_event.kind == KeyEventKind::Press + && self.approval_keymap.open_thread.is_pressed(*key_event) + && let Some(request) = self.current_request.as_ref() + && request.thread_label().is_some() + { + self.app_event_tx + .send(AppEvent::SelectAgentThread(request.thread_id())); + return true; + } + + if self.list_keymap.cancel.is_pressed(*key_event) { + self.cancel_current_request(); + return true; + } + + if let Some(idx) = self + .options + .iter() + .position(|opt| opt.shortcuts.iter().any(|s| s.is_press(*key_event))) + { + self.apply_selection(idx); + true + } else { + false + } + } +} + +impl BottomPaneView for ApprovalOverlay { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Approval) + .with(crate::keymap::KeymapContext::List) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + if self.try_handle_shortcut(&key_event) { + return; + } + self.list.handle_key_event(key_event); + if let Some(idx) = self.list.take_last_selected_index() { + self.apply_selection(idx); + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.cancel_current_request(); + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.done + } + + fn try_consume_approval_request( + &mut self, + request: ApprovalRequest, + ) -> Option { + self.enqueue_request(request); + None + } + + fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool { + self.dismiss_resolved_request(request) + } + + fn terminal_title_requires_action(&self) -> bool { + true + } +} + +impl Renderable for ApprovalOverlay { + fn desired_height(&self, width: u16) -> u16 { + self.list.desired_height(width) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.list.render(area, buf); + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.list.cursor_pos(area) + } +} + +fn approval_footer_hint( + request: &ApprovalRequest, + approval_keymap: &ApprovalKeymap, + list_keymap: &ListKeymap, +) -> Line<'static> { + let mut spans = accept_cancel_hint_line( + list_keymap.primary_hint(ListAction::Accept), + "to confirm", + list_keymap.primary_hint(ListAction::Cancel), + "to cancel", + ) + .spans; + if request.thread_label().is_some() + && let Some(open_thread) = + approval_keymap.primary_hint("open_thread", &approval_keymap.open_thread) + { + if !spans.is_empty() { + spans.push(" or ".into()); + } else { + spans.push("Press ".into()); + } + spans.extend([open_thread.into(), " to open thread".into()]); + } + Line::from(spans) +} + +fn network_approval_target( + network_approval_context: &NetworkApprovalContext, + command: &[String], +) -> String { + if let Some(target) = network_approval_command_target(command) { + return target.to_string(); + } + + let scheme = match network_approval_context.protocol { + NetworkApprovalProtocol::Http => "http", + NetworkApprovalProtocol::Https => "https", + NetworkApprovalProtocol::Socks5Tcp => "socks5-tcp", + NetworkApprovalProtocol::Socks5Udp => "socks5-udp", + }; + format!("{scheme}://{}", network_approval_context.host) +} + +fn network_approval_command_target(command: &[String]) -> Option<&str> { + match command { + [program, target] if program == "network-access" && !target.is_empty() => { + Some(target.as_str()) + } + [command] => command + .strip_prefix("network-access ") + .filter(|target| !target.is_empty()), + _ => None, + } +} + +fn build_header(request: &ApprovalRequest) -> Box { + match request { + ApprovalRequest::Exec(request) => { + let mut header: Vec> = Vec::new(); + if let Some(thread_label) = &request.thread_label { + header.push(Line::from(vec![ + "Thread: ".into(), + thread_label.clone().bold(), + ])); + header.push(Line::from("")); + } + if let Some(environment_id) = &request.environment_id { + header.push(Line::from(vec![ + "Environment: ".into(), + environment_id.clone().bold(), + ])); + header.push(Line::from("")); + } + if let Some(reason) = &request.reason { + header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()])); + header.push(Line::from("")); + } + if let Some(additional_permissions) = &request.additional_permissions + && let Some(rule_line) = format_additional_permissions_rule(additional_permissions) + { + header.push(Line::from(vec![ + "Permission rule: ".into(), + rule_line.cyan(), + ])); + header.push(Line::from("")); + } + if request.kind == CommandExecutionApprovalKind::WriteStdin { + let input = request.command.last().map_or("", String::as_str); + header.push(vec!["Input: ".into(), format!("{input:?}").into()].into()); + } else { + let full_cmd = strip_bash_lc_and_escape(&request.command); + let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); + if let Some(first) = full_cmd_lines.first_mut() { + first.spans.insert(0, Span::from("$ ")); + } + if request.network_approval_context.is_none() { + header.extend(full_cmd_lines); + } + } + Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) + } + ApprovalRequest::Permissions(request) => { + let mut header: Vec> = Vec::new(); + if let Some(thread_label) = &request.thread_label { + header.push(Line::from(vec![ + "Thread: ".into(), + thread_label.clone().bold(), + ])); + header.push(Line::from("")); + } + if let Some(environment_id) = &request.environment_id { + header.push(Line::from(vec![ + "Environment: ".into(), + environment_id.clone().bold(), + ])); + header.push(Line::from("")); + } + if let Some(reason) = &request.reason { + header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()])); + header.push(Line::from("")); + } + if let Some(rule_line) = format_requested_permissions_rule(&request.permissions) { + header.push(Line::from(vec![ + "Permission rule: ".into(), + rule_line.cyan(), + ])); + } + Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) + } + ApprovalRequest::ApplyPatch(request) => super::apply_patch_header::build_header(request), + ApprovalRequest::McpElicitation(request) => { + let mut lines = Vec::new(); + if let Some(thread_label) = &request.thread_label { + lines.push(Line::from(vec![ + "Thread: ".into(), + thread_label.clone().bold(), + ])); + lines.push(Line::from("")); + } + lines.extend([ + Line::from(vec!["Server: ".into(), request.server_name.clone().bold()]), + Line::from(""), + Line::from(request.message.clone()), + ]); + let header = Paragraph::new(lines).wrap(Wrap { trim: false }); + Box::new(header) + } + } +} + +#[derive(Clone)] +enum ApprovalDecision { + Command(CommandExecutionApprovalDecision), + FileChange(FileChangeApprovalDecision), + Permissions(PermissionsDecision), + McpElicitation(McpServerElicitationAction), +} + +#[derive(Clone, Copy)] +enum PermissionsDecision { + GrantForTurn, + GrantForTurnWithStrictAutoReview, + GrantForSession, + Deny, +} + +#[derive(Clone)] +struct ApprovalOption { + label: String, + decision: ApprovalDecision, + shortcuts: Vec, +} + +fn command_decision_to_review_decision( + decision: &CommandExecutionApprovalDecision, +) -> ReviewDecision { + match decision { + CommandExecutionApprovalDecision::Accept => ReviewDecision::Approved, + CommandExecutionApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession, + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment, + } => ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: execpolicy_amendment.clone().into_core(), + }, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment, + } => ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.clone().into_core(), + }, + CommandExecutionApprovalDecision::Decline => ReviewDecision::Denied, + CommandExecutionApprovalDecision::Cancel => ReviewDecision::Abort, + } +} + +fn exec_options( + available_decisions: &[CommandExecutionApprovalDecision], + network_approval_context: Option<&NetworkApprovalContext>, + additional_permissions: Option<&AdditionalPermissionProfile>, + keymap: &ApprovalKeymap, +) -> Vec { + available_decisions + .iter() + .filter_map(|decision| match decision { + CommandExecutionApprovalDecision::Accept => Some(ApprovalOption { + label: if network_approval_context.is_some() { + "Yes, just this once".to_string() + } else { + "Yes, proceed".to_string() + }, + decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Accept), + shortcuts: keymap.approve.clone(), + }), + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment, + } => { + let rendered_prefix = strip_bash_lc_and_escape(&execpolicy_amendment.command); + if rendered_prefix.contains('\n') || rendered_prefix.contains('\r') { + return None; + } + + Some(ApprovalOption { + label: format!( + "Yes, and don't ask again for commands that start with `{rendered_prefix}`" + ), + decision: ApprovalDecision::Command( + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment: execpolicy_amendment.clone(), + }, + ), + shortcuts: keymap.approve_for_prefix.clone(), + }) + } + CommandExecutionApprovalDecision::AcceptForSession => Some(ApprovalOption { + label: if network_approval_context.is_some() { + "Yes, and allow this host for this conversation".to_string() + } else if additional_permissions.is_some() { + "Yes, and allow these permissions for this session".to_string() + } else { + "Yes, and don't ask again for this command in this session".to_string() + }, + decision: ApprovalDecision::Command( + CommandExecutionApprovalDecision::AcceptForSession, + ), + shortcuts: keymap.approve_for_session.clone(), + }), + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment, + } => { + let (label, shortcuts) = match network_policy_amendment.action { + NetworkPolicyRuleAction::Allow => ( + "Yes, and allow this host in the future".to_string(), + keymap.approve_for_prefix.clone(), + ), + NetworkPolicyRuleAction::Deny => ( + "No, and block this host in the future".to_string(), + keymap.deny.clone(), + ), + }; + Some(ApprovalOption { + label, + decision: ApprovalDecision::Command( + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.clone(), + }, + ), + shortcuts, + }) + } + CommandExecutionApprovalDecision::Decline => Some(ApprovalOption { + label: "No, continue without running it".to_string(), + decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Decline), + shortcuts: keymap.deny.clone(), + }), + CommandExecutionApprovalDecision::Cancel => Some(ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Cancel), + shortcuts: keymap.decline.clone(), + }), + }) + .collect() +} + +pub(crate) fn format_additional_permissions_rule( + additional_permissions: &AdditionalPermissionProfile, +) -> Option { + let mut parts = Vec::new(); + if additional_permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) + { + parts.push("network".to_string()); + } + if let Some(file_system) = additional_permissions.file_system.as_ref() { + let reads = format_file_system_entry_paths( + file_system + .entries + .iter() + .flatten() + .filter(|entry| entry.access == FileSystemAccessMode::Read), + ); + if !reads.is_empty() { + parts.push(format!("read {reads}")); + } + let writes = format_file_system_entry_paths( + file_system + .entries + .iter() + .flatten() + .filter(|entry| entry.access == FileSystemAccessMode::Write), + ); + if !writes.is_empty() { + parts.push(format!("write {writes}")); + } + let denied_reads = format_file_system_entry_paths( + file_system + .entries + .iter() + .flatten() + .filter(|entry| entry.access == FileSystemAccessMode::Deny), + ); + if !denied_reads.is_empty() { + parts.push(format!("deny read {denied_reads}")); + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("; ")) + } +} + +pub(crate) fn format_requested_permissions_rule( + permissions: &RequestPermissionProfile, +) -> Option { + let permissions = + crate::app_server_approval_conversions::granted_permission_profile_from_request( + permissions.clone(), + ); + format_additional_permissions_rule(&AdditionalPermissionProfile { + network: permissions.network, + file_system: permissions.file_system, + }) +} + +fn format_file_system_entry_paths<'a>( + entries: impl Iterator, +) -> String { + entries + .map(|entry| match &entry.path { + FileSystemPath::Path { path } => format!("`{path}`"), + FileSystemPath::GlobPattern { pattern } => format!("glob `{pattern}`"), + FileSystemPath::Special { value } => format!("`{}`", special_path_label(value)), + }) + .collect::>() + .join(", ") +} + +fn special_path_label(value: &FileSystemSpecialPath) -> String { + match value { + FileSystemSpecialPath::Root => ":root".to_string(), + FileSystemSpecialPath::Minimal => ":minimal".to_string(), + FileSystemSpecialPath::ProjectRoots { subpath } => path_label(":workspace_roots", subpath), + FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(), + FileSystemSpecialPath::SlashTmp => "/tmp".to_string(), + FileSystemSpecialPath::Unknown { path, subpath } => path_label(path, subpath), + } +} + +fn path_label(base: &str, subpath: &Option) -> String { + match subpath { + Some(subpath) => format!("{base}/{subpath}"), + None => base.to_string(), + } +} + +fn patch_options(keymap: &ApprovalKeymap) -> Vec { + vec![ + ApprovalOption { + label: "Yes, proceed".to_string(), + decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Accept), + shortcuts: keymap.approve.clone(), + }, + ApprovalOption { + label: "Yes, and don't ask again for these files".to_string(), + decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::AcceptForSession), + shortcuts: keymap.approve_for_session.clone(), + }, + ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Cancel), + shortcuts: keymap.decline.clone(), + }, + ] +} + +fn permissions_options(keymap: &ApprovalKeymap) -> Vec { + let deny_shortcuts = keymap + .deny + .iter() + .copied() + .filter(|shortcut| shortcut.parts() != (KeyCode::Esc, KeyModifiers::NONE)) + .collect(); + + vec![ + ApprovalOption { + label: "Yes, grant these permissions for this turn".to_string(), + decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForTurn), + shortcuts: keymap.approve.clone(), + }, + ApprovalOption { + label: "Yes, grant for this turn with strict auto review".to_string(), + decision: ApprovalDecision::Permissions( + PermissionsDecision::GrantForTurnWithStrictAutoReview, + ), + shortcuts: vec![key_hint::plain(KeyCode::Char('r'))], + }, + ApprovalOption { + label: "Yes, grant these permissions for this session".to_string(), + decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForSession), + shortcuts: keymap.approve_for_session.clone(), + }, + ApprovalOption { + label: "No, continue without permissions".to_string(), + decision: ApprovalDecision::Permissions(PermissionsDecision::Deny), + shortcuts: deny_shortcuts, + }, + ] +} + +/// Build MCP elicitation options with stable cancellation semantics. +/// +/// `Esc` is always treated as cancel for elicitation prompts, even if users +/// customize `decline`/`cancel` bindings. We keep this as a hard contract so +/// dismissal remains a safe abort path and never silently maps to "continue +/// without requested info." Any decline/cancel overlap is removed from the +/// decline option in elicitation mode to preserve this invariant. +fn elicitation_options(keymap: &ApprovalKeymap) -> Vec { + let mut cancel_shortcuts = vec![key_hint::plain(KeyCode::Esc)]; + for shortcut in &keymap.cancel { + if !cancel_shortcuts.contains(shortcut) { + cancel_shortcuts.push(*shortcut); + } + } + + let decline_shortcuts: Vec = keymap + .decline + .iter() + .copied() + .filter(|shortcut| !cancel_shortcuts.contains(shortcut)) + .collect(); + + vec![ + ApprovalOption { + label: "Yes, provide the requested info".to_string(), + decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Accept), + shortcuts: keymap.approve.clone(), + }, + ApprovalOption { + label: "No, but continue without it".to_string(), + decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Decline), + shortcuts: decline_shortcuts, + }, + ApprovalOption { + label: "Cancel this request".to_string(), + decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Cancel), + shortcuts: cancel_shortcuts, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::keymap::RuntimeKeymap; + use codex_app_server_protocol::AdditionalFileSystemPermissions; + use codex_app_server_protocol::AdditionalNetworkPermissions; + use codex_app_server_protocol::ExecPolicyAmendment; + use codex_app_server_protocol::NetworkApprovalProtocol; + use codex_app_server_protocol::NetworkPolicyAmendment; + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; + use codex_protocol::models::FileSystemPermissions; + use codex_protocol::models::NetworkPermissions; + use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_absolute_path::test_support::PathExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use crossterm::event::KeyModifiers; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("absolute path") + } + + fn render_overlay_lines(view: &ApprovalOverlay, width: u16) -> String { + let height = view.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + view.render(Rect::new(0, 0, width, height), &mut buf); + (0..buf.area.height) + .map(|row| { + (0..buf.area.width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") + } + + fn render_history_cell_lines( + cell: &dyn crate::history_cell::HistoryCell, + width: u16, + ) -> Vec { + cell.display_lines(width) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() + } + + fn normalize_snapshot_paths(rendered: String) -> String { + [ + (absolute_path("/tmp/readme.txt"), "/tmp/readme.txt"), + (absolute_path("/tmp/out.txt"), "/tmp/out.txt"), + ] + .into_iter() + .fold(rendered, |rendered, (path, normalized)| { + rendered.replace(&path.display().to_string(), normalized) + }) + } + + fn make_overlay( + request: ApprovalRequest, + app_event_tx: AppEventSender, + features: Features, + ) -> ApprovalOverlay { + let keymap = crate::keymap::RuntimeKeymap::defaults(); + make_overlay_with_keymap( + request, + app_event_tx, + features, + keymap.approval, + keymap.list, + ) + } + + fn make_overlay_with_keymap( + request: ApprovalRequest, + app_event_tx: AppEventSender, + features: Features, + approval_keymap: ApprovalKeymap, + list_keymap: ListKeymap, + ) -> ApprovalOverlay { + ApprovalOverlay::new( + request, + app_event_tx, + features, + approval_keymap, + list_keymap, + ) + } + + fn make_exec_request() -> ApprovalRequest { + ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: None, + id: "test".to_string(), + environment_id: None, + command: vec!["echo".to_string(), "hi".to_string()], + reason: Some("reason".to_string()), + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: None, + additional_permissions: None, + }) + } + + fn make_permissions_request() -> ApprovalRequest { + ApprovalRequest::Permissions(PermissionsApprovalRequest { + thread_id: ThreadId::new(), + thread_label: None, + call_id: "test".to_string(), + environment_id: None, + reason: Some("need workspace access".to_string()), + permissions: RequestPermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/readme.txt")]), + Some(vec![absolute_path("/tmp/out.txt")]), + )), + }, + }) + } + + fn make_elicitation_request() -> ApprovalRequest { + ApprovalRequest::McpElicitation(McpElicitationApprovalRequest { + thread_id: ThreadId::new(), + thread_label: None, + server_name: "test-server".to_string(), + request_id: RequestId::String("request-1".to_string()), + message: "Need more information".to_string(), + }) + } + + #[test] + fn approval_shortcuts_display_chords_in_configured_order() { + let chord = crate::key_hint::ShortcutHint::Chord { + prefix: key_hint::ctrl(KeyCode::Char('x')), + completion: key_hint::plain(KeyCode::Char('y')), + }; + let single = crate::key_hint::ShortcutHint::Single(key_hint::plain(KeyCode::Char('y'))); + let binding = |spec: &str| KeybindingSpec(spec.to_string()); + for (specs, expected_shortcut) in [ + (KeybindingsSpec::One(binding("ctrl-x y")), chord), + ( + KeybindingsSpec::Many(vec![binding("y"), binding("ctrl-x y")]), + single, + ), + ( + KeybindingsSpec::Many(vec![binding("ctrl-x y"), binding("y")]), + chord, + ), + ] { + let mut config = TuiKeymap::default(); + config.approval.approve = Some(specs); + let keymap = RuntimeKeymap::from_config(&config).expect("valid approval keymap"); + let request = make_exec_request(); + let (_, params) = ApprovalOverlay::build_options( + &request, + build_header(&request), + &Features::with_defaults(), + &keymap.approval, + &keymap.list, + ); + let approval = params + .items + .iter() + .find(|item| item.name == "Yes, proceed") + .expect("approval selection"); + + assert_eq!(approval.display_shortcut, Some(expected_shortcut)); + if matches!( + expected_shortcut, + crate::key_hint::ShortcutHint::Chord { .. } + ) { + let dispatch_token = keymap.approval.approve.last().expect("chord token"); + assert_eq!( + keymap + .approval + .hint_for_bindings(std::slice::from_ref(dispatch_token)), + Some(expected_shortcut) + ); + } + } + } + + #[test] + fn ctrl_c_aborts_and_clears_queue() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); + view.enqueue_request(make_exec_request()); + assert_eq!(CancellationEvent::Handled, view.on_ctrl_c()); + assert!(view.queue.is_empty()); + assert!(view.is_complete()); + } + + #[test] + fn configured_list_cancel_aborts_exec_approval() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.list.cancel = vec![key_hint::plain(KeyCode::Char('q'))]; + let mut view = make_overlay_with_keymap( + make_exec_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + + assert!(view.is_complete()); + let mut decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ExecApproval { decision: d, .. }, + .. + } = ev + { + decision = Some(d); + break; + } + } + assert_eq!(decision, Some(CommandExecutionApprovalDecision::Cancel)); + } + + #[test] + fn configured_list_cancel_cancels_mcp_elicitation() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.list.cancel = vec![key_hint::plain(KeyCode::Char('q'))]; + let mut view = make_overlay_with_keymap( + make_elicitation_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + + assert!(view.is_complete()); + let mut decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ResolveElicitation { decision: d, .. }, + .. + } = ev + { + decision = Some(d); + break; + } + } + assert_eq!(decision, Some(McpServerElicitationAction::Cancel)); + } + + #[test] + fn shortcut_triggers_selection() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); + assert!(!view.is_complete()); + view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + // We expect at least one thread-scoped approval op message in the queue. + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if matches!(ev, AppEvent::SubmitThreadOp { .. }) { + saw_op = true; + break; + } + } + assert!(saw_op, "expected approval decision to emit an op"); + } + + #[test] + fn deny_shortcut_submits_denied_exec_decision() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = make_overlay( + ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: None, + id: "test".to_string(), + environment_id: None, + command: vec!["echo".to_string(), "hi".to_string()], + reason: None, + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Decline, + ], + network_approval_context: None, + additional_permissions: None, + }), + tx, + Features::with_defaults(), + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + + let mut saw_denied = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ExecApproval { decision, .. }, + .. + } = ev + { + assert_eq!(decision, CommandExecutionApprovalDecision::Decline); + saw_denied = true; + break; + } + } + assert!(saw_denied, "expected deny shortcut to emit denied decision"); + } + + #[test] + fn network_deny_shortcut_submits_policy_deny_decision() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let amendment = NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Deny, + }; + let mut view = make_overlay( + ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: None, + id: "test".to_string(), + environment_id: None, + command: vec!["curl".to_string(), "https://example.com".to_string()], + reason: None, + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment: amendment.clone(), + }, + ], + network_approval_context: Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }), + additional_permissions: None, + }), + tx, + Features::with_defaults(), + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + + let mut saw_deny = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ExecApproval { decision, .. }, + .. + } = ev + { + assert_eq!( + decision, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment: amendment + } + ); + saw_deny = true; + break; + } + } + assert!( + saw_deny, + "expected deny shortcut to emit network policy deny decision" + ); + } + + #[test] + fn resolved_request_dismisses_overlay_without_emitting_abort() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let request = make_exec_request(); + let thread_id = request.thread_id(); + let mut view = make_overlay(request, tx, Features::with_defaults()); + + assert!( + !view.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval { + thread_id: ThreadId::new().to_string(), + id: "test".to_string(), + }) + ); + assert!(!view.is_complete()); + assert!( + view.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval { + thread_id: thread_id.to_string(), + id: "test".to_string(), + }) + ); + assert!( + view.is_complete(), + "resolved request should close the overlay" + ); + assert!( + rx.try_recv().is_err(), + "dismissing a stale request should not emit an approval op" + ); + } + + #[test] + fn o_opens_source_thread_for_cross_thread_approval() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let thread_id = ThreadId::new(); + let mut view = make_overlay( + ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id, + thread_label: Some("Robie [explorer]".to_string()), + id: "test".to_string(), + environment_id: None, + command: vec!["echo".to_string(), "hi".to_string()], + reason: None, + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: None, + additional_permissions: None, + }), + tx, + Features::with_defaults(), + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + + let event = rx.try_recv().expect("expected select-agent-thread event"); + assert_eq!( + matches!(event, AppEvent::SelectAgentThread(id) if id == thread_id), + true + ); + } + + #[test] + fn configured_open_thread_shortcut_opens_source_thread() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let thread_id = ThreadId::new(); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.approval.open_thread = vec![key_hint::plain(KeyCode::Char('x'))]; + let mut view = make_overlay_with_keymap( + ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id, + thread_label: Some("Robie [explorer]".to_string()), + id: "test".to_string(), + environment_id: None, + command: vec!["echo".to_string(), "hi".to_string()], + reason: None, + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: None, + additional_permissions: None, + }), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + + view.handle_key_event(KeyEvent::new( + KeyCode::Char('o'), + /*modifiers*/ KeyModifiers::NONE, + )); + assert!(rx.try_recv().is_err()); + + view.handle_key_event(KeyEvent::new( + KeyCode::Char('x'), + /*modifiers*/ KeyModifiers::NONE, + )); + let event = rx.try_recv().expect("expected select-agent-thread event"); + assert!(matches!(event, AppEvent::SelectAgentThread(id) if id == thread_id)); + } + + #[test] + fn cross_thread_footer_hint_mentions_o_shortcut() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let view = make_overlay( + ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: Some("Robie [explorer]".to_string()), + id: "test".to_string(), + environment_id: None, + command: vec!["echo".to_string(), "hi".to_string()], + reason: None, + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: None, + additional_permissions: None, + }), + tx, + Features::with_defaults(), + ); + + assert_snapshot!( + "approval_overlay_cross_thread_prompt", + render_overlay_lines(&view, /*width*/ 80) + ); + } + + #[test] + fn exec_prefix_option_emits_execpolicy_amendment() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = make_overlay( + ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: None, + id: "test".to_string(), + environment_id: None, + command: vec!["echo".to_string()], + reason: None, + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment: ExecPolicyAmendment { + command: vec!["echo".to_string()], + }, + }, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: None, + additional_permissions: None, + }), + tx, + Features::with_defaults(), + ); + view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ExecApproval { decision, .. }, + .. + } = ev + { + assert_eq!( + decision, + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment: ExecPolicyAmendment { + command: vec!["echo".to_string()], + } + } + ); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected approval decision to emit an op with command prefix" + ); + } + + #[test] + fn network_deny_forever_shortcut_is_not_bound() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = make_overlay( + ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: None, + id: "test".to_string(), + environment_id: None, + command: vec!["curl".to_string(), "https://example.com".to_string()], + reason: None, + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptForSession, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment: NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Allow, + }, + }, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }), + additional_permissions: None, + }), + tx, + Features::with_defaults(), + ); + view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + + assert!( + rx.try_recv().is_err(), + "unexpected approval event emitted for hidden network deny shortcut" + ); + } + + #[test] + fn header_includes_command_snippet() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let command = vec!["echo".into(), "hello".into(), "world".into()]; + let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: None, + id: "test".into(), + environment_id: None, + command, + reason: None, + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: None, + additional_permissions: None, + }); + + let view = make_overlay(exec_request, tx, Features::with_defaults()); + let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(/*width*/ 80))); + view.render( + Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)), + &mut buf, + ); + + let rendered: Vec = (0..buf.area.height) + .map(|row| { + (0..buf.area.width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect() + }) + .collect(); + assert!( + rendered + .iter() + .any(|line| line.contains("echo hello world")), + "expected header to include command snippet, got {rendered:?}" + ); + } + + #[test] + fn network_exec_options_use_expected_labels_and_hide_execpolicy_amendment() { + let network_context = NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }; + let keymap = crate::keymap::RuntimeKeymap::defaults(); + let options = exec_options( + &[ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptForSession, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment: NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Allow, + }, + }, + CommandExecutionApprovalDecision::Cancel, + ], + Some(&network_context), + /*additional_permissions*/ None, + &keymap.approval, + ); + + let labels: Vec = options.into_iter().map(|option| option.label).collect(); + assert_eq!( + labels, + vec![ + "Yes, just this once".to_string(), + "Yes, and allow this host for this conversation".to_string(), + "Yes, and allow this host in the future".to_string(), + "No, and tell Codex what to do differently".to_string(), + ] + ); + } + + #[test] + fn generic_exec_options_can_offer_allow_for_session() { + let keymap = crate::keymap::RuntimeKeymap::defaults(); + let options = exec_options( + &[ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptForSession, + CommandExecutionApprovalDecision::Cancel, + ], + /*network_approval_context*/ None, + /*additional_permissions*/ None, + &keymap.approval, + ); + + let labels: Vec = options.into_iter().map(|option| option.label).collect(); + assert_eq!( + labels, + vec![ + "Yes, proceed".to_string(), + "Yes, and don't ask again for this command in this session".to_string(), + "No, and tell Codex what to do differently".to_string(), + ] + ); + } + + #[test] + fn additional_permissions_exec_options_hide_execpolicy_amendment() { + let keymap = crate::keymap::RuntimeKeymap::defaults(); + let additional_permissions = AdditionalPermissionProfile { + network: None, + file_system: Some( + FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/readme.txt")]), + Some(vec![absolute_path("/tmp/out.txt")]), + ) + .into(), + ), + }; + let options = exec_options( + &[ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], + /*network_approval_context*/ None, + Some(&additional_permissions), + &keymap.approval, + ); + + let labels: Vec = options.into_iter().map(|option| option.label).collect(); + assert_eq!( + labels, + vec![ + "Yes, proceed".to_string(), + "No, and tell Codex what to do differently".to_string(), + ] + ); + } + + #[test] + fn permissions_options_use_expected_labels() { + let keymap = crate::keymap::RuntimeKeymap::defaults(); + let labels: Vec = permissions_options(&keymap.approval) + .into_iter() + .map(|option| option.label) + .collect(); + assert_eq!( + labels, + vec![ + "Yes, grant these permissions for this turn".to_string(), + "Yes, grant for this turn with strict auto review".to_string(), + "Yes, grant these permissions for this session".to_string(), + "No, continue without permissions".to_string(), + ] + ); + } + + #[test] + fn additional_permissions_rule_shows_non_path_file_system_entries() { + let additional_permissions = AdditionalPermissionProfile { + network: None, + file_system: Some(AdditionalFileSystemPermissions { + read: None, + write: None, + entries: Some(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::Deny, + }, + ]), + glob_scan_max_depth: None, + }), + }; + + assert_eq!( + format_additional_permissions_rule(&additional_permissions), + Some("write `:root`; deny read glob `**/*.env`".to_string()) + ); + } + + #[test] + fn additional_permissions_rule_uses_workspace_roots_label() { + let additional_permissions = AdditionalPermissionProfile { + network: None, + file_system: Some(AdditionalFileSystemPermissions { + read: None, + write: None, + entries: Some(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { + subpath: Some(LegacyAppPathString::from_path(std::path::Path::new( + ".git", + ))), + }, + }, + access: FileSystemAccessMode::Read, + }]), + glob_scan_max_depth: None, + }), + }; + + assert_eq!( + format_additional_permissions_rule(&additional_permissions), + Some("read `:workspace_roots/.git`".to_string()) + ); + } + + #[test] + fn permissions_session_shortcut_submits_session_scope() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = make_overlay(make_permissions_request(), tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::RequestPermissionsResponse { response, .. }, + .. + } = ev + { + assert_eq!(response.scope, PermissionGrantScope::Session); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected permission approval decision to emit a session-scoped response" + ); + } + + #[test] + fn permissions_deny_shortcut_uses_deny_keymap() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.approval.deny = vec![key_hint::plain(KeyCode::Char('x'))]; + keymap.approval.decline = Vec::new(); + let mut view = make_overlay_with_keymap( + make_permissions_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::RequestPermissionsResponse { response, .. }, + .. + } = ev + { + assert!(response.permissions.is_empty()); + assert_eq!(response.scope, PermissionGrantScope::Turn); + assert!(!response.strict_auto_review); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected permission deny shortcut to emit an empty permission response" + ); + } + + #[test] + fn permissions_strict_auto_review_shortcut_submits_turn_scope_with_strict_review() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = make_overlay(make_permissions_request(), tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::RequestPermissionsResponse { response, .. }, + .. + } = ev + { + assert_eq!(response.scope, PermissionGrantScope::Turn); + assert!(response.strict_auto_review); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected permission approval decision to emit a strict auto review response" + ); + } + + #[test] + fn additional_permissions_prompt_shows_permission_rule_line() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: None, + id: "test".into(), + environment_id: None, + command: vec!["cat".into(), "/tmp/readme.txt".into()], + reason: None, + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: None, + additional_permissions: Some(AdditionalPermissionProfile { + network: Some(AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: Some( + FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/readme.txt")]), + Some(vec![absolute_path("/tmp/out.txt")]), + ) + .into(), + ), + }), + }); + + let view = make_overlay(exec_request, tx, Features::with_defaults()); + let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(/*width*/ 100))); + view.render( + Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)), + &mut buf, + ); + + let rendered: Vec = (0..buf.area.height) + .map(|row| { + (0..buf.area.width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect() + }) + .collect(); + + assert!( + rendered + .iter() + .any(|line| line.contains("Permission rule:")), + "expected permission-rule line, got {rendered:?}" + ); + assert!( + rendered.iter().any(|line| line.contains("network;")), + "expected network permission text, got {rendered:?}" + ); + } + + #[test] + fn additional_permissions_prompt_snapshot() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: None, + id: "test".into(), + environment_id: None, + command: vec!["cat".into(), "/tmp/readme.txt".into()], + reason: Some("need filesystem access".into()), + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: None, + additional_permissions: Some(AdditionalPermissionProfile { + network: Some(AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: Some( + FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/readme.txt")]), + Some(vec![absolute_path("/tmp/out.txt")]), + ) + .into(), + ), + }), + }); + + let view = make_overlay(exec_request, tx, Features::with_defaults()); + assert_snapshot!( + "approval_overlay_additional_permissions_prompt", + normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) + ); + } + + #[test] + fn permissions_prompt_snapshot() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let view = make_overlay(make_permissions_request(), tx, Features::with_defaults()); + assert_snapshot!( + "approval_overlay_permissions_prompt", + normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) + ); + } + + #[test] + fn apply_patch_prompt_with_thread_label_omits_command_line() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("bug1.txt"), + FileChange::Add { + content: "one\ntwo\nthree\n".to_string(), + }, + ); + changes.insert( + PathBuf::from("old.txt"), + FileChange::Update { + unified_diff: String::new(), + move_path: Some(PathBuf::from("new.txt")), + }, + ); + #[cfg(windows)] + let (foreign_path, foreign_move_path) = ("/remote/old.txt", "/remote/new.txt"); + #[cfg(not(windows))] + let (foreign_path, foreign_move_path) = (r"C:\workspace\old.txt", r"C:\workspace\new.txt"); + changes.insert( + PathBuf::from(foreign_path), + FileChange::Update { + unified_diff: String::new(), + move_path: Some(PathBuf::from(foreign_move_path)), + }, + ); + let request = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest { + thread_id: ThreadId::new(), + thread_label: Some("Banach [worker]".to_string()), + id: "test".to_string(), + reason: None, + cwd: test_path_buf("/tmp").abs(), + changes, + }); + let keymap = crate::keymap::RuntimeKeymap::defaults(); + let view = ApprovalOverlay::new( + request, + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + let rendered = render_overlay_lines(&view, /*width*/ 120); + assert!(rendered.contains("Thread: Banach [worker]")); + assert!(rendered.contains("Description: Apply proposed file edits")); + for path in ["/tmp/bug1.txt", "/tmp/new.txt", "/tmp/old.txt"] { + assert!(rendered.contains(&format!("Destination: {}", test_path_buf(path).display()))); + } + for path in [foreign_path, foreign_move_path] { + assert!(rendered.contains(&format!("Destination: {path}"))); + } + assert!(rendered.contains("o to open thread")); + assert!(!rendered.contains("$ apply_patch")); + } + + #[test] + fn apply_patch_prompt_without_changes_shows_unavailable_destination() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let request = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest { + thread_id: ThreadId::new(), + thread_label: None, + id: "test".to_string(), + reason: None, + cwd: absolute_path("/tmp"), + changes: HashMap::new(), + }); + let view = make_overlay(request, tx, Features::with_defaults()); + assert_snapshot!( + "approval_overlay_patch_destination_unavailable", + normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) + ); + } + + #[test] + fn network_exec_prompt_title_includes_host() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: None, + id: "test".into(), + environment_id: None, + command: vec!["curl".into(), "https://example.com".into()], + reason: Some("network request blocked".into()), + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptForSession, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment: NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Allow, + }, + }, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }), + additional_permissions: None, + }); + + let view = make_overlay(exec_request, tx, Features::with_defaults()); + let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(/*width*/ 100))); + view.render( + Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)), + &mut buf, + ); + assert_snapshot!("network_exec_prompt", format!("{buf:?}")); + + let rendered: Vec = (0..buf.area.height) + .map(|row| { + (0..buf.area.width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect() + }) + .collect(); + + assert!( + rendered.iter().any(|line| { + line.contains("Do you want to approve network access to \"example.com\"?") + }), + "expected network title to include host, got {rendered:?}" + ); + assert!( + !rendered.iter().any(|line| line.contains("$ curl")), + "network prompt should not show command line, got {rendered:?}" + ); + assert!( + !rendered.iter().any(|line| line.contains("don't ask again")), + "network prompt should not show execpolicy option, got {rendered:?}" + ); + } + + #[test] + fn ctrl_shift_a_opens_fullscreen() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )); + + let mut saw_fullscreen = false; + while let Ok(ev) = rx.try_recv() { + if matches!(ev, AppEvent::FullScreenApprovalRequest(_)) { + saw_fullscreen = true; + break; + } + } + assert!(saw_fullscreen, "expected ctrl+shift+a to open fullscreen"); + } + + #[test] + fn exec_history_cell_wraps_with_two_space_indent() { + let command = vec![ + "/bin/zsh".into(), + "-lc".into(), + "git add tui/src/render/mod.rs tui/src/render/renderable.rs".into(), + ]; + let cell = history_cell::new_approval_decision_cell( + history_cell::ApprovalDecisionSubject::Command(command), + ReviewDecision::Approved, + history_cell::ApprovalDecisionActor::User, + ); + let lines = cell.display_lines(/*width*/ 28); + let rendered: Vec = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + let expected = vec![ + "✔ You approved codex to run".to_string(), + " git add tui/src/render/".to_string(), + " mod.rs tui/src/render/".to_string(), + " renderable.rs this time".to_string(), + ]; + assert_eq!(rendered, expected); + } + + #[test] + fn exec_history_cell_does_not_render_blank_action_for_empty_command() { + let approved = history_cell::new_approval_decision_cell( + history_cell::ApprovalDecisionSubject::Command(Vec::new()), + ReviewDecision::Approved, + history_cell::ApprovalDecisionActor::User, + ); + assert_eq!( + render_history_cell_lines(approved.as_ref(), /*width*/ 80), + vec!["✔ You approved this request this time".to_string()] + ); + + let approved_for_session = history_cell::new_approval_decision_cell( + history_cell::ApprovalDecisionSubject::Command(Vec::new()), + ReviewDecision::ApprovedForSession, + history_cell::ApprovalDecisionActor::User, + ); + assert_eq!( + render_history_cell_lines(approved_for_session.as_ref(), /*width*/ 80), + vec!["✔ You approved this request every time this session".to_string()] + ); + } + + #[test] + fn network_access_command_history_uses_target_without_structured_context() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = make_overlay( + ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: ThreadId::new(), + thread_label: None, + id: "test".into(), + environment_id: None, + command: vec![ + "network-access".to_string(), + "https://example.com:8443".to_string(), + ], + reason: None, + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: None, + additional_permissions: None, + }), + tx, + Features::with_defaults(), + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + let mut decision = None; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + decision = Some(cell); + break; + } + } + let decision = decision.expect("expected decision cell in history"); + assert_eq!( + render_history_cell_lines(decision.as_ref(), /*width*/ 80), + vec![ + "✔ You approved codex network access to https://example.com:8443 this time" + .to_string(), + ] + ); + } + + #[test] + fn esc_cancels_mcp_elicitation() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = make_overlay(make_elicitation_request(), tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let mut decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ResolveElicitation { decision: d, .. }, + .. + } = ev + { + decision = Some(d); + break; + } + } + assert_eq!(decision, Some(McpServerElicitationAction::Cancel)); + } + + #[test] + fn esc_still_cancels_elicitation_with_custom_overlap() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.approval.decline = vec![ + key_hint::plain(KeyCode::Esc), + key_hint::plain(KeyCode::Char('n')), + ]; + keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))]; + + let mut view = make_overlay_with_keymap( + make_elicitation_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let mut esc_decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ResolveElicitation { decision, .. }, + .. + } = ev + { + esc_decision = Some(decision); + break; + } + } + assert_eq!(esc_decision, Some(McpServerElicitationAction::Cancel)); + + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.approval.decline = vec![ + key_hint::plain(KeyCode::Esc), + key_hint::plain(KeyCode::Char('n')), + ]; + keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))]; + + let mut view = make_overlay_with_keymap( + make_elicitation_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + let mut n_decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ResolveElicitation { decision, .. }, + .. + } = ev + { + n_decision = Some(decision); + break; + } + } + assert_eq!(n_decision, Some(McpServerElicitationAction::Decline)); + } + + #[test] + fn enter_sets_last_selected_index_without_dismissing() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!( + view.is_complete(), + "exec approval should complete without queued requests" + ); + + let mut decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ExecApproval { decision: d, .. }, + .. + } = ev + { + decision = Some(d); + break; + } + } + assert_eq!(decision, Some(CommandExecutionApprovalDecision::Accept)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..84e373830e961387f2a2b12d2954baadb4953470 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -0,0 +1,172 @@ +use crate::app::app_server_requests::ResolvedAppServerRequest; +use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::McpServerElicitationFormRequest; +use crate::keymap::KeymapContextSet; +use crate::render::renderable::Renderable; +use codex_app_server_protocol::ToolRequestUserInputParams; +use crossterm::event::KeyEvent; +use std::time::Instant; + +use super::CancellationEvent; + +/// Reason an active bottom-pane view finished. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ViewCompletion { + Accepted, + Cancelled, +} + +/// Trait implemented by every view that can be shown in the bottom pane. +pub(crate) trait BottomPaneView: Renderable { + /// Handle a key event while the view is active. A redraw is always + /// scheduled after this call. + fn handle_key_event(&mut self, _key_event: KeyEvent) {} + + /// Return the keymap contexts whose handlers are active in this view. + fn keymap_contexts(&self) -> KeymapContextSet { + KeymapContextSet::default() + } + + /// Return `true` if the view has finished and should be removed. + fn is_complete(&self) -> bool { + false + } + + /// Return the completion reason once the view has finished. + fn completion(&self) -> Option { + None + } + + /// Return true when this view should be removed after a child view is accepted. + fn dismiss_after_child_accept(&self) -> bool { + false + } + + /// Clear any pending child-flow cleanup marker after a child view is cancelled. + fn clear_dismiss_after_child_accept(&mut self) {} + + /// Stable identifier for views that need external refreshes while open. + fn view_id(&self) -> Option<&'static str> { + None + } + + /// Actual item index for list-based views that want to preserve selection + /// across external refreshes. + fn selected_index(&self) -> Option { + None + } + + /// Apply a matching background suggestion when this view supports text prefills. + fn apply_text_suggestion( + &mut self, + _request_id: uuid::Uuid, + _suggestion: Option<&str>, + ) -> bool { + false + } + + /// Active tab id for tabbed list-based views. + #[allow(dead_code)] + fn active_tab_id(&self) -> Option<&str> { + None + } + + /// Handle Ctrl-C while this view is active. + fn on_ctrl_c(&mut self) -> CancellationEvent { + CancellationEvent::NotHandled + } + + /// Return true if Esc should be routed through `handle_key_event` instead + /// of the `on_ctrl_c` cancellation path. + fn prefer_esc_to_handle_key_event(&self) -> bool { + false + } + + /// Return true when this key event will interrupt the active agent turn. + fn will_interrupt_turn_on_key_event(&self, _key_event: KeyEvent) -> bool { + false + } + + /// Optional paste handler. Return true if the view modified its state and + /// needs a redraw. + fn handle_paste(&mut self, _pasted: String) -> bool { + false + } + + /// Flush any pending paste-burst state. Return true if state changed. + /// + /// This lets a modal that reuses `ChatComposer` participate in the same + /// time-based paste burst flushing as the primary composer. + fn flush_paste_burst_if_due(&mut self) -> bool { + false + } + + /// Whether the view is currently holding paste-burst transient state. + /// + /// When `true`, the bottom pane will schedule a short delayed redraw to + /// give the burst time window a chance to flush. + fn is_in_paste_burst(&self) -> bool { + false + } + + /// Process time-based state immediately before rendering. + /// + /// Return true when state changed and the bottom pane should redraw or + /// complete the active view. + fn pre_draw_tick(&mut self, _now: Instant) -> bool { + false + } + + /// Try to handle approval request; return the original value if not + /// consumed. + fn try_consume_approval_request( + &mut self, + request: ApprovalRequest, + ) -> Option { + Some(request) + } + + /// Try to handle request_user_input; return the original value if not + /// consumed. + fn try_consume_user_input_request( + &mut self, + request: ToolRequestUserInputParams, + ) -> Option { + Some(request) + } + + /// Try to handle a supported MCP server elicitation form request; return the original value if + /// not consumed. + fn try_consume_mcp_server_elicitation_request( + &mut self, + request: McpServerElicitationFormRequest, + ) -> Option { + Some(request) + } + + /// Return true when this view already presents the matching app-server request. + fn matches_app_server_request(&self, _request: &ResolvedAppServerRequest) -> bool { + false + } + + /// Dismiss a request that was resolved by another client. + /// + /// Returns `true` when the view changed state. + fn dismiss_app_server_request(&mut self, _request: &ResolvedAppServerRequest) -> bool { + false + } + + /// Whether this view means the session is blocked waiting for the user. + /// + /// Views that return `true` surface an "Action Required" terminal title + /// instead of the normal working spinner so terminal tabs clearly show that + /// Codex needs user input. + fn terminal_title_requires_action(&self) -> bool { + false + } + + /// Return the next time-based redraw this view needs while it is active. + fn next_frame_delay(&self) -> Option { + None + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs new file mode 100644 index 0000000000000000000000000000000000000000..2b86759a673579358316f8142d2dcca3245ec992 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -0,0 +1,13071 @@ +//! The chat composer is the bottom-pane text input state machine. +//! +//! It edits the [`TextArea`] buffer and attachment elements, routes popup keys, promotes +//! completed slash commands to atomic elements, and handles Enter submission/newlines. +//! It also shows Luna Reserve's yellow prompt arrow and detects unbracketed paste bursts +//! from raw key streams, particularly on Windows. +//! The live voice strip renders after effort ignition. +//! +//! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab +//! submit ordinary text without enabling shell mode. +//! +//! # Mention Menus +//! +//! By default, `@` lists plugins, filesystem entries, and skills. Skills are hidden when their +//! owning plugin is listed. `$` lists individual skills and apps, but not plugins. +//! Disabling `mentions_v2` restores file-only `@` search and adds plugins back to `$`. +//! +//! # Key Event Routing +//! +//! Plain Left opens agents when the local-daemon composer is empty and available for input. +//! Explicit editor remaps take precedence. +//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a +//! popup-specific handler if a popup is visible and otherwise to +//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call +//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor. +//! Fresh Vim drafts start in Insert; Normal `/` and `?` search the composer. +//! Backspace on an empty Vim search query cancels search and any pending operator. +//! +//! # Completion and Popup Dismissal +//! +//! Popup targeting resolves an editable token range around the cursor and treats atomic text +//! elements as hard boundaries. When that range begins immediately after an atomic element, the +//! insertion path first adds a horizontal separator and shifts the range so the completion cannot +//! merge with the atomic element. After replacing the range, completion leaves the cursor after one +//! horizontal separator. It advances across an existing separator when no suffix would be joined; +//! otherwise it inserts a space, preserving the existing separator before a non-whitespace suffix. +//! It also inserts a space rather than crossing a line break. +//! +//! `Esc` records the active token as dismissed. A completed value that begins with `@` or `$` is +//! also re-dismissed before popup synchronization, because separator affinity can still identify +//! the completed token to the left of the cursor. Synchronization keeps the popup hidden only while +//! the query, complete token text, and ordinal among matching occurrences remain the same. +//! Whitespace and atomic-element edges delimit occurrences, while nested sigils do not. This +//! preserves dismissal across offset-only edits without suppressing a later identical token. +//! +//! Slash-command dismissal is tracked separately. Pressing `Esc` while the command popup is active +//! records the first-line `/name` token and keeps the popup closed while that token is unchanged. +//! Editing the command token clears the dismissal and allows the popup to reopen. +//! +//! # History Navigation (↑/↓) +//! +//! The Up/Down history path is managed by [`ChatComposerHistory`]. It merges: +//! +//! - Persistent cross-session history (text-only storage; task links recover element ranges and +//! mention bindings, but attachments cannot be restored). +//! - Local in-session history (full text + text elements + local/remote image attachments). +//! +//! Plain-text history recall strips images and their placeholders. +//! When recalling a persistent entry, encoded task links restore atomic elements and bindings. +//! Recall moves the cursor to the end. Question editors copy primary history on recall/search; +//! draft capture cancels previews, and restoration resets traversal. +//! Ctrl+R searches history in the footer and previews matches in the composer. +//! Typing and pasting edit the active search query, including large pastes and image paths. +//! Enter accepts the preview; Esc restores the original draft. +//! Vim undo/redo snapshots complete drafts and groups direct edits with active Vim transactions. +//! An active edit keeps one separately capped snapshot; canceling does not evict committed history. +//! Canceled history previews restore history and active commands; accepting another prompt resets them. +//! Normal-mode Ctrl+R redoes an edit, or does nothing when redo is empty. Insert-mode Ctrl+R +//! keeps prompt-history search; explicitly configured keybindings retain precedence. +//! Vim queries stay draft-local. +//! +//! Slash commands are staged for local history instead of being recorded immediately. Command +//! recall is a two-phase handoff: stage the submitted slash text here, then record it after +//! `ChatWidget` dispatches the command. +//! +//! # Startup Draft Handoff +//! +//! Startup uses a provisional plain-text composer: editing remains available, but submission, +//! popups, attachments, and other actions are disabled. [`ComposerDraftSnapshot`] transfers its +//! text, cursor, pending paste placeholders, local history, and recent activity to the fully +//! initialized composer. +//! `ChatWidget` merges the draft with any existing initial prompt and attachments, rebasing cursor +//! and placeholder positions while preserving both composers' contents. The draft remains deferred +//! until protected views close, input is enabled, and required sandbox setup completes. +//! +//! # Submission and Prompt Expansion +//! +//! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is +//! running, `Tab` submits just like Enter so input is never dropped. +//! Vim Replace shares Insert's composer actions; only textarea editing differs. +//! Literal completion/paste edits discard stale Replace recovery; subsequent typing records anew. +//! Token markers added during Replace are removed when restoring overwritten characters. +//! `Tab` does not submit when entering a `!` shell command. +//! +//! On submit/queue paths, the composer: +//! +//! - Expands pending paste placeholders so element ranges align with the final text. +//! - Trims whitespace and rebases elements when `trim_submission` is enabled (the default). +//! Otherwise, preserves both. +//! - Treats a leading `!` revealed only by paste expansion as literal model input, not shell input. +//! - Prunes local attached images so only placeholders that survive expansion are sent. +//! - Preserves remote image URLs as separate attachments even when text is empty. +//! +//! When these paths clear the visible textarea after a successful submit or slash-command +//! dispatch, they intentionally preserve the textarea kill buffer. That lets users `Ctrl+K` part +//! of a draft, perform a composer action such as changing reasoning level, and then `Ctrl+Y` the +//! killed text back into the now-empty draft. Replacing the chat widget carries that buffer into +//! the fresh composer so Vim yanks survive `/new` and thread switches. +//! +//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion +//! and attachment pruning, and clears pending paste state on success. +//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so +//! pasted content and text elements are preserved when extracting args. +//! +//! # Parent-Owned Thread Mode +//! +//! Parent-owned subagent threads keep the draft editable while blocking agent-directed submission. +//! On the `Enter` and `Tab` submission paths, normal prompts, disallowed slash commands, and `!` +//! shell commands return `ParentOwnedInputBlocked` without clearing the draft. Bare local and +//! navigation slash commands remain available so users can leave or manage the view. Transcript +//! exports also remain available, including an explicit destination filename. +//! +//! During reconnection, `handle_disconnected_key` edits the draft directly without +//! popup dispatch or submission. Enter and Tab leave the draft intact until reconnection succeeds. +//! Collapsed pastes expand into editable text so the full draft can be copied before quitting. +//! +//! # Reasoning Effort Animations +//! +//! The composer observes the effective reasoning tier whenever model-dependent surfaces refresh. +//! The first observation, session configuration, and restored threads establish a baseline; +//! genuine changes to Max/Ultra can then queue composer and status-line transitions when motion +//! and color support allow them. +//! Repeated selections do not restart either effect, dropping below Max clears them, and +//! restoration clears any transition queued while replaying the saved session. Rendering advances +//! active transitions through the frame requester until they finish. +//! +//! # Large Paste Placeholders +//! +//! Large pastes insert an element placeholder in the buffer and store the full text in +//! `pending_pastes`. The placeholder label is derived from the pasted character count: +//! +//! - First paste of a given size uses `[Pasted Content N chars]`. +//! - Additional pending pastes of the same size add a numeric suffix (`#2`, `#3`, ...), where the +//! next suffix is computed from the placeholders that still exist in `pending_pastes`. +//! - When all placeholders for a size are cleared or deleted, the next paste of that size reuses +//! the base label without a suffix. +//! +//! # Remote Image Rows (Up/Down/Delete) +//! +//! Remote image URLs are rendered as non-editable `[Image #N]` rows above the textarea (inside the +//! same composer block). These rows represent image attachments rehydrated from app-server/backtrack +//! history; TUI users can remove them, but cannot type into that row region. +//! +//! Keyboard behavior: +//! +//! - `Up` at textarea cursor `0` enters remote-row selection at the last remote image. +//! - `Up`/`Down` move selection between remote rows. +//! - `Down` on the last row clears selection and returns control to the textarea. +//! - `Delete`/`Backspace` remove the selected remote image row. +//! +//! Placeholder numbering is unified across remote and local images: +//! +//! - Remote rows occupy `[Image #1]..[Image #M]`. +//! - Local placeholders are offset after that range (`[Image #M+1]..`). +//! - Deleting a remote row relabels local placeholders to keep numbering contiguous. +//! +//! # Non-bracketed Paste Bursts +//! +//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of +//! `KeyCode::Char`, `KeyCode::Enter`, and `KeyCode::Tab` key events instead of a single paste event. +//! +//! To avoid misinterpreting these bursts as real typing (and to prevent transient UI effects like +//! shortcut overlays toggling on a pasted `?`), we feed text-producing character events (plain, +//! Shift, or Windows AltGr) into +//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them +//! through [`ChatComposer::handle_paste`]. +//! Parent views must keep flushing editors that lose focus while input is buffered; a hidden +//! editor's pending burst can otherwise keep the shared draw loop waiting indefinitely. +//! +//! The burst detector intentionally treats ASCII and non-ASCII differently: +//! +//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the +//! stream is paste-like. +//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow +//! burst detection for actual paste streams. +//! +//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state +//! machine and treats the key stream as normal typing. When toggling from enabled → disabled, the +//! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input. +//! +//! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`. +//! +//! # PasteBurst Integration Points +//! +//! The burst detector is consulted in a few specific places: +//! +//! - [`ChatComposer::handle_input_basic`]: flushes any due burst first, then intercepts plain char +//! input to either buffer it or insert normally. +//! - [`ChatComposer::handle_non_ascii_char`]: handles the non-ASCII/IME path without holding the +//! first char, while still allowing paste detection via retro-capture. +//! - Unmodified Tab joins detected bursts, including short Unicode prefixes, before popup dispatch. +//! Expired bursts are flushed first so manual Tab keeps its normal shortcut behavior. +//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called +//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a +//! normal typed character. +//! +//! # Input Disabled Mode +//! +//! The composer can be temporarily read-only (`input_enabled = false`). In that mode it ignores +//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the +//! overall state machine, since it affects which transitions are even possible from a given UI +//! state. +//! +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::key_hint::ShortcutHint; +use crate::key_hint::has_ctrl_or_alt; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::ui_consts::FOOTER_INDENT_COLS; +use codex_message_history::HistoryBatchCursor; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Margin; +use ratatui::layout::Rect; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Paragraph; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; + +use codex_protocol::openai_models::ReasoningEffort; + +use super::chat_composer_history::ChatComposerHistory; +use super::chat_composer_history::HistoryEntry; +use super::chat_composer_history::HistoryEntryResponse; +use super::chat_composer_history::HistorySearchResult; +use super::command_popup::CommandItem; +use super::effort_ignition::EffortIgnition; +use super::effort_ignition::EffortTier; +use super::effort_ignition::IGNITION_FRAME_TICK; +use super::effort_ignition::IgnitionStyle; +use super::effort_status_line::EFFORT_STATUS_LINE_FRAME_TICK; +use super::effort_status_line::EffortStatusLineTransition; +use super::file_search_popup::FileSearchPopup; +use super::footer::CollaborationModeIndicator; +use super::footer::FooterKeyHints; +use super::footer::FooterMode; +use super::footer::FooterProps; +use super::footer::GoalStatusIndicator; +use super::footer::SummaryLeft; +use super::footer::can_show_left_with_context; +use super::footer::context_window_line; +use super::footer::esc_hint_mode; +use super::footer::footer_height; +use super::footer::footer_hint_items_width; +use super::footer::footer_line_width; +use super::footer::inset_footer_hint_area; +use super::footer::max_left_width_for_right; +use super::footer::passive_footer_status_line; +use super::footer::render_context_right; +use super::footer::render_footer_from_props; +use super::footer::render_footer_hint_items; +use super::footer::render_footer_line; +use super::footer::reset_mode_after_activity; +use super::footer::side_conversation_context_line; +use super::footer::single_line_footer_layout; +use super::footer::status_line_right_indicator_line; +use super::footer::toggle_shortcut_mode; +use super::footer::uses_passive_footer_status_layout; +use super::mentions_v2::MentionV2Popup; +use super::mentions_v2::MentionV2Selection; +use super::paste_burst::CharDecision; +use super::paste_burst::PasteBurst; +use super::prompt_args::parse_slash_name; +use super::skill_popup::MentionItem; +use super::skill_popup::SkillPopup; +use super::slash_commands::ServiceTierCommand; +use super::slash_commands::SlashCommandItem; +use super::voice_strip::VoiceStrip; +use crate::bottom_pane::paste_burst::FlushResult; +use crate::history_cell::sanitize_user_text; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::EditorKeymap; +use crate::keymap::KeymapContext; +use crate::keymap::KeymapContextSet; +use crate::keymap::RuntimeKeymap; +use crate::keymap::VimNormalKeymap; +use crate::keymap::user_bindings; +use crate::onboarding::mark_underlined_hyperlink; +use crate::render::Insets; +use crate::render::RectExt; +use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::style::user_message_style; +use codex_protocol::ThreadId; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use codex_protocol::user_input::TextElement; + +mod agents_navigation; +mod attachment_state; +mod completion_target; +mod composer_layout; +mod draft_state; +mod footer_state; +mod history_search; +mod inline_input; +mod paste_input; +mod popup_state; +mod reconnect; +mod slash_input; +mod vim_history; +mod vim_search; + +use self::attachment_state::AttachmentState; +use self::draft_state::ComposerMentionBinding; +use self::draft_state::DraftState; +use self::footer_state::FooterState; +use self::history_search::HistorySearchSession; +use self::popup_state::ActivePopup; +use self::popup_state::DismissedToken; +use self::popup_state::PopupState; +use self::slash_input::SlashInput; +use self::slash_input::SlashValidation; +use self::slash_input::SubmissionValidation; +use self::vim_history::VimHistory; +use crate::app_event::AppEvent; +use crate::app_event::ConnectorsSnapshot; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::LocalImageAttachment; +use crate::bottom_pane::MentionBinding; +use crate::bottom_pane::textarea::KillBufferSnapshot; +use crate::bottom_pane::textarea::TextArea; +use crate::clipboard_paste::normalize_pasted_path; +use crate::clipboard_paste::pasted_image_format; +use crate::history_cell; +use crate::skills_helpers::skill_display_name; +use crate::tui::FrameRequester; +use crate::ui_consts::LIVE_PREFIX_COLS; +#[cfg(test)] +use codex_app_server_protocol::SkillInterface; +use codex_app_server_protocol::SkillMetadata; +use codex_connectors::AppInfo; +use codex_file_search::FileMatch; +#[cfg(test)] +use codex_plugin::AppConnectorId; +use codex_plugin::PluginCapabilitySummary; +use std::cell::OnceCell; +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::VecDeque; +use std::ops::Range; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use ratatui::style::Color; + +/// If the pasted content exceeds this number of characters, replace it with a +/// placeholder in the UI. +const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; + +fn user_input_too_large_message(actual_chars: usize) -> String { + format!( + "Message exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters ({actual_chars} provided)." + ) +} + +/// Result returned when the user interacts with the text area. +#[derive(Debug, PartialEq)] +pub enum InputResult { + Submitted { + text: String, + text_elements: Vec, + }, + Queued { + text: String, + text_elements: Vec, + action: QueuedInputAction, + pending_pastes: Vec<(String, String)>, + }, + /// A bare slash command parsed by the composer. + /// + /// Callers that dispatch this variant are also responsible for resolving any pending local + /// command-history entry that the composer staged before clearing the visible input. + Command(SlashCommand), + /// A bare model service-tier command parsed by the composer. + ServiceTierCommand(ServiceTierCommand), + /// An inline slash command and its trimmed argument text. + /// + /// The `TextElement` ranges are rebased into the argument string, while any pending local + /// command-history entry still represents the original command invocation that should be + /// committed only if dispatch accepts it. + CommandWithArgs(SlashCommand, String, Vec), + /// Agent-directed input was attempted while viewing a parent-owned spawned child thread. + ParentOwnedInputBlocked, + None, +} + +fn parent_owned_command_is_allowed(command: SlashCommand, args: &str) -> bool { + if command == SlashCommand::Export { + return true; + } + + args.is_empty() + && matches!( + command, + SlashCommand::Feedback + | SlashCommand::New + | SlashCommand::Clear + | SlashCommand::Resume + | SlashCommand::App + | SlashCommand::Side + | SlashCommand::Btw + | SlashCommand::Agents + | SlashCommand::MultiAgents + | SlashCommand::Vim + | SlashCommand::Keymap + | SlashCommand::ElevateSandbox + | SlashCommand::Experimental + | SlashCommand::Memories + | SlashCommand::Quit + | SlashCommand::Exit + | SlashCommand::Logout + | SlashCommand::Copy + | SlashCommand::Raw + | SlashCommand::Diff + | SlashCommand::Mention + | SlashCommand::Skills + | SlashCommand::Import + | SlashCommand::Hooks + | SlashCommand::Status + | SlashCommand::Daemon + | SlashCommand::Usage + | SlashCommand::Ide + | SlashCommand::DebugConfig + | SlashCommand::Title + | SlashCommand::Statusline + | SlashCommand::Theme + | SlashCommand::Pets + | SlashCommand::Ps + | SlashCommand::Stop + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate + | SlashCommand::Mcp + | SlashCommand::Apps + | SlashCommand::Plugins + | SlashCommand::Rollout + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueuedInputAction { + Plain, + /// Preserve model-input provenance when paste expansion reveals a leading shell sigil. + Literal, + ParseSlash, + RunShell, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PendingPasteHandling { + Expand, + Preserve, +} + +/// Shared composer behavior; defaults match the main chat input. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ChatComposerConfig { + /// Whether command/file/skill popups are allowed to appear. + pub(crate) popups_enabled: bool, + /// Whether `/...` input is parsed and dispatched as slash commands. + pub(crate) slash_commands_enabled: bool, + /// Whether a leading `!` switches the editor into shell mode. + pub(crate) shell_commands_enabled: bool, + /// Whether pasting a file path can attach local images. + pub(crate) image_paste_enabled: bool, + /// Strip leading and trailing whitespace from submissions. + pub(crate) trim_submission: bool, + /// Embedded editors reset Vim only when their owner accepts the answer. + pub(crate) reset_vim_on_submission: bool, +} + +impl Default for ChatComposerConfig { + fn default() -> Self { + Self { + popups_enabled: true, + slash_commands_enabled: true, + shell_commands_enabled: true, + image_paste_enabled: true, + trim_submission: true, + reset_vim_on_submission: true, + } + } +} + +impl ChatComposerConfig { + /// A minimal preset for plain-text inputs embedded in other surfaces. + /// + /// This disables popups, slash and shell commands, and image-path attachment behavior + /// so the composer behaves like a simple notes field. + pub(crate) const fn plain_text() -> Self { + Self { + popups_enabled: false, + slash_commands_enabled: false, + shell_commands_enabled: false, + image_paste_enabled: false, + trim_submission: true, + reset_vim_on_submission: true, + } + } +} + +pub(crate) struct ChatComposer { + draft: DraftState, + popups: PopupState, + app_event_tx: AppEventSender, + history: ChatComposerHistory, + agents_navigation_enabled: bool, + footer: FooterState, + has_focus: bool, + frame_requester: Option, + effort_tier: Option, + effort_animation_style: Option, + effort_ignition: Option, + voice_strip: Option, + effort_status_line_transition: Option, + effort_observed: bool, + luna_reserve_active: bool, + attachments: AttachmentState, + placeholder_text: String, + blocks_direct_input: bool, + is_task_running: bool, + queue_submissions: bool, + /// Slash-command draft staged for local recall after application-level dispatch. + /// + /// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can + /// prepare their argument text without also double-recording the full command invocation. + pending_slash_command_history: Option, + skills: Option>, + plugins: Option>, + task_mentions: Option>, + connectors_snapshot: Option, + collaboration_modes_enabled: bool, + config: ChatComposerConfig, + connectors_enabled: bool, + plugins_command_enabled: bool, + token_activity_command_enabled: bool, + service_tier_commands_enabled: bool, + service_tier_commands: Vec, + mentions_v2_enabled: bool, + goal_command_enabled: bool, + voice_command_enabled: bool, + worktrees_enabled: bool, + windows_degraded_sandbox_active: bool, + side_conversation_active: bool, + history_search: Option, + vim_history: VimHistory, + submit_keys: Vec, + queue_keys: Vec, + toggle_shortcuts_keys: Vec, + history_search_previous_keys: Vec, + history_search_next_keys: Vec, + editor_keymap: Arc, + vim_normal_keymap: VimNormalKeymap, +} + +/// A resolved legacy `$` target plus any catalog built while disambiguating shell syntax. +struct MentionCompletionTarget { + range: Range, + query: String, + prebuilt_mentions: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub(super) struct ComposerDraft { + pub(super) text: String, + text_elements: Vec, + local_image_paths: Vec, + remote_image_urls: Vec, + mention_bindings: Vec, + pending_pastes: Vec<(String, String)>, + cursor: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ComposerDraftSnapshot { + pub(crate) text: String, + pub(crate) cursor: usize, + pub(crate) text_elements: Vec, + pub(crate) local_images: Vec, + pub(crate) remote_image_urls: Vec, + pub(crate) mention_bindings: Vec, + pub(crate) pending_pastes: Vec<(String, String)>, + pub(crate) startup_local_history: Vec, + pub(crate) last_composer_activity_at: Option, +} + +const FOOTER_SPACING_HEIGHT: u16 = 0; + +impl ChatComposer { + fn slash_input(&self) -> SlashInput<'_> { + SlashInput::new( + self.slash_commands_enabled(), + self.draft.is_bash_mode, + self.builtin_command_flags(), + &self.service_tier_commands, + ) + } + + pub fn new( + has_input_focus: bool, + app_event_tx: AppEventSender, + enhanced_keys_supported: bool, + placeholder_text: String, + disable_paste_burst: bool, + ) -> Self { + Self::new_with_config( + has_input_focus, + app_event_tx, + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + ChatComposerConfig::default(), + ) + } + + /// Construct a composer with explicit feature gating. + /// + /// This enables reuse in contexts like request-user-input where we want + /// the same visuals and editing behavior without slash commands or popups. + pub(crate) fn new_with_config( + has_input_focus: bool, + app_event_tx: AppEventSender, + enhanced_keys_supported: bool, + placeholder_text: String, + disable_paste_burst: bool, + config: ChatComposerConfig, + ) -> Self { + let use_shift_enter_hint = enhanced_keys_supported; + let default_keymap = RuntimeKeymap::defaults(); + let default_editor_keymap = default_keymap.editor.clone(); + let default_vim_normal_keymap = default_keymap.vim_normal.clone(); + + let mut this = Self { + draft: DraftState::new(), + popups: PopupState::default(), + app_event_tx, + history: ChatComposerHistory::new(), + agents_navigation_enabled: false, + footer: FooterState { + quit_shortcut_expires_at: None, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + esc_backtrack_hint: false, + use_shift_enter_hint, + mode: FooterMode::ComposerEmpty, + hint_override: None, + flash: None, + context_window_percent: None, + context_window_used_tokens: None, + context_window_pending: false, + collaboration_mode_indicator: None, + goal_status_indicator: None, + ide_context_active: false, + status_line_value: None, + status_line_hyperlink_url: None, + status_line_enabled: false, + side_conversation_context_label: None, + active_agent_label: None, + external_editor_key: default_keymap + .primary_hint(KeymapContext::Global, "open_external_editor"), + show_transcript_key: default_keymap + .primary_hint(KeymapContext::Global, "open_transcript"), + insert_newline_key: footer_insert_newline_key( + &default_keymap.editor.insert_newline, + use_shift_enter_hint, + ) + .map(ShortcutHint::from), + queue_key: default_keymap.primary_hint(KeymapContext::Composer, "queue"), + toggle_shortcuts_key: default_keymap + .primary_hint(KeymapContext::Composer, "toggle_shortcuts"), + history_search_key: default_keymap + .primary_hint(KeymapContext::Composer, "history_search_previous"), + reasoning_down_key: default_keymap + .primary_hint(KeymapContext::Chat, "decrease_reasoning_effort"), + reasoning_up_key: default_keymap + .primary_hint(KeymapContext::Chat, "increase_reasoning_effort"), + }, + has_focus: has_input_focus, + frame_requester: None, + effort_tier: None, + effort_animation_style: None, + effort_ignition: None, + voice_strip: None, + effort_status_line_transition: None, + effort_observed: false, + luna_reserve_active: false, + attachments: AttachmentState::default(), + placeholder_text, + blocks_direct_input: false, + is_task_running: false, + queue_submissions: false, + pending_slash_command_history: None, + skills: None, + plugins: None, + task_mentions: None, + connectors_snapshot: None, + collaboration_modes_enabled: false, + config, + connectors_enabled: false, + plugins_command_enabled: false, + token_activity_command_enabled: false, + service_tier_commands_enabled: false, + service_tier_commands: Vec::new(), + mentions_v2_enabled: false, + goal_command_enabled: false, + voice_command_enabled: false, + worktrees_enabled: false, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + history_search: None, + vim_history: VimHistory::default(), + submit_keys: vec![key_hint::plain(KeyCode::Enter)], + queue_keys: vec![key_hint::plain(KeyCode::Tab)], + toggle_shortcuts_keys: vec![ + key_hint::plain(KeyCode::Char('?')), + key_hint::shift(KeyCode::Char('?')), + ], + history_search_previous_keys: default_keymap.composer.history_search_previous.clone(), + history_search_next_keys: default_keymap.composer.history_search_next.clone(), + editor_keymap: default_editor_keymap, + vim_normal_keymap: default_vim_normal_keymap, + }; + this.draft.textarea.set_keymap_bindings(&default_keymap); + // Apply configuration via the setter to keep side-effects centralized. + this.set_disable_paste_burst(disable_paste_burst); + this + } + + pub(crate) fn set_frame_requester(&mut self, frame_requester: FrameRequester) { + self.frame_requester = Some(frame_requester); + } + + /// Records the effective reasoning tier, captures the outgoing status + /// line, and queues the one-shot effects for a genuine Max/Ultra change + /// after the initial baseline. + pub(crate) fn set_active_reasoning_effort( + &mut self, + effort: Option<&ReasoningEffort>, + animations_enabled: bool, + ) -> bool { + let tier = EffortTier::from_effort(effort); + let is_baseline = !self.effort_observed; + self.effort_observed = true; + if self.effort_tier == tier { + return false; + } + self.effort_tier = tier; + self.effort_ignition = None; + self.effort_status_line_transition = None; + if let Some(tier) = tier + && !is_baseline + && animations_enabled + { + let style = IgnitionStyle::random(self.effort_animation_style); + self.effort_ignition = Some(EffortIgnition::new(tier, style)); + self.effort_animation_style = Some(style); + if self.footer.status_line_enabled + && let Some(previous) = passive_footer_status_line(&self.footer_props()) + { + self.effort_status_line_transition = + Some(EffortStatusLineTransition::new(tier, previous)); + } + if let Some(frame_requester) = &self.frame_requester { + frame_requester.schedule_frame(); + } + } + true + } + + /// Establishes the current tier without retaining or starting a one-shot effect. + pub(crate) fn set_active_reasoning_effort_baseline( + &mut self, + effort: Option<&ReasoningEffort>, + ) { + self.effort_tier = EffortTier::from_effort(effort); + self.effort_observed = true; + self.effort_ignition = None; + self.effort_status_line_transition = None; + } + + pub fn set_skill_mentions(&mut self, skills: Option>) { + self.skills = skills; + self.refresh_mentions_v2_popup_candidates(); + self.sync_popups(); + } + + pub fn set_plugin_mentions(&mut self, plugins: Option>) { + self.plugins = plugins; + self.refresh_mentions_v2_popup_candidates(); + self.sync_popups(); + } + + pub(crate) fn set_task_mentions_enabled(&mut self, enabled: bool) { + self.task_mentions = enabled.then(Vec::new); + self.refresh_mentions_v2_popup_candidates(); + if enabled + && matches!(self.popups.active, ActivePopup::MentionV2(_)) + && let Some(query) = self.current_mentions_v2_token() + && !query.is_empty() + { + self.app_event_tx.send(AppEvent::StartFileSearch(query)); + } + } + + pub(crate) fn task_mentions_enabled(&self) -> bool { + self.task_mentions.is_some() + } + + pub(crate) fn on_task_search_result( + &mut self, + query: &str, + mut matches: Vec, + ) { + if self.current_mentions_v2_token().as_deref() != Some(query) { + return; + } + let mentioned_ids = self + .current_mention_elements() + .into_iter() + .filter_map(|(id, _, _)| self.draft.mention_bindings.get(&id)) + .filter_map(|binding| crate::task_mentions::valid_thread_path(&binding.path)) + .collect::>(); + matches.retain(|task| { + mentioned_ids.len() < crate::task_mentions::MAX_REFERENCED_TASKS + && !mentioned_ids.contains(task.thread_id.as_str()) + }); + let Some(task_mentions) = &mut self.task_mentions else { + return; + }; + *task_mentions = matches; + self.refresh_mentions_v2_popup_candidates(); + } + + /// Refreshes an open mention catalog when skill or plugin metadata changes. + fn refresh_mentions_v2_popup_candidates(&mut self) { + let ActivePopup::MentionV2(popup) = &mut self.popups.active else { + return; + }; + popup.set_candidates(super::mentions_v2::build_search_catalog( + self.skills.as_deref(), + self.plugins.as_deref(), + self.task_mentions.as_deref().unwrap_or_default(), + )); + } + + pub fn set_plugins_command_enabled(&mut self, enabled: bool) { + self.plugins_command_enabled = enabled; + } + + pub fn set_token_activity_command_enabled(&mut self, enabled: bool) { + self.token_activity_command_enabled = enabled; + } + + pub fn set_mentions_v2_enabled(&mut self, enabled: bool) { + self.mentions_v2_enabled = enabled; + self.history.set_at_mention_restore_enabled(enabled); + self.sync_popups(); + } + + /// Toggle composer-side image paste handling. + /// + /// This only affects whether image-like paste content is converted into attachments; the + /// `ChatWidget` layer still performs capability checks before images are submitted. + pub fn set_image_paste_enabled(&mut self, enabled: bool) { + self.config.image_paste_enabled = enabled; + } + + pub fn set_connector_mentions(&mut self, connectors_snapshot: Option) { + self.connectors_snapshot = connectors_snapshot; + self.sync_popups(); + } + + pub(crate) fn take_mention_bindings(&mut self) -> Vec { + let elements = self.current_mention_elements(); + let mut ordered = Vec::new(); + for (id, sigil, mention) in elements { + if let Some(binding) = self.draft.mention_bindings.remove(&id) + && binding.sigil == sigil + && binding.mention == mention + { + ordered.push(MentionBinding { + sigil: binding.sigil, + mention: binding.mention, + path: binding.path, + }); + } + } + self.draft.mention_bindings.clear(); + ordered + } + + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.collaboration_modes_enabled = enabled; + } + + pub fn set_connectors_enabled(&mut self, enabled: bool) { + self.connectors_enabled = enabled; + } + + pub fn set_service_tier_commands_enabled(&mut self, enabled: bool) { + self.service_tier_commands_enabled = enabled; + } + + pub fn set_service_tier_commands(&mut self, commands: Vec) { + self.service_tier_commands = commands; + self.sync_popups(); + } + + pub fn set_goal_command_enabled(&mut self, enabled: bool) { + self.goal_command_enabled = enabled; + } + + pub fn set_voice_command_enabled(&mut self, enabled: bool) { + self.voice_command_enabled = enabled; + } + + /// Replace composer, editor, and footer-hint key bindings from one runtime snapshot. + /// + /// Submit and queue bindings are cached here because composer dispatch must + /// check them before generic textarea editing. The embedded textarea receives + /// the same snapshot's editor bindings so a live remap cannot leave submit + /// keys updated while cursor/editing keys still use old defaults. + pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + self.submit_keys = keymap.composer.submit.clone(); + self.queue_keys = keymap.composer.queue.clone(); + self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone(); + self.history_search_previous_keys = keymap.composer.history_search_previous.clone(); + self.history_search_next_keys = keymap.composer.history_search_next.clone(); + self.editor_keymap = keymap.editor.clone(); + self.vim_normal_keymap = keymap.vim_normal.clone(); + self.draft.textarea.set_keymap_bindings(keymap); + self.footer.external_editor_key = + keymap.primary_hint(KeymapContext::Global, "open_external_editor"); + self.footer.show_transcript_key = + keymap.primary_hint(KeymapContext::Global, "open_transcript"); + self.footer.insert_newline_key = + match keymap.primary_hint(KeymapContext::Editor, "insert_newline") { + hint @ Some(ShortcutHint::Chord { .. }) => hint, + _ => footer_insert_newline_key( + &keymap.editor.insert_newline, + self.footer.use_shift_enter_hint, + ) + .map(ShortcutHint::from), + }; + self.footer.queue_key = keymap.primary_hint(KeymapContext::Composer, "queue"); + self.footer.toggle_shortcuts_key = + keymap.primary_hint(KeymapContext::Composer, "toggle_shortcuts"); + self.footer.history_search_key = + keymap.primary_hint(KeymapContext::Composer, "history_search_previous"); + self.footer.reasoning_down_key = + keymap.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort"); + self.footer.reasoning_up_key = + keymap.primary_hint(KeymapContext::Chat, "increase_reasoning_effort"); + } + + /// Return the contexts whose handlers can consume the next composer key. + pub(crate) fn keymap_contexts(&self) -> KeymapContextSet { + if self.draft.textarea.vim_query().is_some() { + return KeymapContextSet::new(KeymapContext::Editor); + } + if self.history_search.is_some() { + return KeymapContextSet::new(KeymapContext::Composer); + } + if !self.draft.input_enabled || self.popups.active() { + return KeymapContextSet::default(); + } + let contexts = self.draft.textarea.keymap_contexts(); + contexts.with(KeymapContext::Composer) + } + + pub fn set_collaboration_mode_indicator( + &mut self, + indicator: Option, + ) { + self.footer.collaboration_mode_indicator = indicator; + } + + pub fn set_goal_status_indicator(&mut self, indicator: Option) { + self.footer.goal_status_indicator = indicator; + } + + pub fn set_ide_context_active(&mut self, active: bool) { + self.footer.ide_context_active = active; + } + + pub fn set_side_conversation_active(&mut self, active: bool) { + self.side_conversation_active = active; + } + + /// Compatibility shim for tests that still toggle the removed steer mode flag. + #[cfg(test)] + pub fn set_steer_enabled(&mut self, _enabled: bool) {} + /// Centralized feature gating keeps config checks out of call sites. + fn popups_enabled(&self) -> bool { + self.config.popups_enabled + } + + fn slash_commands_enabled(&self) -> bool { + self.config.slash_commands_enabled + } + + fn image_paste_enabled(&self) -> bool { + self.config.image_paste_enabled + } + #[cfg(target_os = "windows")] + pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) { + self.windows_degraded_sandbox_active = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 4] { + self.layout_areas_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) + } + + fn layout_areas_with_textarea_right_reserve( + &self, + area: Rect, + textarea_right_reserve: u16, + ) -> [Rect; 4] { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(&footer_props)); + let footer_total_height = footer_hint_height + Self::footer_spacing(footer_hint_height); + let popup_height = self + .popups + .active + .required_height(area.width, footer_total_height); + let popup_constraint = Constraint::Max(popup_height); + let voice_rows = if self.voice_strip.is_some() { 3 } else { 0 }; + let [composer_rect, popup_rect] = + Layout::vertical([Constraint::Min(3 + voice_rows), popup_constraint]).areas(area); + // Keep the draft visible when clipped. + let voice_rows = voice_rows * u16::from(composer_rect.height >= 6); + let mut textarea_rect = composer_rect.inset(Insets::tlbr( + /*top*/ 1 + voice_rows, + LIVE_PREFIX_COLS, + /*bottom*/ 1, + /*right*/ 1u16.saturating_add(textarea_right_reserve), + )); + let remote_images_height = self + .attachments + .remote_image_lines() + .len() + .try_into() + .unwrap_or(u16::MAX) + .min(textarea_rect.height.saturating_sub(1)); + let remote_images_separator = u16::from(remote_images_height > 0); + let consumed = remote_images_height.saturating_add(remote_images_separator); + let remote_images_rect = Rect { + x: textarea_rect.x, + y: textarea_rect.y, + width: textarea_rect.width, + height: remote_images_height, + }; + textarea_rect.y = textarea_rect.y.saturating_add(consumed); + textarea_rect.height = textarea_rect.height.saturating_sub(consumed); + [composer_rect, remote_images_rect, textarea_rect, popup_rect] + } + + fn footer_spacing(footer_hint_height: u16) -> u16 { + if footer_hint_height == 0 { + 0 + } else { + FOOTER_SPACING_HEIGHT + } + } + + pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.cursor_pos_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) + } + + pub(crate) fn cursor_pos_with_textarea_right_reserve( + &self, + area: Rect, + textarea_right_reserve: u16, + ) -> Option<(u16, u16)> { + if !self.draft.input_enabled || self.attachments.selected_remote_image_index.is_some() { + return None; + } + + if let Some(pos) = self + .vim_search_cursor_pos(area) + .or_else(|| self.history_search_cursor_pos(area)) + { + return Some(pos); + } + + let [_, _, textarea_rect, _] = + self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve); + let state = *self.draft.textarea_state.borrow(); + self.draft + .textarea + .cursor_pos_with_state(textarea_rect, state) + } + /// Returns true if the composer currently contains no user-entered input. + pub(crate) fn is_empty(&self) -> bool { + self.draft.textarea.is_empty() && !self.draft.is_bash_mode && self.attachments.is_empty() + } + + /// Record local persistent-history metadata so the composer can navigate + /// cross-session history. + pub(crate) fn set_history_metadata( + &mut self, + thread_id: ThreadId, + log_id: u64, + entry_count: usize, + ) { + self.history.set_metadata(thread_id, log_id, entry_count); + } + + /// Integrate an asynchronous response to an on-demand history lookup. + /// + /// If the entry is present and the offset still matches the active history cursor, the + /// composer rehydrates the entry immediately. This path intentionally routes through + /// [`Self::apply_history_entry`] so cursor placement remains aligned with keyboard history + /// recall semantics. + pub(crate) fn on_history_entry_response( + &mut self, + log_id: u64, + offset: usize, + entry: Option, + ) -> bool { + match self + .history + .on_entry_response(log_id, offset, entry, &self.app_event_tx) + { + HistoryEntryResponse::Found(entry) => { + // Persistent ↑/↓ history is text-only (backwards-compatible and avoids persisting + // attachments), but local in-session ↑/↓ history can rehydrate elements and image paths. + self.apply_history_entry(entry); + true + } + HistoryEntryResponse::Search(result) => { + self.apply_history_search_result(result); + true + } + HistoryEntryResponse::Ignored => false, + } + } + + pub(crate) fn on_history_batch_response( + &mut self, + log_id: u64, + cursor: HistoryBatchCursor, + entries: Vec, + next_older_cursor: Option, + ) -> bool { + let result = self.history.on_batch_response( + log_id, + cursor, + entries, + next_older_cursor, + &self.app_event_tx, + ); + self.apply_history_batch_result(result) + } + + /// Applies a failed batch lookup without conflating it with history exhaustion. + /// + /// The history state machine either schedules a bounded retry, preserves an existing match, or + /// returns the search UI to its idle draft when no match has been selected yet. + pub(crate) fn on_history_batch_error( + &mut self, + log_id: u64, + cursor: HistoryBatchCursor, + ) -> bool { + let result = self + .history + .on_batch_error(log_id, cursor, &self.app_event_tx); + self.apply_history_batch_result(result) + } + + fn apply_history_batch_result(&mut self, result: Option) -> bool { + let Some(result) = result else { + return false; + }; + self.apply_history_search_result(result); + true + } + + pub(crate) fn record_replayed_user_message_history(&mut self, entry: HistoryEntry) { + self.history.record_replayed_submission(entry); + } + + /// Integrate pasted text into the composer. + /// + /// Acts as the only place where paste text is integrated, both for: + /// + /// - Real/explicit paste events surfaced by the terminal, and + /// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers + /// and later flushes here. + /// + /// Behavior: + /// + /// - If history search is active, inserts nonempty text into its query and ignores empty pastes. + /// - If Vim search is active, inserts text into its query. + /// - Otherwise, if the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a + /// placeholder element (expanded on submit) and stores the full text in `pending_pastes`. + /// - Otherwise, if the paste looks like an image path, attaches the image and inserts a + /// trailing space so the user can keep typing naturally. + /// - Otherwise, inserts the pasted text directly into the textarea. + /// + /// For composer edits, clears any paste-burst Enter suppression state so a real paste cannot + /// affect the next user Enter key, then syncs popup state. + pub fn handle_paste(&mut self, pasted: String) -> bool { + let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); + let pasted = sanitize_user_text(pasted.into()); + if self.history_search.is_some() { + if !pasted.is_empty() { + self.update_history_search_query(|query| query.push_str(&pasted)); + } + return true; + } + if let Some(query) = self.draft.textarea.vim_query_mut() { + query.editor.insert_str(&pasted); + return true; + } + let started_vim_edit = self.begin_direct_vim_edit(); + let char_count = pasted.chars().count(); + if char_count > LARGE_PASTE_CHAR_THRESHOLD { + let placeholder = self.next_large_paste_placeholder(char_count); + self.draft.textarea.insert_element(&placeholder); + self.draft + .pending_pastes + .push((placeholder, pasted.into_owned())); + } else if char_count > 1 + && self.image_paste_enabled() + && self.handle_paste_image_path(&pasted) + { + let cursor = self.draft.textarea.cursor(); + self.draft.textarea.insert_str_at(cursor, " "); + } else { + self.insert_str(&pasted); + } + self.draft.paste_burst.clear_after_explicit_paste(); + self.sync_popups(); + if started_vim_edit { + self.finish_vim_edit(); + } + true + } + + pub fn handle_paste_image_path(&mut self, pasted: &str) -> bool { + let Some(path_buf) = normalize_pasted_path(pasted) else { + return false; + }; + + // normalize_pasted_path already handles Windows → WSL path conversion, + // so we can directly try to read the image dimensions. + match image::image_dimensions(&path_buf) { + Ok((width, height)) => { + tracing::info!("OK: {pasted}"); + tracing::debug!("image dimensions={}x{}", width, height); + let format = pasted_image_format(&path_buf); + tracing::debug!("attached image format={}", format.label()); + self.attach_image(path_buf); + true + } + Err(err) => { + tracing::trace!("ERR: {err}"); + false + } + } + } + + /// Enable or disable paste-burst handling. + /// + /// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic + /// is unwanted or has already been handled elsewhere. + /// + /// When transitioning from enabled → disabled, we "defuse" any in-flight burst state so it + /// cannot affect subsequent normal typing: + /// + /// - First, flush any held/buffered text immediately via + /// [`PasteBurst::flush_before_modified_input`], and feed it through `handle_paste(String)`. + /// This preserves user input and routes it through the same integration path as explicit + /// pastes (large-paste placeholders, image-path detection, and popup sync). + /// - Then clear the burst timing and Enter-suppression window via + /// [`PasteBurst::clear_after_explicit_paste`]. + /// + /// We intentionally do not use `clear_window_after_non_char()` here: it clears timing state + /// without emitting any buffered text, which can leave a non-empty buffer unable to flush + /// later (because `flush_if_due()` relies on `last_plain_char_time` to time out). + pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { + let was_disabled = self.draft.disable_paste_burst; + self.draft.disable_paste_burst = disabled; + if disabled && !was_disabled { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + self.draft.paste_burst.clear_after_explicit_paste(); + } + } + + /// Replace the composer content with text from an external editor. + /// Clears pending paste placeholders and keeps only attachments whose + /// placeholder labels still appear in the new text. Image placeholders + /// are renumbered to `[Image #M+1]..[Image #N]` (where `M` is the number of + /// remote images). Cursor is placed at the end after rebuilding elements. + pub(crate) fn apply_external_edit(&mut self, text: String) { + self.vim_history = VimHistory::default(); + self.draft.pending_pastes.clear(); + let (text, _) = self.imported_text_for_textarea(text, Vec::new()); + + // Count placeholder occurrences in the new text. + let mut placeholder_counts: HashMap = HashMap::new(); + for placeholder in self + .attachments + .local_images + .iter() + .map(|image| &image.placeholder) + { + if placeholder_counts.contains_key(placeholder) { + continue; + } + let count = text.match_indices(placeholder).count(); + if count > 0 { + placeholder_counts.insert(placeholder.clone(), count); + } + } + + // Keep attachments only while we have matching occurrences left. + let mut kept_images = Vec::new(); + for img in self.attachments.local_images.drain(..) { + if let Some(count) = placeholder_counts.get_mut(&img.placeholder) + && *count > 0 + { + *count -= 1; + kept_images.push(img); + } + } + self.attachments.local_images = kept_images; + + // Import literally so placeholders remain atomic and Replace recovery starts empty. + self.draft.textarea.set_text_clearing_elements(""); + let mut remaining: HashMap<&str, usize> = HashMap::new(); + for img in &self.attachments.local_images { + *remaining.entry(img.placeholder.as_str()).or_insert(0) += 1; + } + + let mut occurrences: Vec<(usize, &str)> = Vec::new(); + for placeholder in remaining.keys() { + for (pos, _) in text.match_indices(placeholder) { + occurrences.push((pos, *placeholder)); + } + } + occurrences.sort_unstable_by_key(|(pos, _)| *pos); + + let mut idx = 0usize; + for (pos, ph) in occurrences { + let Some(count) = remaining.get_mut(ph) else { + continue; + }; + if *count == 0 { + continue; + } + if pos > idx { + self.draft.textarea.insert_str_at(idx, &text[idx..pos]); + } + self.draft.textarea.insert_element(ph); + *count -= 1; + idx = pos + ph.len(); + } + if idx < text.len() { + self.draft.textarea.insert_str_at(idx, &text[idx..]); + } + + // Keep local image placeholders normalized in attachment order after the + // remote-image prefix. + self.attachments + .relabel_local_images(&mut self.draft.textarea); + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); + self.sync_popups(); + } + + /// Enable or disable Vim editing for the composer textarea. + /// + /// The composer flushes buffered typing and clears paste-burst state when the mode + /// changes because Vim normal mode treats rapid character sequences as + /// commands, not as candidate literal paste text. It also resets transient + /// footer mode so the visible hints match the new editing surface. + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + self.draft.textarea.enable_vim_search(); + self.draft.textarea.set_vim_enabled(enabled); + self.vim_history = VimHistory::default(); + self.draft.paste_burst.clear_after_explicit_paste(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); + } + + /// Enable Vim while keeping already-active text entry in insert mode. + pub(crate) fn enable_vim_in_insert_mode(&mut self) { + self.set_vim_enabled(/*enabled*/ true); + self.resume_text_entry(); + } + + /// Resume text entry after a parent view takes focus, preserving Vim undo history. + pub(crate) fn resume_text_entry(&mut self) { + self.draft.textarea.enter_vim_insert_mode(); + } + + pub(crate) fn take_kill_buffer_snapshot(&mut self) -> KillBufferSnapshot { + self.draft.textarea.take_kill_buffer_snapshot() + } + + pub(crate) fn restore_kill_buffer_snapshot(&mut self, snapshot: KillBufferSnapshot) { + self.draft.textarea.restore_kill_buffer_snapshot(snapshot); + } + + /// Restore draft history transferred from the startup composer. + pub(crate) fn restore_startup_local_history( + &mut self, + startup_local_history: Vec, + ) { + for entry in startup_local_history { + self.history.record_local_submission(entry); + } + } + + /// Toggle Vim editing and return the new enabled state. + /// + /// This is the app-level command target for the configurable Vim toggle + /// keybinding; callers should use the returned value for status messages + /// instead of rereading state after additional composer mutations. + pub(crate) fn toggle_vim_enabled(&mut self) -> bool { + let enabled = !self.draft.textarea.is_vim_enabled(); + self.set_vim_enabled(enabled); + enabled + } + + /// Return whether Vim editing is enabled. + pub(crate) fn is_vim_enabled(&self) -> bool { + self.draft.textarea.is_vim_enabled() + } + + /// Return whether Escape should be routed to the textarea before popups. + /// + /// Vim insert mode owns Escape as a transition back to normal mode. The app + /// event layer asks this before running generic Escape behavior so the same + /// key does not both leave insert mode and dismiss unrelated UI. + pub(crate) fn should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool { + self.draft + .textarea + .should_handle_vim_insert_escape(key_event) + } + + pub(crate) fn vim_mode_indicator_span(&self) -> Option> { + self.draft.textarea.vim_mode_indicator_span() + } + + fn mode_indicator_line(&self, show_cycle_hint: bool) -> Option> { + let mut spans: Vec> = Vec::new(); + if let Some(vim_mode) = self.vim_mode_indicator_span() { + spans.push(vim_mode); + } + if let Some(indicators) = status_line_right_indicator_line( + self.footer.collaboration_mode_indicator, + self.footer.goal_status_indicator.as_ref(), + self.footer.ide_context_active, + show_cycle_hint, + ) { + if !spans.is_empty() { + spans.push(" | ".dim()); + } + spans.extend(indicators.spans); + } + if spans.is_empty() { + None + } else { + Some(Line::from(spans)) + } + } + + fn right_footer_line_with_context(&self) -> Line<'static> { + let mut line = if self.footer.context_window_pending { + Line::default() + } else { + context_window_line( + self.footer.context_window_percent, + self.footer.context_window_used_tokens, + ) + }; + if let Some(vim_mode) = self.vim_mode_indicator_span() { + line.spans.push(" | ".dim()); + line.spans.push(vim_mode); + } + line + } + + pub(crate) fn current_text_with_pending(&self) -> String { + let text = self.current_text(); + if self.draft.pending_pastes.is_empty() { + return text; + } + + let (text, _) = Self::expand_pending_pastes( + &text, + self.current_text_elements(), + &self.draft.pending_pastes, + ); + text + } + + /// Returns whether the composer currently accepts interactive draft edits. + pub(crate) fn input_enabled(&self) -> bool { + self.draft.input_enabled + } + + pub(crate) fn pending_pastes(&self) -> Vec<(String, String)> { + self.draft.pending_pastes.clone() + } + + pub(crate) fn set_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) { + let text = self.current_text(); + self.draft.pending_pastes = pending_pastes + .into_iter() + .filter(|(placeholder, _)| text.contains(placeholder)) + .collect(); + } + + /// Override the footer hint items displayed beneath the composer. Passing + /// `None` restores the default shortcut footer. + pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + self.footer.hint_override = items; + } + + pub(crate) fn set_remote_image_urls(&mut self, urls: Vec) { + self.attachments + .set_remote_image_urls(urls, &mut self.draft.textarea); + self.sync_popups(); + } + + pub(crate) fn remote_image_urls(&self) -> Vec { + self.attachments.remote_image_urls() + } + + pub(crate) fn take_remote_image_urls(&mut self) -> Vec { + let urls = self + .attachments + .take_remote_image_urls(&mut self.draft.textarea); + self.sync_popups(); + urls + } + + /// Replace the entire composer content with `text` and reset cursor. + /// + /// This is the "fresh draft" path: it clears pending paste payloads and + /// mention link targets. Callers restoring a previously submitted draft + /// that must keep sigiled mention target resolution should use + /// [`Self::set_text_content_with_mention_bindings`] instead. + pub(crate) fn set_text_content( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.set_text_content_with_mention_bindings( + text, + text_elements, + local_image_paths, + Vec::new(), + ); + } + + /// Restore draft content; clear pending input and undo history. The cursor starts at zero. + pub(crate) fn set_text_content_with_mention_bindings( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + mention_bindings: Vec, + ) { + // Clear any existing content, placeholders, and attachments first. + self.footer.flash = None; + self.vim_history = VimHistory::default(); + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; + self.draft.pending_pastes.clear(); + self.draft.mention_bindings.clear(); + + let (text, text_elements) = self.imported_text_for_textarea(text, text_elements); + self.draft + .textarea + .set_text_with_elements(&text, &text_elements); + self.attachments + .reset_local_images(local_image_paths, &mut self.draft.textarea); + + self.bind_mentions_from_snapshot(mention_bindings); + self.draft.textarea.set_cursor(/*pos*/ 0); + self.sync_popups(); + } + + pub(crate) fn current_cursor(&self) -> usize { + self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 } + } + + #[cfg(test)] + pub(crate) fn cursor(&self) -> usize { + self.current_cursor() + } + + fn history_navigation_cursor(&self) -> usize { + if self.draft.is_bash_mode && self.draft.textarea.cursor() == 0 { + 0 + } else if self.draft.textarea.is_vim_normal_mode() + && !self.draft.textarea.text().is_empty() + && self.draft.textarea.cursor() == self.draft.textarea.vim_normal_end_cursor() + { + self.current_text().len() + } else { + self.current_cursor() + } + } + + pub(crate) fn set_current_cursor(&mut self, cursor: usize) { + let visible_cursor = if self.draft.is_bash_mode { + cursor.saturating_sub(1) + } else { + cursor + }; + self.draft + .textarea + .set_cursor(visible_cursor.min(self.draft.textarea.text().len())); + } + + fn current_text_elements(&self) -> Vec { + let shift = if self.draft.is_bash_mode { 1 } else { 0 }; + self.draft + .textarea + .text_elements() + .into_iter() + .filter_map(|element| Self::shift_text_element(element, shift)) + .collect() + } + + fn shift_text_element(element: TextElement, shift: isize) -> Option { + let start = element.byte_range.start.checked_add_signed(shift)?; + let end = element.byte_range.end.checked_add_signed(shift)?; + if start >= end { + return None; + } + + Some(element.map_range(|_| (start..end).into())) + } + + pub(super) fn snapshot_draft(&self) -> ComposerDraft { + ComposerDraft { + text: self.current_text(), + text_elements: self.current_text_elements(), + local_image_paths: self.attachments.local_image_paths(), + remote_image_urls: self.attachments.remote_image_urls(), + mention_bindings: self.snapshot_mention_bindings(), + pending_pastes: self.draft.pending_pastes.clone(), + cursor: self.current_cursor(), + } + } + + pub(super) fn restore_draft(&mut self, draft: ComposerDraft) { + let ComposerDraft { + text, + text_elements, + local_image_paths, + remote_image_urls, + mention_bindings, + pending_pastes, + cursor, + } = draft; + self.set_remote_image_urls(remote_image_urls); + self.set_text_content_with_mention_bindings( + text, + text_elements, + local_image_paths, + mention_bindings, + ); + self.set_pending_pastes(pending_pastes); + self.set_current_cursor(cursor); + self.sync_popups(); + } + + /// Update the placeholder text without changing input enablement. + pub(crate) fn set_placeholder_text(&mut self, placeholder: String) { + self.placeholder_text = placeholder; + } + + pub(crate) fn set_parent_owned_thread(&mut self) { + self.blocks_direct_input = true; + self.placeholder_text = "Viewing sub-agent — direct input is disabled".to_string(); + } + + /// Move the cursor to the end of the current text buffer. + pub(crate) fn move_cursor_to_end(&mut self) { + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); + self.sync_popups(); + } + + fn move_cursor_to_history_entry_end(&mut self) { + let cursor = if self.draft.textarea.is_vim_normal_mode() { + self.draft.textarea.vim_normal_end_cursor() + } else { + self.draft.textarea.text().len() + }; + self.draft.textarea.set_cursor(cursor); + self.sync_popups(); + } + + /// Convert canonical composer text into the textarea's internal representation. + /// + /// Shell mode stores the leading `!` as prompt state instead of editable text, + /// so full-buffer imports must absorb that prefix before rebuilding the textarea. + fn imported_text_for_textarea( + &mut self, + text: String, + text_elements: Vec, + ) -> (String, Vec) { + if self.config.shell_commands_enabled + && let Some(stripped) = text.strip_prefix('!') + { + self.draft.is_bash_mode = true; + ( + stripped.to_string(), + text_elements + .into_iter() + .filter_map(|element| Self::shift_text_element(element, /*shift*/ -1)) + .collect(), + ) + } else { + self.draft.is_bash_mode = false; + (text, text_elements) + } + } + + /// Flush buffered typing before clearing so cancellation preserves the complete draft in history. + pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + if self.is_empty() { + return None; + } + let previous = self.current_text(); + let text_elements = self.current_text_elements(); + let local_image_paths = self.attachments.local_image_paths(); + let pending_pastes = std::mem::take(&mut self.draft.pending_pastes); + let remote_image_urls = self.attachments.remote_image_urls(); + let mention_bindings = self.snapshot_mention_bindings(); + self.set_text_content(String::new(), Vec::new(), Vec::new()); + self.attachments.clear_remote_image_urls(); + self.history.reset_navigation(); + self.history.record_local_submission(HistoryEntry { + text: previous.clone(), + text_elements, + local_image_paths, + remote_image_urls, + mention_bindings, + pending_pastes, + }); + Some(previous) + } + + /// Get the current composer text. + pub(crate) fn current_text(&self) -> String { + if self.draft.is_bash_mode { + format!("!{}", self.draft.textarea.text()) + } else { + self.draft.textarea.text().to_string() + } + } + + /// Recall content at its history boundary, preserving pending pastes and omitting images + /// in plain-text editors. + fn apply_history_entry(&mut self, entry: HistoryEntry) { + let HistoryEntry { + text, + text_elements, + local_image_paths, + remote_image_urls, + mention_bindings, + pending_pastes, + } = entry; + self.set_remote_image_urls(remote_image_urls); + self.set_text_content_with_mention_bindings( + text, + text_elements, + local_image_paths, + mention_bindings, + ); + self.set_pending_pastes(pending_pastes); + if !self.config.image_paste_enabled { + for image in self.attachments.local_images() { + if let Some(element) = self.draft.textarea.text_elements().into_iter().find(|e| { + e.placeholder(self.draft.textarea.text()) == Some(image.placeholder.as_str()) + }) { + let range = element.byte_range; + self.draft + .textarea + .replace_range(range.start..range.end, ""); + } + } + self.attachments = AttachmentState::default(); + } + self.history.record_recalled_text(self.current_text()); + self.move_cursor_to_history_entry_end(); + } + + pub(crate) fn text_elements(&self) -> Vec { + self.current_text_elements() + } + + pub(crate) fn draft_snapshot(&self) -> ComposerDraftSnapshot { + ComposerDraftSnapshot { + text: self.current_text(), + cursor: self.current_cursor(), + text_elements: self.text_elements(), + local_images: self.local_images(), + remote_image_urls: self.remote_image_urls(), + mention_bindings: self.mention_bindings(), + pending_pastes: self.pending_pastes(), + startup_local_history: self.history.startup_local_history().to_vec(), + last_composer_activity_at: None, + } + } + + #[cfg(test)] + pub(crate) fn local_image_paths(&self) -> Vec { + self.attachments.local_image_paths() + } + + #[cfg(test)] + pub(crate) fn status_line_text(&self) -> Option { + self.footer.status_line_text() + } + + pub(crate) fn local_images(&self) -> Vec { + self.attachments.local_images() + } + + pub(crate) fn mention_bindings(&self) -> Vec { + self.snapshot_mention_bindings() + } + + pub(crate) fn take_recent_submission_mention_bindings(&mut self) -> Vec { + std::mem::take(&mut self.draft.recent_submission_mention_bindings) + } + + /// Commit the staged slash-command draft to local Up-arrow recall. + /// + /// Call this after command dispatch. Calling it more than once is harmless because the pending + /// slot is consumed on the first call. + pub(crate) fn record_pending_slash_command_history(&mut self) { + if let Some(entry) = self.pending_slash_command_history.take() { + self.history.record_local_submission(entry); + } + } + + /// Insert an attachment placeholder and track it for the next submission. + pub fn attach_image(&mut self, path: PathBuf) { + let started_vim_edit = self.begin_direct_vim_edit(); + self.attachments + .attach_image(&mut self.draft.textarea, path); + if started_vim_edit { + self.finish_vim_edit(); + } + } + + #[cfg(test)] + pub fn take_recent_submission_images(&mut self) -> Vec { + self.attachments.take_recent_submission_images() + } + + pub fn take_recent_submission_images_with_placeholders(&mut self) -> Vec { + self.attachments + .take_recent_submission_images_with_placeholders() + } + + /// Flushes any due paste-burst state. + /// + /// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits: + /// + /// - If a burst times out, flush it via `handle_paste(String)`. + /// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it + /// as normal typed input. + /// + /// This also allows a single "held" ASCII char to render even when it turns out not to be part + /// of a paste burst. + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { + self.handle_paste_burst_flush(Instant::now()) + } + + /// Returns whether the composer is currently in any paste-burst related transient state. + /// + /// This includes actively buffering, having a non-empty burst buffer, or holding the first + /// ASCII char for flicker suppression. + pub(crate) fn is_in_paste_burst(&self) -> bool { + self.draft.paste_burst.is_active() + } + + /// Returns a delay that reliably exceeds the paste-burst timing threshold. + /// + /// Use this in tests to avoid boundary flakiness around the `PasteBurst` timeout. + pub(crate) fn recommended_paste_flush_delay() -> Duration { + PasteBurst::recommended_flush_delay() + } + + /// Integrate results from an asynchronous file search. + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + // Only apply if user is still editing a token starting with `query`. + let current_opt = if self.mentions_v2_enabled { + self.current_mentions_v2_token() + } else { + Self::current_at_token(&self.draft.textarea) + }; + let Some(current_token) = current_opt else { + return; + }; + + if !current_token.starts_with(&query) { + return; + } + + match &mut self.popups.active { + ActivePopup::File(popup) => { + popup.set_matches(&query, matches); + } + ActivePopup::MentionV2(popup) => { + popup.set_file_matches(&query, matches); + } + _ => {} + } + } + + /// Show the transient "press again to quit" hint for `key`. + /// + /// The owner (`BottomPane`/`ChatWidget`) is responsible for scheduling a + /// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear + /// even when the UI is otherwise idle. + pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) { + self.footer.quit_shortcut_expires_at = Instant::now() + .checked_add(super::QUIT_SHORTCUT_TIMEOUT) + .or_else(|| Some(Instant::now())); + self.footer.quit_shortcut_key = key; + self.footer.mode = FooterMode::QuitShortcutReminder; + self.set_has_focus(has_focus); + } + + /// Clear the "press again to quit" hint immediately. + pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) { + self.footer.quit_shortcut_expires_at = None; + self.footer.mode = reset_mode_after_activity(self.footer.mode); + self.set_has_focus(has_focus); + } + + /// Whether the quit shortcut hint should currently be shown. + /// + /// This is time-based rather than event-based: it may become false without + /// any additional user input, so the UI schedules a redraw when the hint + /// expires. + pub(crate) fn quit_shortcut_hint_visible(&self) -> bool { + self.footer + .quit_shortcut_expires_at + .is_some_and(|expires_at| Instant::now() < expires_at) + } + + fn next_large_paste_placeholder(&self, char_count: usize) -> String { + let base = format!("[Pasted Content {char_count} chars]"); + let prefix = format!("{base} #"); + let mut max_suffix = 0usize; + + for (placeholder, _) in &self.draft.pending_pastes { + if placeholder == &base { + max_suffix = max_suffix.max(1); + continue; + } + if let Some(suffix) = placeholder.strip_prefix(&prefix) + && let Ok(value) = suffix.parse::() + { + max_suffix = max_suffix.max(value); + } + } + + if max_suffix == 0 { + base + } else { + format!("{base} #{}", max_suffix + 1) + } + } + + pub(crate) fn insert_str(&mut self, text: &str) { + let started_vim_edit = self.begin_direct_vim_edit(); + self.draft.textarea.insert_str(text); + self.sync_bash_mode_from_text(); + self.sync_popups(); + if started_vim_edit { + self.finish_vim_edit(); + } + } + + /// Handle a key event coming from the main UI. + pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if !self.draft.input_enabled { + return (InputResult::None, false); + } + + if matches!(key_event.kind, KeyEventKind::Release) { + return (InputResult::None, false); + } + + if self.history_search.is_none() + && !self.popups.active() + && self.draft.textarea.wants_vim_search_key(key_event) + { + return self.handle_input_basic(key_event); + } + + if self.history_search.is_some() { + return self.handle_history_search_key(key_event); + } + + if self.handle_vim_history_key(key_event) { + return (InputResult::None, true); + } + + if Self::is_history_search_key(&key_event, &self.history_search_previous_keys) { + return self.begin_history_search(); + } + + if self.handle_paste_tab(key_event, Instant::now()) { + return (InputResult::None, true); + } + + let result = match &mut self.popups.active { + ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), + ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), + ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event), + ActivePopup::MentionV2(_) => self.handle_key_event_with_mentions_v2_popup(key_event), + ActivePopup::None => self.handle_key_event_without_popup(key_event), + }; + self.reset_vim_mode_after_successful_dispatch(&result.0); + // Update (or hide/show) popup after processing the key. + self.sync_popups(); + result + } + + /// Whether a popup or query owns input. + pub(crate) fn popup_active(&self) -> bool { + self.history_search.is_some() + || self.draft.textarea.vim_query().is_some() + || self.popups.active() + } + + #[inline] + fn clamp_to_char_boundary(text: &str, pos: usize) -> usize { + let mut p = pos.min(text.len()); + if p < text.len() && !text.is_char_boundary(p) { + p = text + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= p) + .last() + .unwrap_or(0); + } + p + } + + /// Handle non-ASCII character input (often IME) while still supporting paste-burst detection. + /// + /// This handler exists because non-ASCII input often comes from IMEs, where characters can + /// legitimately arrive in short bursts that should **not** be treated as paste. + /// + /// The key differences from the ASCII path: + /// + /// - We never hold the first character (`PasteBurst::on_plain_char_no_hold`), because holding a + /// non-ASCII char can feel like dropped input. + /// - If a burst is detected, we may need to retroactively remove already-inserted text before + /// the cursor and move it into the paste buffer (see `PasteBurst::decide_begin_buffer`). + /// + /// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp + /// the cursor to a UTF-8 char boundary before slicing `textarea.text()`. + #[inline] + fn handle_non_ascii_char(&mut self, input: KeyEvent, now: Instant) -> (InputResult, bool) { + if self.draft.disable_paste_burst { + // When burst detection is disabled, treat IME/non-ASCII input as normal typing. + // In particular, do not retro-capture or buffer already-inserted prefix text. + self.draft.textarea.input(input); + let text_after = self.draft.textarea.text(); + self.draft + .pending_pastes + .retain(|(placeholder, _)| text_after.contains(placeholder)); + return (InputResult::None, true); + } + if let KeyEvent { + code: KeyCode::Char(ch), + .. + } = input + { + if self.draft.paste_burst.try_append_char_if_active(ch, now) { + return (InputResult::None, true); + } + // Non-ASCII input often comes from IMEs and can arrive in quick bursts. + // We do not want to hold the first char (flicker suppression) on this path, but we + // still want to detect paste-like bursts. Before applying any non-ASCII input, flush + // any existing burst buffer (including a pending first char from the ASCII path) so + // we don't carry that transient state forward. + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + if let Some(decision) = self.draft.paste_burst.on_plain_char_no_hold(now) { + match decision { + CharDecision::BufferAppend => { + self.draft.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + CharDecision::BeginBuffer { retro_chars } => { + // For non-ASCII we inserted prior chars immediately, so if this turns out + // to be paste-like we need to retroactively grab & remove the already- + // inserted prefix from the textarea before buffering the burst. + let cur = self.draft.textarea.cursor(); + let txt = self.draft.textarea.text(); + let safe_cur = Self::clamp_to_char_boundary(txt, cur); + let before = &txt[..safe_cur]; + if let Some(grab) = self.draft.paste_burst.decide_begin_buffer( + now, + before, + retro_chars as usize, + ) { + if grab.grabbed.is_empty() + || self.draft.textarea.retract_paste_burst(grab.start_byte) + { + self.draft.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + self.draft.paste_burst.clear_after_explicit_paste(); + } + // If decide_begin_buffer opted not to start buffering, + // fall through to normal insertion below. + } + _ => unreachable!("on_plain_char_no_hold returned unexpected variant"), + } + } + } + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + self.draft.textarea.input(input); + + let text_after = self.draft.textarea.text(); + self.draft + .pending_pastes + .retain(|(placeholder, _)| text_after.contains(placeholder)); + (InputResult::None, true) + } + + /// Handle key events when file search popup is visible. + fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_empty_prompt_shortcut(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); + if next_mode != self.footer.mode { + self.footer.mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer.mode = reset_mode_after_activity(self.footer.mode); + } + let ActivePopup::File(popup) = &mut self.popups.active else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + if let Some((range, query)) = completion_target::current_prefixed_token_range( + &self.draft.textarea, + '@', + /*allow_empty*/ false, + ) { + self.popups.dismissed_file_token = + Some(DismissedToken::new(&self.draft.textarea, range, query)); + } + self.popups.active = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + let Some(sel) = popup.selected_match() else { + self.popups.active = ActivePopup::None; + return if key_event.code == KeyCode::Enter { + self.handle_key_event_without_popup(key_event) + } else { + (InputResult::None, true) + }; + }; + + let sel_path = sel.to_string_lossy().to_string(); + if let Some((token_range, _)) = + self.current_editable_at_token_range_with_options(/*allow_empty*/ false) + { + self.insert_selected_file_path(token_range, &sel_path); + } + self.popups.active = ActivePopup::None; + (InputResult::None, true) + } + input => self.handle_input_basic(input), + } + } + + /// Handle key events when the legacy skill mention popup is visible. + fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_empty_prompt_shortcut(&key_event) { + return (InputResult::None, true); + } + self.footer.mode = reset_mode_after_activity(self.footer.mode); + + let ActivePopup::Skill(popup) = &mut self.popups.active else { + unreachable!(); + }; + + let mut selected_mention: Option<(String, Option)> = None; + let mut close_popup = false; + + let result = match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + if let Some(target) = self.current_mention_target() { + self.popups.dismissed_mention_token = Some(DismissedToken::new( + &self.draft.textarea, + target.range, + target.query, + )); + } + self.popups.active = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + if let Some(mention) = popup.selected_mention() { + selected_mention = Some((mention.insert_text.clone(), mention.path.clone())); + } + close_popup = true; + (InputResult::None, true) + } + input => self.handle_input_basic(input), + }; + + if close_popup { + if let Some((insert_text, path)) = selected_mention + && let Some(target) = self.current_mention_target() + { + self.insert_selected_mention(target.range, &insert_text, path.as_deref()); + } + self.popups.active = ActivePopup::None; + } + + result + } + + fn handle_key_event_with_mentions_v2_popup( + &mut self, + key_event: KeyEvent, + ) -> (InputResult, bool) { + if self.handle_empty_prompt_shortcut(&key_event) { + return (InputResult::None, true); + } + self.footer.mode = reset_mode_after_activity(self.footer.mode); + let can_switch_search_mode = self.current_editable_at_token().is_some(); + + let ActivePopup::MentionV2(popup) = &mut self.popups.active else { + unreachable!(); + }; + + let mut selected: Option = None; + let mut close_popup = false; + let mut submit_without_popup = false; + + let result = match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } => { + if can_switch_search_mode { + popup.previous_search_mode(); + (InputResult::None, true) + } else { + self.handle_input_basic(key_event) + } + } + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. + } => { + if can_switch_search_mode { + popup.next_search_mode(); + (InputResult::None, true) + } else { + self.handle_input_basic(key_event) + } + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + if let Some((range, query)) = self.current_mentions_v2_token_range() { + self.popups.dismissed_mention_token = + Some(DismissedToken::new(&self.draft.textarea, range, query)); + } + self.popups.active = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } => { + selected = popup.selected(); + close_popup = true; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + selected = popup.selected(); + close_popup = true; + submit_without_popup = selected.is_none(); + (InputResult::None, true) + } + input => self.handle_input_basic(input), + }; + + if close_popup { + let token_range = self + .current_editable_at_token_range_with_options(/*allow_empty*/ true) + .map(|(range, _)| range); + if let (Some(selected), Some(token_range)) = (selected, token_range) { + match selected { + MentionV2Selection::File(path) => { + self.insert_selected_file_path( + token_range, + path.to_string_lossy().as_ref(), + ); + } + MentionV2Selection::Tool { insert_text, path } => { + self.insert_selected_mention(token_range, &insert_text, path.as_deref()); + } + } + } + self.popups.active = ActivePopup::None; + if submit_without_popup { + return self.handle_key_event_without_popup(key_event); + } + } + + result + } + + fn is_image_path(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".png") + || lower.ends_with(".jpg") + || lower.ends_with(".jpeg") + || lower.ends_with(".gif") + || lower.ends_with(".webp") + } + + /// Leaves the cursor after one horizontal separator following a completion. + /// + /// Another separator is preserved before any non-whitespace suffix so subsequent typing does + /// not merge into it. Line breaks are never reused as separators; a space is inserted before + /// them so subsequent typing stays on the completed token's line. + fn advance_past_completion_separator(&mut self) { + let cursor = self.draft.textarea.cursor(); + let existing_separator_len = self.draft.textarea.text()[cursor..] + .chars() + .next() + .filter(|c| { + c.is_whitespace() + && !matches!( + *c, + '\n' | '\r' + | '\u{000B}' + | '\u{000C}' + | '\u{0085}' + | '\u{2028}' + | '\u{2029}' + ) + }) + .map(char::len_utf8); + if let Some(separator_len) = existing_separator_len { + let after_separator = cursor + separator_len; + let separator_precedes_suffix = self.draft.textarea.text()[after_separator..] + .chars() + .next() + .is_some_and(|c| !c.is_whitespace()); + if separator_precedes_suffix { + self.draft.textarea.insert_str_at(cursor, " "); + } else { + self.draft.textarea.set_cursor(after_separator); + } + } else { + self.draft.textarea.insert_str_at(cursor, " "); + } + } + + /// Dismisses popup synchronization only for the exact token occurrence just inserted. + /// + /// Matching both range and text prevents an identical token later in the draft from inheriting + /// the completed token's dismissal state. + fn dismiss_completed_prefixed_token( + &mut self, + prefix: char, + inserted_range: Range, + inserted_text: &str, + ) { + let Some(completed_token) = inserted_text.strip_prefix(prefix) else { + return; + }; + // Completion leaves the cursor on separator whitespace, where normal token affinity would + // otherwise immediately reopen the popup for sigil-prefixed inserted text. + let Some((current_range, current_token)) = completion_target::current_prefixed_token_range( + &self.draft.textarea, + prefix, + /*allow_empty*/ true, + ) else { + return; + }; + if current_range != inserted_range || current_token != completed_token { + return; + } + + if prefix == '@' && !self.mentions_v2_enabled { + self.popups.dismissed_file_token = Some(DismissedToken::new( + &self.draft.textarea, + current_range, + current_token, + )); + } else { + self.popups.dismissed_mention_token = Some(DismissedToken::new( + &self.draft.textarea, + current_range, + current_token, + )); + } + } + + fn insert_selected_file_path(&mut self, token_range: Range, selected_path: &str) { + let started_vim_edit = self.begin_direct_vim_edit(); + let token_range = self.separate_completion_from_adjacent_element(token_range); + if Self::is_image_path(selected_path) { + let path_buf = PathBuf::from(selected_path); + match image::image_dimensions(&path_buf) { + Ok((width, height)) => { + tracing::debug!("selected image dimensions={}x{}", width, height); + let start_idx = token_range.start; + self.draft.textarea.replace_range(token_range, ""); + self.draft.textarea.set_cursor(start_idx); + self.attach_image(path_buf); + self.advance_past_completion_separator(); + } + Err(err) => { + tracing::trace!("image dimensions lookup failed: {err}"); + self.insert_selected_path(token_range, selected_path); + } + } + } else { + self.insert_selected_path(token_range, selected_path); + } + if started_vim_edit { + self.finish_vim_edit(); + } + } + + fn trim_text_elements( + original: &str, + trimmed: &str, + elements: Vec, + ) -> Vec { + if trimmed.is_empty() || elements.is_empty() { + return Vec::new(); + } + let trimmed_start = original.len().saturating_sub(original.trim_start().len()); + let trimmed_end = trimmed_start.saturating_add(trimmed.len()); + + elements + .into_iter() + .filter_map(|elem| { + let start = elem.byte_range.start; + let end = elem.byte_range.end; + if end <= trimmed_start || start >= trimmed_end { + return None; + } + let new_start = start.saturating_sub(trimmed_start); + let new_end = end.saturating_sub(trimmed_start).min(trimmed.len()); + if new_start >= new_end { + return None; + } + let placeholder = trimmed.get(new_start..new_end).map(str::to_string); + Some(TextElement::new( + ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + )) + }) + .collect() + } + + /// Expand large-paste placeholders using element ranges and rebuild other element spans. + pub(crate) fn expand_pending_pastes( + text: &str, + mut elements: Vec, + pending_pastes: &[(String, String)], + ) -> (String, Vec) { + if pending_pastes.is_empty() || elements.is_empty() { + return (text.to_string(), elements); + } + + // Stage 1: index pending paste payloads by placeholder for deterministic replacements. + let mut pending_by_placeholder: HashMap<&str, VecDeque<&str>> = HashMap::new(); + for (placeholder, actual) in pending_pastes { + pending_by_placeholder + .entry(placeholder.as_str()) + .or_default() + .push_back(actual.as_str()); + } + + // Stage 2: walk elements in order and rebuild text/spans in a single pass. + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut rebuilt = String::with_capacity(text.len()); + let mut rebuilt_elements = Vec::with_capacity(elements.len()); + let mut cursor = 0usize; + + for elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if start > end { + continue; + } + if start > cursor { + rebuilt.push_str(&text[cursor..start]); + } + let elem_text = &text[start..end]; + let placeholder = elem.placeholder(text).map(str::to_string); + let replacement = placeholder + .as_deref() + .and_then(|ph| pending_by_placeholder.get_mut(ph)) + .and_then(VecDeque::pop_front); + if let Some(actual) = replacement { + // Stage 3: inline actual paste payloads and drop their placeholder elements. + rebuilt.push_str(actual); + } else { + // Stage 4: keep non-paste elements, updating their byte ranges for the new text. + let new_start = rebuilt.len(); + rebuilt.push_str(elem_text); + let new_end = rebuilt.len(); + let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); + rebuilt_elements.push(TextElement::new( + ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + )); + } + cursor = end; + } + + // Stage 5: append any trailing text that followed the last element. + if cursor < text.len() { + rebuilt.push_str(&text[cursor..]); + } + + (rebuilt, rebuilt_elements) + } + + pub fn skills(&self) -> Option<&Vec> { + self.skills.as_ref() + } + + pub fn plugins(&self) -> Option<&Vec> { + self.plugins.as_ref() + } + + fn mentions_enabled(&self) -> bool { + let skills_ready = self + .skills + .as_ref() + .is_some_and(|skills| !skills.is_empty()); + let plugins_ready = self + .plugins + .as_ref() + .is_some_and(|plugins| !plugins.is_empty()); + let connectors_ready = self.connectors_enabled + && self + .connectors_snapshot + .as_ref() + .is_some_and(|snapshot| !snapshot.connectors.is_empty()); + skills_ready || plugins_ready || connectors_ready + } + + fn current_prefixed_token( + textarea: &TextArea, + prefix: char, + allow_empty: bool, + ) -> Option { + completion_target::current_prefixed_token_range(textarea, prefix, allow_empty) + .map(|(_, token)| token) + } + + /// Extract the `@token` that the cursor is currently positioned on, if any. + /// + /// The returned string **does not** include the leading `@`. + fn current_at_token(textarea: &TextArea) -> Option { + Self::current_prefixed_token(textarea, '@', /*allow_empty*/ false) + } + + /// Returns the active prefixed token only when its sigil and name remain editable plaintext. + /// + /// Atomic elements and tokens whose mention prefix is already atomic are excluded so bound + /// mentions are not offered for completion again. + fn current_editable_prefixed_token_range( + &self, + prefix: char, + allow_empty: bool, + ) -> Option<(Range, String)> { + let (range, token) = completion_target::current_prefixed_token_range( + &self.draft.textarea, + prefix, + allow_empty, + )?; + completion_target::prefixed_token_range_is_editable( + &self.draft.textarea, + prefix, + &range, + &token, + ) + .then_some((range, token)) + } + + fn current_editable_at_token_range_with_options( + &self, + allow_empty: bool, + ) -> Option<(Range, String)> { + self.current_editable_prefixed_token_range('@', allow_empty) + } + + fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option { + self.current_editable_at_token_range_with_options(allow_empty) + .map(|(_, token)| token) + } + + fn current_editable_at_token(&self) -> Option { + self.current_editable_at_token_with_options(/*allow_empty*/ false) + } + + fn current_mentions_v2_token_range(&self) -> Option<(Range, String)> { + if !self.mentions_v2_enabled { + return None; + } + self.current_editable_at_token_range_with_options(/*allow_empty*/ true) + } + + fn current_mentions_v2_token(&self) -> Option { + self.current_mentions_v2_token_range() + .map(|(_, token)| token) + } + + /// Resolves the active legacy `$` target without eagerly cloning the mention catalog. + /// + /// Definite shell syntax is rejected. Ambiguous shell-like queries are accepted only when a + /// bindable mention matches. The query-independent catalog built during that check is carried + /// forward only when the selected target is also ambiguous. + fn current_mention_target(&self) -> Option { + if !self.mentions_enabled() { + return None; + } + let mentions: OnceCell> = OnceCell::new(); + let (range, query) = { + let dollar_query_is_completable = + |query: &str| match completion_target::dollar_query_kind(query) { + completion_target::DollarQueryKind::Completable => true, + completion_target::DollarQueryKind::AmbiguousShellParameter => mentions + .get_or_init(|| { + self.mention_items() + .into_iter() + .filter(|mention| { + mention.path.is_some() + && Self::mention_token_from_insert_text( + &mention.insert_text, + ) + .is_some() + }) + .collect() + }) + .iter() + .any(|mention| mention.fuzzy_match_query(query).is_some()), + completion_target::DollarQueryKind::ShellVariable + | completion_target::DollarQueryKind::DefiniteShellParameter + | completion_target::DollarQueryKind::Invalid => false, + }; + let (range, query) = + completion_target::current_prefixed_token_range_with_dollar_predicate( + &self.draft.textarea, + '$', + /*allow_empty*/ true, + dollar_query_is_completable, + )?; + if !completion_target::prefixed_token_range_is_editable( + &self.draft.textarea, + '$', + &range, + &query, + ) || !dollar_query_is_completable(&query) + { + return None; + } + (range, query) + }; + let prebuilt_mentions = if matches!( + completion_target::dollar_query_kind(&query), + completion_target::DollarQueryKind::AmbiguousShellParameter + ) { + mentions.into_inner() + } else { + None + }; + + Some(MentionCompletionTarget { + range, + query, + prebuilt_mentions, + }) + } + + /// Replace the active `@token` (the one under the cursor) with `path`. + fn insert_selected_path(&mut self, token_range: Range, path: &str) { + // If the path contains whitespace, wrap it in double quotes so the + // local prompt arg parser treats it as a single argument. Avoid adding + // quotes when the path already contains one to keep behavior simple. + let needs_quotes = path.chars().any(char::is_whitespace); + let inserted = if needs_quotes && !path.contains('"') { + format!("\"{path}\"") + } else { + path.to_string() + }; + + // Replace just the active `@token` so unrelated text elements, such as + // large-paste placeholders, remain atomic and can still expand on submit. + let start_idx = token_range.start; + self.draft.textarea.replace_range(token_range, &inserted); + let inserted_range = start_idx..start_idx.saturating_add(inserted.len()); + self.draft.textarea.set_cursor(inserted_range.end); + self.advance_past_completion_separator(); + self.dismiss_completed_prefixed_token('@', inserted_range, &inserted); + } + + fn insert_selected_mention( + &mut self, + token_range: Range, + insert_text: &str, + path: Option<&str>, + ) { + let token_range = self.separate_completion_from_adjacent_element(token_range); + // Remove the active token and insert the selected mention as an atomic element. + let start_idx = token_range.start; + self.draft.textarea.replace_range(token_range, ""); + self.draft.textarea.set_cursor(start_idx); + let id = self.draft.textarea.insert_element(insert_text); + let inserted_range = start_idx..start_idx.saturating_add(insert_text.len()); + + let mention = if path + .and_then(crate::task_mentions::valid_thread_path) + .is_some() + { + insert_text + .strip_prefix('@') + .filter(|name| !name.is_empty()) + .map(|name| ('@', name.to_string())) + } else { + Self::mention_token_from_insert_text(insert_text) + }; + if let (Some(path), Some((sigil, mention))) = (path, mention) { + self.draft.mention_bindings.insert( + id, + ComposerMentionBinding { + sigil, + mention, + path: path.to_string(), + }, + ); + } + if path + .and_then(crate::task_mentions::valid_thread_path) + .is_some() + && let Some(task_mentions) = &mut self.task_mentions + { + task_mentions.clear(); + } + + self.advance_past_completion_separator(); + if let Some(sigil) = insert_text.chars().next() + && matches!(sigil, '$' | '@') + { + self.dismiss_completed_prefixed_token(sigil, inserted_range, insert_text); + } + } + + /// Inserts a leading separator when a completion starts directly after an atomic element. + /// + /// The returned range is shifted to keep replacing the same editable token after insertion. + fn separate_completion_from_adjacent_element( + &mut self, + mut token_range: Range, + ) -> Range { + let starts_after_element = self + .draft + .textarea + .text_element_ranges() + .any(|range| range.end == token_range.start); + if starts_after_element { + self.draft + .textarea + .replace_range(token_range.start..token_range.start, " "); + token_range.start += 1; + token_range.end += 1; + } + token_range + } + + fn mention_token_from_insert_text(insert_text: &str) -> Option<(char, String)> { + let sigil = insert_text.chars().next()?; + if !matches!(sigil, '$' | '@') { + return None; + } + let name = &insert_text[sigil.len_utf8()..]; + if name.is_empty() { + return None; + } + if name + .as_bytes() + .iter() + .all(|byte| is_mention_name_char(*byte)) + { + Some((sigil, name.to_string())) + } else { + None + } + } + + fn current_mention_elements(&self) -> Vec<(u64, char, String)> { + self.draft + .textarea + .text_element_snapshots() + .into_iter() + .filter_map(|snapshot| { + if let Some(binding) = self.draft.mention_bindings.get(&snapshot.id) + && crate::task_mentions::valid_thread_path(&binding.path).is_some() + && snapshot.text == format!("{}{}", binding.sigil, binding.mention) + { + return Some((snapshot.id, binding.sigil, binding.mention.clone())); + } + Self::mention_token_from_insert_text(snapshot.text.as_str()) + .map(|(sigil, mention)| (snapshot.id, sigil, mention)) + }) + .collect() + } + + fn snapshot_mention_bindings(&self) -> Vec { + let mut ordered = Vec::new(); + for (id, sigil, mention) in self.current_mention_elements() { + if let Some(binding) = self.draft.mention_bindings.get(&id) + && binding.sigil == sigil + && binding.mention == mention + { + ordered.push(MentionBinding { + sigil: binding.sigil, + mention: binding.mention.clone(), + path: binding.path.clone(), + }); + } + } + ordered + } + + fn bind_mentions_from_snapshot(&mut self, mention_bindings: Vec) { + self.draft.mention_bindings.clear(); + if mention_bindings.is_empty() { + return; + } + + let text = self.draft.textarea.text().to_string(); + let mut scan_from = 0usize; + for binding in mention_bindings { + let token = format!("{}{}", binding.sigil, binding.mention); + let range = if crate::task_mentions::valid_thread_path(&binding.path).is_some() { + self.draft + .textarea + .text_element_snapshots() + .into_iter() + .find(|element| element.range.start >= scan_from && element.text == token) + .map(|element| element.range) + } else { + find_next_mention_token_range(text.as_str(), token.as_str(), scan_from) + }; + let Some(range) = range else { + continue; + }; + + let id = if let Some(id) = self.draft.textarea.add_element_range(range.clone()) { + Some(id) + } else { + self.draft + .textarea + .element_id_for_exact_range(range.clone()) + }; + + if let Some(id) = id { + self.draft.mention_bindings.insert( + id, + ComposerMentionBinding { + sigil: binding.sigil, + mention: binding.mention, + path: binding.path, + }, + ); + scan_from = range.end; + } + } + } + + fn plugin_at_mention_highlights(&self) -> Vec<(Range, Style)> { + self.draft + .textarea + .text_element_snapshots() + .into_iter() + .filter_map(|snapshot| { + let binding = self.draft.mention_bindings.get(&snapshot.id)?; + if !binding.path.starts_with("plugin://") || !snapshot.text.starts_with('@') { + return None; + } + Some((snapshot.range, Style::default().fg(Color::Magenta))) + }) + .collect() + } + + /// Prepare text for submission/queuing. Returns None if submission should be suppressed. + /// On success, clears pending paste payloads because placeholders have been expanded. + /// + /// When `record_history` is true, the final submission is stored for ↑/↓ recall. + fn prepare_submission_text( + &mut self, + record_history: bool, + ) -> Option<(String, Vec)> { + self.prepare_submission_text_with_options( + record_history, + SlashValidation::Immediate, + PendingPasteHandling::Expand, + ) + } + + fn prepare_submission_text_with_options( + &mut self, + record_history: bool, + slash_validation: SlashValidation, + pending_paste_handling: PendingPasteHandling, + ) -> Option<(String, Vec)> { + let mut text = self.current_text(); + let original_input = text.clone(); + let original_text_elements = self.current_text_elements(); + let original_mention_bindings = self.snapshot_mention_bindings(); + let original_local_image_paths = self.attachments.local_image_paths(); + let original_pending_pastes = self.draft.pending_pastes.clone(); + let mut text_elements = original_text_elements.clone(); + let input_starts_with_space = original_input.starts_with(' '); + self.draft.recent_submission_mention_bindings.clear(); + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; + + if pending_paste_handling == PendingPasteHandling::Expand + && !self.draft.pending_pastes.is_empty() + { + // Expand placeholders so element byte ranges stay aligned. + let (expanded, expanded_elements) = + Self::expand_pending_pastes(&text, text_elements, &self.draft.pending_pastes); + text = expanded; + text_elements = expanded_elements; + } + + if self.config.trim_submission { + let expanded_input = text.clone(); + text = text.trim().to_string(); + text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); + } + + if slash_validation == SlashValidation::Immediate + && let SubmissionValidation::UnknownCommand(name) = self + .slash_input() + .validate_submission(&text, input_starts_with_space) + { + let message = format!( + r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, /*hint*/ None), + ))); + self.set_text_content_with_mention_bindings( + original_input.clone(), + original_text_elements, + original_local_image_paths, + original_mention_bindings, + ); + self.draft + .pending_pastes + .clone_from(&original_pending_pastes); + self.draft.textarea.set_cursor(original_input.len()); + return None; + } + + let actual_chars = text.chars().count(); + if actual_chars > MAX_USER_INPUT_TEXT_CHARS { + let message = user_input_too_large_message(actual_chars); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(message), + ))); + self.set_text_content_with_mention_bindings( + original_input.clone(), + original_text_elements, + original_local_image_paths, + original_mention_bindings, + ); + self.draft + .pending_pastes + .clone_from(&original_pending_pastes); + self.draft.textarea.set_cursor(original_input.len()); + // Embedded composers may cover the transcript that receives the full error. + if self.footer.hint_override.is_some() { + self.show_footer_flash( + Line::from( + format!("Message too long; limit {MAX_USER_INPUT_TEXT_CHARS} characters") + .red(), + ), + Duration::from_secs(5), + ); + } + return None; + } + self.attachments + .prune_local_images_for_submission(&text, &text_elements); + if text.trim().is_empty() && self.attachments.is_empty() { + return None; + } + self.draft.recent_submission_mention_bindings = original_mention_bindings.clone(); + if record_history && (!text.is_empty() || !self.attachments.is_empty()) { + let preserve_literal_paste = self.slash_commands_enabled() + && text.starts_with('!') + && !original_input.trim_start().starts_with('!'); + let (history_text, history_text_elements) = if preserve_literal_paste { + let history_text = original_input.trim().to_string(); + let history_text_elements = Self::trim_text_elements( + &original_input, + &history_text, + original_text_elements, + ); + (history_text, history_text_elements) + } else { + (text.clone(), text_elements.clone()) + }; + self.history.record_local_submission(HistoryEntry { + text: history_text, + text_elements: history_text_elements, + local_image_paths: self.attachments.local_image_paths(), + remote_image_urls: self.attachments.remote_image_urls(), + mention_bindings: original_mention_bindings, + pending_pastes: if pending_paste_handling == PendingPasteHandling::Preserve + || preserve_literal_paste + { + original_pending_pastes.clone() + } else { + Vec::new() + }, + }); + } + self.draft.pending_pastes.clear(); + Some((text, text_elements)) + } + + /// Common logic for handling message submission/queuing. + /// Returns the appropriate InputResult based on `should_queue`. + fn handle_submission(&mut self, should_queue: bool) -> (InputResult, bool) { + let result = self.handle_submission_with_time(should_queue, Instant::now()); + self.reset_vim_mode_after_successful_dispatch(&result.0); + result + } + + fn reset_vim_mode_after_successful_dispatch(&mut self, result: &InputResult) { + if matches!( + result, + InputResult::Submitted { .. } + | InputResult::Queued { .. } + | InputResult::Command(_) + | InputResult::ServiceTierCommand(_) + | InputResult::CommandWithArgs(_, _, _) + ) && self.config.reset_vim_on_submission + { + self.reset_vim_mode(); + } + } + + fn handle_submission_with_time( + &mut self, + should_queue: bool, + now: Instant, + ) -> (InputResult, bool) { + if !should_queue && self.handle_paste_enter(now) { + return (InputResult::None, true); + } + + if let Some(result) = self.handle_parent_owned_submission() { + return result; + } + if should_queue { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + let visible_shell_command = self.is_bang_shell_command(); + let original_input = self.current_text(); + let original_text_elements = self.current_text_elements(); + let original_pending_pastes = self.draft.pending_pastes.clone(); + let raw_text = self.draft.textarea.text(); + let defer_slash_validation = self.slash_input().should_parse_on_dequeue(raw_text); + let preserve_pending_pastes = defer_slash_validation + && !self.draft.pending_pastes.is_empty() + && parse_slash_name(raw_text) + .is_some_and(|(name, _, _)| name == SlashCommand::Goal.command()); + let pending_pastes = if preserve_pending_pastes { + self.draft.pending_pastes.clone() + } else { + Vec::new() + }; + if let Some((text, text_elements)) = self.prepare_submission_text_with_options( + /*record_history*/ true, + if defer_slash_validation { + SlashValidation::Deferred + } else { + SlashValidation::Immediate + }, + if preserve_pending_pastes { + PendingPasteHandling::Preserve + } else { + PendingPasteHandling::Expand + }, + ) { + let action = if text.starts_with('!') && !visible_shell_command { + QueuedInputAction::Literal + } else { + slash_input::queued_input_action(&text, defer_slash_validation) + }; + let (text, text_elements, pending_pastes) = if action == QueuedInputAction::Literal + { + let text = original_input.trim().to_string(); + let text_elements = + Self::trim_text_elements(&original_input, &text, original_text_elements); + (text, text_elements, original_pending_pastes) + } else { + (text, text_elements, pending_pastes) + }; + return ( + InputResult::Queued { + text, + text_elements, + action, + pending_pastes, + }, + true, + ); + } + return (InputResult::None, true); + } + + // If the first line is a bare built-in slash command (no args), + // dispatch it even when the slash popup isn't visible. This preserves + // the workflow: type a prefix ("/di"), press Tab to complete to + // "/diff ", then press Enter/Ctrl+Shift+Q to run it. Tab moves the cursor beyond + // the '/name' token and our caret-based heuristic hides the popup, + // but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit + // literal text. + if let Some(result) = self.try_dispatch_bare_slash_command() { + return (result, true); + } + + let original_input = self.current_text(); + let original_text_elements = self.current_text_elements(); + let original_mention_bindings = self.snapshot_mention_bindings(); + let original_local_image_paths = self.attachments.local_image_paths(); + let original_pending_pastes = self.draft.pending_pastes.clone(); + if let Some(result) = self.try_dispatch_slash_command_with_args() { + return (result, true); + } + + if let Some((text, text_elements)) = + self.prepare_submission_text(/*record_history*/ true) + { + if self.slash_commands_enabled() + && text.starts_with('!') + && !original_input.trim_start().starts_with('!') + { + let text = original_input.trim().to_string(); + let text_elements = + Self::trim_text_elements(&original_input, &text, original_text_elements); + ( + InputResult::Queued { + text, + text_elements, + action: QueuedInputAction::Literal, + pending_pastes: original_pending_pastes, + }, + true, + ) + } else { + // Do not clear local attachments here; ChatWidget drains them via + // take_recent_submission_images(). + ( + InputResult::Submitted { + text, + text_elements, + }, + true, + ) + } + } else { + // Restore suppressed input, preserving validation feedback. + let flash = self.footer.flash.take(); + self.set_text_content_with_mention_bindings( + original_input, + original_text_elements, + original_local_image_paths, + original_mention_bindings, + ); + self.draft.pending_pastes = original_pending_pastes; + self.footer.flash = flash; + (InputResult::None, true) + } + } + + fn handle_parent_owned_submission(&mut self) -> Option<(InputResult, bool)> { + if !self.blocks_direct_input { + return None; + } + + let text = self.current_text(); + let allowed_slash_command = parse_slash_name(&text).is_some_and(|(name, args, _)| { + matches!( + self.slash_input().command(name), + Some(SlashCommandItem::Builtin(command)) + if name == command.command() + && parent_owned_command_is_allowed(command, args) + ) + }); + if text.starts_with('/') && allowed_slash_command { + return None; + } + + Some((InputResult::ParentOwnedInputBlocked, true)) + } + + /// Check if the first line is a bare slash command (no args) and dispatch it. + /// Returns Some(InputResult) if a command was dispatched, None otherwise. + fn try_dispatch_bare_slash_command(&mut self) -> Option { + let command = self + .slash_input() + .bare_command(self.draft.textarea.text())?; + if self.reject_slash_command_if_unavailable(&command) { + self.stage_slash_command_history(&command); + self.record_pending_slash_command_history(); + return Some(InputResult::None); + } + self.stage_slash_command_history(&command); + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; + Some(match command { + SlashCommandItem::Builtin(cmd) => InputResult::Command(cmd), + SlashCommandItem::ServiceTier(command) => InputResult::ServiceTierCommand(command), + }) + } + + /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. + /// Returns Some(InputResult) if a command was dispatched, None otherwise. + fn try_dispatch_slash_command_with_args(&mut self) -> Option { + let text = self.draft.textarea.text().to_string(); + let inline_command = self.slash_input().inline_command(&text)?; + let command = inline_command.command; + if self.reject_slash_command_if_unavailable(&command) { + self.stage_slash_command_history(&command); + self.record_pending_slash_command_history(); + return Some(InputResult::None); + } + + self.stage_slash_command_history(&command); + + let mut args_elements = slash_input::args_elements( + inline_command.rest, + inline_command.rest_offset, + &self.draft.textarea.text_elements(), + ); + let trimmed_rest = inline_command.rest.trim(); + args_elements = Self::trim_text_elements(inline_command.rest, trimmed_rest, args_elements); + let SlashCommandItem::Builtin(cmd) = command else { + return None; + }; + Some(InputResult::CommandWithArgs( + cmd, + trimmed_rest.to_string(), + args_elements, + )) + } + + /// Expand pending placeholders and extract normalized inline-command args. + /// + /// Inline-arg commands are initially dispatched using the raw draft so command rejection does + /// not consume user input. Once a command needs its args, this helper performs the usual + /// submission preparation (paste expansion, element trimming) and rebases element ranges from + /// full-text offsets to command-arg offsets. + /// + /// Callers that already staged slash-command history should normally pass `false` for + /// `record_history`; otherwise a command such as `/plan investigate` would be entered into + /// local recall through both the slash-command path and the message-submission path. + pub(crate) fn prepare_inline_args_submission( + &mut self, + record_history: bool, + ) -> Option<(String, Vec)> { + let (prepared_text, prepared_elements) = self.prepare_submission_text(record_history)?; + let (prepared_rest, prepared_rest_offset) = slash_input::prepared_args(&prepared_text)?; + let mut args_elements = + slash_input::args_elements(prepared_rest, prepared_rest_offset, &prepared_elements); + let trimmed_rest = prepared_rest.trim(); + args_elements = Self::trim_text_elements(prepared_rest, trimmed_rest, args_elements); + self.draft.textarea.enter_vim_insert_mode(); + Some((trimmed_rest.to_string(), args_elements)) + } + + fn reject_slash_command_if_unavailable(&self, command: &SlashCommandItem) -> bool { + if !self.is_task_running || command.available_during_task() { + return false; + } + let message = format!( + "'/{}' is disabled while a task is in progress.", + command.command() + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(message), + ))); + true + } + + /// Stage the current slash-command text for later local recall. + /// + /// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget` + /// commits the staged entry after dispatch so command recall follows the submitted text, not + /// the command outcome. + fn stage_slash_command_history(&mut self, command: &SlashCommandItem) { + if matches!(command, SlashCommandItem::Builtin(SlashCommand::Clear)) { + return; + } + self.stage_slash_command_history_text(self.draft.textarea.text().trim().to_string()); + } + + /// Stage a popup-selected command using its canonical command text. + /// + /// Popup filtering text can be partial, so recording the selected command avoids recalling + /// `/di` after the user actually accepted `/diff`. + fn stage_selected_slash_command_history(&mut self, command: &CommandItem) { + if matches!(command, CommandItem::Builtin(SlashCommand::Clear)) { + return; + } + self.stage_slash_command_history_text(format!("/{}", command.command())); + } + + /// Store the provided command text and the current composer adornments in the pending slot. + /// + /// The pending entry intentionally has the same shape as other local history entries so recall + /// can rehydrate attachments, mention bindings, and pending paste placeholders if command + /// workflows start carrying those through in the future. + fn stage_slash_command_history_text(&mut self, text: String) { + self.pending_slash_command_history = Some(HistoryEntry { + text, + text_elements: self.draft.textarea.text_elements(), + local_image_paths: self.attachments.local_image_paths(), + remote_image_urls: self.attachments.remote_image_urls(), + mention_bindings: self.snapshot_mention_bindings(), + pending_pastes: self.draft.pending_pastes.clone(), + }); + } + + fn handle_remote_image_selection_key( + &mut self, + key_event: &KeyEvent, + ) -> Option<(InputResult, bool)> { + let removes_remote_image = matches!(key_event.code, KeyCode::Delete | KeyCode::Backspace) + && self.attachments.selected_remote_image_index.is_some(); + let started_vim_edit = removes_remote_image && self.begin_direct_vim_edit(); + let result = self + .attachments + .handle_remote_image_selection_key(key_event, &mut self.draft.textarea); + if started_vim_edit { + self.finish_vim_edit(); + } + result + } + + /// Handle key event when no popup is visible. + fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if let Some((result, redraw)) = self.handle_remote_image_selection_key(&key_event) { + return (result, redraw); + } + if self.attachments.selected_remote_image_index.is_some() { + self.attachments.clear_remote_image_selection(); + } + if self.handle_empty_prompt_shortcut(&key_event) { + return (InputResult::None, true); + } + if self.draft.is_bash_mode && key_event.code == KeyCode::Esc { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + if self.draft.textarea.is_empty() { + self.draft.is_bash_mode = false; + return (InputResult::None, true); + } + } + if self.should_handle_vim_insert_escape(key_event) { + return self.handle_input_basic(key_event); + } + if self.draft.textarea.is_vim_normal_mode() && self.draft.textarea.is_vim_operator_pending() + { + return self.handle_input_basic(key_event); + } + if self.config.shell_commands_enabled + && self.draft.textarea.is_vim_normal_mode() + && self.is_empty() + && matches!( + key_event, + KeyEvent { + code: KeyCode::Char('!'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } + ) + { + self.footer.mode = reset_mode_after_activity(self.footer.mode); + self.draft.is_bash_mode = true; + self.draft.textarea.enter_vim_insert_mode(); + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + if self.is_empty() { + let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); + if next_mode != self.footer.mode { + self.footer.mode = next_mode; + return (InputResult::None, true); + } + } + } else { + self.footer.mode = reset_mode_after_activity(self.footer.mode); + } + if self.queue_keys.is_pressed(key_event) + && (self.is_task_running || self.queue_submissions || !self.is_bang_shell_command()) + { + return self.handle_submission(self.is_task_running || self.queue_submissions); + } + + if self.submit_keys.is_pressed(key_event) { + return self.handle_submission(self.queue_submissions); + } + + if let KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } = key_event + && self.is_empty() + { + return (InputResult::None, false); + } + + let (history_up_pressed, history_down_pressed) = if self.draft.textarea.is_vim_normal_mode() + { + if self.draft.textarea.is_vim_operator_pending() { + (false, false) + } else { + ( + self.vim_normal_keymap.move_up.is_pressed(key_event), + self.vim_normal_keymap.move_down.is_pressed(key_event), + ) + } + } else { + ( + self.editor_keymap.move_up.is_pressed(key_event), + self.editor_keymap.move_down.is_pressed(key_event), + ) + }; + if history_up_pressed || history_down_pressed { + if self + .history + .should_handle_navigation(&self.current_text(), self.history_navigation_cursor()) + { + let replace_entry = if history_up_pressed { + self.history.navigate_up(&self.app_event_tx) + } else { + self.history.navigate_down(&self.app_event_tx) + }; + if let Some(entry) = replace_entry { + self.apply_history_entry(entry); + return (InputResult::None, true); + } + } + return self.handle_input_basic(key_event); + } + + self.handle_input_basic(key_event) + } + + fn is_bang_shell_command(&self) -> bool { + self.config.shell_commands_enabled && self.current_text().trim_start().starts_with('!') + } + + fn shell_mode_footer_line(&self) -> Option> { + self.is_bang_shell_command() + .then_some(()) + .map(|_| Line::from(vec![Span::from("Shell mode").light_red()])) + } + + /// Applies any due `PasteBurst` flush at time `now`. + /// + /// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations. + /// + /// Callers: + /// + /// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render. + /// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag. + fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { + match self.draft.paste_burst.flush_if_due(now) { + FlushResult::Paste(pasted) => { + self.handle_paste(pasted); + true + } + FlushResult::Typed(ch) => { + self.insert_str(ch.to_string().as_str()); + true + } + FlushResult::None => false, + } + } + + /// Handles keys that mutate the textarea, including paste-burst detection. + /// + /// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain + /// character streams are converted into explicit paste operations on terminals that do not + /// reliably provide bracketed paste. + /// + /// Ordering is important: + /// + /// - Always flush any *due* paste burst first so buffered text does not lag behind unrelated + /// edits. + /// - Then handle the incoming key, intercepting only text-producing character input. + /// - For non-text keys, flush via `flush_before_modified_input()` before applying the key; + /// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a + /// timestamp to time out against. + fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { + // Ignore key releases here to avoid treating them as additional input + // (e.g., appending the same character twice via paste-burst logic). + if !matches!(input.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return (InputResult::None, false); + } + + self.handle_input_basic_with_time(input, Instant::now()) + } + + fn handle_input_basic_with_time( + &mut self, + input: KeyEvent, + now: Instant, + ) -> (InputResult, bool) { + if !self.draft.textarea.is_vim_normal_mode() { + self.begin_vim_edit(input); + } + // If we have a buffered non-bracketed paste burst and enough time has + // elapsed since the last char, flush it before handling a new input. + self.handle_paste_burst_flush(now); + + if !matches!(input.code, KeyCode::Esc) { + self.footer.mode = reset_mode_after_activity(self.footer.mode); + } + + // If we're capturing a burst and receive Enter, accumulate it instead of inserting. + if matches!(input.code, KeyCode::Enter) + && !self.draft.disable_paste_burst + && self.draft.paste_burst.is_active() + && self + .draft + .paste_burst + .append_control_char_if_active('\n', now) + { + return (InputResult::None, true); + } + + let has_non_text_modifier = has_ctrl_or_alt(input.modifiers) + || input + .modifiers + .intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META); + + // Intercept text-producing Char inputs to optionally accumulate into a burst buffer. + // + // This preserves plain, Shift, and Windows AltGr input while keeping shortcut modifiers + // out of the burst detector and flushing any in-flight text before a non-text key. + if let KeyEvent { + code: KeyCode::Char(ch), + .. + } = input + { + if !has_non_text_modifier + && !self.draft.disable_paste_burst + && self.draft.textarea.allows_paste_burst() + { + // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid + // holding the first char while still allowing burst detection for paste input. + if !ch.is_ascii() { + return self.handle_non_ascii_char(input, now); + } + + match self.draft.paste_burst.on_plain_char(ch, now) { + CharDecision::BufferAppend => { + self.draft.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + CharDecision::BeginBuffer { retro_chars } => { + let cur = self.draft.textarea.cursor(); + let txt = self.draft.textarea.text(); + let safe_cur = Self::clamp_to_char_boundary(txt, cur); + let before = &txt[..safe_cur]; + if let Some(grab) = self.draft.paste_burst.decide_begin_buffer( + now, + before, + retro_chars as usize, + ) { + if grab.grabbed.is_empty() + || self.draft.textarea.retract_paste_burst(grab.start_byte) + { + self.draft.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + self.draft.paste_burst.clear_after_explicit_paste(); + } + // If decide_begin_buffer opted not to start buffering, + // fall through to normal insertion below. + } + CharDecision::BeginBufferFromPending => { + // First char was held; now append the current one. + self.draft.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + CharDecision::RetainFirstChar => { + // Keep the first fast char pending momentarily. + return (InputResult::None, true); + } + } + } + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + } + + // Flush any buffered burst before applying a non-char input (arrow keys, etc). + // + // `clear_window_after_non_char()` clears `last_plain_char_time`. If we cleared that while + // `PasteBurst.buffer` is non-empty, `flush_if_due()` would no longer have a timestamp to + // time out against, and the buffered paste could remain stuck until another plain char + // arrives. + if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter) + && let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() + { + self.handle_paste(pasted); + } + // For non-char inputs (or after flushing), handle normally. + // Track element removals so we can drop any corresponding placeholders without scanning + // the full text. (Placeholders are atomic elements; when deleted, the element disappears.) + let elements_before = if self.draft.pending_pastes.is_empty() && self.attachments.is_empty() + { + None + } else { + Some(self.draft.textarea.element_payloads()) + }; + + if self.draft.is_bash_mode + && self.draft.textarea.vim_query().is_none() + && matches!(input.code, KeyCode::Backspace) + && self.draft.textarea.cursor() == 0 + { + let started_vim_edit = self.begin_direct_vim_edit(); + self.draft.is_bash_mode = false; + if started_vim_edit { + self.finish_vim_edit(); + } + return (InputResult::None, true); + } + + self.begin_vim_edit(input); + self.draft.textarea.input(input); + self.sync_bash_mode_from_text(); + + if let Some(elements_before) = elements_before { + self.reconcile_deleted_elements(elements_before); + } + self.finish_vim_edit(); + + // Update the paste-burst heuristic for text, shortcut, and non-char events. + match input.code { + KeyCode::Char(_) => { + if has_non_text_modifier { + self.draft.paste_burst.clear_window_after_non_char(); + } + } + KeyCode::Enter => { + // Keep burst window alive (supports blank lines in paste). + } + _ => { + // Other keys: clear burst window (buffer should have been flushed above if needed). + self.draft.paste_burst.clear_window_after_non_char(); + } + } + + (InputResult::None, true) + } + + fn sync_bash_mode_from_text(&mut self) { + if self.config.shell_commands_enabled + && !self.draft.is_bash_mode + && self.draft.textarea.text().starts_with('!') + { + self.draft.textarea.replace_range(0..1, ""); + self.draft.is_bash_mode = true; + } + } + + fn reconcile_deleted_elements(&mut self, elements_before: Vec) { + let elements_after: HashSet = + self.draft.textarea.element_payloads().into_iter().collect(); + + let removed_payloads = elements_before + .into_iter() + .filter(|payload| !elements_after.contains(payload)) + .collect::>(); + for removed in &removed_payloads { + self.draft.pending_pastes.retain(|(ph, _)| ph != removed); + } + self.attachments + .remove_deleted_local_placeholders(&removed_payloads, &mut self.draft.textarea); + } + + /// Handle empty-prompt agents navigation and the shortcut-overlay toggle. + /// + /// This only toggles when the composer is empty and no paste burst is in + /// progress, so typing/pasting `?` still inserts text instead of opening + /// help. The bound key list intentionally supports terminal-variant + /// modifier reporting (for example `?` vs `shift-?`). + fn handle_empty_prompt_shortcut(&mut self, key_event: &KeyEvent) -> bool { + if key_event.kind != KeyEventKind::Press { + return false; + } + + if key_hint::plain(KeyCode::Left).is_press(*key_event) && self.agents_navigation_available() + { + self.app_event_tx.send(AppEvent::OpenAgentsOverview); + return true; + } + + let toggles = self.footer.hint_override.is_none() + && self.toggle_shortcuts_keys.is_pressed(*key_event) + && self.is_empty() + && !self.is_in_paste_burst(); + + if !toggles { + return false; + } + + let next = toggle_shortcut_mode( + self.footer.mode, + self.quit_shortcut_hint_visible(), + self.is_empty(), + ); + let changed = next != self.footer.mode; + self.footer.mode = next; + changed + } + + fn footer_props(&self) -> FooterProps { + let mode = self.footer_mode(); + let is_wsl = { + #[cfg(target_os = "linux")] + { + mode == FooterMode::ShortcutOverlay && crate::clipboard_paste::is_probably_wsl() + } + #[cfg(not(target_os = "linux"))] + { + false + } + }; + + FooterProps { + mode, + esc_backtrack_hint: self.footer.esc_backtrack_hint, + use_shift_enter_hint: self.footer.use_shift_enter_hint, + is_task_running: self.is_task_running, + queue_submissions: self.queue_submissions, + quit_shortcut_key: self.footer.quit_shortcut_key, + collaboration_modes_enabled: self.collaboration_modes_enabled, + is_wsl, + status_line_value: self.footer.status_line_value.clone(), + status_line_enabled: self.footer.status_line_enabled, + key_hints: FooterKeyHints { + agents: self + .agents_navigation_available() + .then_some(key_hint::plain(KeyCode::Left).into()), + toggle_shortcuts: self.footer.toggle_shortcuts_key, + queue: self.footer.queue_key, + insert_newline: self.footer.insert_newline_key, + external_editor: self.footer.external_editor_key, + edit_previous: Some(key_hint::plain(KeyCode::Esc).into()), + show_transcript: self.footer.show_transcript_key, + history_search: self.footer.history_search_key, + reasoning_down: self.footer.reasoning_down_key, + reasoning_up: self.footer.reasoning_up_key, + }, + active_agent_label: self.footer.active_agent_label.clone(), + } + } + + /// Resolve the effective footer mode via a small priority waterfall. + /// + /// The base mode is derived solely from whether the composer is empty: + /// `ComposerEmpty` iff empty, otherwise `ComposerHasDraft`. Transient + /// modes (Esc hint, overlay, quit reminder) can override that base when + /// their conditions are active. + fn footer_mode(&self) -> FooterMode { + if self.history_search.is_some() || self.draft.textarea.vim_query().is_some() { + return FooterMode::HistorySearch; + } + + let base_mode = if self.is_empty() { + FooterMode::ComposerEmpty + } else { + FooterMode::ComposerHasDraft + }; + + match self.footer.mode { + FooterMode::HistorySearch => FooterMode::HistorySearch, + FooterMode::EscHint => FooterMode::EscHint, + FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, + FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => { + FooterMode::QuitShortcutReminder + } + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft + if self.quit_shortcut_hint_visible() => + { + FooterMode::QuitShortcutReminder + } + FooterMode::QuitShortcutReminder => base_mode, + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => base_mode, + } + } + + fn custom_footer_height(&self) -> Option { + if self.draft.textarea.vim_query().is_some() || self.footer.flash_visible() { + return Some(1); + } + self.footer + .hint_override + .as_ref() + .map(|items| if items.is_empty() { 0 } else { 1 }) + } + + pub(crate) fn sync_popups(&mut self) { + self.sync_slash_command_elements(); + if self.history_search.is_some() || self.draft.textarea.vim_query().is_some() { + if self.popups.current_file_query.is_some() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + self.popups.current_file_query = None; + } + self.popups.active = ActivePopup::None; + self.popups.dismissed_file_token = None; + self.popups.dismissed_mention_token = None; + return; + } + if !self.popups_enabled() { + self.popups.active = ActivePopup::None; + return; + } + let mut mentions_v2_token = self.current_mentions_v2_token_range(); + let mut file_token = if self.mentions_v2_enabled { + None + } else { + self.current_editable_at_token_range_with_options(/*allow_empty*/ false) + }; + let browsing_history = self + .history + .should_handle_navigation(&self.current_text(), self.history_navigation_cursor()); + // When browsing input history (shell-style Up/Down recall), skip all popup + // synchronization so nothing steals focus from continued history navigation. + if browsing_history { + if self.popups.current_file_query.is_some() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + self.popups.current_file_query = None; + } + self.popups.active = ActivePopup::None; + return; + } + let mut mention_target = self.current_mention_target(); + let at_token_start = mentions_v2_token + .as_ref() + .or(file_token.as_ref()) + .map(|(range, _)| range.start); + let mention_token_start = mention_target.as_ref().map(|target| target.range.start); + if let (Some(at_token_start), Some(mention_token_start)) = + (at_token_start, mention_token_start) + { + if at_token_start > mention_token_start { + mention_target = None; + } else if mention_token_start > at_token_start { + mentions_v2_token = None; + file_token = None; + } + } + + let allow_command_popup = self.slash_commands_enabled() + && !self.draft.is_bash_mode + && file_token.is_none() + && mentions_v2_token.is_none() + && mention_target.is_none(); + self.sync_command_popup(allow_command_popup); + + if matches!(self.popups.active, ActivePopup::Command(_)) { + if self.popups.current_file_query.is_some() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + self.popups.current_file_query = None; + } + self.popups.dismissed_file_token = None; + self.popups.dismissed_mention_token = None; + return; + } + + if let Some((range, token)) = mentions_v2_token { + self.sync_mentions_v2_popup(range, token); + return; + } + + if let Some(target) = mention_target { + if self.popups.current_file_query.is_some() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + self.popups.current_file_query = None; + } + self.sync_mention_popup(target); + return; + } + self.popups.dismissed_mention_token = None; + + if let Some((range, token)) = file_token { + self.sync_file_search_popup(range, token); + return; + } + + if self.popups.current_file_query.is_some() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + self.popups.current_file_query = None; + } + self.popups.dismissed_file_token = None; + if matches!( + self.popups.active, + ActivePopup::File(_) | ActivePopup::Skill(_) | ActivePopup::MentionV2(_) + ) { + self.popups.active = ActivePopup::None; + } + } + + /// Synchronize `self.command_popup` with the current text in the + /// textarea. This must be called after every modification that can change + /// the text so the popup is shown/updated/hidden as appropriate. + fn sync_command_popup(&mut self, allow: bool) { + let text = self.draft.textarea.text(); + let first_line_end = text.find('\n').unwrap_or(text.len()); + let first_line = &text[..first_line_end]; + // Keep an explicitly dismissed popup closed until the command token changes. + let command_token = slash_input::command_popup_filter_text(first_line, /*cursor*/ 0); + if let Some(command_token) = command_token.as_deref() + && self.popups.dismissed_command_token.as_deref() == Some(command_token) + { + return; + } + self.popups.dismissed_command_token = None; + + if !allow { + if matches!(self.popups.active, ActivePopup::Command(_)) { + self.popups.active = ActivePopup::None; + } + return; + } + // Determine whether the caret is inside the initial '/name' token on the first line. + let cursor = self.draft.textarea.cursor(); + let caret_on_first_line = cursor <= first_line_end; + + let is_editing_slash_command_name = caret_on_first_line + && self + .slash_input() + .is_editing_command_name(first_line, cursor); + let command_filter_text = caret_on_first_line + .then(|| slash_input::command_popup_filter_text(first_line, cursor)) + .flatten(); + + // If the cursor is currently positioned within an `@token`, prefer the + // file-search popup over the slash popup so users can insert a file path + // as an argument to the command (e.g., "/review @docs/..."). + if Self::current_at_token(&self.draft.textarea).is_some() { + if matches!(self.popups.active, ActivePopup::Command(_)) { + self.popups.active = ActivePopup::None; + } + return; + } + match &mut self.popups.active { + ActivePopup::Command(popup) => { + if is_editing_slash_command_name { + if let Some(command_filter_text) = command_filter_text.as_deref() { + popup.on_composer_text_change(command_filter_text.to_string()); + } + } else { + self.popups.active = ActivePopup::None; + } + } + _ => { + if is_editing_slash_command_name + && let Some(command_filter_text) = command_filter_text.as_deref() + { + let command_popup = self.slash_input().command_popup(command_filter_text); + self.popups.active = ActivePopup::Command(command_popup); + } + } + } + } + + /// Synchronize the legacy file-search popup with the current `@` token. + fn sync_file_search_popup(&mut self, range: Range, query: String) { + if self + .popups + .dismissed_file_token + .as_ref() + .is_some_and(|dismissed| dismissed.matches(&self.draft.textarea, &range, &query)) + { + return; + } + + if query.is_empty() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + } else { + self.app_event_tx + .send(AppEvent::StartFileSearch(query.clone())); + } + + match &mut self.popups.active { + ActivePopup::File(popup) => { + if query.is_empty() { + popup.set_empty_prompt(); + } else { + popup.set_query(&query); + } + } + _ => { + let mut popup = FileSearchPopup::new(); + if query.is_empty() { + popup.set_empty_prompt(); + } else { + popup.set_query(&query); + } + self.popups.active = ActivePopup::File(popup); + } + } + + if query.is_empty() { + self.popups.current_file_query = None; + } else { + self.popups.current_file_query = Some(query); + } + self.popups.dismissed_file_token = None; + } + + fn sync_mention_popup(&mut self, target: MentionCompletionTarget) { + let MentionCompletionTarget { + range, + query, + prebuilt_mentions, + } = target; + if self + .popups + .dismissed_mention_token + .as_ref() + .is_some_and(|dismissed| dismissed.matches(&self.draft.textarea, &range, &query)) + { + return; + } + + let mentions = prebuilt_mentions.unwrap_or_else(|| self.mention_items()); + if mentions.is_empty() { + self.popups.active = ActivePopup::None; + return; + } + + match &mut self.popups.active { + ActivePopup::Skill(popup) => { + popup.set_query(&query); + popup.set_mentions(mentions); + } + _ => { + let mut popup = SkillPopup::new(mentions); + popup.set_query(&query); + self.popups.active = ActivePopup::Skill(popup); + } + } + } + + fn sync_mentions_v2_popup(&mut self, range: Range, query: String) { + if self + .popups + .dismissed_mention_token + .as_ref() + .is_some_and(|dismissed| dismissed.matches(&self.draft.textarea, &range, &query)) + { + return; + } + + if query.is_empty() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + self.popups.current_file_query = None; + } else { + let new_popup = !matches!(self.popups.active, ActivePopup::MentionV2(_)); + if new_popup { + // A fresh popup has no cached matches, and the app-owned file-search manager can + // retain an identical query. Reset it before issuing the query so results arrive. + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + } + if new_popup || self.popups.current_file_query.as_deref() != Some(query.as_str()) { + self.app_event_tx + .send(AppEvent::StartFileSearch(query.clone())); + self.popups.current_file_query = Some(query.clone()); + } + } + + match &mut self.popups.active { + ActivePopup::MentionV2(popup) => { + popup.set_query(&query); + } + _ => { + let candidates = super::mentions_v2::build_search_catalog( + self.skills.as_deref(), + self.plugins.as_deref(), + self.task_mentions.as_deref().unwrap_or_default(), + ); + self.popups.active = + ActivePopup::MentionV2(MentionV2Popup::new(candidates, &query)); + } + } + + self.popups.dismissed_mention_token = None; + } + + fn mention_items(&self) -> Vec { + let mut mentions = Vec::new(); + if let Some(skills) = self.skills.as_ref() { + for skill in skills { + let display_name = skill_display_name(skill); + let description = skill_description(skill); + let skill_name = skill.name.clone(); + let search_terms = if display_name == skill.name { + vec![skill_name.clone()] + } else { + vec![skill_name.clone(), display_name.clone()] + }; + mentions.push(MentionItem { + display_name, + description, + insert_text: format!("${skill_name}"), + search_terms, + path: Some(skill.path.to_string_lossy().into_owned()), + category_tag: Some("[Skill]".to_string()), + sort_rank: 1, + }); + } + } + + if !self.mentions_v2_enabled + && let Some(plugins) = self.plugins.as_ref() + { + for plugin in plugins { + let (plugin_name, marketplace_name) = plugin + .config_name + .split_once('@') + .unwrap_or((plugin.config_name.as_str(), "")); + let mut capability_labels = Vec::new(); + if plugin.has_skills { + capability_labels.push("skills".to_string()); + } + if !plugin.mcp_server_names.is_empty() { + let mcp_server_count = plugin.mcp_server_names.len(); + capability_labels.push(if mcp_server_count == 1 { + "1 MCP server".to_string() + } else { + format!("{mcp_server_count} MCP servers") + }); + } + if !plugin.app_connector_ids.is_empty() { + let app_count = plugin.app_connector_ids.len(); + capability_labels.push(if app_count == 1 { + "1 app".to_string() + } else { + format!("{app_count} apps") + }); + } + let description = plugin.description.clone().or_else(|| { + Some(if capability_labels.is_empty() { + "Plugin".to_string() + } else { + format!("Plugin · {}", capability_labels.join(" · ")) + }) + }); + let mut search_terms = vec![plugin_name.to_string(), plugin.config_name.clone()]; + if plugin.display_name != plugin_name { + search_terms.push(plugin.display_name.clone()); + } + if !marketplace_name.is_empty() { + search_terms.push(marketplace_name.to_string()); + } + mentions.push(MentionItem { + display_name: plugin.display_name.clone(), + description, + insert_text: format!("${plugin_name}"), + search_terms, + path: Some(format!("plugin://{}", plugin.config_name)), + category_tag: Some("[Plugin]".to_string()), + sort_rank: 0, + }); + } + } + + if self.connectors_enabled + && let Some(snapshot) = self.connectors_snapshot.as_ref() + { + for connector in &snapshot.connectors { + if !connector.is_accessible || !connector.is_enabled { + continue; + } + let display_name = codex_connectors::metadata::connector_display_label(connector); + let description = Some(Self::connector_brief_description(connector)); + let slug = codex_connectors::metadata::connector_mention_slug(connector); + let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()]; + let connector_id = connector.id.as_str(); + mentions.push(MentionItem { + display_name: display_name.clone(), + description, + insert_text: format!("${slug}"), + search_terms, + path: Some(format!("app://{connector_id}")), + category_tag: Some("[App]".to_string()), + sort_rank: 1, + }); + } + } + + mentions + } + + fn connector_brief_description(connector: &AppInfo) -> String { + Self::connector_description(connector).unwrap_or_default() + } + + fn connector_description(connector: &AppInfo) -> Option { + connector + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) + } + + fn set_has_focus(&mut self, has_focus: bool) { + self.has_focus = has_focus; + } + + #[allow(dead_code)] + pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { + self.draft.input_enabled = enabled; + self.draft.input_disabled_placeholder = if enabled { None } else { placeholder }; + + // Avoid leaving interactive popups open while input is blocked. + if !enabled && self.popups.active() { + self.popups.active = ActivePopup::None; + } + } + + pub(crate) fn show_shutdown_in_progress(&mut self) { + self.set_input_enabled(/*enabled*/ false, Some("Shutting down...".to_string())); + self.footer.quit_shortcut_expires_at = None; + self.footer.mode = FooterMode::ComposerEmpty; + self.footer.hint_override = Some(Vec::new()); + self.footer.flash = None; + } + + pub fn set_task_running(&mut self, running: bool) { + self.is_task_running = running; + } + + pub(crate) fn set_queue_submissions(&mut self, queue_submissions: bool) { + self.queue_submissions = queue_submissions; + } + + pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { + if self.footer.context_window_percent == percent + && self.footer.context_window_used_tokens == used_tokens + { + return; + } + self.footer.context_window_percent = percent; + self.footer.context_window_used_tokens = used_tokens; + } + + pub(crate) fn set_context_window_pending(&mut self, pending: bool) { + self.footer.context_window_pending = pending; + } + + pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { + self.footer.esc_backtrack_hint = show; + if show { + self.footer.mode = esc_hint_mode(self.footer.mode, self.is_task_running); + } else { + self.footer.mode = reset_mode_after_activity(self.footer.mode); + } + } + + pub(crate) fn set_status_line(&mut self, status_line: Option>) -> bool { + if self.footer.status_line_value == status_line { + return false; + } + self.footer.status_line_value = status_line; + true + } + + pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) -> bool { + if self.footer.status_line_hyperlink_url == url { + return false; + } + self.footer.status_line_hyperlink_url = url; + true + } + + pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool { + if self.footer.status_line_enabled == enabled { + return false; + } + self.footer.status_line_enabled = enabled; + true + } + + pub(crate) fn set_side_conversation_context_label(&mut self, label: Option) -> bool { + if self.footer.side_conversation_context_label == label { + return false; + } + self.footer.side_conversation_context_label = label; + true + } + + /// Replaces the contextual footer label for the currently viewed agent. + /// + /// Returning `false` means the value was unchanged, so callers can skip redraw work. This + /// field is intentionally just cached presentation state; `ChatComposer` does not infer which + /// thread is active on its own. + pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) -> bool { + if self.footer.active_agent_label == active_agent_label { + return false; + } + self.footer.active_agent_label = active_agent_label; + true + } +} + +fn footer_insert_newline_key( + bindings: &[KeyBinding], + enhanced_keys_supported: bool, +) -> Option { + let bindings = user_bindings(bindings); + let shift_enter = key_hint::shift(KeyCode::Enter); + if enhanced_keys_supported && bindings.contains(&shift_enter) { + return Some(shift_enter); + } + + let plain_enter = key_hint::plain(KeyCode::Enter); + bindings + .iter() + .copied() + .find(|binding| *binding != plain_enter) + .or_else(|| bindings.first().copied()) +} + +fn skill_description(skill: &SkillMetadata) -> Option { + let description = skill + .interface + .as_ref() + .and_then(|interface| interface.short_description.as_deref()) + .or(skill.short_description.as_deref()) + .unwrap_or(&skill.description); + let trimmed = description.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn is_mention_name_char(byte: u8) -> bool { + matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') +} + +fn ends_plaintext_at_mention(bytes: &[u8], index: usize) -> bool { + bytes.get(index).is_none_or(|byte| { + byte.is_ascii_whitespace() + || *byte == b'.' + && bytes.get(index + 1).is_none_or(|next| { + next.is_ascii_whitespace() + || !next.is_ascii_alphanumeric() && *next != b'_' && *next != b'-' + }) + || !matches!(*byte, b'.' | b'/' | b'\\') + && !byte.is_ascii_alphanumeric() + && *byte != b'_' + && *byte != b'-' + }) +} + +fn ends_plaintext_at_dollar_mention(bytes: &[u8], index: usize) -> bool { + bytes + .get(index) + .is_none_or(|byte| !is_mention_name_char(*byte)) +} + +fn starts_plaintext_at_mention(text: &str, index: usize) -> bool { + if index == 0 { + return true; + } + + text.get(..index) + .and_then(|prefix| prefix.chars().next_back()) + .is_some_and(|ch| ch.is_whitespace() || !is_mention_name_char_char(ch)) +} + +fn is_mention_name_char_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') +} + +fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option> { + if token.is_empty() || from >= text.len() { + return None; + } + let bytes = text.as_bytes(); + let token_bytes = token.as_bytes(); + let sigil = *token_bytes.first()?; + let mut index = from; + + while index < bytes.len() { + if bytes[index] != sigil { + index += 1; + continue; + } + + let end = index.saturating_add(token_bytes.len()); + if end > bytes.len() { + return None; + } + if &bytes[index..end] != token_bytes { + index += 1; + continue; + } + + // Fix for restored `@` mentions: rebinding must not attach to embedded substrings such + // as email addresses, while preserving the existing `$` mention matching behavior. + let starts_plaintext_mention = if sigil == b'@' { + starts_plaintext_at_mention(text, index) + } else { + true + }; + // Fix for restored `@` mentions: mirror history encoding's trailing boundary so path-like + // text such as `@sample/pkg` is not rebound as the plain `@sample` mention. + let ends_plaintext_mention = if sigil == b'@' { + ends_plaintext_at_mention(bytes, end) + } else { + ends_plaintext_at_dollar_mention(bytes, end) + }; + + if starts_plaintext_mention && ends_plaintext_mention { + return Some(index..end); + } + + index = end; + } + + None +} + +impl Renderable for ChatComposer { + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.cursor_pos_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) + } + + fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle { + if self.draft.textarea.uses_vim_insert_cursor() { + crossterm::cursor::SetCursorStyle::SteadyBar + } else { + crossterm::cursor::SetCursorStyle::DefaultUserShape + } + } + + fn desired_height(&self, width: u16) -> u16 { + self.desired_height_with_textarea_right_reserve(width, /*textarea_right_reserve*/ 0) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_with_mask(area, buf, /*mask_char*/ None); + } +} + +impl ChatComposer { + pub(crate) fn set_luna_reserve_active(&mut self, active: bool) -> bool { + if self.luna_reserve_active == active { + return false; + } + self.luna_reserve_active = active; + true + } + + pub(crate) fn desired_height_with_textarea_right_reserve( + &self, + width: u16, + textarea_right_reserve: u16, + ) -> u16 { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(&footer_props)); + let footer_total_height = footer_hint_height + Self::footer_spacing(footer_hint_height); + const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; + let inner_width = + width.saturating_sub(COLS_WITH_MARGIN.saturating_add(textarea_right_reserve)); + let remote_images_height: u16 = self + .attachments + .remote_image_lines() + .len() + .try_into() + .unwrap_or(u16::MAX); + let remote_images_separator = u16::from(remote_images_height > 0); + self.draft.textarea.desired_height(inner_width) + + remote_images_height + + remote_images_separator + + 2 + + if self.voice_strip.is_some() { 3 } else { 0 } + + self + .popups + .active + .required_height(width, footer_total_height) + } +} + +impl ChatComposer { + pub(crate) fn render_with_mask(&self, area: Rect, buf: &mut Buffer, mask_char: Option) { + self.render_with_mask_and_textarea_right_reserve( + area, buf, mask_char, /*textarea_right_reserve*/ 0, + ); + } + + pub(crate) fn render_with_mask_and_textarea_right_reserve( + &self, + area: Rect, + buf: &mut Buffer, + mask_char: Option, + textarea_right_reserve: u16, + ) { + let [composer_rect, remote_images_rect, textarea_rect, popup_rect] = + self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve); + match &self.popups.active { + ActivePopup::Command(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::File(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::Skill(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::MentionV2(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::None => { + let footer_props = self.footer_props(); + let show_cycle_hint = !footer_props.is_task_running + && self.footer.collaboration_mode_indicator.is_some(); + let show_shortcuts_hint = match footer_props.mode { + FooterMode::ComposerEmpty => !self.is_in_paste_burst(), + FooterMode::ComposerHasDraft => false, + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, + }; + let show_queue_hint = match footer_props.mode { + FooterMode::ComposerHasDraft => footer_props.is_task_running, + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ComposerEmpty + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, + }; + let custom_height = self.custom_footer_height(); + let footer_hint_height = + custom_height.unwrap_or_else(|| footer_height(&footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { + let [_, hint_rect] = Layout::vertical([ + Constraint::Length(footer_spacing), + Constraint::Length(footer_hint_height), + ]) + .areas(popup_rect); + hint_rect + } else { + popup_rect + }; + if let Some(input) = self.draft.textarea.vim_query() { + input.render(inset_footer_hint_area(hint_rect), buf); + } else if let Some(line) = self.history_search_footer_line() { + render_footer_line(hint_rect, buf, line); + } else { + let available_width = + hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; + let status_line_active = uses_passive_footer_status_layout(&footer_props); + let combined_status_line = if status_line_active { + passive_footer_status_line(&footer_props) + } else { + None + }; + let transition_visible = status_line_active + && !self.footer.flash_visible() + && self.footer.hint_override.is_none(); + let transition_active = transition_visible + && self + .effort_status_line_transition + .as_ref() + .is_some_and(|transition| !transition.is_finished()); + let combined_status_line = if transition_visible + && let Some(transition) = &self.effort_status_line_transition + && !transition.is_finished() + { + transition.render_line( + combined_status_line.as_ref(), + hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16), + ) + } else { + combined_status_line + }; + let mut truncated_status_line = if status_line_active { + combined_status_line.as_ref().map(|line| { + truncate_line_with_ellipsis_if_overflow(line.clone(), available_width) + }) + } else { + None + }; + let left_mode_indicator = if status_line_active { + None + } else { + self.footer.collaboration_mode_indicator + }; + let active_footer_hint_override = self.footer.hint_override.as_ref(); + let mut left_width = if self.footer.flash_visible() { + self.footer + .flash + .as_ref() + .map(|flash| flash.line.width() as u16) + .unwrap_or(0) + } else if let Some(items) = active_footer_hint_override { + footer_hint_items_width(items) + } else if status_line_active { + truncated_status_line + .as_ref() + .map(|line| line.width() as u16) + .unwrap_or(0) + } else { + footer_line_width( + &footer_props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ) + }; + let right_line = + if let Some(label) = self.footer.side_conversation_context_label.as_ref() { + Some(side_conversation_context_line(label)) + } else if let Some(line) = self.shell_mode_footer_line() { + Some(line) + } else if transition_active { + None + } else if status_line_active { + let full = self.mode_indicator_line(show_cycle_hint); + let compact = self.mode_indicator_line(/*show_cycle_hint*/ false); + let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0); + if can_show_left_with_context(hint_rect, left_width, full_width) { + full + } else { + compact + } + } else { + Some(self.right_footer_line_with_context()) + }; + let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0); + if status_line_active + && let Some(max_left) = max_left_width_for_right(hint_rect, right_width) + && left_width > max_left + && let Some(line) = combined_status_line.as_ref().map(|line| { + truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize) + }) + { + left_width = line.width() as u16; + truncated_status_line = Some(line); + } + let can_show_left_and_context = + can_show_left_with_context(hint_rect, left_width, right_width); + let has_override = + self.footer.flash_visible() || active_footer_hint_override.is_some(); + let single_line_layout = if has_override || status_line_active { + None + } else { + match footer_props.mode { + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => { + // Both of these modes render the single-line footer style (with + // either the shortcuts hint or the optional queue hint). We still + // want the single-line collapse rules so the mode label can win over + // the context indicator on narrow widths. + Some(single_line_footer_layout( + hint_rect, + right_width, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + footer_props.key_hints, + )) + } + FooterMode::EscHint + | FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay => None, + } + }; + let show_right = if matches!( + footer_props.mode, + FooterMode::EscHint + | FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + ) { + false + } else { + single_line_layout + .as_ref() + .map(|(_, show_context)| *show_context) + .unwrap_or(can_show_left_and_context) + }; + + if let Some((summary_left, _)) = single_line_layout { + match summary_left { + SummaryLeft::Default => { + if status_line_active { + if let Some(line) = truncated_status_line.clone() { + render_footer_line(hint_rect, buf, line); + } else { + render_footer_from_props( + hint_rect, + buf, + &footer_props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); + } + } else { + render_footer_from_props( + hint_rect, + buf, + &footer_props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); + } + } + SummaryLeft::Custom(line) => { + render_footer_line(hint_rect, buf, line); + } + SummaryLeft::None => {} + } + } else if self.footer.flash_visible() { + if let Some(flash) = self.footer.flash.as_ref() { + Widget::render(&flash.line, inset_footer_hint_area(hint_rect), buf); + } + } else if let Some(items) = active_footer_hint_override { + render_footer_hint_items(hint_rect, buf, items); + } else if status_line_active { + if let Some(line) = truncated_status_line { + render_footer_line(hint_rect, buf, line); + } + } else { + render_footer_from_props( + hint_rect, + buf, + &footer_props, + self.footer.collaboration_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); + } + if show_right && let Some(line) = &right_line { + render_context_right(hint_rect, buf, line); + } + if status_line_active + && let Some(url) = self.footer.status_line_hyperlink_url.as_deref() + { + mark_underlined_hyperlink(buf, hint_rect, url); + } + if transition_visible + && let Some(transition) = &self.effort_status_line_transition + && !transition.is_finished() + && let Some(frame_requester) = &self.frame_requester + { + frame_requester.schedule_frame_in(EFFORT_STATUS_LINE_FRAME_TICK); + } + } + } + } + let style = user_message_style(); + Block::default().style(style).render(composer_rect, buf); + if !remote_images_rect.is_empty() { + Paragraph::new(self.attachments.remote_image_lines()) + .style(style) + .render(remote_images_rect, buf); + } + if !textarea_rect.is_empty() { + let prompt = if self.draft.input_enabled { + if self.draft.is_bash_mode { + Span::from("!").light_red().bold() + } else if self.luna_reserve_active { + // Reserve keeps one arrow at every reasoning effort; only its foreground changes. + "›" + .fg(crate::terminal_palette::best_color((246, 197, 67))) + .bold() + } else if let Some(tier) = self.effort_tier { + let charge = self + .effort_ignition + .as_ref() + .map(EffortIgnition::charge_alpha) + .unwrap_or(1.0); + tier.prompt(charge) + } else { + "›".bold() + } + } else { + "›".dim() + }; + buf.set_span( + textarea_rect.x - LIVE_PREFIX_COLS, + textarea_rect.y, + &prompt, + textarea_rect.width, + ); + } + + let mut state = self.draft.textarea_state.borrow_mut(); + let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode; + if self.draft.input_enabled { + if let Some(mask_char) = mask_char { + self.draft + .textarea + .render_ref_masked(textarea_rect, buf, &mut state, mask_char); + } else { + let mut highlights = self.plugin_at_mention_highlights(); + let search_highlight_style = + Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); + highlights.extend( + self.history_search_highlight_ranges() + .into_iter() + .chain(self.draft.textarea.vim_search_highlights()) + .map(|range| (range, search_highlight_style)), + ); + if highlights.is_empty() { + StatefulWidgetRef::render_ref( + &(&self.draft.textarea), + textarea_rect, + buf, + &mut state, + ); + } else { + self.draft.textarea.render_ref_styled_with_highlights( + textarea_rect, + buf, + &mut state, + Style::default(), + &highlights, + ); + } + } + } + if !self.draft.input_enabled || textarea_is_empty { + let text = if self.draft.input_enabled { + self.placeholder_text.as_str().to_string() + } else { + self.draft + .input_disabled_placeholder + .as_deref() + .unwrap_or("Input disabled.") + .to_string() + }; + if !textarea_rect.is_empty() { + let placeholder = Span::from(text).dim(); + Line::from(vec![placeholder]).render(textarea_rect.inner(Margin::new(0, 0)), buf); + } + } + if matches!(self.popups.active, ActivePopup::None) + && let Some(ignition) = &self.effort_ignition + && !ignition.is_finished() + { + let protected_top = if remote_images_rect.is_empty() { + textarea_rect.y + } else { + remote_images_rect.y + }; + let protected = Rect::new( + composer_rect.x, + protected_top, + composer_rect.width, + textarea_rect.bottom().saturating_sub(protected_top), + ); + if ignition.render(composer_rect, protected, buf) + && let Some(frame_requester) = &self.frame_requester + { + frame_requester.schedule_frame_in(IGNITION_FRAME_TICK); + } + } + drop(state); + self.render_voice_strip(composer_rect, buf); + } +} + +#[cfg(test)] +#[path = "chat_composer/agents_navigation_tests.rs"] +mod agents_navigation_tests; + +#[cfg(test)] +#[path = "chat_composer_effort_tests.rs"] +mod effort_tests; + +#[cfg(test)] +#[path = "chat_composer/embedded_input_tests.rs"] +mod embedded_input_tests; + +#[cfg(test)] +#[path = "chat_composer/paste_tests.rs"] +mod paste_tests; + +#[cfg(test)] +#[path = "chat_composer/snapshot_tests.rs"] +mod snapshot_tests; + +#[cfg(test)] +mod tests { + use super::attachment_state::AttachedImage; + use super::*; + use crate::test_support::PathBufExt; + use crate::test_support::test_path_buf; + use image::ImageBuffer; + use image::Rgba; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + use tempfile::tempdir; + + use crate::app_event::AppEvent; + + use crate::bottom_pane::AppEventSender; + use crate::bottom_pane::ChatComposer; + use crate::bottom_pane::InputResult; + use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; + use crate::bottom_pane::textarea::TextArea; + use codex_protocol::models::local_image_label_text; + use tokio::sync::mpsc::UnboundedReceiver; + use tokio::sync::mpsc::unbounded_channel; + + pub(super) fn new_test_composer() -> (ChatComposer, UnboundedReceiver) { + let (tx, rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + ( + ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ), + rx, + ) + } + + #[test] + fn shortcut_footer_displays_configured_chords() { + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; + + let mut config = TuiKeymap::default(); + config.global.open_external_editor = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-g ctrl-g".to_string(), + ))); + config.editor.insert_newline = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x enter".to_string(), + ))); + let keymap = RuntimeKeymap::from_config(&config).expect("valid composer chords"); + let (mut composer, _rx) = new_test_composer(); + composer.set_keymap_bindings(&keymap); + composer.footer.mode = FooterMode::ShortcutOverlay; + + let hints = composer.footer_props().key_hints; + assert_eq!( + hints.external_editor, + Some(ShortcutHint::Chord { + prefix: key_hint::ctrl(KeyCode::Char('g')), + completion: key_hint::ctrl(KeyCode::Char('g')), + }) + ); + assert_eq!( + hints.insert_newline, + Some(ShortcutHint::Chord { + prefix: key_hint::ctrl(KeyCode::Char('x')), + completion: key_hint::plain(KeyCode::Enter), + }) + ); + + snapshot_composer_state( + "footer_mode_configured_key_chords", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_keymap_bindings(&keymap); + composer.footer.mode = FooterMode::ShortcutOverlay; + }, + ); + } + + #[test] + fn parent_owned_thread_placeholder_snapshot() { + snapshot_composer_state( + "parent_owned_thread_placeholder", + /*enhanced_keys_supported*/ false, + ChatComposer::set_parent_owned_thread, + ); + } + + #[test] + fn light_terminal_palette_renders_light_composer_snapshot() { + let colors = crate::terminal_probe::DefaultColors { + fg: (0x55, 0x57, 0x53), + bg: (0xff, 0xff, 0xff), + }; + + crate::terminal_palette::with_test_default_colors(colors, || { + let (composer, _rx) = new_test_composer(); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 6, + ); + let mut buffer = Buffer::empty(area); + composer.render(area, &mut buffer); + + assert_eq!( + buffer[(0, 1)].bg, + crate::terminal_palette::rgb_color((244, 244, 244)) + ); + insta::assert_snapshot!("light_terminal_palette_composer", format!("{buffer:?}")); + }); + } + + #[test] + fn footer_hint_row_is_separated_from_composer() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let row_to_string = |y: u16| { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + row + }; + + let mut hint_row: Option<(u16, String)> = None; + for y in 0..area.height { + let row = row_to_string(y); + if row.contains("? for shortcuts") { + hint_row = Some((y, row)); + break; + } + } + + let (hint_row_idx, hint_row_contents) = + hint_row.expect("expected footer hint row to be rendered"); + assert_eq!( + hint_row_idx, + area.height - 1, + "hint row should occupy the bottom line: {hint_row_contents:?}", + ); + + assert!( + hint_row_idx > 0, + "expected a spacing row above the footer hints", + ); + + let spacing_row = row_to_string(hint_row_idx - 1); + assert_eq!( + spacing_row.trim(), + "", + "expected blank spacing row above hints but saw: {spacing_row:?}", + ); + } + + #[test] + fn footer_flash_overrides_footer_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("FLASH"), + "expected flash content to render in footer row, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("K label"), + "expected flash to override hint override, saw: {bottom_row:?}", + ); + } + + #[test] + fn footer_flash_expires_and_falls_back_to_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + composer.footer.flash.as_mut().unwrap().expires_at = + Instant::now() - Duration::from_secs(1); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("K label"), + "expected hint override to render after flash expired, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("FLASH"), + "expected expired flash to be hidden, saw: {bottom_row:?}", + ); + } + + pub(super) fn snapshot_composer_state_with_width( + name: &str, + width: u16, + enhanced_keys_supported: bool, + setup: F, + ) where + F: FnOnce(&mut ChatComposer), + { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + enhanced_keys_supported, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + setup(&mut composer); + let footer_props = composer.footer_props(); + let footer_lines = footer_height(&footer_props); + let footer_spacing = ChatComposer::footer_spacing(footer_lines); + let height = footer_lines + footer_spacing + 8; + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap(); + insta::assert_snapshot!(name, terminal.backend()); + } + + fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) + where + F: FnOnce(&mut ChatComposer), + { + snapshot_composer_state_with_width( + name, + /*width*/ 100, + enhanced_keys_supported, + setup, + ); + } + + #[test] + fn footer_mode_snapshots() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + snapshot_composer_state( + "footer_mode_shortcut_overlay", + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_esc_backtrack_hint(/*show*/ true); + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + }, + ); + + snapshot_composer_state( + "footer_mode_shortcut_overlay_queue_submissions", + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_queue_submissions(/*queue_submissions*/ true); + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + }, + ); + + snapshot_composer_state( + "footer_mode_ctrl_c_quit", + /*enhanced_keys_supported*/ true, + |composer| { + composer.show_quit_shortcut_hint( + key_hint::ctrl(KeyCode::Char('c')), + /*has_focus*/ true, + ); + }, + ); + + snapshot_composer_state( + "footer_mode_ctrl_c_interrupt", + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_task_running(/*running*/ true); + composer.show_quit_shortcut_hint( + key_hint::ctrl(KeyCode::Char('c')), + /*has_focus*/ true, + ); + }, + ); + + snapshot_composer_state( + "footer_mode_ctrl_c_then_esc_hint", + /*enhanced_keys_supported*/ true, + |composer| { + composer.show_quit_shortcut_hint( + key_hint::ctrl(KeyCode::Char('c')), + /*has_focus*/ true, + ); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }, + ); + + snapshot_composer_state( + "footer_mode_esc_hint_from_overlay", + /*enhanced_keys_supported*/ true, + |composer| { + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }, + ); + + snapshot_composer_state( + "footer_mode_esc_hint_backtrack", + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_esc_backtrack_hint(/*show*/ true); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }, + ); + + snapshot_composer_state( + "footer_mode_overlay_then_external_esc_hint", + /*enhanced_keys_supported*/ true, + |composer| { + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + composer.set_esc_backtrack_hint(/*show*/ true); + }, + ); + + snapshot_composer_state( + "footer_mode_hidden_while_typing", + /*enhanced_keys_supported*/ true, + |composer| { + type_chars_humanlike(composer, &['h']); + }, + ); + + snapshot_composer_state( + "footer_mode_history_search", + /*enhanced_keys_supported*/ true, + |composer| { + composer + .history + .record_local_submission(HistoryEntry::new("cargo test".to_string())); + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + }, + ); + + snapshot_composer_state( + "footer_mode_history_search_unavailable", + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + composer.begin_history_search(); + for ch in ['g', 'i', 't'] { + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + composer.apply_history_search_result(HistorySearchResult::Pending); + composer.apply_history_search_result(HistorySearchResult::Unavailable); + }, + ); + + snapshot_composer_state( + "footer_mode_shell_command_absorbs_bang", + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from( + "gpt-5.4 high fast · ~/code/codex-1 · Context 0% used", + ))); + composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new()); + }, + ); + + snapshot_composer_state( + "footer_mode_shell_command_escape_exits_empty_mode", + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from( + "gpt-5.4 high fast · ~/code/codex-1 · Context 0% used", + ))); + composer.set_text_content("!".to_string(), Vec::new(), Vec::new()); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }, + ); + } + + #[test] + fn shell_command_cursor_uses_absorbed_prefix() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let area = Rect::new(0, 0, 40, 5); + + composer.set_text_content("!git".to_string(), Vec::new(), Vec::new()); + composer.move_cursor_to_end(); + assert_eq!(composer.cursor_pos(area), Some((5, 1))); + + composer.set_text_content("! git".to_string(), Vec::new(), Vec::new()); + composer.move_cursor_to_end(); + assert_eq!(composer.cursor_pos(area), Some((6, 1))); + } + + #[test] + fn shell_command_uses_shell_accent_style() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from( + "gpt-5.4 high fast · ~/code/codex-1 · Context 0% used", + ))); + composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new()); + + let area = Rect::new(0, 0, 100, 9); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let prompt_cell = &buf[(0, 1)]; + assert_eq!(prompt_cell.symbol(), "!"); + assert_eq!(prompt_cell.style().fg, Some(Color::LightRed)); + + let footer_y = area.height - 1; + let footer_text = (0..area.width) + .map(|x| buf[(x, footer_y)].symbol().chars().next().unwrap_or(' ')) + .collect::(); + let shell_label_x = footer_text + .find("Shell mode") + .expect("expected shell mode footer label"); + assert_eq!( + buf[(shell_label_x as u16, footer_y)].style().fg, + Some(Color::LightRed) + ); + } + + fn plugin_mention_foreground_color(composer: &ChatComposer) -> Option { + let area = Rect::new(0, 0, 40, 5); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let textarea_row = 1; + let row_text = (0..area.width) + .map(|x| { + buf[(x, textarea_row)] + .symbol() + .chars() + .next() + .unwrap_or(' ') + }) + .collect::(); + let mention_x = row_text + .find("@sample") + .expect("expected plugin mention in composer row"); + buf[(mention_x as u16, textarea_row)].style().fg + } + + #[test] + fn plugin_at_mentions_use_plugin_accent_style() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content_with_mention_bindings( + "@sample plugin".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + + assert_eq!( + plugin_mention_foreground_color(&composer), + Some(Color::Magenta) + ); + } + + #[test] + fn plugin_at_mentions_render_with_plugin_accent_snapshot() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content_with_mention_bindings( + "@sample plugin".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + + let area = Rect::new(0, 0, 40, 5); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let textarea_row = 1; + let mut text = String::new(); + let mut magenta = String::new(); + for x in 0..area.width { + let cell = &buf[(x, textarea_row)]; + text.push(cell.symbol().chars().next().unwrap_or(' ')); + magenta.push(if cell.style().fg == Some(Color::Magenta) { + '^' + } else { + ' ' + }); + } + while text.ends_with(' ') { + text.pop(); + } + while magenta.ends_with(' ') { + magenta.pop(); + } + + insta::assert_snapshot!( + "plugin_at_mentions_render_with_plugin_accent", + format!("text: {text}\nmagenta: {magenta}") + ); + } + + #[test] + fn recalled_plugin_at_mentions_keep_plugin_accent_style() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content_with_mention_bindings( + "@sample plugin".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + let (_, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert!(needs_redraw); + + assert_eq!( + plugin_mention_foreground_color(&composer), + Some(Color::Magenta) + ); + } + + #[test] + fn status_line_hyperlink_marks_pr_number_cells() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let url = "https://github.com/openai/codex/pull/20252"; + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from(Span::styled( + "PR #20252", + Style::default().cyan().underlined(), + )))); + composer.set_status_line_hyperlink(Some(url.to_string())); + + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let marked_cells = (area.top()..area.bottom()) + .flat_map(|y| (area.left()..area.right()).map(move |x| (x, y))) + .filter(|&(x, y)| buf[(x, y)].symbol().contains(url)) + .count(); + assert_eq!( + marked_cells, + "PR #20252".chars().filter(|ch| !ch.is_whitespace()).count() + ); + } + + #[test] + fn esc_exits_empty_shell_mode() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['!']); + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.current_text(), "!"); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert!(needs_redraw); + assert!(!composer.draft.is_bash_mode); + assert_eq!(composer.current_text(), ""); + } + + #[test] + fn esc_keeps_shell_mode_when_paste_burst_flushes_pending_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['!']); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + assert!(composer.is_in_paste_burst()); + assert_eq!(composer.current_text(), "!"); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert!(needs_redraw); + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.current_text(), "!g"); + } + + #[test] + fn footer_collapse_snapshots() { + fn setup_collab_footer( + composer: &mut ChatComposer, + context_percent: i64, + indicator: Option, + ) { + composer.set_collaboration_modes_enabled(/*enabled*/ true); + composer.set_collaboration_mode_indicator(indicator); + composer.set_context_window(Some(context_percent), /*used_tokens*/ None); + } + + // Empty textarea, agent idle: shortcuts hint can show, and cycle hint is hidden. + snapshot_composer_state_with_width( + "footer_collapse_empty_full", + /*width*/ 120, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, /*context_percent*/ 100, /*indicator*/ None, + ); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_empty_mode_cycle_with_context", + /*width*/ 60, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, /*context_percent*/ 100, /*indicator*/ None, + ); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_empty_mode_cycle_without_context", + /*width*/ 44, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, /*context_percent*/ 100, /*indicator*/ None, + ); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_empty_mode_only", + /*width*/ 26, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, /*context_percent*/ 100, /*indicator*/ None, + ); + }, + ); + + // Empty textarea, plan mode idle: shortcuts hint and cycle hint are available. + snapshot_composer_state_with_width( + "footer_collapse_plan_empty_full", + /*width*/ 120, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, + /*context_percent*/ 100, + Some(CollaborationModeIndicator::Plan), + ); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_empty_mode_cycle_with_context", + /*width*/ 60, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, + /*context_percent*/ 100, + Some(CollaborationModeIndicator::Plan), + ); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_empty_mode_cycle_without_context", + /*width*/ 44, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, + /*context_percent*/ 100, + Some(CollaborationModeIndicator::Plan), + ); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_empty_mode_only", + /*width*/ 26, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, + /*context_percent*/ 100, + Some(CollaborationModeIndicator::Plan), + ); + }, + ); + + // Textarea has content, agent running: queue hint is shown. + snapshot_composer_state_with_width( + "footer_collapse_queue_full", + /*width*/ 120, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, /*context_percent*/ 98, /*indicator*/ None, + ); + composer.set_task_running(/*running*/ true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_queue_short_with_context", + /*width*/ 50, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, /*context_percent*/ 98, /*indicator*/ None, + ); + composer.set_task_running(/*running*/ true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_queue_message_without_context", + /*width*/ 40, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, /*context_percent*/ 98, /*indicator*/ None, + ); + composer.set_task_running(/*running*/ true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_queue_short_without_context", + /*width*/ 30, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, /*context_percent*/ 98, /*indicator*/ None, + ); + composer.set_task_running(/*running*/ true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_queue_mode_only", + /*width*/ 20, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, /*context_percent*/ 98, /*indicator*/ None, + ); + composer.set_task_running(/*running*/ true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + + // Textarea has content, plan mode active, agent running: queue hint + mode. + snapshot_composer_state_with_width( + "footer_collapse_plan_queue_full", + /*width*/ 120, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, + /*context_percent*/ 98, + Some(CollaborationModeIndicator::Plan), + ); + composer.set_task_running(/*running*/ true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_queue_short_with_context", + /*width*/ 50, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, + /*context_percent*/ 98, + Some(CollaborationModeIndicator::Plan), + ); + composer.set_task_running(/*running*/ true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_queue_message_without_context", + /*width*/ 40, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, + /*context_percent*/ 98, + Some(CollaborationModeIndicator::Plan), + ); + composer.set_task_running(/*running*/ true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_queue_short_without_context", + /*width*/ 30, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, + /*context_percent*/ 98, + Some(CollaborationModeIndicator::Plan), + ); + composer.set_task_running(/*running*/ true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_queue_mode_only", + /*width*/ 20, + /*enhanced_keys_supported*/ true, + |composer| { + setup_collab_footer( + composer, + /*context_percent*/ 98, + Some(CollaborationModeIndicator::Plan), + ); + composer.set_task_running(/*running*/ true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + } + + #[test] + fn esc_hint_stays_hidden_with_draft_content() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['d']); + + assert!(!composer.is_empty()); + assert_eq!(composer.current_text(), "d"); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); + assert!(!composer.footer.esc_backtrack_hint); + } + + #[test] + fn empty_vim_insert_escape_enters_normal_without_esc_hint() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_vim_enabled(/*enabled*/ true); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + + assert!(composer.is_empty()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert!(needs_redraw); + assert!(composer.is_empty()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); + assert!(!composer.footer.esc_backtrack_hint); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Replace".cyan()) + ); + snapshot_composer_state( + "vim_replace_mode", + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_vim_enabled(/*enabled*/ true); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)); + composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + }, + ); + composer.insert_str("ok"); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + assert_eq!(composer.current_text(), ""); + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + } + + #[test] + fn slash_opens_command_popup_in_vim_insert_mode() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_vim_enabled(/*enabled*/ true); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert!(needs_redraw); + assert_eq!(composer.draft.textarea.text(), "/"); + assert_eq!(composer.draft.textarea.cursor(), "/".len()); + assert!(matches!(composer.popups.active, ActivePopup::Command(_))); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + } + + #[test] + fn slash_command_dispatches_after_vim_insert_slash() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_vim_enabled(/*enabled*/ true); + + for ch in ['i', '/', 'd', 'i', 'f', 'f'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(composer.draft.textarea.text(), "/diff"); + assert!(matches!(composer.popups.active, ActivePopup::Command(_))); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(needs_redraw); + assert!(composer.is_empty()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + assert!(matches!(result, InputResult::Command(SlashCommand::Diff))); + } + + #[test] + fn inline_slash_command_dispatch_resets_vim_mode_to_insert() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_collaboration_modes_enabled(/*enabled*/ true); + composer.set_vim_enabled(/*enabled*/ true); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("/plan investigate this".to_string(), Vec::new(), Vec::new()); + composer.popups.active = ActivePopup::None; + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(needs_redraw); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + match result { + InputResult::CommandWithArgs(cmd, args, text_elements) => { + assert_eq!(cmd, SlashCommand::Plan); + assert_eq!(args, "investigate this"); + assert!(text_elements.is_empty()); + } + _ => panic!("expected CommandWithArgs"), + } + composer + .prepare_inline_args_submission(/*record_history*/ false) + .unwrap(); + for code in [KeyCode::Char('x'), KeyCode::Esc, KeyCode::Char('.')] { + composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); + } + assert_eq!(composer.current_text(), "xx"); + } + + #[test] + fn bang_enters_shell_mode_in_vim_normal_mode() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_vim_enabled(/*enabled*/ true); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert!(needs_redraw); + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.current_text(), "!"); + assert_eq!(composer.draft.textarea.text(), ""); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + } + + #[test] + fn shell_command_can_be_typed_after_vim_normal_bang() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_vim_enabled(/*enabled*/ true); + + for ch in ['!', 'e', 'c', 'h', 'o'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.current_text(), "!echo"); + assert_eq!(composer.draft.textarea.text(), "echo"); + assert!(matches!(composer.popups.active, ActivePopup::None)); + } + + #[test] + fn base_footer_mode_tracks_empty_state_after_quit_hint_expires() { + use crossterm::event::KeyCode; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['d']); + composer + .show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), /*has_focus*/ true); + composer.footer.quit_shortcut_expires_at = + Some(Instant::now() - std::time::Duration::from_secs(1)); + + assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); + + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + assert_eq!(composer.footer_mode(), FooterMode::ComposerEmpty); + } + + #[test] + fn clear_for_ctrl_c_records_cleared_draft() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content("draft text".to_string(), Vec::new(), Vec::new()); + assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); + assert!(composer.is_empty()); + + assert_eq!( + composer.history.navigate_up(&composer.app_event_tx), + Some(HistoryEntry::new("draft text".to_string())) + ); + } + + #[test] + fn clear_for_ctrl_c_preserves_pending_paste_history_entry() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large.clone()); + let char_count = large.chars().count(); + let placeholder = format!("[Pasted Content {char_count} chars]"); + assert_eq!(composer.draft.textarea.text(), placeholder); + assert_eq!( + composer.draft.pending_pastes, + vec![(placeholder.clone(), large.clone())] + ); + + composer.clear_for_ctrl_c(); + assert!(composer.is_empty()); + + let history_entry = composer + .history + .navigate_up(&composer.app_event_tx) + .expect("expected history entry"); + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.clone()), + )]; + assert_eq!( + history_entry, + HistoryEntry::with_pending( + placeholder.clone(), + text_elements, + Vec::new(), + vec![(placeholder.clone(), large.clone())] + ) + ); + + composer.apply_history_entry(history_entry); + assert_eq!(composer.draft.textarea.text(), placeholder); + assert_eq!( + composer.draft.pending_pastes, + vec![(placeholder.clone(), large)] + ); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder] + ); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5)); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + } + + #[test] + fn large_paste_numbering_reuses_after_ctrl_c_clear() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let base = format!("[Pasted Content {} chars]", paste.chars().count()); + + composer.handle_paste(paste.clone()); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + + assert_eq!(composer.clear_for_ctrl_c(), Some(base.clone())); + assert!(composer.draft.textarea.text().is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); + + composer.handle_paste(paste); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, base); + } + + #[test] + fn vim_mode_resets_to_insert_after_submission() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(/*enabled*/ true); + composer.set_vim_enabled(/*enabled*/ true); + + assert!(composer.draft.textarea.is_vim_enabled()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("h".to_string(), Vec::new(), Vec::new()); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(composer.draft.textarea.is_vim_enabled()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + assert!(composer.is_empty()); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, "h"), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn vim_mode_resets_to_insert_after_queued_submission() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(/*enabled*/ true); + composer.set_task_running(/*running*/ true); + composer.set_vim_enabled(/*enabled*/ true); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("queued".to_string(), Vec::new(), Vec::new()); + let (result, _) = composer.handle_submission(/*should_queue*/ true); + + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + assert!(composer.is_empty()); + match result { + InputResult::Queued { text, .. } => assert_eq!(text, "queued"), + _ => panic!("expected Queued"), + } + } + + #[test] + fn vim_mode_stays_insert_after_suppressed_submission() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(/*enabled*/ true); + composer.set_vim_enabled(/*enabled*/ true); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("/not-a-command".to_string(), Vec::new(), Vec::new()); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert_eq!(composer.draft.textarea.text(), "/not-a-command"); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + } + + #[test] + fn esc_switches_vim_insert_to_normal() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_vim_enabled(/*enabled*/ true); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("hey".to_string(), Vec::new(), Vec::new()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + assert_eq!(composer.draft.textarea.cursor(), "hey".len()); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + assert_eq!(composer.draft.textarea.cursor(), "he".len()); + } + + #[test] + fn vim_insert_uses_bar_cursor_style() { + use crate::render::renderable::Renderable; + use crossterm::cursor::SetCursorStyle; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use crossterm::queue; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let area = Rect::new(0, 0, 80, 10); + let style_output = |style| { + let mut output = Vec::new(); + queue!(output, style).expect("queue cursor style"); + output + }; + let default = style_output(SetCursorStyle::DefaultUserShape); + let steady_bar = style_output(SetCursorStyle::SteadyBar); + + assert_eq!(style_output(composer.cursor_style(area)), default,); + + composer.set_vim_enabled(/*enabled*/ true); + assert_eq!(style_output(composer.cursor_style(area)), default,); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("hey".to_string(), Vec::new(), Vec::new()); + assert_eq!(style_output(composer.cursor_style(area)), steady_bar); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(style_output(composer.cursor_style(area)), default,); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)); + assert_eq!(style_output(composer.cursor_style(area)), default,); + } + + #[test] + fn clear_for_ctrl_c_preserves_image_draft_state() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let path = PathBuf::from("example.png"); + composer.attach_image(path.clone()); + let placeholder = local_image_label_text(/*label_number*/ 1); + + composer.clear_for_ctrl_c(); + assert!(composer.is_empty()); + + let history_entry = composer + .history + .navigate_up(&composer.app_event_tx) + .expect("expected history entry"); + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.clone()), + )]; + assert_eq!( + history_entry, + HistoryEntry::with_pending( + placeholder.clone(), + text_elements, + vec![path.clone()], + Vec::new() + ) + ); + + composer.apply_history_entry(history_entry); + assert_eq!(composer.draft.textarea.text(), placeholder); + assert_eq!(composer.local_image_paths(), vec![path]); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder] + ); + } + + #[test] + fn clear_for_ctrl_c_preserves_remote_offset_image_labels() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let remote_image_url = "https://example.com/one.png".to_string(); + composer.set_remote_image_urls(vec![remote_image_url.clone()]); + let text = "[Image #2] draft".to_string(); + let text_elements = vec![TextElement::new( + (0.."[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + )]; + let local_image_path = PathBuf::from("/tmp/local-draft.png"); + composer.set_text_content(text, text_elements, vec![local_image_path.clone()]); + let expected_text = composer.current_text(); + let expected_elements = composer.text_elements(); + assert_eq!(expected_text, "[Image #2] draft"); + assert_eq!( + expected_elements[0].placeholder(&expected_text), + Some("[Image #2]") + ); + + assert_eq!(composer.clear_for_ctrl_c(), Some(expected_text.clone())); + + assert_eq!( + composer.history.navigate_up(&composer.app_event_tx), + Some(HistoryEntry::with_pending_and_remote( + expected_text, + expected_elements, + vec![local_image_path], + Vec::new(), + vec![remote_image_url], + )) + ); + } + + #[test] + fn apply_history_entry_preserves_local_placeholders_after_remote_prefix() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let remote_image_url = "https://example.com/one.png".to_string(); + let local_image_path = PathBuf::from("/tmp/local-draft.png"); + composer.apply_history_entry(HistoryEntry::with_pending_and_remote( + "[Image #2] draft".to_string(), + vec![TextElement::new( + (0.."[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + )], + vec![local_image_path.clone()], + Vec::new(), + vec![remote_image_url.clone()], + )); + + let restored_text = composer.current_text(); + assert_eq!(restored_text, "[Image #2] draft"); + let restored_elements = composer.text_elements(); + assert_eq!(restored_elements.len(), 1); + assert_eq!( + restored_elements[0].placeholder(&restored_text), + Some("[Image #2]") + ); + assert_eq!(composer.local_image_paths(), vec![local_image_path]); + assert_eq!(composer.remote_image_urls(), vec![remote_image_url]); + } + + /// Behavior: `?` toggles the shortcut overlay only when the composer is otherwise empty. After + /// any typing has occurred, `?` should be inserted as a literal character. + #[test] + fn question_mark_only_toggles_on_first_char() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert!(needs_redraw, "toggling overlay should request redraw"); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); + + // Toggle back to prompt mode so subsequent typing captures characters. + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); + + type_chars_humanlike(&mut composer, &['h']); + assert_eq!(composer.draft.textarea.text(), "h"); + assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert!(needs_redraw, "typing should still mark the view dirty"); + let _ = flush_after_paste_burst(&mut composer); + assert_eq!(composer.draft.textarea.text(), "h?"); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); + assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); + } + + #[test] + fn shift_question_mark_toggles_shortcut_overlay_when_empty() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(true); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)); + assert_eq!(result, InputResult::None); + assert!(needs_redraw, "toggling overlay should request redraw"); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); + } + + /// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut + /// overlay; it should be treated as part of the pasted content. + #[test] + fn question_mark_does_not_toggle_during_paste_burst() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Force an active paste burst so this test doesn't depend on tight timing. + composer + .draft + .paste_burst + .begin_with_retro_grabbed(String::new(), Instant::now()); + + for ch in ['h', 'i', '?', 't', 'h', 'e', 'r', 'e'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert!(composer.is_in_paste_burst()); + assert_eq!(composer.draft.textarea.text(), ""); + + let _ = flush_after_paste_burst(&mut composer); + + assert_eq!(composer.draft.textarea.text(), "hi?there"); + assert_ne!(composer.footer.mode, FooterMode::ShortcutOverlay); + } + + #[test] + fn set_connector_mentions_refreshes_open_mention_popup() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_connectors_enabled(/*enabled*/ true); + composer.set_text_content("$".to_string(), Vec::new(), Vec::new()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let connectors = vec![AppInfo { + id: "connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + icon_assets: None, + icon_dark_assets: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors })); + + let ActivePopup::Skill(popup) = &composer.popups.active else { + panic!("expected mention popup to open after connectors update"); + }; + let mention = popup + .selected_mention() + .expect("expected connector mention to be selected"); + assert_eq!(mention.insert_text, "$notion".to_string()); + assert_eq!(mention.path, Some("app://connector_1".to_string())); + } + + #[test] + fn set_connector_mentions_skips_disabled_connectors() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_connectors_enabled(/*enabled*/ true); + composer.set_text_content("$".to_string(), Vec::new(), Vec::new()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let connectors = vec![AppInfo { + id: "connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + icon_assets: None, + icon_dark_assets: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: false, + plugin_display_names: Vec::new(), + }]; + composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors })); + + assert!( + matches!(composer.popups.active, ActivePopup::None), + "disabled connectors should not appear in the mention popup" + ); + } + + #[test] + fn set_plugin_mentions_refreshes_open_mention_popup() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content("$".to_string(), Vec::new(), Vec::new()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "Sample Plugin".to_string(), + plugin_namespace: None, + description: None, + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: Vec::new(), + }])); + + let ActivePopup::Skill(popup) = &composer.popups.active else { + panic!("expected mention popup to open after plugin update"); + }; + let mention = popup + .selected_mention() + .expect("expected plugin mention to be selected"); + assert_eq!(mention.insert_text, "$sample".to_string()); + assert_eq!(mention.path, Some("plugin://sample@test".to_string())); + } + + fn test_skill_metadata(name: &str) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: "Example skill used in tests.".to_string(), + short_description: None, + interface: None, + dependencies: None, + path: test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs(), + scope: crate::test_support::skill_scope_user(), + enabled: true, + plugin_id: None, + } + } + + fn test_skill_binding(name: &str) -> MentionBinding { + MentionBinding { + sigil: '$', + mention: name.to_string(), + path: test_path_buf(&format!("/tmp/{name}/SKILL.md")) + .abs() + .display() + .to_string(), + } + } + + fn test_plugin_binding(name: &str) -> MentionBinding { + MentionBinding { + sigil: '@', + mention: name.to_string(), + path: format!("plugin://{name}@test"), + } + } + + fn test_plugin_summary(name: &str, description: &str) -> PluginCapabilitySummary { + PluginCapabilitySummary { + config_name: format!("{name}@test"), + display_name: name.to_string(), + plugin_namespace: None, + description: Some(description.to_string()), + has_skills: false, + mcp_server_names: vec![name.to_string()], + app_connector_ids: Vec::new(), + } + } + + fn configure_partially_bound_skill_mentions(composer: &mut ChatComposer) { + composer.set_skill_mentions(Some(vec![test_skill_metadata("unbound-skill")])); + + composer.set_text_content_with_mention_bindings( + "$unbound-skill $bound-skill continue".to_string(), + Vec::new(), + Vec::new(), + vec![test_skill_binding("bound-skill")], + ); + composer.draft.textarea.set_cursor("$unbound-skill ".len()); + composer.sync_popups(); + } + + fn configure_bound_skill_left_of_unbound_skill(composer: &mut ChatComposer) { + composer.set_skill_mentions(Some(vec![test_skill_metadata("unbound-skill")])); + composer.set_text_content_with_mention_bindings( + "$bound-skill$unbound-skill".to_string(), + Vec::new(), + Vec::new(), + vec![test_skill_binding("bound-skill")], + ); + composer.draft.textarea.set_cursor("$bound-skill".len()); + composer.sync_popups(); + } + + fn configure_skill_target_between_bound_mentions(composer: &mut ChatComposer) { + composer.set_skill_mentions(Some(vec![test_skill_metadata("other")])); + composer.set_text_content_with_mention_bindings( + "$bound1 $oth $bound2".to_string(), + Vec::new(), + Vec::new(), + vec![test_skill_binding("bound1"), test_skill_binding("bound2")], + ); + composer + .draft + .textarea + .replace_range("$bound1".len().."$bound1 ".len(), ""); + composer + .draft + .textarea + .replace_range("$bound1$oth".len().."$bound1$oth ".len(), ""); + composer.draft.textarea.set_cursor("$bound1$o".len()); + composer.sync_popups(); + } + + fn configure_skill_mention_with_trailing_space(composer: &mut ChatComposer) { + composer.set_skill_mentions(Some(vec![test_skill_metadata("figma")])); + let text = "$figma "; + composer.set_text_content(text.to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(text.len()); + composer.sync_popups(); + } + + fn configure_bound_plugin_left_of_unbound_plugin(composer: &mut ChatComposer) { + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_plugin_mentions(Some(vec![test_plugin_summary( + "other", + "Plugin used to test adjacent mention targeting.", + )])); + composer.set_text_content_with_mention_bindings( + "@sample@other".to_string(), + Vec::new(), + Vec::new(), + vec![test_plugin_binding("sample")], + ); + composer.draft.textarea.set_cursor("@sample".len()); + composer.sync_popups(); + } + + fn configure_unbound_plugin_left_of_bound_plugin(composer: &mut ChatComposer) { + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_plugin_mentions(Some(vec![test_plugin_summary( + "left", + "Plugin used to test bound mention fallback.", + )])); + composer.set_text_content_with_mention_bindings( + "@left @bound".to_string(), + Vec::new(), + Vec::new(), + vec![test_plugin_binding("bound")], + ); + composer.draft.textarea.set_cursor("@left ".len()); + composer.sync_popups(); + } + + fn configure_plugin_target_between_bound_mentions(composer: &mut ChatComposer) { + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_plugin_mentions(Some(vec![test_plugin_summary( + "other", + "Plugin used to test middle mention targeting.", + )])); + composer.set_text_content_with_mention_bindings( + "@bound1 @oth @bound2".to_string(), + Vec::new(), + Vec::new(), + vec![test_plugin_binding("bound1"), test_plugin_binding("bound2")], + ); + composer + .draft + .textarea + .replace_range("@bound1".len().."@bound1 ".len(), ""); + composer + .draft + .textarea + .replace_range("@bound1@oth".len().."@bound1@oth ".len(), ""); + composer.draft.textarea.set_cursor("@bound1@o".len()); + composer.sync_popups(); + } + + #[test] + fn skill_popup_targets_unbound_mention_left_of_bound_mention() { + let (mut composer, _rx) = new_test_composer(); + configure_partially_bound_skill_mentions(&mut composer); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + composer.current_text(), + "$unbound-skill $bound-skill continue" + ); + assert_eq!( + composer.mention_bindings(), + vec![ + test_skill_binding("unbound-skill"), + test_skill_binding("bound-skill"), + ] + ); + + composer.insert_str("foo"); + assert_eq!( + composer.current_text(), + "$unbound-skill foo $bound-skill continue" + ); + } + + #[test] + fn skill_popup_targets_unbound_mention_left_of_bound_mention_snapshot() { + snapshot_composer_state( + "skill_popup_targets_unbound_mention_left_of_bound_mention", + /*enhanced_keys_supported*/ false, + configure_partially_bound_skill_mentions, + ); + } + + #[test] + fn skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention_snapshot() { + snapshot_composer_state( + "skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention", + /*enhanced_keys_supported*/ false, + configure_bound_skill_left_of_unbound_skill, + ); + } + + #[test] + fn skill_popup_falls_back_from_bound_skill_with_path_suffix_snapshot() { + snapshot_composer_state( + "skill_popup_falls_back_from_bound_skill_with_path_suffix", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_skill_mentions(Some(vec![test_skill_metadata("left")])); + composer.set_text_content_with_mention_bindings( + "$left $bound/path".to_string(), + Vec::new(), + Vec::new(), + vec![test_skill_binding("bound")], + ); + composer.draft.textarea.set_cursor("$left ".len()); + composer.sync_popups(); + }, + ); + } + + #[test] + fn skill_popup_closes_after_trailing_space_snapshot() { + snapshot_composer_state( + "skill_popup_closes_after_trailing_space", + /*enhanced_keys_supported*/ false, + configure_skill_mention_with_trailing_space, + ); + } + + #[test] + fn unified_mention_popup_falls_back_from_bound_plugin_on_right_snapshot() { + snapshot_composer_state( + "unified_mention_popup_falls_back_from_bound_plugin_on_right", + /*enhanced_keys_supported*/ false, + configure_unbound_plugin_left_of_bound_plugin, + ); + } + + #[test] + fn adjacent_plugin_completion_inserts_separator_snapshot() { + snapshot_composer_state( + "adjacent_plugin_completion_inserts_separator", + /*enhanced_keys_supported*/ false, + |composer| { + configure_bound_plugin_left_of_unbound_plugin(composer); + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + }, + ); + } + + #[test] + fn skill_completion_replaces_only_middle_adjacent_target() { + let (mut composer, _rx) = new_test_composer(); + configure_skill_target_between_bound_mentions(&mut composer); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(composer.current_text(), "$bound1 $other $bound2"); + assert_eq!( + composer.mention_bindings(), + vec![ + test_skill_binding("bound1"), + test_skill_binding("other"), + test_skill_binding("bound2"), + ] + ); + } + + #[test] + fn unified_completion_replaces_only_middle_adjacent_target() { + let (mut composer, _rx) = new_test_composer(); + configure_plugin_target_between_bound_mentions(&mut composer); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(composer.current_text(), "@bound1 @other @bound2"); + assert_eq!( + composer.mention_bindings(), + vec![ + test_plugin_binding("bound1"), + test_plugin_binding("other"), + test_plugin_binding("bound2"), + ] + ); + } + + #[test] + fn skill_popup_falls_back_from_shell_variable_to_skill_on_right() { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![test_skill_metadata("rustdoc")])); + composer.set_text_content("$HOME $rustdoc".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$HOME ".len()); + composer.sync_popups(); + + let ActivePopup::Skill(popup) = &composer.popups.active else { + panic!("expected skill popup to fall back to the right token"); + }; + assert_eq!( + popup + .selected_mention() + .expect("expected rustdoc selection") + .insert_text, + "$rustdoc" + ); + } + + #[test] + fn file_popup_ignores_shell_positional_parameter_snapshot() { + snapshot_composer_state( + "file_popup_ignores_shell_positional_parameter", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_skill_mentions(Some(vec![test_skill_metadata("available")])); + composer.set_text_content("@src $1_suffix".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@src ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::File(_))); + }, + ); + } + + #[test] + fn file_popup_ignores_bare_shell_parameter_with_matching_skill_snapshot() { + snapshot_composer_state( + "file_popup_ignores_bare_shell_parameter_with_matching_skill", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_skill_mentions(Some(vec![test_skill_metadata("_tool")])); + composer.set_text_content("@src $_".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@src ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::File(_))); + }, + ); + } + + #[test] + fn file_popup_ignores_definite_shell_parameters_with_matching_skills() { + for (query, skill_name) in [("12", "12factor"), ("-", "-tool")] { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![test_skill_metadata(skill_name)])); + composer.set_text_content(format!("@src ${query}"), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@src ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::File(_))); + } + } + + #[test] + fn skill_popup_accepts_loaded_hyphen_leading_skill_name() { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![test_skill_metadata("-tool")])); + composer.set_text_content("$-t".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$-t".len()); + composer.sync_popups(); + + let ActivePopup::Skill(popup) = &composer.popups.active else { + panic!("expected skill popup for -tool"); + }; + assert_eq!( + popup + .selected_mention() + .expect("expected -tool selection") + .insert_text, + "$-tool" + ); + } + + #[test] + fn file_popup_ignores_unbindable_qualified_skill_snapshot() { + snapshot_composer_state( + "file_popup_ignores_unbindable_qualified_skill", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_skill_mentions(Some(vec![test_skill_metadata("1plugin:deploy")])); + composer.set_text_content("@src $1plugin:d".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@src ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::File(_))); + }, + ); + } + + #[test] + fn skill_popup_preserves_normal_target_after_ambiguous_probe_snapshot() { + snapshot_composer_state( + "skill_popup_preserves_normal_target_after_ambiguous_probe", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_skill_mentions(Some(vec![test_skill_metadata("plugin:deploy")])); + composer.set_text_content("$1x $plugin:d".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$1x ".len()); + composer.sync_popups(); + + let ActivePopup::Skill(popup) = &composer.popups.active else { + panic!("expected the right qualified skill popup"); + }; + assert_eq!( + popup + .selected_mention() + .expect("expected qualified skill selection") + .insert_text, + "$plugin:deploy" + ); + }, + ); + } + + #[test] + fn mention_target_prebuilds_catalog_only_for_ambiguous_query() { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![test_skill_metadata("1password")])); + + composer.set_text_content("$normal".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$normal".len()); + let normal_target = composer + .current_mention_target() + .expect("expected normal mention target"); + assert!(normal_target.prebuilt_mentions.is_none()); + + composer.set_text_content("$1p".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$1p".len()); + let ambiguous_target = composer + .current_mention_target() + .expect("expected ambiguous mention target"); + assert_eq!( + ambiguous_target.prebuilt_mentions.map(|mentions| { + mentions + .into_iter() + .map(|mention| mention.display_name) + .collect::>() + }), + Some(vec!["1password".to_string()]) + ); + } + + #[test] + fn skill_popup_accepts_digit_leading_skill_snapshot() { + snapshot_composer_state( + "skill_popup_accepts_digit_leading_skill", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_skill_mentions(Some(vec![test_skill_metadata("1password")])); + composer.set_text_content("$1p".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$1p".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::Skill(_))); + }, + ); + } + + #[test] + fn skill_popup_does_not_fuzzy_match_shell_variable_snapshot() { + snapshot_composer_state( + "skill_popup_does_not_fuzzy_match_shell_variable", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_skill_mentions(Some(vec![test_skill_metadata("home")])); + composer.set_text_content("$HOME".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$HOME".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::None)); + }, + ); + } + + #[test] + fn skill_popup_preserves_loaded_shell_like_skill_at_separator() { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![ + test_skill_metadata("1password"), + test_skill_metadata("rustdoc"), + ])); + composer.set_text_content("$1p $rustdoc".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$1p ".len()); + composer.sync_popups(); + + let ActivePopup::Skill(popup) = &composer.popups.active else { + panic!("expected the left shell-like skill popup"); + }; + assert_eq!( + popup + .selected_mention() + .expect("expected 1password selection") + .insert_text, + "$1password" + ); + } + + #[test] + fn skill_popup_accepts_lowercase_skill_named_like_env_var() { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![test_skill_metadata("home")])); + composer.set_text_content("$home".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$home".len()); + composer.sync_popups(); + + let ActivePopup::Skill(popup) = &composer.popups.active else { + panic!("expected skill popup for lowercase home skill"); + }; + assert_eq!( + popup + .selected_mention() + .expect("expected home skill selection") + .insert_text, + "$home" + ); + } + + #[test] + fn legacy_file_popup_falls_back_when_right_skill_is_unavailable() { + let (mut composer, _rx) = new_test_composer(); + composer.set_text_content("@src $missing".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@src ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::File(_))); + } + + #[test] + fn unified_popup_falls_back_when_right_skill_is_unavailable() { + let (mut composer, _rx) = new_test_composer(); + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_text_content("@src $missing".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@src ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_))); + } + + #[test] + fn skill_popup_falls_back_when_right_at_mention_is_bound() { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![test_skill_metadata("old")])); + composer.set_text_content_with_mention_bindings( + "$old @bound".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "bound".to_string(), + path: "plugin://bound@test".to_string(), + }], + ); + composer.draft.textarea.set_cursor("$old ".len()); + composer.sync_popups(); + + let ActivePopup::Skill(popup) = &composer.popups.active else { + panic!("expected the left skill popup"); + }; + assert_eq!( + popup + .selected_mention() + .expect("expected old skill selection") + .insert_text, + "$old" + ); + } + + #[test] + fn legacy_popup_prefers_right_at_token_over_left_skill() { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![test_skill_metadata("old")])); + composer.set_text_content("$old @new".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$old ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::File(_))); + } + + #[test] + fn unified_popup_prefers_right_skill_over_left_at_token() { + let (mut composer, _rx) = new_test_composer(); + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_skill_mentions(Some(vec![test_skill_metadata("new")])); + composer.set_text_content("@old $new".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@old ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::Skill(_))); + } + + #[test] + fn skill_popup_closes_at_plain_text_after_whitespace() { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![test_skill_metadata("old")])); + composer.set_text_content("$old word".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$old ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::None)); + } + + #[test] + fn skill_popup_closes_between_spaces_before_plain_text_snapshot() { + snapshot_composer_state( + "skill_popup_closes_between_spaces_before_plain_text", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_skill_mentions(Some(vec![test_skill_metadata("old")])); + composer.set_text_content("$old word".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$old ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::None)); + }, + ); + } + + fn assert_typed_skill_prefix_starts_new_mention(inserted: &str) { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![test_skill_metadata("rustdoc")])); + composer.set_text_content("$simplify-code".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(/*pos*/ 0); + + composer.insert_str(inserted); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(composer.current_text(), "$rustdoc $simplify-code"); + assert_eq!( + composer.mention_bindings(), + vec![test_skill_binding("rustdoc")] + ); + } + + #[test] + fn typing_empty_skill_prefix_before_existing_skill_starts_new_mention() { + assert_typed_skill_prefix_starts_new_mention("$"); + } + + #[test] + fn typing_partial_skill_prefix_before_existing_skill_starts_new_mention() { + assert_typed_skill_prefix_starts_new_mention("$r"); + } + + #[test] + fn set_skill_mentions_refreshes_open_mention_popup() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content("$".to_string(), Vec::new(), Vec::new()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let skill_path = test_path_buf("/tmp/skill/SKILL.md").abs(); + composer.set_skill_mentions(Some(vec![SkillMetadata { + name: "codex".to_string(), + description: "Primary personal Codex repo skill.".to_string(), + short_description: None, + interface: None, + dependencies: None, + path: skill_path.clone(), + scope: crate::test_support::skill_scope_user(), + enabled: true, + plugin_id: None, + }])); + + let ActivePopup::Skill(popup) = &composer.popups.active else { + panic!("expected mention popup to open after skills update"); + }; + let mention = popup + .selected_mention() + .expect("expected skill mention to be selected"); + assert_eq!(mention.insert_text, "$codex".to_string()); + assert_eq!(mention.path, Some(skill_path.display().to_string())); + } + + #[test] + fn mention_items_keep_direct_skills_and_apps_with_unified_mentions() { + let skill_path = test_path_buf("/tmp/repo/google-calendar/SKILL.md").abs(); + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_connectors_enabled(/*enabled*/ true); + composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new()); + composer.set_skill_mentions(Some(vec![SkillMetadata { + name: "google-calendar:availability".to_string(), + description: "Find availability and plan event changes".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("Google Calendar".to_string()), + short_description: None, + icon_small: None, + icon_large: None, + icon_small_url: None, + icon_large_url: None, + brand_color: None, + default_prompt: None, + }), + dependencies: None, + path: skill_path.clone(), + scope: crate::test_support::skill_scope_repo(), + enabled: true, + plugin_id: Some("google-calendar@debug".to_string()), + }])); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "google-calendar@debug".to_string(), + display_name: "Google Calendar".to_string(), + plugin_namespace: None, + description: Some( + "Connect Google Calendar for scheduling, availability, and event management." + .to_string(), + ), + has_skills: true, + mcp_server_names: vec!["google-calendar".to_string()], + app_connector_ids: vec![AppConnectorId("google_calendar".to_string())], + }])); + composer.set_connector_mentions(Some(ConnectorsSnapshot { + connectors: vec![AppInfo { + id: "google_calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Look up events and availability".to_string()), + logo_url: None, + logo_url_dark: None, + icon_assets: None, + icon_dark_assets: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/google-calendar".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: vec!["Google Calendar".to_string()], + }], + })); + + let mentions = composer.mention_items(); + assert_eq!(mentions.len(), 3); + assert_eq!(mentions[0].category_tag, Some("[Skill]".to_string())); + assert_eq!(mentions[0].path, Some(skill_path.display().to_string())); + assert_eq!(mentions[0].display_name, "Google Calendar".to_string()); + assert_eq!(mentions[1].category_tag, Some("[Plugin]".to_string())); + assert_eq!( + mentions[1].path, + Some("plugin://google-calendar@debug".to_string()) + ); + assert_eq!(mentions[2].category_tag, Some("[App]".to_string())); + assert_eq!(mentions[2].path, Some("app://google_calendar".to_string())); + + composer.set_mentions_v2_enabled(/*enabled*/ true); + assert_eq!( + composer + .mention_items() + .into_iter() + .map(|mention| (mention.insert_text, mention.path)) + .collect::>(), + vec![ + ( + "$google-calendar:availability".to_string(), + Some(skill_path.display().to_string()), + ), + ( + "$google-calendar".to_string(), + Some("app://google_calendar".to_string()), + ), + ], + ); + } + + #[test] + fn plugin_mention_popup_snapshot() { + snapshot_composer_state( + "plugin_mention_popup", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_text_content("$sa".to_string(), Vec::new(), Vec::new()); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "Sample Plugin".to_string(), + plugin_namespace: None, + description: Some( + "Plugin that includes the Figma MCP server and Skills for common workflows" + .to_string(), + ), + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: vec![AppConnectorId("calendar".to_string())], + }])); + }, + ); + } + + #[test] + fn default_unified_mention_popup_snapshot() { + snapshot_composer_state( + "default_unified_mention_popup", + /*enhanced_keys_supported*/ false, + |composer| { + let features = codex_features::Features::with_defaults(); + composer + .set_mentions_v2_enabled(features.enabled(codex_features::Feature::MentionsV2)); + composer.set_skill_mentions(Some(vec![ + SkillMetadata { + name: "sample-tools:search".to_string(), + plugin_id: Some("sample@test".to_string()), + ..test_skill_metadata("sample-search") + }, + SkillMetadata { + name: "sample:summarize".to_string(), + plugin_id: Some("sample@test".to_string()), + ..test_skill_metadata("sample-summarize") + }, + SkillMetadata { + name: "sample:notes".to_string(), + ..test_skill_metadata("sample-notes") + }, + SkillMetadata { + name: "sample:other".to_string(), + plugin_id: Some("sample@other-marketplace".to_string()), + ..test_skill_metadata("sample-other") + }, + ])); + composer.set_text_content("@sa".to_string(), Vec::new(), Vec::new()); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "Sample Plugin".to_string(), + plugin_namespace: Some("sample".to_string()), + description: Some("Plugin with skills and an MCP server".to_string()), + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: Vec::new(), + }])); + }, + ); + } + + #[test] + fn task_mention_popup_and_selected_composer_snapshots() { + for selected in [false, true] { + snapshot_composer_state( + if selected { + "task_mention_selected_composer" + } else { + "task_mention_popup" + }, + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_task_mentions_enabled(/*enabled*/ true); + composer.set_text_content("@migr".to_string(), Vec::new(), Vec::new()); + composer.on_task_search_result( + "migr", + vec![ + crate::task_mentions::TaskMention { + thread_id: "task-123".to_string(), + title: "Review database migration".to_string(), + cwd: "/workspace/project".to_string(), + snippet: "database migration".to_string(), + }, + crate::task_mentions::TaskMention { + thread_id: "task-456".to_string(), + title: "migration".to_string(), + cwd: "/another/project".to_string(), + snippet: "migration".to_string(), + }, + ], + ); + if selected { + composer + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(composer.mention_bindings().as_slice(), + [MentionBinding { mention, path, .. }] + if mention == "Review database migration" + && path == "thread://task-123")); + assert!(composer.task_mentions.as_ref().is_some_and(Vec::is_empty)); + let area = Rect::new(0, 0, 40, 5); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + assert_eq!(buf[(2, 1)].style().fg, Some(Color::Cyan)); + } + }, + ); + } + } + + #[test] + fn task_mention_titles_are_normalized_and_bounded_before_insertion() { + let (mut composer, _rx) = new_test_composer(); + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_task_mentions_enabled(/*enabled*/ true); + composer.set_text_content("@task".to_string(), Vec::new(), Vec::new()); + let task = crate::task_mentions::TaskMention { + thread_id: "task-123".to_string(), + title: format!("task\n\t{}é{}", "a".repeat(154), "é".repeat(200)), + cwd: "/workspace/project".to_string(), + snippet: String::new(), + }; + composer.on_task_search_result("task", vec![task.clone()]); + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + composer.mention_bindings(), + vec![MentionBinding { + sigil: '@', + mention: format!("task {}é", "a".repeat(154)), + path: "thread://task-123".to_string(), + }] + ); + composer.draft.textarea.set_text_clearing_elements("@task"); + composer.sync_popups(); + composer.on_task_search_result("task", vec![task]); + assert!(matches!(&composer.popups.active, + ActivePopup::MentionV2(popup) if popup.selected().is_some())); + + let history = HistoryEntry::new( + "[@sample](plugin://sample@test) @sample [@sample](thread://task-123)x".to_string(), + ); + composer.set_text_content_with_mention_bindings( + history.text, + history.text_elements, + Vec::new(), + history.mention_bindings, + ); + assert_eq!( + composer.mention_bindings(), + ["plugin://sample@test", "thread://task-123"] + .into_iter() + .map(|path| MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: path.to_string(), + }) + .collect::>() + ); + assert_eq!( + composer.draft.textarea.text_element_snapshots()[1] + .range + .start, + "@sample @sample ".len() + ); + } + + #[test] + fn bare_unified_mention_resets_an_inherited_file_search() { + let (mut composer, mut rx) = new_test_composer(); + composer.set_mentions_v2_enabled(/*enabled*/ true); + + composer.insert_str("@"); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::StartFileSearch(query)) if query.is_empty() + )); + + composer.insert_str("s"); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::StartFileSearch(query)) if query == "s" + )); + } + + #[test] + fn reopening_identical_unified_mention_restarts_file_search() { + let (mut composer, mut rx) = new_test_composer(); + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_text_content("@foo @foo".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@f".len()); + composer.sync_popups(); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::StartFileSearch(query)) if query.is_empty() + )); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::StartFileSearch(query)) if query == "foo" + )); + + composer.on_file_search_result("foo".to_string(), Vec::new()); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + composer.draft.textarea.set_cursor("@foo @foo".len()); + composer.sync_popups(); + assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_))); + let queries = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::StartFileSearch(query) => Some(query), + _ => None, + }) + .collect::>(); + assert_eq!(queries, vec![String::new(), "foo".to_string()]); + } + + #[test] + fn restored_unified_mention_restarts_file_search() { + let (mut composer, mut rx) = new_test_composer(); + composer.set_mentions_v2_enabled(/*enabled*/ true); + + composer.set_text_content("@foo".to_string(), Vec::new(), Vec::new()); + + assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_))); + let queries = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::StartFileSearch(query) => Some(query), + _ => None, + }) + .collect::>(); + assert_eq!(queries, vec![String::new(), "foo".to_string()]); + } + + #[test] + fn mention_popup_type_prefixes_snapshot() { + snapshot_composer_state_with_width( + "mention_popup_type_prefixes", + /*width*/ 72, + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_connectors_enabled(/*enabled*/ true); + composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new()); + composer.set_skill_mentions(Some(vec![SkillMetadata { + name: "google-calendar-skill".to_string(), + description: "Find availability and plan event changes".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("Google Calendar".to_string()), + short_description: None, + icon_small: None, + icon_large: None, + icon_small_url: None, + icon_large_url: None, + brand_color: None, + default_prompt: None, + }), + dependencies: None, + path: test_path_buf("/tmp/repo/google-calendar/SKILL.md").abs(), + scope: crate::test_support::skill_scope_repo(), + enabled: true, + plugin_id: None, + }])); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "google-calendar@debug".to_string(), + display_name: "Google Calendar".to_string(), + plugin_namespace: None, + description: Some( + "Connect Google Calendar for scheduling, availability, and event management." + .to_string(), + ), + has_skills: false, + mcp_server_names: vec!["google-calendar".to_string()], + app_connector_ids: Vec::new(), + }])); + composer.set_connector_mentions(Some(ConnectorsSnapshot { + connectors: vec![AppInfo { + id: "google_calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Look up events and availability".to_string()), + logo_url: None, + logo_url_dark: None, + icon_assets: None, + icon_dark_assets: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/google-calendar".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }], + })); + }, + ); + } + + #[test] + fn set_connector_mentions_excludes_disabled_apps_from_mention_popup() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_connectors_enabled(/*enabled*/ true); + composer.set_text_content("$".to_string(), Vec::new(), Vec::new()); + + let connectors = vec![AppInfo { + id: "connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + icon_assets: None, + icon_dark_assets: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: false, + plugin_display_names: Vec::new(), + }]; + composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors })); + + assert!(matches!(composer.popups.active, ActivePopup::None)); + } + + #[test] + fn shortcut_overlay_persists_while_task_running() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); + + composer.set_task_running(/*running*/ true); + + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay); + } + + #[test] + fn test_current_at_token_basic_cases() { + let test_cases = vec![ + // Valid @ tokens + ("@hello", 3, Some("hello".to_string()), "Basic ASCII token"), + ( + "@file.txt", + 4, + Some("file.txt".to_string()), + "ASCII with extension", + ), + ( + "hello @world test", + 8, + Some("world".to_string()), + "ASCII token in middle", + ), + ( + "@test123", + 5, + Some("test123".to_string()), + "ASCII with numbers", + ), + // Unicode examples + ("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"), + ( + "@testЙЦУ.rs", + 8, + Some("testЙЦУ.rs".to_string()), + "Mixed ASCII and Cyrillic", + ), + ("@诶", 2, Some("诶".to_string()), "Chinese character"), + ("@👍", 2, Some("👍".to_string()), "Emoji token"), + // Invalid cases (should return None) + ("hello", 2, None, "No @ symbol"), + ( + "@", + 1, + Some("".to_string()), + "Only @ symbol triggers empty query", + ), + ("@ hello", 2, None, "@ followed by space"), + ("test @ world", 6, None, "@ with spaces around"), + ]; + + for (input, cursor_pos, expected, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, expected, + "Failed for case: {description} - input: '{input}', cursor: {cursor_pos}" + ); + } + } + + #[test] + fn test_current_at_token_cursor_positions() { + let test_cases = vec![ + // Different cursor positions within a token + ("@test", 0, Some("test".to_string()), "Cursor at @"), + ("@test", 1, Some("test".to_string()), "Cursor after @"), + ("@test", 5, Some("test".to_string()), "Cursor at end"), + // Multiple tokens - cursor determines which token + ("@file1 @file2", 0, Some("file1".to_string()), "First token"), + ( + "@file1 @file2", + 8, + Some("file2".to_string()), + "Second token", + ), + // Edge cases + ("@", 0, Some("".to_string()), "Only @ symbol"), + ("@a", 2, Some("a".to_string()), "Single character after @"), + ("", 0, None, "Empty input"), + ]; + + for (input, cursor_pos, expected, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, expected, + "Failed for cursor position case: {description} - input: '{input}', cursor: {cursor_pos}", + ); + } + } + + #[test] + fn test_current_at_token_whitespace_boundaries() { + let test_cases = vec![ + // Space boundaries + ( + "aaa@aaa", + 4, + None, + "Connected @ token - no completion by design", + ), + ( + "aaa @aaa", + 5, + Some("aaa".to_string()), + "@ token after space", + ), + ( + "test @file.txt", + 7, + Some("file.txt".to_string()), + "@ token after space", + ), + // Full-width space boundaries + ( + "test @İstanbul", + 8, + Some("İstanbul".to_string()), + "@ token after full-width space", + ), + ( + "@ЙЦУ @诶", + 10, + Some("诶".to_string()), + "Full-width space between Unicode tokens", + ), + // Tab and newline boundaries + ( + "test\t@file", + 6, + Some("file".to_string()), + "@ token after tab", + ), + ]; + + for (input, cursor_pos, expected, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, expected, + "Failed for whitespace boundary case: {description} - input: '{input}', cursor: {cursor_pos}", + ); + } + } + + #[test] + fn test_current_at_token_tracks_tokens_with_second_at() { + let input = "npx -y @kaeawc/auto-mobile@latest"; + let token_start = input.find("@kaeawc").expect("scoped npm package present"); + let version_at = input + .rfind("@latest") + .expect("version suffix present in scoped npm package"); + let test_cases = vec![ + (token_start, "Cursor at leading @"), + (token_start + 8, "Cursor inside scoped package name"), + (version_at, "Cursor at version @"), + (input.len(), "Cursor at end of token"), + ]; + + for (cursor_pos, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, + Some("kaeawc/auto-mobile@latest".to_string()), + "Failed for case: {description} - input: '{input}', cursor: {cursor_pos}" + ); + } + } + + #[test] + fn test_current_at_token_allows_file_queries_with_second_at() { + let input = "@icons/icon@2x.png"; + let version_at = input + .rfind("@2x") + .expect("second @ in file token should be present"); + let test_cases = vec![ + (0, "Cursor at leading @"), + (8, "Cursor before second @"), + (version_at, "Cursor at second @"), + (input.len(), "Cursor at end of token"), + ]; + + for (cursor_pos, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert!( + result.is_some(), + "Failed for case: {description} - input: '{input}', cursor: {cursor_pos}" + ); + } + } + + #[test] + fn test_current_at_token_ignores_mid_word_at() { + let input = "foo@bar"; + let at_pos = input.find('@').expect("@ present"); + let test_cases = vec![ + (at_pos, "Cursor at mid-word @"), + (input.len(), "Cursor at end of word containing @"), + ]; + + for (cursor_pos, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, None, + "Failed for case: {description} - input: '{input}', cursor: {cursor_pos}" + ); + } + } + + #[test] + fn set_text_content_rebinds_at_sigiled_mentions() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mention_bindings = vec![MentionBinding { + sigil: '@', + mention: "figma".to_string(), + path: "/tmp/user/figma/SKILL.md".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + "@figma please".to_string(), + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_matching_sigil_only() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mention_bindings = vec![MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + "@figma then $figma".to_string(), + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + let bound_tokens = composer + .current_mention_elements() + .into_iter() + .map(|(_, sigil, mention)| (sigil, mention)) + .collect::>(); + assert_eq!(bound_tokens, vec![('$', "figma".to_string())]); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_both_sigil_forms() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mention_bindings = vec![ + MentionBinding { + sigil: '@', + mention: "figma".to_string(), + path: "plugin://figma@test".to_string(), + }, + MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }, + ]; + composer.set_text_content_with_mention_bindings( + "@figma then $figma".to_string(), + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + let bound_tokens = composer + .current_mention_elements() + .into_iter() + .map(|(_, sigil, mention)| (sigil, mention)) + .collect::>(); + assert_eq!( + bound_tokens, + vec![('@', "figma".to_string()), ('$', "figma".to_string())] + ); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_at_mentions_after_email_substrings() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let text = "foo@sample.com then @sample".to_string(); + let mention_start = text.rfind("@sample").expect("expected bound mention token"); + let mention_range = mention_start..mention_start + "@sample".len(); + let mention_bindings = vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + text, + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + // Fix coverage: only the plaintext `@sample` should be atomic; the email substring stays editable. + assert_eq!( + composer + .draft + .textarea + .text_element_snapshots() + .into_iter() + .map(|snapshot| snapshot.range) + .collect::>(), + vec![mention_range] + ); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_at_mentions_after_punctuation() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let text = "Please ask (@sample)".to_string(); + let mention_start = text.find("@sample").expect("expected bound mention token"); + let mention_range = mention_start..mention_start + "@sample".len(); + let mention_bindings = vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + text, + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + assert_eq!( + composer + .draft + .textarea + .text_element_snapshots() + .into_iter() + .map(|snapshot| snapshot.range) + .collect::>(), + vec![mention_range] + ); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn bound_at_mentions_do_not_block_arrow_navigation() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content_with_mention_bindings( + "go @figma now".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "figma".to_string(), + path: "plugin://figma@debug".to_string(), + }], + ); + composer.draft.textarea.set_cursor("go".len()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go".len() + 1); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go @figma".len()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go ".len()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go".len()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + } + + #[test] + fn restored_bound_at_mentions_do_not_open_mention_popup() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + for (text, move_cursor_to_end) in [ + ("@sample".to_string(), false), + ("Please ask @sample.".to_string(), true), + ] { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + plugin_namespace: None, + description: None, + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: Vec::new(), + }])); + + composer.set_text_content_with_mention_bindings( + text.clone(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + if move_cursor_to_end { + composer.move_cursor_to_end(); + } + + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let (result, consumed) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(consumed); + match result { + InputResult::Submitted { + text: submitted, .. + } => assert_eq!(submitted, text), + _ => panic!("expected restored bound mention to submit"), + } + } + } + + #[test] + fn enter_submits_when_file_popup_has_no_selection() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let input = "npx -y @kaeawc/auto-mobile@latest"; + composer.draft.textarea.insert_str(input); + composer.draft.textarea.set_cursor(input.len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::File(_))); + + let (result, consumed) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(consumed); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, input), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn enter_submits_when_unified_mention_popup_has_no_selection() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_mentions_v2_enabled(/*enabled*/ true); + + let input = "npx -y @kaeawc/auto-mobile@latest"; + composer.draft.textarea.insert_str(input); + composer.draft.textarea.set_cursor(input.len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_))); + + let (result, consumed) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(consumed); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, input), + _ => panic!("expected Submitted"), + } + } + + /// Behavior: if the ASCII path has a pending first char (flicker suppression) and a non-ASCII + /// char arrives next, the pending ASCII char should still be preserved and the overall input + /// should submit normally (i.e. we should not misclassify this as a paste burst). + #[test] + fn ascii_prefix_survives_non_ascii_followup() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert!(composer.is_in_paste_burst()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"), + _ => panic!("expected Submitted"), + } + } + + /// Behavior: a single non-ASCII char should be inserted immediately (IME-friendly) and should + /// not create any paste-burst state. + #[test] + fn non_ascii_char_inserts_immediately_without_burst_state() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); + + assert_eq!(composer.draft.textarea.text(), "あ"); + assert!(!composer.is_in_paste_burst()); + } + + /// Behavior: while we're capturing a paste-like burst, Enter should be treated as a newline + /// within the burst (not as "submit"), and the whole payload should flush as one paste. + #[test] + fn non_ascii_burst_buffers_enter_and_flushes_multiline() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer + .draft + .paste_burst + .begin_with_retro_grabbed(String::new(), Instant::now()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + + assert!(composer.draft.textarea.text().is_empty()); + let _ = flush_after_paste_burst(&mut composer); + assert_eq!(composer.draft.textarea.text(), "你好\nhi"); + } + + /// Behavior: a paste-like burst may include a full-width/ideographic space (U+3000). It should + /// still be captured as a single paste payload and preserve the exact Unicode content. + #[test] + fn non_ascii_burst_preserves_ideographic_space_and_ascii() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer + .draft + .paste_burst + .begin_with_retro_grabbed(String::new(), Instant::now()); + + for ch in ['你', ' ', '好'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + for ch in ['h', 'i'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + assert!(composer.draft.textarea.text().is_empty()); + let _ = flush_after_paste_burst(&mut composer); + assert_eq!(composer.draft.textarea.text(), "你 好\nhi"); + } + + /// Behavior: a large multi-line payload containing both non-ASCII and ASCII (e.g. "UTF-8", + /// "Unicode") should be captured as a single paste-like burst, and Enter key events should + /// become `\n` within the buffered content. + #[test] + fn non_ascii_burst_buffers_large_multiline_mixed_ascii_and_unicode() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + const LARGE_MIXED_PAYLOAD: &str = "天地玄黄 宇宙洪荒\n\ +日月盈昃 辰宿列张\n\ +寒来暑往 秋收冬藏\n\ +\n\ +你好世界 编码测试\n\ +汉字处理 UTF-8\n\ +终端显示 正确无误\n\ +\n\ +风吹竹林 月照大江\n\ +白云千载 青山依旧\n\ +程序员 与 Unicode 同行"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Force an active burst so the test doesn't depend on timing heuristics. + composer + .draft + .paste_burst + .begin_with_retro_grabbed(String::new(), Instant::now()); + + for ch in LARGE_MIXED_PAYLOAD.chars() { + let code = if ch == '\n' { + KeyCode::Enter + } else { + KeyCode::Char(ch) + }; + let _ = composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); + } + + assert!(composer.draft.textarea.text().is_empty()); + let _ = flush_after_paste_burst(&mut composer); + assert_eq!(composer.draft.textarea.text(), LARGE_MIXED_PAYLOAD); + } + + /// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a + /// newline into the buffered payload and flush as a single paste later. + #[test] + fn ascii_burst_treats_enter_as_newline_even_when_parent_owned() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + for parent_owned in [false, true] { + let (mut composer, _rx) = new_test_composer(); + if parent_owned { + composer.set_parent_owned_thread(); + } + let mut now = Instant::now(); + let step = Duration::from_millis(1); + + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + now, + ); + now += step; + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + now, + ); + now += step; + + let (result, _) = + composer.handle_submission_with_time(/*should_queue*/ false, now); + assert!( + matches!(result, InputResult::None), + "Enter during a burst should insert newline, not submit" + ); + + for ch in ['t', 'h', 'e', 'r', 'e'] { + now += step; + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + now, + ); + } + + assert!(composer.draft.textarea.text().is_empty()); + let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; + let flushed = composer.handle_paste_burst_flush(flush_time); + assert!(flushed, "expected paste burst to flush"); + assert_eq!(composer.draft.textarea.text(), "hi\nthere"); + } + } + + /// Behavior: startup-pending submissions are queued immediately, so Enter should flush any + /// buffered burst text into that queued message instead of turning into a draft newline. + #[test] + fn queued_submission_flushes_ascii_burst_instead_of_inserting_newline() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mut now = Instant::now(); + let step = Duration::from_millis(1); + for ch in ['h', 'i'] { + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + now, + ); + now += step; + } + assert!(composer.is_in_paste_burst()); + + let (result, _) = composer.handle_submission_with_time(/*should_queue*/ true, now); + + assert_eq!( + result, + InputResult::Queued { + text: "hi".to_string(), + text_elements: Vec::new(), + action: QueuedInputAction::Plain, + pending_pastes: Vec::new(), + } + ); + assert!(composer.draft.textarea.text().is_empty()); + assert!(!composer.is_in_paste_burst()); + } + + /// Behavior: even if Enter suppression would normally be active for a burst, Enter should + /// still dispatch a built-in slash command when the first line begins with `/`. + #[test] + fn slash_context_enter_ignores_paste_burst_enter_suppression() { + use crate::slash_command::SlashCommand; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.draft.textarea.set_text_clearing_elements("/diff"); + composer.draft.textarea.set_cursor("/diff".len()); + composer + .draft + .paste_burst + .begin_with_retro_grabbed(String::new(), Instant::now()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Command(SlashCommand::Diff))); + } + + /// Behavior: if a burst is buffering text and the user presses a non-char key, flush the + /// buffered burst *before* applying that key so the buffer cannot get stuck. + #[test] + fn non_char_key_flushes_active_burst_before_input() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Force an active burst so we can deterministically buffer characters without relying on + // timing. + composer + .draft + .paste_burst + .begin_with_retro_grabbed(String::new(), Instant::now()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + assert!(composer.draft.textarea.text().is_empty()); + assert!(composer.is_in_paste_burst()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "hi"); + assert_eq!(composer.draft.textarea.cursor(), 1); + assert!(!composer.is_in_paste_burst()); + } + + /// Behavior: enabling `disable_paste_burst` flushes any held first character (flicker + /// suppression) and then inserts subsequent chars immediately without creating burst state. + #[test] + fn disable_paste_burst_flushes_pending_first_char_and_inserts_immediately() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // First ASCII char is normally held briefly. Flip the config mid-stream and ensure the + // held char is not dropped. + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + assert!(composer.is_in_paste_burst()); + assert!(composer.draft.textarea.text().is_empty()); + + composer.set_disable_paste_burst(/*disabled*/ true); + assert_eq!(composer.draft.textarea.text(), "a"); + assert!(!composer.is_in_paste_burst()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "ab"); + assert!(!composer.is_in_paste_burst()); + } + + /// Behavior: a small explicit paste inserts text directly (no placeholder), and the submitted + /// text matches what is visible in the textarea. + #[test] + fn handle_paste_small_inserts_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let sanitized = "_count_rows\tindent\ntwo"; + let needs_redraw = + composer.handle_paste("_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}".to_string()); + assert!(needs_redraw); + assert_eq!(composer.draft.textarea.text(), sanitized); + assert!(composer.draft.pending_pastes.is_empty()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, sanitized), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn empty_enter_returns_none() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Ensure composer is empty and press Enter. + assert!(composer.draft.textarea.text().is_empty()); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::None => {} + other => panic!("expected None for empty enter, got: {other:?}"), + } + } + + /// Behavior: a large explicit paste inserts a placeholder into the textarea, stores the full + /// content in `pending_pastes`, and expands the placeholder to the full content on submit. + #[test] + fn handle_paste_large_uses_placeholder_and_replaces_on_submit() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); + let needs_redraw = composer.handle_paste(large.clone()); + assert!(needs_redraw); + let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); + assert_eq!(composer.draft.textarea.text(), placeholder); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, placeholder); + assert_eq!(composer.draft.pending_pastes[0].1, large); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, large), + _ => panic!("expected Submitted"), + } + assert!(composer.draft.pending_pastes.is_empty()); + } + + #[test] + fn submit_at_character_limit_succeeds() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(true); + let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS); + composer.draft.textarea.set_text_clearing_elements(&input); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == input + )); + } + + #[test] + fn oversized_submit_reports_error_and_restores_draft() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(true); + let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); + composer.draft.textarea.set_text_clearing_elements(&input); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!(composer.draft.textarea.text(), input); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.contains(&user_input_too_large_message(input.chars().count()))); + found_error = true; + break; + } + } + assert!(found_error, "expected oversized-input error history cell"); + } + + #[test] + fn oversized_queued_submission_reports_error_and_restores_draft() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(false); + let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); + composer.draft.textarea.set_text_clearing_elements(&input); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!(composer.draft.textarea.text(), input); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.contains(&user_input_too_large_message(input.chars().count()))); + found_error = true; + break; + } + } + assert!(found_error, "expected oversized-input error history cell"); + } + + /// Behavior: editing that removes a paste placeholder should also clear the associated + /// `pending_pastes` entry so it cannot be submitted accidentally. + #[test] + fn edit_clears_pending_paste() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.handle_paste(large); + assert_eq!(composer.draft.pending_pastes.len(), 1); + + // Any edit that removes the placeholder should clear pending_paste + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(composer.draft.pending_pastes.is_empty()); + } + + #[test] + fn ui_snapshots() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut terminal = match Terminal::new(TestBackend::new(100, 10)) { + Ok(t) => t, + Err(e) => panic!("Failed to create terminal: {e}"), + }; + + let test_cases = vec![ + ("empty", None), + ("small", Some("short".to_string())), + ("large", Some("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5))), + ("multiple_pastes", None), + ("backspace_after_pastes", None), + ]; + + for (name, input) in test_cases { + // Create a fresh composer for each test case + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender.clone(), + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + if let Some(text) = input { + composer.handle_paste(text); + } else if name == "multiple_pastes" { + // First large paste + composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3)); + // Second large paste + composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7)); + // Small paste + composer.handle_paste(" another short paste".to_string()); + } else if name == "backspace_after_pastes" { + // Three large pastes + composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2)); + composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4)); + composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6)); + // Move cursor to end and press backspace + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}")); + + insta::assert_snapshot!(name, terminal.backend()); + } + } + + #[test] + fn image_placeholder_snapshots() { + snapshot_composer_state( + "image_placeholder_single", + /*enhanced_keys_supported*/ false, + |composer| { + composer.attach_image(PathBuf::from("/tmp/image1.png")); + }, + ); + + snapshot_composer_state( + "image_placeholder_multiple", + /*enhanced_keys_supported*/ false, + |composer| { + composer.attach_image(PathBuf::from("/tmp/image1.png")); + composer.attach_image(PathBuf::from("/tmp/image2.png")); + }, + ); + } + + #[test] + fn remote_image_rows_snapshots() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + snapshot_composer_state( + "remote_image_rows", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_remote_image_urls(vec![ + "https://example.com/one.png".to_string(), + "https://example.com/two.png".to_string(), + ]); + composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new()); + }, + ); + + snapshot_composer_state( + "remote_image_rows_selected", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_remote_image_urls(vec![ + "https://example.com/one.png".to_string(), + "https://example.com/two.png".to_string(), + ]); + composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(/*pos*/ 0); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + }, + ); + + snapshot_composer_state( + "remote_image_rows_after_delete_first", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_remote_image_urls(vec![ + "https://example.com/one.png".to_string(), + "https://example.com/two.png".to_string(), + ]); + composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(/*pos*/ 0); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); + }, + ); + } + + #[test] + fn slash_popup_model_first_for_mo_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Type "/mo" humanlike so paste-burst doesn’t interfere. + type_chars_humanlike(&mut composer, &['/', 'm', 'o']); + + let mut terminal = match Terminal::new(TestBackend::new(60, 5)) { + Ok(t) => t, + Err(e) => panic!("Failed to create terminal: {e}"), + }; + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); + + // Visual snapshot should show the slash popup with /model as the first entry. + insta::assert_snapshot!("slash_popup_mo", terminal.backend()); + } + + #[test] + fn slash_popup_model_first_for_mo_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + type_chars_humanlike(&mut composer, &['/', 'm', 'o']); + + match &composer.popups.active { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "model") + } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected model command, got service tier {command:?}") + } + None => panic!("no selected command for '/mo'"), + }, + _ => panic!("slash popup not active after typing '/mo'"), + } + } + + #[test] + fn slash_popup_resume_for_res_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Type "/res" humanlike so paste-burst doesn’t interfere. + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + // Snapshot should show /resume as the first entry for /res. + insta::assert_snapshot!("slash_popup_res", terminal.backend()); + } + + #[test] + fn slash_popup_archive_for_ar_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'a', 'r']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_ar", terminal.backend()); + } + + #[test] + fn slash_popup_resume_for_res_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + match &composer.popups.active { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "resume") + } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected resume command, got service tier {command:?}") + } + None => panic!("no selected command for '/res'"), + }, + _ => panic!("slash popup not active after typing '/res'"), + } + } + + #[test] + fn slash_popup_pets_for_pet_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'p', 'e', 't']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_pet", terminal.backend()); + } + + #[test] + fn slash_popup_pets_for_pet_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + type_chars_humanlike(&mut composer, &['/', 'p', 'e', 't']); + + match &composer.popups.active { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "pets") + } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected pets command, got service tier {command:?}") + } + None => panic!("no selected command for '/pet'"), + }, + _ => panic!("slash popup not active after typing '/pet'"), + } + } + + #[test] + fn slash_popup_btw_for_bt_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'b', 't']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_bt", terminal.backend()); + } + + #[test] + fn slash_popup_btw_for_bt_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + type_chars_humanlike(&mut composer, &['/', 'b', 't']); + + match &composer.popups.active { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "btw") + } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected btw command, got service tier {command:?}") + } + None => panic!("no selected command for '/bt'"), + }, + _ => panic!("slash popup not active after typing '/bt'"), + } + } + + #[test] + fn slash_popup_side_for_si_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 's', 'i']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_si", terminal.backend()); + } + + #[test] + fn slash_popup_side_for_si_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + type_chars_humanlike(&mut composer, &['/', 's', 'i']); + + match &composer.popups.active { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "side") + } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected side command, got service tier {command:?}") + } + None => panic!("no selected command for '/si'"), + }, + _ => panic!("slash popup not active after typing '/si'"), + } + } + + #[test] + fn service_tier_slash_command_dispatches_from_catalog_name() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_service_tier_commands_enabled(/*enabled*/ true); + composer.set_service_tier_commands(vec![ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "Fastest inference with increased plan usage".to_string(), + }]); + type_chars_humanlike(&mut composer, &['/', 'f', 'a', 's', 't']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + result, + InputResult::ServiceTierCommand(ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "Fastest inference with increased plan usage".to_string(), + }) + ); + } + + fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool { + std::thread::sleep(PasteBurst::recommended_active_flush_delay()); + composer.flush_paste_burst_if_due() + } + + // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer + pub(super) fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyEventKind; + use crossterm::event::KeyModifiers; + for &ch in chars { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let _ = composer.flush_paste_burst_if_due(); + if ch == ' ' { + let _ = composer.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Char(' '), + KeyModifiers::NONE, + KeyEventKind::Release, + )); + } + } + } + + #[test] + fn slash_init_dispatches_command_and_does_not_submit_literal_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Type the slash command. + type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']); + + // Press Enter to dispatch the selected command. + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // When a slash command is dispatched, the composer should return a + // Command result (not submit literal text) and clear its textarea. + match result { + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "init"); + } + InputResult::CommandWithArgs(_, _, _) => { + panic!("expected command dispatch without args for '/init'") + } + InputResult::ServiceTierCommand(command) => { + panic!("expected init command, got service tier {command:?}") + } + InputResult::Submitted { text, .. } => { + panic!("expected command dispatch, but composer submitted literal text: {text}") + } + InputResult::Queued { .. } => { + panic!("expected command dispatch, but composer queued literal text") + } + InputResult::ParentOwnedInputBlocked => { + panic!("expected command dispatch, but parent-owned input was blocked") + } + InputResult::None => panic!("expected Command result for '/init'"), + } + assert!( + composer.draft.textarea.is_empty(), + "composer should be cleared" + ); + } + + #[test] + fn kill_buffer_persists_after_submit() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(true); + composer.draft.textarea.insert_str("restore me"); + composer.draft.textarea.set_cursor(/*pos*/ 0); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert!(composer.draft.textarea.is_empty()); + + composer.draft.textarea.insert_str("hello"); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + assert!(composer.draft.textarea.is_empty()); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(composer.draft.textarea.text(), "restore me"); + } + + #[test] + fn kill_buffer_persists_after_slash_command_dispatch() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.draft.textarea.insert_str("restore me"); + composer.draft.textarea.set_cursor(/*pos*/ 0); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert!(composer.draft.textarea.is_empty()); + + composer.draft.textarea.insert_str("/diff"); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "diff"); + } + _ => panic!("expected Command result for '/diff'"), + } + assert!(composer.draft.textarea.is_empty()); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(composer.draft.textarea.text(), "restore me"); + } + + #[test] + fn slash_command_disabled_while_task_running_keeps_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ true); + composer + .draft + .textarea + .set_text_clearing_elements("/review these changes"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!("/review these changes", composer.draft.textarea.text()); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.contains("disabled while a task is in progress")); + found_error = true; + break; + } + } + assert!(found_error, "expected error history cell to be sent"); + } + + #[test] + fn enter_queues_when_queue_submissions_is_enabled() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_queue_submissions(/*queue_submissions*/ true); + composer + .draft + .textarea + .set_text_clearing_elements("queued before session"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + result, + InputResult::Queued { + text: "queued before session".to_string(), + text_elements: Vec::new(), + action: QueuedInputAction::Plain, + pending_pastes: Vec::new(), + } + ); + } + + #[test] + fn tab_queues_slash_led_prompts_while_task_running_without_validation() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + fn assert_queued_slash(input: &str) { + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ true); + composer.draft.textarea.set_text_clearing_elements(input); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + match result { + InputResult::Queued { + text, + text_elements, + action, + .. + } => { + assert_eq!(text, input); + assert!(text_elements.is_empty()); + assert_eq!(action, QueuedInputAction::ParseSlash); + } + other => panic!("expected slash-led input to queue, got {other:?}"), + } + assert!(composer.draft.textarea.is_empty()); + assert!( + rx.try_recv().is_err(), + "queueing should not report slash errors" + ); + } + + assert_queued_slash("/compact"); + assert_queued_slash("/review check regressions"); + assert_queued_slash("/fast"); + assert_queued_slash("/does-not-exist"); + } + + #[test] + fn remapped_submit_does_not_fall_back_to_enter() { + use crate::key_hint; + use crate::keymap::RuntimeKeymap; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .draft + .textarea + .set_text_clearing_elements("explain the change"); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![key_hint::ctrl(KeyCode::Char('j'))]; + composer.set_keymap_bindings(&keymap); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!("explain the change\n", composer.draft.textarea.text()); + } + + #[test] + fn remapped_queue_does_not_fall_back_to_tab() { + use crate::key_hint; + use crate::keymap::RuntimeKeymap; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ true); + composer + .draft + .textarea + .set_text_clearing_elements("queue me"); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.queue = vec![key_hint::ctrl(KeyCode::Char('q'))]; + composer.set_keymap_bindings(&keymap); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!("queue me", composer.draft.textarea.text()); + } + + #[test] + fn remapped_history_search_does_not_fall_back_to_ctrl_r() { + use crate::key_hint; + use crate::keymap::RuntimeKeymap; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.history_search_previous = vec![key_hint::plain(KeyCode::F(2))]; + composer.set_keymap_bindings(&keymap); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(!composer.history_search_active()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + assert!(composer.history_search_active()); + } + + #[test] + fn tab_queues_leading_space_slash_as_plain_text_while_task_running() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ true); + composer + .draft + .textarea + .set_text_clearing_elements(" /does-not-exist"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + match result { + InputResult::Queued { text, action, .. } => { + assert_eq!(text, "/does-not-exist"); + assert_eq!(action, QueuedInputAction::Plain); + } + other => panic!("expected leading-space slash input to queue, got {other:?}"), + } + } + + #[test] + fn tab_queues_bang_shell_prompts_while_task_running_without_execution() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + fn assert_queued_shell(input: &str, expected_text: &str) { + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ true); + composer.draft.textarea.set_text_clearing_elements(input); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + match result { + InputResult::Queued { + text, + text_elements, + action, + .. + } => { + assert_eq!(text, expected_text); + assert!(text_elements.is_empty()); + assert_eq!(action, QueuedInputAction::RunShell); + } + other => panic!("expected bang shell input to queue, got {other:?}"), + } + assert!(composer.draft.textarea.is_empty()); + assert!( + rx.try_recv().is_err(), + "queueing should not show shell help immediately" + ); + } + + assert_queued_shell("!echo hi", "!echo hi"); + assert_queued_shell("!", "!"); + assert_queued_shell(" !echo hi", "!echo hi"); + } + + #[test] + fn slash_tab_completion_moves_cursor_to_end() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'c']); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert_eq!(composer.draft.textarea.text(), "/compact "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); + } + + #[test] + fn slash_tab_completion_wins_over_queueing_while_task_running() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ true); + + type_chars_humanlike(&mut composer, &['/', 'm', 'o']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); + } + + #[test] + fn slash_key_completes_selected_slash_command_as_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'm']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); + } + + #[test] + fn slash_tab_then_enter_dispatches_builtin_command() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Type a prefix and complete with Tab, which inserts a trailing space + // and moves the cursor beyond the '/name' token (hides the popup). + type_chars_humanlike(&mut composer, &['/', 'd', 'i']); + let (_res, _redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "/diff "); + + // Press Enter: should dispatch the command, not submit literal text. + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), + InputResult::CommandWithArgs(_, _, _) => { + panic!("expected command dispatch without args for '/diff'") + } + InputResult::ServiceTierCommand(command) => { + panic!("expected diff command, got service tier {command:?}") + } + InputResult::Submitted { text, .. } => { + panic!("expected command dispatch after Tab completion, got literal submit: {text}") + } + InputResult::Queued { .. } => { + panic!("expected command dispatch after Tab completion, got literal queue") + } + InputResult::ParentOwnedInputBlocked => { + panic!("expected command dispatch, but parent-owned input was blocked") + } + InputResult::None => panic!("expected Command result for '/diff'"), + } + assert!(composer.draft.textarea.is_empty()); + } + + #[test] + fn slash_command_elementizes_on_space() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_collaboration_modes_enabled(/*enabled*/ true); + + type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']); + + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); + assert_eq!(text, "/plan "); + assert_eq!(elements.len(), 1); + assert_eq!(elements[0].placeholder(&text), Some("/plan")); + } + + #[test] + fn slash_command_elementizes_only_known_commands() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_collaboration_modes_enabled(/*enabled*/ true); + + type_chars_humanlike(&mut composer, &['/', 'U', 's', 'e', 'r', 's', ' ']); + + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); + assert_eq!(text, "/Users "); + assert!(elements.is_empty()); + } + + #[test] + fn slash_command_element_removed_when_not_at_start() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']); + + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); + assert_eq!(text, "/review "); + assert_eq!(elements.len(), 1); + + composer.draft.textarea.set_cursor(/*pos*/ 0); + type_chars_humanlike(&mut composer, &['x']); + + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); + assert_eq!(text, "x/review "); + assert!(elements.is_empty()); + } + + #[test] + fn tab_submits_when_no_task_running() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['h', 'i']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Submitted { ref text, .. } if text == "hi" + )); + assert!(composer.draft.textarea.is_empty()); + } + + #[test] + fn tab_does_not_submit_for_bang_shell_command() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ false); + + type_chars_humanlike(&mut composer, &['!', 'l', 's']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert!( + composer.current_text().starts_with("!ls"), + "expected Tab not to submit or clear a `!` command" + ); + } + + #[test] + fn bang_prefixed_slash_text_submits_literal_shell_command() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['!', '/', 'd', 'i', 'f', 'f']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Submitted { ref text, .. } if text == "!/diff" + )); + } + + #[test] + fn slash_mention_dispatches_command_and_inserts_at() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "mention"); + } + InputResult::CommandWithArgs(_, _, _) => { + panic!("expected command dispatch without args for '/mention'") + } + InputResult::ServiceTierCommand(command) => { + panic!("expected mention command, got service tier {command:?}") + } + InputResult::Submitted { text, .. } => { + panic!("expected command dispatch, but composer submitted literal text: {text}") + } + InputResult::Queued { .. } => { + panic!("expected command dispatch, but composer queued literal text") + } + InputResult::ParentOwnedInputBlocked => { + panic!("expected command dispatch, but parent-owned input was blocked") + } + InputResult::None => panic!("expected Command result for '/mention'"), + } + assert!( + composer.draft.textarea.is_empty(), + "composer should be cleared" + ); + composer.insert_str("@"); + assert_eq!(composer.draft.textarea.text(), "@"); + } + + #[test] + fn slash_plan_args_preserve_text_elements() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_collaboration_modes_enabled(/*enabled*/ true); + + type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']); + let placeholder = local_image_label_text(/*label_number*/ 1); + composer.attach_image(PathBuf::from("/tmp/plan.png")); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::CommandWithArgs(cmd, args, text_elements) => { + assert_eq!(cmd.command(), "plan"); + assert_eq!(args, placeholder); + assert_eq!(text_elements.len(), 1); + assert_eq!( + text_elements[0].placeholder(&args), + Some(placeholder.as_str()) + ); + } + _ => panic!("expected CommandWithArgs for /plan with args"), + } + } + + #[test] + fn file_completion_preserves_large_paste_placeholder_elements() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); + + composer.handle_paste(large.clone()); + composer.insert_str(" @ma"); + composer.on_file_search_result( + "ma".to_string(), + vec![FileMatch { + score: 1, + path: PathBuf::from("src/main.rs"), + match_type: codex_file_search::MatchType::File, + root: PathBuf::from("/tmp"), + indices: None, + }], + ); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + let text = composer.draft.textarea.text().to_string(); + assert_eq!(text, format!("{placeholder} src/main.rs ")); + let elements = composer.draft.textarea.text_elements(); + assert_eq!(elements.len(), 1); + assert_eq!(elements[0].placeholder(&text), Some(placeholder.as_str())); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, format!("{large} src/main.rs")); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + } + + fn complete_file( + composer: &mut ChatComposer, + text: &str, + cursor: usize, + query: &str, + selected_path: PathBuf, + ) { + composer.set_text_content(text.to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(cursor); + composer.sync_popups(); + let root = selected_path + .parent() + .filter(|_| selected_path.is_absolute()) + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("/tmp")); + composer.on_file_search_result( + query.to_string(), + vec![FileMatch { + score: 1, + path: selected_path, + match_type: codex_file_search::MatchType::File, + root, + indices: None, + }], + ); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + } + + #[test] + fn legacy_file_completion_ignores_shell_variable_to_the_right() { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![test_skill_metadata("available")])); + complete_file( + &mut composer, + "@src $HOME", + /*cursor*/ "@src ".len(), + "src", + PathBuf::from("src/main.rs"), + ); + + assert_eq!(composer.current_text(), "src/main.rs $HOME"); + } + + #[test] + fn unified_popup_preserves_empty_at_before_existing_text() { + let (mut composer, _rx) = new_test_composer(); + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_text_content("@ word".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_))); + } + + #[test] + fn adjacent_file_completion_inserts_leading_separator() { + let (mut composer, _rx) = new_test_composer(); + configure_bound_plugin_left_of_unbound_plugin(&mut composer); + composer.insert_selected_file_path("@sample".len().."@sample@other".len(), "src/main.rs"); + + assert_eq!(composer.current_text(), "@sample src/main.rs "); + } + + #[test] + fn adjacent_image_completion_inserts_leading_separator() { + let tmp = tempdir().expect("create TempDir"); + let image_path = tmp.path().join("image.png"); + let image: ImageBuffer, Vec> = + ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255])); + image.save(&image_path).expect("write temp png"); + + let (mut composer, _rx) = new_test_composer(); + configure_bound_plugin_left_of_unbound_plugin(&mut composer); + composer.set_vim_enabled(/*enabled*/ true); + let before = composer.draft_snapshot(); + composer.insert_selected_file_path( + "@sample".len().."@sample@other".len(), + image_path.to_str().expect("UTF-8 path"), + ); + + assert_eq!(composer.current_text(), "@sample [Image #1] "); + assert_eq!(composer.local_image_paths(), vec![image_path]); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + assert_eq!(composer.draft_snapshot(), before); + } + + #[test] + fn file_completion_preserves_separator_before_existing_suffix() { + let (mut composer, _rx) = new_test_composer(); + complete_file( + &mut composer, + "@ma next", + /*cursor*/ "@ma".len(), + "ma", + PathBuf::from("src/main.rs"), + ); + composer.insert_str("foo"); + + assert_eq!(composer.current_text(), "src/main.rs foo next"); + } + + #[test] + fn file_completion_preserves_separator_before_sigiled_suffix() { + for suffix in ["@next", "$next"] { + let (mut composer, _rx) = new_test_composer(); + composer.set_text_content(format!("@ma {suffix}"), Vec::new(), Vec::new()); + composer.insert_selected_path(0.."@ma".len(), "src/main.rs"); + composer.insert_str("foo"); + + assert_eq!(composer.current_text(), format!("src/main.rs foo {suffix}")); + } + } + + #[test] + fn file_completion_inserts_separator_before_line_break() { + let (mut composer, _rx) = new_test_composer(); + complete_file( + &mut composer, + "@ma\nnext", + /*cursor*/ "@ma".len(), + "ma", + PathBuf::from("src/main.rs"), + ); + composer.insert_str("foo"); + + assert_eq!(composer.current_text(), "src/main.rs foo\nnext"); + } + + #[test] + fn file_completion_for_sigil_path_does_not_reopen_popup() { + let (mut composer, _rx) = new_test_composer(); + complete_file( + &mut composer, + "@ma\nnext", + /*cursor*/ "@ma".len(), + "ma", + PathBuf::from("@scope/main.rs"), + ); + + assert_eq!(composer.current_text(), "@scope/main.rs \nnext"); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let (result, consumed) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(consumed); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, "@scope/main.rs \nnext"), + _ => panic!("expected completed path to submit"), + } + } + + #[test] + fn file_completion_does_not_dismiss_identical_next_token() { + let (mut composer, _rx) = new_test_composer(); + complete_file( + &mut composer, + "@ma @scope/main.rs", + /*cursor*/ "@ma".len(), + "ma", + PathBuf::from("@scope/main.rs"), + ); + composer.draft.textarea.set_cursor("@scope/main.rs ".len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::File(_))); + } + + #[test] + fn dismissed_file_popup_tracks_token_across_leading_whitespace_edits() { + let (mut composer, _rx) = new_test_composer(); + composer.set_text_content("@ma".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@ma".len()); + composer.sync_popups(); + assert!(matches!(composer.popups.active, ActivePopup::File(_))); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer.insert_str(" "); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + for _ in 0..3 { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(matches!(composer.popups.active, ActivePopup::None)); + } + } + + #[test] + fn dismissed_file_popup_ignores_token_substrings_in_leading_paste() { + let (mut composer, _rx) = new_test_composer(); + composer.set_text_content("@ma".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@ma".len()); + composer.sync_popups(); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer.handle_paste("email@ma.com ".to_string()); + + assert_eq!(composer.current_text(), "email@ma.com @ma"); + assert!(matches!(composer.popups.active, ActivePopup::None)); + } + + /// Behavior: multiple paste operations can coexist; placeholders should be expanded to their + /// original content on submission. + #[test] + fn test_multiple_pastes_submission() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Define test cases: (paste content, is_large) + let test_cases = [ + ("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true), + (" and ".to_string(), false), + ("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true), + ]; + + // Expected states after each paste + let mut expected_text = String::new(); + let mut expected_pending_count = 0; + + // Apply all pastes and build expected state + let states: Vec<_> = test_cases + .iter() + .map(|(content, is_large)| { + composer.handle_paste(content.clone()); + if *is_large { + let placeholder = format!("[Pasted Content {} chars]", content.chars().count()); + expected_text.push_str(&placeholder); + expected_pending_count += 1; + } else { + expected_text.push_str(content); + } + (expected_text.clone(), expected_pending_count) + }) + .collect(); + + // Verify all intermediate states were correct + assert_eq!( + states, + vec![ + ( + format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()), + 1 + ), + ( + format!( + "[Pasted Content {} chars] and ", + test_cases[0].0.chars().count() + ), + 1 + ), + ( + format!( + "[Pasted Content {} chars] and [Pasted Content {} chars]", + test_cases[0].0.chars().count(), + test_cases[2].0.chars().count() + ), + 2 + ), + ] + ); + + // Submit and verify final expansion + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + if let InputResult::Submitted { text, .. } = result { + assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0)); + } else { + panic!("expected Submitted"); + } + } + + #[test] + fn test_placeholder_deletion() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Define test cases: (content, is_large) + let test_cases = [ + ("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true), + (" and ".to_string(), false), + ("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true), + ]; + + // Apply all pastes + let mut current_pos = 0; + let states: Vec<_> = test_cases + .iter() + .map(|(content, is_large)| { + composer.handle_paste(content.clone()); + if *is_large { + let placeholder = format!("[Pasted Content {} chars]", content.chars().count()); + current_pos += placeholder.len(); + } else { + current_pos += content.len(); + } + ( + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), + current_pos, + ) + }) + .collect(); + + // Delete placeholders one by one and collect states + let mut deletion_states = vec![]; + + // First deletion + composer.draft.textarea.set_cursor(states[0].2); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + deletion_states.push(( + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), + )); + + // Second deletion + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + deletion_states.push(( + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), + )); + + // Verify all states + assert_eq!( + deletion_states, + vec![ + (" and [Pasted Content 1006 chars]".to_string(), 1), + (" and ".to_string(), 0), + ] + ); + } + + /// Behavior: if multiple large pastes share the same placeholder label (same char count), + /// deleting one placeholder removes only its corresponding `pending_pastes` entry. + #[test] + fn deleting_duplicate_length_pastes_removes_only_target() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let placeholder_base = format!("[Pasted Content {} chars]", paste.chars().count()); + let placeholder_second = format!("{placeholder_base} #2"); + + composer.handle_paste(paste.clone()); + composer.handle_paste(paste.clone()); + assert_eq!( + composer.draft.textarea.text(), + format!("{placeholder_base}{placeholder_second}") + ); + assert_eq!(composer.draft.pending_pastes.len(), 2); + + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!(composer.draft.textarea.text(), placeholder_base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, placeholder_base); + assert_eq!(composer.draft.pending_pastes[0].1, paste); + } + + /// Behavior: large-paste placeholder numbering continues when another placeholder of the + /// same length still exists, so a new paste gets a new unique placeholder label. + #[test] + fn large_paste_numbering_continues_with_same_length_placeholder() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let base = format!("[Pasted Content {} chars]", paste.chars().count()); + let second = format!("{base} #2"); + let third = format!("{base} #3"); + + composer.handle_paste(paste.clone()); + composer.handle_paste(paste.clone()); + assert_eq!(composer.draft.textarea.text(), format!("{base}{second}")); + + composer.draft.textarea.set_cursor(base.len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), second); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, second); + + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); + composer.handle_paste(paste); + + assert_eq!(composer.draft.textarea.text(), format!("{second}{third}")); + assert_eq!(composer.draft.pending_pastes.len(), 2); + assert_eq!(composer.draft.pending_pastes[0].0, second); + assert_eq!(composer.draft.pending_pastes[1].0, third); + } + + /// Behavior: if all placeholders of a given length are removed, numbering resets to the + /// base placeholder on the next paste. + #[test] + fn large_paste_numbering_reuses_after_all_deleted() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let base = format!("[Pasted Content {} chars]", paste.chars().count()); + + composer.handle_paste(paste.clone()); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(composer.draft.textarea.text().is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); + + composer.handle_paste(paste); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, base); + } + + #[test] + fn test_partial_placeholder_deletion() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Define test cases: (cursor_position_from_end, expected_pending_count) + let test_cases = [ + 5, // Delete from middle - should clear tracking + 0, // Delete from end - should clear tracking + ]; + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let placeholder = format!("[Pasted Content {} chars]", paste.chars().count()); + + let states: Vec<_> = test_cases + .into_iter() + .map(|pos_from_end| { + composer.handle_paste(paste.clone()); + composer + .draft + .textarea + .set_cursor(placeholder.len() - pos_from_end); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + let result = ( + composer.draft.textarea.text().contains(&placeholder), + composer.draft.pending_pastes.len(), + ); + composer.draft.textarea.set_text_clearing_elements(""); + result + }) + .collect(); + + assert_eq!( + states, + vec![ + (false, 0), // After deleting from middle + (false, 0), // After deleting from end + ] + ); + } + + // --- Image attachment tests --- + #[test] + fn attach_image_and_submit_includes_local_image_paths() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let path = PathBuf::from("/tmp/image1.png"); + composer.attach_image(path.clone()); + composer.handle_paste(" hi".into()); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1] hi"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn submit_captures_recent_mention_bindings_before_clearing_textarea() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mention_bindings = vec![MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "/tmp/user/figma/SKILL.md".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + "$figma please".to_string(), + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + assert_eq!( + composer.take_recent_submission_mention_bindings(), + mention_bindings + ); + assert!(composer.take_mention_bindings().is_empty()); + } + + #[test] + fn history_navigation_restores_remote_and_local_image_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let remote_image_url = "https://example.com/remote.png".to_string(); + composer.set_remote_image_urls(vec![remote_image_url.clone()]); + let path = PathBuf::from("/tmp/image1.png"); + composer.attach_image(path.clone()); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + let _ = composer.take_remote_image_urls(); + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + + let text = composer.current_text(); + assert_eq!(text, "[Image #2]"); + let text_elements = composer.text_elements(); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #2]")); + assert_eq!(composer.local_image_paths(), vec![path]); + assert_eq!(composer.remote_image_urls(), vec![remote_image_url]); + } + + #[test] + fn history_navigation_restores_remote_only_submissions() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let remote_image_urls = vec![ + "https://example.com/one.png".to_string(), + "https://example.com/two.png".to_string(), + ]; + composer.set_remote_image_urls(remote_image_urls.clone()); + + let (submitted_text, submitted_elements) = composer + .prepare_submission_text(/*record_history*/ true) + .expect("remote-only submission should be prepared"); + assert_eq!(submitted_text, ""); + assert!(submitted_elements.is_empty()); + + let _ = composer.take_remote_image_urls(); + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(composer.current_text(), ""); + assert!(composer.text_elements().is_empty()); + assert_eq!(composer.remote_image_urls(), remote_image_urls); + } + + #[test] + fn history_navigation_leaves_cursor_at_end_of_line() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + type_chars_humanlike(&mut composer, &['s', 'e', 'c', 'o', 'n', 'd']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "first"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert!(composer.draft.textarea.is_empty()); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); + } + + #[test] + fn vim_normal_operator_pending_consumes_submit_key() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + assert!(composer.draft.textarea.is_vim_operator_pending()); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert_eq!(composer.draft.textarea.text(), "hello"); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".magenta()) + ); + assert!(!composer.draft.textarea.is_vim_operator_pending()); + } + + #[test] + fn remapped_editor_history_navigation_does_not_fall_back_to_up() { + use crate::key_hint; + use crate::keymap::RuntimeKeymap; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + let mut keymap = RuntimeKeymap::defaults(); + Arc::make_mut(&mut keymap.editor).move_up = vec![key_hint::plain(KeyCode::F(2))]; + composer.set_keymap_bindings(&keymap); + assert!(Arc::ptr_eq(&composer.editor_keymap, &keymap.editor)); + assert_eq!(Arc::strong_count(&keymap.editor), 3); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert!(composer.draft.textarea.is_empty()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "first"); + } + + #[test] + fn history_navigation_from_start_of_bang_command_recalls_older_entry() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + type_chars_humanlike(&mut composer, &['!', 'g', 'i', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(composer.current_text(), "!git"); + + composer.draft.textarea.set_cursor(/*pos*/ 0); + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(composer.current_text(), "first"); + } + + #[test] + fn vim_normal_history_navigation_from_start_of_bang_command_recalls_older_entry() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + type_chars_humanlike(&mut composer, &['!', 'g', 'i', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + composer.set_vim_enabled(/*enabled*/ true); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.current_text(), "!git"); + assert_eq!(composer.draft.textarea.cursor(), "git".len() - 1); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.current_text(), "first"); + assert_eq!(composer.draft.textarea.cursor(), "first".len() - 1); + } + + #[test] + fn set_text_content_reattaches_images_without_placeholder_metadata() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let placeholder = local_image_label_text(/*label_number*/ 1); + let text = format!("{placeholder} restored"); + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + /*placeholder*/ None, + )]; + let path = PathBuf::from("/tmp/image1.png"); + + composer.set_text_content(text, text_elements, vec![path.clone()]); + + assert_eq!(composer.local_image_paths(), vec![path]); + } + + #[test] + fn large_paste_preserves_image_text_elements_on_submit() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_paste.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let expected = format!("{large_content} [Image #1]"); + assert_eq!(text, expected); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: large_content.len() + 1, + end: large_content.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn large_paste_with_leading_whitespace_trims_and_shifts_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let large_content = format!(" {}", "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5)); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_trim.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let trimmed = large_content.trim().to_string(); + assert_eq!(text, format!("{trimmed} [Image #1]")); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: trimmed.len() + 1, + end: trimmed.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn pasted_crlf_normalizes_newlines_for_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let pasted = "line1\r\nline2\r\n".to_string(); + composer.handle_paste(pasted); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_crlf.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "line1\nline2\n [Image #1]"); + assert!(!text.contains('\r')); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: "line1\nline2\n ".len(), + end: "line1\nline2\n [Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn suppressed_submission_restores_pending_paste_payload() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer + .draft + .textarea + .set_text_clearing_elements("/unknown "); + composer.draft.textarea.set_cursor("/unknown ".len()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + let placeholder = composer + .draft + .pending_pastes + .first() + .expect("expected pending paste") + .0 + .clone(); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::None)); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!( + composer.draft.textarea.text(), + format!("/unknown {placeholder}") + ); + + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.insert_str(" "); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, format!("/unknown {large_content}")); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + assert!(composer.draft.pending_pastes.is_empty()); + } + + #[test] + fn attach_image_without_text_submits_empty_text_and_images() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let path = PathBuf::from("/tmp/image2.png"); + composer.attach_image(path.clone()); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1]"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(imgs.len(), 1); + assert_eq!(imgs[0], path); + assert!(composer.attachments.local_images.is_empty()); + } + + #[test] + fn duplicate_image_placeholders_get_suffix() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let path = PathBuf::from("/tmp/image_dup.png"); + composer.attach_image(path.clone()); + composer.handle_paste(" ".into()); + composer.attach_image(path); + + let text = composer.draft.textarea.text().to_string(); + assert!(text.contains("[Image #1]")); + assert!(text.contains("[Image #2]")); + assert_eq!( + composer.attachments.local_images[0].placeholder, + "[Image #1]" + ); + assert_eq!( + composer.attachments.local_images[1].placeholder, + "[Image #2]" + ); + } + + #[test] + fn image_placeholder_backspace_behaves_like_text_placeholder() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let path = PathBuf::from("/tmp/image3.png"); + composer.attach_image(path.clone()); + let placeholder = composer.attachments.local_images[0].placeholder.clone(); + + // Case 1: backspace at end + composer + .draft + .textarea + .move_cursor_to_end_of_line(/*move_down_at_eol*/ false); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(!composer.draft.textarea.text().contains(&placeholder)); + assert!(composer.attachments.local_images.is_empty()); + + // Re-add and ensure backspace at element start does not delete the placeholder. + composer.attach_image(path); + let placeholder2 = composer.attachments.local_images[0].placeholder.clone(); + // Move cursor to roughly middle of placeholder + if let Some(start_pos) = composer.draft.textarea.text().find(&placeholder2) { + let mid_pos = start_pos + (placeholder2.len() / 2); + composer.draft.textarea.set_cursor(mid_pos); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(composer.draft.textarea.text().contains(&placeholder2)); + assert_eq!(composer.attachments.local_images.len(), 1); + } else { + panic!("Placeholder not found in textarea"); + } + } + + #[test] + fn backspace_with_multibyte_text_before_placeholder_does_not_panic() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Insert an image placeholder at the start + let path = PathBuf::from("/tmp/image_multibyte.png"); + composer.attach_image(path); + // Add multibyte text after the placeholder + composer.draft.textarea.insert_str("日本語"); + + // Cursor is at end; pressing backspace should delete the last character + // without panicking and leave the placeholder intact. + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!(composer.attachments.local_images.len(), 1); + assert!(composer.draft.textarea.text().starts_with("[Image #1]")); + } + + #[test] + fn deleting_one_of_duplicate_image_placeholders_removes_one_entry() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let path1 = PathBuf::from("/tmp/image_dup1.png"); + let path2 = PathBuf::from("/tmp/image_dup2.png"); + + composer.attach_image(path1); + // separate placeholders with a space for clarity + composer.handle_paste(" ".into()); + composer.attach_image(path2.clone()); + + let placeholder1 = composer.attachments.local_images[0].placeholder.clone(); + let placeholder2 = composer.attachments.local_images[1].placeholder.clone(); + let text = composer.draft.textarea.text().to_string(); + let start1 = text.find(&placeholder1).expect("first placeholder present"); + let end1 = start1 + placeholder1.len(); + composer.draft.textarea.set_cursor(end1); + + // Backspace should delete the first placeholder and its mapping. + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + let new_text = composer.draft.textarea.text().to_string(); + assert_eq!( + 1, + new_text.matches(&placeholder1).count(), + "one placeholder remains after deletion" + ); + assert_eq!( + 0, + new_text.matches(&placeholder2).count(), + "second placeholder was relabeled" + ); + assert_eq!( + 1, + new_text.matches("[Image #1]").count(), + "remaining placeholder relabeled to #1" + ); + assert_eq!( + vec![AttachedImage { + path: path2, + placeholder: "[Image #1]".to_string() + }], + composer.attachments.local_images, + "one image mapping remains" + ); + } + + #[test] + fn deleting_reordered_image_one_renumbers_text_in_place() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let path1 = PathBuf::from("/tmp/image_first.png"); + let path2 = PathBuf::from("/tmp/image_second.png"); + let placeholder1 = local_image_label_text(/*label_number*/ 1); + let placeholder2 = local_image_label_text(/*label_number*/ 2); + + // Placeholders can be reordered in the text buffer; deleting image #1 should renumber + // image #2 wherever it appears, not just after the cursor. + let text = format!("Test {placeholder2} test {placeholder1}"); + let start2 = text.find(&placeholder2).expect("placeholder2 present"); + let start1 = text.find(&placeholder1).expect("placeholder1 present"); + let text_elements = vec![ + TextElement::new( + ByteRange { + start: start2, + end: start2 + placeholder2.len(), + }, + Some(placeholder2), + ), + TextElement::new( + ByteRange { + start: start1, + end: start1 + placeholder1.len(), + }, + Some(placeholder1.clone()), + ), + ]; + composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); + + let end1 = start1 + placeholder1.len(); + composer.draft.textarea.set_cursor(end1); + + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!( + composer.draft.textarea.text(), + format!("Test {placeholder1} test ") + ); + assert_eq!( + vec![AttachedImage { + path: path2, + placeholder: placeholder1 + }], + composer.attachments.local_images, + "attachment renumbered after deletion" + ); + } + + #[test] + fn deleting_first_text_element_renumbers_following_text_element() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let path1 = PathBuf::from("/tmp/image_first.png"); + let path2 = PathBuf::from("/tmp/image_second.png"); + + // Insert two adjacent atomic elements. + composer.attach_image(path1); + composer.attach_image(path2.clone()); + assert_eq!(composer.draft.textarea.text(), "[Image #1][Image #2]"); + assert_eq!(composer.attachments.local_images.len(), 2); + + // Delete the first element using normal textarea editing (forward Delete at cursor start). + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); + + // Remaining image should be renumbered and the textarea element updated. + assert_eq!(composer.attachments.local_images.len(), 1); + assert_eq!(composer.attachments.local_images[0].path, path2); + assert_eq!( + composer.attachments.local_images[0].placeholder, + "[Image #1]" + ); + assert_eq!(composer.draft.textarea.text(), "[Image #1]"); + } + + #[test] + fn pasting_filepath_attaches_image() { + let tmp = tempdir().expect("create TempDir"); + let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png"); + let img: ImageBuffer, Vec> = + ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255])); + img.save(&tmp_path).expect("failed to write temp png"); + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string()); + assert!(needs_redraw); + assert!(composer.draft.textarea.text().starts_with("[Image #1] ")); + + let imgs = composer.take_recent_submission_images(); + assert_eq!(imgs, vec![tmp_path]); + } + + #[test] + fn slash_path_input_submits_without_command_error() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer + .draft + .textarea + .set_text_clearing_elements("/Users/example/project/src/main.rs"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + if let InputResult::Submitted { text, .. } = result { + assert_eq!(text, "/Users/example/project/src/main.rs"); + } else { + panic!("expected Submitted"); + } + assert!(composer.draft.textarea.is_empty()); + match rx.try_recv() { + Ok(event) => panic!("unexpected event: {event:?}"), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + Err(err) => panic!("unexpected channel state: {err:?}"), + } + } + + #[test] + fn slash_with_leading_space_submits_as_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer + .draft + .textarea + .set_text_clearing_elements(" /this-looks-like-a-command"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + if let InputResult::Submitted { text, .. } = result { + assert_eq!(text, "/this-looks-like-a-command"); + } else { + panic!("expected Submitted"); + } + assert!(composer.draft.textarea.is_empty()); + match rx.try_recv() { + Ok(event) => panic!("unexpected event: {event:?}"), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + Err(err) => panic!("unexpected channel state: {err:?}"), + } + } + + #[test] + fn shortcut_modified_input_does_not_enter_paste_bursts() { + snapshot_composer_state( + "shortcut_modified_space_is_not_rendered", + /*enhanced_keys_supported*/ false, + |composer| { + let now = Instant::now(); + for input in [ + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char(' '), KeyModifiers::SUPER), + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE), + ] { + composer.handle_input_basic_with_time(input, now); + } + + assert!( + composer.handle_paste_burst_flush( + now + PasteBurst::recommended_active_flush_delay() + ) + ); + }, + ); + + for modifiers in [ + KeyModifiers::SUPER, + KeyModifiers::SHIFT | KeyModifiers::SUPER, + KeyModifiers::HYPER, + KeyModifiers::META, + ] { + let (mut composer, _rx) = new_test_composer(); + let now = Instant::now(); + + composer + .handle_input_basic_with_time(KeyEvent::new(KeyCode::Char(' '), modifiers), now); + + assert!( + !composer.is_in_paste_burst(), + "shortcut modifiers {modifiers:?} must not start a paste burst" + ); + assert!( + !composer.handle_paste_burst_flush(now + PasteBurst::recommended_flush_delay()) + ); + assert_eq!(composer.draft.textarea.text(), ""); + } + } + + #[test] + fn plain_and_shift_spaces_insert_normally() { + for modifiers in [KeyModifiers::NONE, KeyModifiers::SHIFT] { + let (mut composer, _rx) = new_test_composer(); + let now = Instant::now(); + + composer + .handle_input_basic_with_time(KeyEvent::new(KeyCode::Char(' '), modifiers), now); + + assert!(composer.is_in_paste_burst()); + assert_eq!(composer.draft.textarea.text(), ""); + assert!(composer.handle_paste_burst_flush(now + PasteBurst::recommended_flush_delay())); + assert_eq!(composer.draft.textarea.text(), " "); + assert!(!composer.is_in_paste_burst()); + + let snapshot_name = if modifiers == KeyModifiers::NONE { + "plain_space_is_rendered" + } else { + "shift_space_is_rendered" + }; + snapshot_composer_state( + snapshot_name, + /*enhanced_keys_supported*/ false, + |composer| { + let now = Instant::now(); + for input in [ + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char(' '), modifiers), + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE), + ] { + composer.handle_input_basic_with_time(input, now); + } + + assert!(composer.handle_paste_burst_flush( + now + PasteBurst::recommended_active_flush_delay() + )); + }, + ); + } + } + + #[test] + fn non_text_input_flushes_pending_paste_without_inserting_text() { + let (mut composer, _rx) = new_test_composer(); + let now = Instant::now(); + + composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + now, + ); + composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE), + now, + ); + + assert!(composer.is_in_paste_burst()); + assert_eq!(composer.draft.textarea.text(), ""); + + composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char(' '), KeyModifiers::SUPER), + now, + ); + + assert_eq!(composer.draft.textarea.text(), "ab"); + assert!(!composer.is_in_paste_burst()); + assert!(!composer.handle_paste_burst_flush(now + PasteBurst::recommended_flush_delay())); + } + + #[test] + fn platform_specific_altgr_input_remains_text() { + for modifiers in [ + KeyModifiers::CONTROL | KeyModifiers::ALT, + KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT, + ] { + let (mut composer, _rx) = new_test_composer(); + let now = Instant::now(); + + composer + .handle_input_basic_with_time(KeyEvent::new(KeyCode::Char('@'), modifiers), now); + + if cfg!(windows) { + assert!(composer.is_in_paste_burst()); + assert!( + composer.handle_paste_burst_flush(now + PasteBurst::recommended_flush_delay()) + ); + assert_eq!(composer.draft.textarea.text(), "@"); + } else { + assert!(!composer.is_in_paste_burst()); + assert!( + !composer.handle_paste_burst_flush(now + PasteBurst::recommended_flush_delay()) + ); + assert_eq!(composer.draft.textarea.text(), ""); + } + } + } + + /// Behavior: the first fast ASCII character is held briefly to avoid flicker; if no burst + /// follows, it should eventually flush as normal typed input (not as a paste). + #[test] + fn pending_first_ascii_char_flushes_as_typed() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + assert!(composer.is_in_paste_burst()); + assert!(composer.draft.textarea.text().is_empty()); + + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let flushed = composer.flush_paste_burst_if_due(); + assert!(flushed, "expected pending first char to flush"); + assert_eq!(composer.draft.textarea.text(), "h"); + assert!(!composer.is_in_paste_burst()); + } + + /// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If + /// the payload is small, it should insert directly (no placeholder). + #[test] + fn burst_paste_fast_small_buffers_and_flushes_on_stop() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let count = 32; + let mut now = Instant::now(); + let step = Duration::from_millis(1); + for _ in 0..count { + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + now, + ); + assert!( + composer.is_in_paste_burst(), + "expected active paste burst during fast typing" + ); + assert!( + composer.draft.textarea.text().is_empty(), + "text should not appear during burst" + ); + now += step; + } + + assert!( + composer.draft.textarea.text().is_empty(), + "text should remain empty until flush" + ); + let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; + let flushed = composer.handle_paste_burst_flush(flush_time); + assert!(flushed, "expected buffered text to flush after stop"); + assert_eq!(composer.draft.textarea.text(), "a".repeat(count)); + assert!( + composer.draft.pending_pastes.is_empty(), + "no placeholder for small burst" + ); + } + + /// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If + /// the payload is large, it should insert a placeholder and defer the full text until submit. + #[test] + fn burst_paste_fast_large_inserts_placeholder_on_flush() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder + let mut now = Instant::now(); + let step = Duration::from_millis(1); + for _ in 0..count { + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + now, + ); + now += step; + } + + // Nothing should appear until we stop and flush + assert!(composer.draft.textarea.text().is_empty()); + let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; + let flushed = composer.handle_paste_burst_flush(flush_time); + assert!(flushed, "expected flush after stopping fast input"); + + let expected_placeholder = format!("[Pasted Content {count} chars]"); + assert_eq!(composer.draft.textarea.text(), expected_placeholder); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, expected_placeholder); + assert_eq!(composer.draft.pending_pastes[0].1.len(), count); + assert!(composer.draft.pending_pastes[0].1.chars().all(|c| c == 'x')); + } + + /// Behavior: human-like typing (with delays between chars) should not be classified as a paste + /// burst. Characters should appear immediately and should not trigger a paste placeholder. + #[test] + fn humanlike_typing_1000_chars_appears_live_no_placeholder() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config + let mut now = Instant::now(); + let step = ChatComposer::recommended_paste_flush_delay(); + for _ in 0..count { + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE), + now, + ); + now += step; + let _ = composer.handle_paste_burst_flush(now); + } + + assert_eq!(composer.draft.textarea.text(), "z".repeat(count)); + assert!(composer.draft.pending_pastes.is_empty()); + } + + #[test] + fn slash_popup_not_activated_for_slash_space_text_history_like_input() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use tokio::sync::mpsc::unbounded_channel; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Simulate history-like content: "/ test" + composer.set_text_content("/ test".to_string(), Vec::new(), Vec::new()); + + // After set_text_content -> sync_popups is called; popup should NOT be Command. + assert!( + matches!(composer.popups.active, ActivePopup::None), + "expected no slash popup for '/ test'" + ); + + // Up should be handled by history navigation path, not slash popup handler. + let (result, _redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + } + + #[test] + fn slash_popup_activated_for_bare_slash_and_valid_prefixes() { + // use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use tokio::sync::mpsc::unbounded_channel; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + // Case 1: bare "/" + composer.set_text_content("/".to_string(), Vec::new(), Vec::new()); + assert!( + matches!(composer.popups.active, ActivePopup::Command(_)), + "bare '/' should activate slash popup" + ); + + // Case 2: valid prefix "/re" (matches /review, /resume, etc.) + composer.set_text_content("/re".to_string(), Vec::new(), Vec::new()); + assert!( + matches!(composer.popups.active, ActivePopup::Command(_)), + "'/re' should activate slash popup via prefix match" + ); + + // Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback) + composer.set_text_content("/ac".to_string(), Vec::new(), Vec::new()); + assert!( + matches!(composer.popups.active, ActivePopup::Command(_)), + "'/ac' should activate slash popup via fuzzy match" + ); + + // Case 4: invalid prefix "/zzz" – still allowed to open popup if it + // matches no built-in command; our current logic will not open popup. + // Verify that explicitly. + composer.set_text_content("/zzz".to_string(), Vec::new(), Vec::new()); + assert!( + matches!(composer.popups.active, ActivePopup::None), + "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" + ); + } + + #[test] + fn bare_slash_command_can_be_recalled_after_recording_pending_history() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content("/diff".to_string(), Vec::new(), Vec::new()); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(result, InputResult::Command(SlashCommand::Diff)); + composer.record_pending_slash_command_history(); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert_eq!(composer.current_text(), "/diff"); + } + + #[test] + fn popup_selected_slash_command_records_canonical_command_history() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content("/di".to_string(), Vec::new(), Vec::new()); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(result, InputResult::Command(SlashCommand::Diff)); + composer.record_pending_slash_command_history(); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert_eq!(composer.current_text(), "/diff"); + } + + #[test] + fn inline_slash_command_can_be_recalled_after_recording_pending_history() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_collaboration_modes_enabled(/*enabled*/ true); + + composer.set_text_content("/plan investigate this".to_string(), Vec::new(), Vec::new()); + composer.popups.active = ActivePopup::None; + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::CommandWithArgs(cmd, args, text_elements) => { + assert_eq!(cmd, SlashCommand::Plan); + assert_eq!(args, "investigate this"); + assert!(text_elements.is_empty()); + } + other => panic!("expected inline /plan command, got {other:?}"), + } + composer.record_pending_slash_command_history(); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert_eq!(composer.current_text(), "/plan investigate this"); + } + + #[test] + fn apply_external_edit_rebuilds_text_and_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let placeholder = local_image_label_text(/*label_number*/ 1); + composer.draft.textarea.insert_element(&placeholder); + composer.attachments.local_images.push(AttachedImage { + placeholder: placeholder.clone(), + path: PathBuf::from("img.png"), + }); + composer + .draft + .pending_pastes + .push(("[Pasted]".to_string(), "data".to_string())); + + composer.apply_external_edit(format!("Edited {placeholder} text")); + + assert_eq!( + composer.current_text(), + format!("Edited {placeholder} text") + ); + assert!(composer.draft.pending_pastes.is_empty()); + assert_eq!(composer.attachments.local_images.len(), 1); + assert_eq!( + composer.attachments.local_images[0].placeholder, + placeholder + ); + assert_eq!( + composer.draft.textarea.cursor(), + composer.current_text().len() + ); + } + + #[test] + fn apply_external_edit_absorbs_bash_prefix_without_duplication() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new()); + + composer.apply_external_edit("!git status".to_string()); + + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); + assert_eq!(composer.current_text(), "!git status"); + } + + #[test] + fn apply_external_edit_can_leave_bash_mode() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new()); + + composer.apply_external_edit("git status".to_string()); + + assert!(!composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); + assert_eq!(composer.current_text(), "git status"); + } + + #[test] + fn apply_external_edit_can_enter_bash_mode() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content("git status".to_string(), Vec::new(), Vec::new()); + + composer.apply_external_edit("!git status".to_string()); + + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); + assert_eq!(composer.current_text(), "!git status"); + } + + #[test] + fn apply_external_edit_drops_missing_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let placeholder = local_image_label_text(/*label_number*/ 1); + composer.draft.textarea.insert_element(&placeholder); + composer.attachments.local_images.push(AttachedImage { + placeholder: placeholder.clone(), + path: PathBuf::from("img.png"), + }); + + composer.apply_external_edit("No images here".to_string()); + + assert_eq!(composer.current_text(), "No images here".to_string()); + assert!(composer.attachments.local_images.is_empty()); + } + + #[test] + fn apply_external_edit_renumbers_image_placeholders() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let first_path = PathBuf::from("img1.png"); + let second_path = PathBuf::from("img2.png"); + composer.attach_image(first_path); + composer.attach_image(second_path.clone()); + + let placeholder2 = local_image_label_text(/*label_number*/ 2); + composer.apply_external_edit(format!("Keep {placeholder2}")); + + let placeholder1 = local_image_label_text(/*label_number*/ 1); + assert_eq!(composer.current_text(), format!("Keep {placeholder1}")); + assert_eq!(composer.attachments.local_images.len(), 1); + assert_eq!( + composer.attachments.local_images[0].placeholder, + placeholder1 + ); + assert_eq!(composer.local_image_paths(), vec![second_path]); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder1] + ); + } + + #[test] + fn current_text_with_pending_expands_placeholders() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let placeholder = "[Pasted Content 5 chars]".to_string(); + composer.draft.textarea.insert_element(&placeholder); + composer + .draft + .pending_pastes + .push((placeholder.clone(), "hello".to_string())); + + assert_eq!( + composer.current_text_with_pending(), + "hello".to_string(), + "placeholder should expand to actual text" + ); + } + + #[test] + fn current_text_with_pending_expands_overlapping_placeholders() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let first_paste = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let second_paste = "b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let base = format!("[Pasted Content {} chars]", first_paste.chars().count()); + let second = format!("{base} #2"); + + composer.handle_paste(first_paste.clone()); + composer.handle_paste(second_paste.clone()); + + assert_eq!(composer.current_text(), format!("{base}{second}")); + assert_eq!( + composer.current_text_with_pending(), + format!("{first_paste}{second_paste}") + ); + } + + #[test] + fn apply_external_edit_limits_duplicates_to_occurrences() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let placeholder = local_image_label_text(/*label_number*/ 1); + composer.draft.textarea.insert_element(&placeholder); + composer.attachments.local_images.push(AttachedImage { + placeholder: placeholder.clone(), + path: PathBuf::from("img.png"), + }); + + composer.apply_external_edit(format!("{placeholder} extra {placeholder}")); + + assert_eq!( + composer.current_text(), + format!("{placeholder} extra {placeholder}") + ); + assert_eq!(composer.attachments.local_images.len(), 1); + } + + #[test] + fn remote_images_do_not_modify_textarea_text_or_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_remote_image_urls(vec![ + "https://example.com/one.png".to_string(), + "https://example.com/two.png".to_string(), + ]); + + assert_eq!(composer.current_text(), ""); + assert_eq!(composer.text_elements(), Vec::::new()); + } + + #[test] + fn attach_image_after_remote_prefix_uses_offset_label() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_remote_image_urls(vec![ + "https://example.com/one.png".to_string(), + "https://example.com/two.png".to_string(), + ]); + composer.attach_image(PathBuf::from("/tmp/local.png")); + + assert_eq!( + composer.attachments.local_images[0].placeholder, + "[Image #3]" + ); + assert_eq!(composer.current_text(), "[Image #3]"); + } + + #[test] + fn prepare_submission_keeps_remote_offset_local_placeholder_numbering() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]); + let base_text = "[Image #2] hello".to_string(); + let base_elements = vec![TextElement::new( + (0.."[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + )]; + composer.set_text_content( + base_text, + base_elements, + vec![PathBuf::from("/tmp/local.png")], + ); + + let (submitted_text, submitted_elements) = composer + .prepare_submission_text(/*record_history*/ true) + .expect("remote+local submission should be generated"); + assert_eq!(submitted_text, "[Image #2] hello"); + assert_eq!( + submitted_elements, + vec![TextElement::new( + (0.."[Image #2]".len()).into(), + Some("[Image #2]".to_string()) + )] + ); + } + + #[test] + fn prepare_submission_with_only_remote_images_returns_empty_text() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]); + let (submitted_text, submitted_elements) = composer + .prepare_submission_text(/*record_history*/ true) + .expect("remote-only submission should be generated"); + assert_eq!(submitted_text, ""); + assert!(submitted_elements.is_empty()); + } + + #[test] + fn delete_selected_remote_image_relabels_local_placeholders() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_remote_image_urls(vec![ + "https://example.com/one.png".to_string(), + "https://example.com/two.png".to_string(), + ]); + composer.attach_image(PathBuf::from("/tmp/local.png")); + composer.draft.textarea.set_cursor(/*pos*/ 0); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); + assert_eq!( + composer.remote_image_urls(), + vec!["https://example.com/one.png".to_string()] + ); + assert_eq!(composer.current_text(), "[Image #2]"); + assert_eq!( + composer.attachments.local_images[0].placeholder, + "[Image #2]" + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); + assert_eq!(composer.remote_image_urls(), Vec::::new()); + assert_eq!(composer.current_text(), "[Image #1]"); + assert_eq!( + composer.attachments.local_images[0].placeholder, + "[Image #1]" + ); + } + + #[test] + fn input_disabled_ignores_keypresses_and_hides_cursor() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); + composer.set_input_enabled( + /*enabled*/ false, + Some("Input disabled for test.".to_string()), + ); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert!(!needs_redraw); + assert_eq!(composer.current_text(), "hello"); + + let area = Rect { + x: 0, + y: 0, + width: 40, + height: 5, + }; + assert_eq!(composer.cursor_pos(area), None); + } + + #[test] + fn shutdown_in_progress_disables_input_and_uses_hint_without_footer() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); + composer.show_shutdown_in_progress(); + + assert!(!composer.input_enabled()); + assert_eq!(composer.current_text(), "hello"); + assert_eq!(composer.custom_footer_height(), Some(0)); + + let area = Rect { + x: 0, + y: 0, + width: 40, + height: 5, + }; + assert_eq!(composer.cursor_pos(area), None); + + let mut terminal = Terminal::new(TestBackend::new(40, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap(); + insta::assert_snapshot!("shutdown_in_progress", terminal.backend()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation.rs b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation.rs new file mode 100644 index 0000000000000000000000000000000000000000..f2fdbf0dd236fac92ce184603fe6eb51047d298d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation.rs @@ -0,0 +1,29 @@ +//! Availability of the empty-prompt agents shortcut and its footer hint. +//! +//! Only local-daemon sessions enable this shortcut. Draft contents, transient input surfaces, +//! and explicit editor remaps take precedence over navigation. + +use super::*; + +impl ChatComposer { + pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) { + self.agents_navigation_enabled = enabled; + } + + pub(super) fn agents_navigation_available(&self) -> bool { + let move_left = if self.draft.textarea.is_vim_normal_mode() { + &self.vim_normal_keymap.move_left + } else { + &self.editor_keymap.move_left + }; + self.agents_navigation_enabled + && move_left.is_pressed(KeyCode::Left.into()) + && self.has_focus + && self.draft.input_enabled + && self.slash_commands_enabled() + && self.is_empty() + && !self.is_in_paste_burst() + && !self.popup_active() + && !self.draft.textarea.is_vim_operator_pending() + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/completion_target_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/completion_target_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..095877d59e509a31682d40e9203c9744f3fb7652 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/completion_target_tests.rs @@ -0,0 +1,219 @@ +use super::*; +use crate::bottom_pane::textarea::TextArea; +use pretty_assertions::assert_eq; + +#[test] +fn current_prefixed_token_affinity_does_not_cross_line_break() { + for (text, prefix, allow_empty) in [ + ("@file\n continue", '@', false), + ("continue \n@file", '@', false), + ("$skill\n continue", '$', true), + ("continue \n$skill", '$', true), + ] { + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor(text.find(" ").expect("indentation present") + 1); + + assert_eq!( + current_prefixed_token_range(&textarea, prefix, allow_empty), + None + ); + } +} + +#[test] +fn current_prefixed_token_affinity_does_not_cross_separator_before_plain_text() { + for (text, prefix, allow_empty) in [("@old word", '@', false), ("$old word", '$', true)] { + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor(text.find(" ").expect("separator present") + 1); + + assert_eq!( + current_prefixed_token_range(&textarea, prefix, allow_empty), + None + ); + } +} + +#[test] +fn current_prefixed_token_prefers_right_after_adjacent_target() { + let bound = "@bound"; + let left = "@bound@old"; + let text = "@bound@old @new"; + let right_start = left.len() + 2; + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.add_element_range(0..bound.len()); + textarea.set_cursor(left.len()); + + assert_eq!( + current_prefixed_token_range(&textarea, '@', /*allow_empty*/ false), + Some((right_start..text.len(), "new".to_string())) + ); +} + +#[test] +fn current_prefixed_token_prefers_right_at_token_start() { + let text = "$old $new"; + let right_start = "$old ".len(); + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor(right_start); + + assert_eq!( + current_prefixed_token_range(&textarea, '$', /*allow_empty*/ true), + Some((right_start..text.len(), "new".to_string())) + ); +} + +#[test] +fn current_prefixed_token_falls_back_from_bound_at_token_on_right() { + let text = "@left @bound"; + let bound_start = text.find("@bound").expect("bound mention"); + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.add_element_range(bound_start..text.len()); + textarea.set_cursor("@left ".len()); + + assert_eq!( + current_prefixed_token_range(&textarea, '@', /*allow_empty*/ true), + Some((0.."@left".len(), "left".to_string())) + ); +} + +#[test] +fn current_prefixed_token_falls_back_from_bound_dollar_suffix_on_right() { + for suffix in ["/path", ".config"] { + let text = format!("$left $bound{suffix}"); + let bound_start = text.find("$bound").expect("bound skill mention"); + let bound_end = bound_start + "$bound".len(); + let mut textarea = TextArea::new(); + textarea.insert_str(&text); + textarea.add_element_range(bound_start..bound_end); + textarea.set_cursor("$left ".len()); + + assert_eq!( + current_prefixed_token_range(&textarea, '$', /*allow_empty*/ true), + Some((0.."$left".len(), "left".to_string())) + ); + } +} + +#[test] +fn current_prefixed_token_targets_later_segment_between_bound_elements() { + let text = "$bound1$one$bound2$two$bound3"; + let bound2_start = text.find("$bound2").expect("second bound mention"); + let two_start = text.find("$two").expect("later plaintext target"); + let bound3_start = text.find("$bound3").expect("third bound mention"); + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.add_element_range(0.."$bound1".len()); + textarea.add_element_range(bound2_start..two_start); + textarea.add_element_range(bound3_start..text.len()); + textarea.set_cursor(two_start + "$t".len()); + + assert_eq!( + current_prefixed_token_range(&textarea, '$', /*allow_empty*/ true), + Some((two_start..bound3_start, "two".to_string())) + ); +} + +#[test] +fn current_prefixed_token_bounds_right_candidate_before_bound_element() { + let text = "continue @other@bound"; + let other_start = text.find("@other").expect("editable mention"); + let bound_start = text.find("@bound").expect("bound mention"); + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.add_element_range(bound_start..text.len()); + textarea.set_cursor(other_start); + + assert_eq!( + current_prefixed_token_range(&textarea, '@', /*allow_empty*/ false), + Some((other_start..bound_start, "other".to_string())) + ); +} + +#[test] +fn current_prefixed_token_stops_before_overlapping_element() { + let editable = "$other"; + let placeholder = "[Pasted Content 1001 chars]"; + let text = format!("{editable}{placeholder}"); + let mut textarea = TextArea::new(); + textarea.insert_str(&text); + textarea.add_element_range(editable.len()..text.len()); + textarea.set_cursor(editable.len()); + + assert_eq!( + current_prefixed_token_range(&textarea, '$', /*allow_empty*/ true), + Some((0..editable.len(), "other".to_string())) + ); +} + +#[test] +fn current_prefixed_token_stops_at_trailing_space_before_line_break() { + let text = "$figma \nnext"; + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor("$figma ".len()); + + assert_eq!( + current_prefixed_token_range(&textarea, '$', /*allow_empty*/ true), + None + ); +} + +#[test] +fn current_prefixed_token_keeps_nested_dollar_prefix_in_same_token() { + let text = "$HOME/$USER"; + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor(text.find("$USER").expect("nested prefix present")); + + assert_eq!( + current_prefixed_token_range(&textarea, '$', /*allow_empty*/ true), + Some((0..text.len(), "HOME/$USER".to_string())) + ); +} + +#[test] +fn typed_qualified_suffix_preserves_bound_plain_mention() { + // Qualified skill bindings and history persistence are intentionally deferred. Typing a + // suffix after a bound plain mention must not reinterpret it as an editable qualified one. + let bound = "$google-calendar"; + let text = "$google-calendar:availability"; + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.add_element_range(0..bound.len()); + textarea.set_cursor(text.len()); + + let (range, token) = current_prefixed_token_range(&textarea, '$', /*allow_empty*/ true) + .expect("dollar token should be present"); + assert_eq!((range.clone(), token.as_str()), (0..text.len(), &text[1..])); + assert!(!prefixed_token_range_is_editable( + &textarea, '$', &range, &token, + )); +} + +#[test] +fn dollar_query_classifies_shell_and_skill_syntax() { + assert!(!dollar_query_is_completable("{CODEX_HOME}/config.toml")); + assert!(!dollar_query_is_completable("(pwd)")); + assert!(dollar_query_is_completable("home:search")); + assert!(dollar_query_is_completable("home")); + assert!(!dollar_query_is_completable("HOME")); + for query in ["0", "1", "12", "-", "_"] { + assert!(!dollar_query_is_completable(query)); + assert_eq!( + dollar_query_kind(query), + DollarQueryKind::DefiniteShellParameter + ); + } + for query in ["1_suffix", "1foo", "-x"] { + assert!(!dollar_query_is_completable(query)); + assert_eq!( + dollar_query_kind(query), + DollarQueryKind::AmbiguousShellParameter + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/embedded_input_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/embedded_input_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..0a79c5206326e7c0079c4689ef0cc9295c9e911a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/embedded_input_tests.rs @@ -0,0 +1,79 @@ +//! Embedded composers preserve literal drafts and show validation feedback in place. + +use super::tests::new_test_composer; +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn plain_text_composer_keeps_command_prefixes_literal() { + let (mut composer, _rx) = new_test_composer(); + composer.config = ChatComposerConfig::plain_text(); + composer.set_disable_paste_burst(/*disabled*/ true); + composer.set_footer_hint_override(Some(Vec::new())); + for (prefix, modifiers) in [ + ('!', KeyModifiers::NONE), + ('/', KeyModifiers::NONE), + ('?', KeyModifiers::NONE), + ('?', KeyModifiers::SHIFT), + ] { + composer.handle_key_event(KeyEvent::new(KeyCode::Char(prefix), modifiers)); + composer.handle_paste("keep this literal".into()); + assert_eq!( + composer.handle_key_event(KeyCode::Tab.into()).0, + InputResult::Submitted { + text: format!("{prefix}keep this literal"), + text_elements: Vec::new(), + } + ); + } +} + +#[test] +fn buffered_typing_survives_vim_changes_and_cancellation() { + let (mut composer, _rx) = new_test_composer(); + composer.handle_key_event(KeyCode::Char('x').into()); + composer.set_vim_enabled(/*enabled*/ true); + assert_eq!(composer.current_text(), "x"); + composer.set_vim_enabled(/*enabled*/ false); + composer.handle_key_event(KeyCode::Char('y').into()); + assert_eq!(composer.clear_for_ctrl_c(), Some("xy".into())); + assert!(!composer.is_in_paste_burst()); + composer.handle_key_event(KeyCode::Up.into()); + assert_eq!(composer.current_text(), "xy"); +} + +#[test] +fn paste_enter_retains_a_one_character_first_line() { + let (mut composer, _rx) = new_test_composer(); + composer.handle_key_event(KeyCode::Char('x').into()); + assert!(composer.handle_paste_enter(Instant::now())); + assert_eq!(composer.clear_for_ctrl_c(), Some("x\n".into())); +} + +#[test] +fn embedded_composer_shows_validation_error_without_losing_draft() { + let (mut composer, mut rx) = new_test_composer(); + composer.config = ChatComposerConfig::plain_text(); + composer.set_footer_hint_override(Some(vec![("enter".into(), "submit".into())])); + composer.set_context_window_pending(/*pending*/ true); + let text = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); + composer.handle_paste(text.clone()); + assert_eq!( + composer.handle_key_event(KeyCode::Enter.into()).0, + InputResult::None + ); + assert_eq!(composer.current_text_with_pending(), text); + assert!(matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)))); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 5, + ); + let mut buffer = Buffer::empty(area); + composer.render(area, &mut buffer); + let footer = (0..area.width) + .map(|x| buffer[(x, area.bottom() - 1)].symbol()) + .collect::(); + insta::assert_snapshot!(footer.trim_end(), @" Message too long; limit 1048576 characters"); + assert!(composer.footer_flash_delay().is_some()); + composer.footer.flash.as_mut().unwrap().expires_at = Instant::now() - Duration::from_secs(1); + assert_eq!(composer.footer_flash_delay(), None); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..36fb5a4e708fad81989ec98e6c5331964aa36a3b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs @@ -0,0 +1,60 @@ +//! Offline edits retain the same attachment bookkeeping as ordinary text edits. + +use super::super::tests::new_test_composer; +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn reconnect_expands_pastes_preserving_images_and_cursor() { + let (mut composer, _) = new_test_composer(); + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + composer.handle_paste(paste.clone()); + composer.attach_image(PathBuf::from("local.png")); + composer.handle_paste(paste); + let expanded = composer.current_text_with_pending(); + let images = composer.draft_snapshot().local_images; + composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE)); + let draft = composer.draft_snapshot(); + assert_eq!( + ( + draft.text, + draft.cursor, + draft.local_images, + draft.pending_pastes + ), + ( + format!("{expanded}!"), + expanded.len() + 1, + images, + Vec::new() + ) + ); + assert_eq!(draft.text_elements.len(), 1); +} + +#[test] +fn reconnect_edit_removes_deleted_attachment_and_paste_metadata() { + let (mut composer, _) = new_test_composer(); + composer.attach_image(PathBuf::from("local.png")); + composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + let draft = composer.draft_snapshot(); + assert_eq!( + (draft.text, draft.local_images, draft.pending_pastes), + (String::new(), Vec::new(), Vec::new()) + ); +} + +#[test] +fn reconnect_edit_cancels_history_preview_without_losing_original_draft() { + let (mut composer, _) = new_test_composer(); + composer.set_text_content("original draft".into(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(/*pos*/ 14); + composer.begin_history_search(); + composer.apply_history_search_result(HistorySearchResult::Found(HistoryEntry::new( + "history preview".into(), + ))); + composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE)); + assert_eq!(composer.current_text(), "original draft!"); + assert!(!composer.history_search_active()); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs new file mode 100644 index 0000000000000000000000000000000000000000..0a1604e5e3d5ecc1ea26be5f49bdea4be27006a6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs @@ -0,0 +1,687 @@ +//! Slash-command input parsing, cursor detection, and completion helpers. + +use std::ops::Range; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; + +use crate::bottom_pane::command_popup::CommandItem; +use crate::bottom_pane::command_popup::CommandPopup; +use crate::bottom_pane::command_popup::CommandPopupFlags; +use crate::bottom_pane::prompt_args::parse_slash_name; +use crate::bottom_pane::slash_commands::BuiltinCommandFlags; +use crate::bottom_pane::slash_commands::ServiceTierCommand; +use crate::bottom_pane::slash_commands::SlashCommandItem; +use crate::bottom_pane::slash_commands::find_slash_command; +use crate::bottom_pane::slash_commands::has_slash_command_prefix; +use crate::slash_command::SlashCommand; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; + +use super::super::footer::reset_mode_after_activity; +use super::ActivePopup; +use super::ChatComposer; +use super::InputResult; +use super::QueuedInputAction; +use super::parent_owned_command_is_allowed; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum SlashValidation { + Immediate, + Deferred, +} + +pub(super) enum SubmissionValidation { + Valid, + UnknownCommand(String), +} + +pub(super) struct InlineCommand<'a> { + pub(super) command: SlashCommandItem, + pub(super) rest: &'a str, + pub(super) rest_offset: usize, +} + +pub(super) struct SlashInput<'a> { + enabled: bool, + is_bash_mode: bool, + command_flags: BuiltinCommandFlags, + service_tier_commands: &'a [ServiceTierCommand], +} + +impl<'a> SlashInput<'a> { + pub(super) fn new( + enabled: bool, + is_bash_mode: bool, + command_flags: BuiltinCommandFlags, + service_tier_commands: &'a [ServiceTierCommand], + ) -> Self { + Self { + enabled, + is_bash_mode, + command_flags, + service_tier_commands, + } + } + + pub(super) fn validate_submission( + &self, + text: &str, + input_starts_with_space: bool, + ) -> SubmissionValidation { + if !self.enabled { + return SubmissionValidation::Valid; + } + let Some((name, _rest, _rest_offset)) = parse_slash_name(text) else { + return SubmissionValidation::Valid; + }; + if input_starts_with_space || name.contains('/') { + return SubmissionValidation::Valid; + } + if self.command(name).is_some() { + SubmissionValidation::Valid + } else { + SubmissionValidation::UnknownCommand(name.to_string()) + } + } + + pub(super) fn bare_command(&self, text: &str) -> Option { + if !self.enabled || self.is_bash_mode { + return None; + } + let first_line = text.lines().next().unwrap_or(""); + let (name, rest, _rest_offset) = parse_slash_name(first_line)?; + if !rest.is_empty() { + return None; + } + let command = self.command(name)?; + if command.supports_inline_args() + && parse_slash_name(text).is_some_and(|(_, full_rest, _)| !full_rest.is_empty()) + { + return None; + } + Some(command) + } + + pub(super) fn inline_command<'text>(&self, text: &'text str) -> Option> { + if !self.enabled || self.is_bash_mode || text.starts_with(' ') { + return None; + } + + let (name, rest, rest_offset) = parse_slash_name(text)?; + if rest.is_empty() || name.contains('/') { + return None; + } + + let command = self.command(name)?; + command.supports_inline_args().then_some(InlineCommand { + command, + rest, + rest_offset, + }) + } + + pub(super) fn should_parse_on_dequeue(&self, text: &str) -> bool { + self.enabled && !text.starts_with(' ') && text.trim().starts_with('/') + } + + pub(super) fn command_element_range( + &self, + first_line: &str, + cursor: usize, + ) -> Option> { + if self.is_bash_mode { + return None; + } + let (name, _rest, _rest_offset) = parse_slash_name(first_line)?; + if name.contains('/') { + return None; + } + let element_end = 1 + name.len(); + // A draft tail can make an in-progress prefix look complete ("/re" + "view"). + // Keep it editable until the cursor leaves the command name. + if cursor <= first_line.len() && (1..element_end).contains(&cursor) { + return None; + } + let has_space_after = first_line + .get(element_end..) + .and_then(|tail| tail.chars().next()) + .is_some_and(char::is_whitespace); + if !has_space_after { + return None; + } + self.command(name).is_some().then_some(0..element_end) + } + + pub(super) fn is_editing_command_name(&self, first_line: &str, cursor: usize) -> bool { + let Some((name, rest)) = command_under_cursor(first_line, cursor) else { + return false; + }; + if !self.enabled { + return false; + } + if name.is_empty() { + return rest.is_empty(); + } + + has_slash_command_prefix(name, self.command_flags, self.service_tier_commands) + } + + pub(super) fn command_popup(&self, filter_text: &str) -> CommandPopup { + let mut command_popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: self.command_flags.collaboration_modes_enabled, + connectors_enabled: self.command_flags.connectors_enabled, + plugins_command_enabled: self.command_flags.plugins_command_enabled, + token_activity_command_enabled: self.command_flags.token_activity_command_enabled, + service_tier_commands_enabled: self.command_flags.service_tier_commands_enabled, + goal_command_enabled: self.command_flags.goal_command_enabled, + voice_command_enabled: self.command_flags.voice_command_enabled, + worktrees_enabled: self.command_flags.worktrees_enabled, + windows_degraded_sandbox_active: self.command_flags.allow_elevate_sandbox, + side_conversation_active: self.command_flags.side_conversation_active, + }, + self.service_tier_commands.to_vec(), + ); + command_popup.on_composer_text_change(filter_text.to_string()); + command_popup + } + + pub(super) fn command(&self, name: &str) -> Option { + find_slash_command(name, self.command_flags, self.service_tier_commands) + } +} + +pub(super) fn queued_input_action( + prepared_text: &str, + defer_slash_validation: bool, +) -> QueuedInputAction { + if defer_slash_validation && prepared_text.starts_with('/') { + QueuedInputAction::ParseSlash + } else if prepared_text.starts_with('!') { + QueuedInputAction::RunShell + } else { + QueuedInputAction::Plain + } +} + +impl ChatComposer { + pub(super) fn builtin_command_flags(&self) -> BuiltinCommandFlags { + BuiltinCommandFlags { + collaboration_modes_enabled: self.collaboration_modes_enabled, + connectors_enabled: self.connectors_enabled, + plugins_command_enabled: self.plugins_command_enabled, + token_activity_command_enabled: self.token_activity_command_enabled, + service_tier_commands_enabled: self.service_tier_commands_enabled, + goal_command_enabled: self.goal_command_enabled, + voice_command_enabled: self.voice_command_enabled, + worktrees_enabled: self.worktrees_enabled, + allow_elevate_sandbox: self.windows_degraded_sandbox_active, + side_conversation_active: self.side_conversation_active, + } + } + + pub fn set_worktrees_enabled(&mut self, enabled: bool) { + self.worktrees_enabled = enabled; + } + + /// Handle key event when the slash-command popup is visible. + pub(super) fn handle_key_event_with_slash_popup( + &mut self, + key_event: KeyEvent, + ) -> (InputResult, bool) { + if self.handle_empty_prompt_shortcut(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + // Always dismiss the popup without changing the draft. + let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); + self.popups.dismissed_command_token = + command_popup_filter_text(first_line, /*cursor*/ 0); + self.popups.active = ActivePopup::None; + return (InputResult::None, true); + } + self.footer.mode = reset_mode_after_activity(self.footer.mode); + let ActivePopup::Command(popup) = &mut self.popups.active else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } => { + // Ensure popup filtering/selection reflects the latest composer text + // before applying completion. + let text = self.draft.textarea.text(); + let first_line = text.lines().next().unwrap_or("").to_owned(); + let cursor = self.draft.textarea.cursor(); + let filter_text = command_popup_filter_text(&first_line, cursor) + .unwrap_or_else(|| first_line.clone()); + popup.on_composer_text_change(filter_text); + if let Some(selected_cmd) = popup.selected_item() { + if selected_command_dispatches_immediately_on_tab(&selected_cmd) + && let CommandItem::Builtin(cmd) = &selected_cmd + { + self.stage_selected_slash_command_history(&selected_cmd); + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; + return (InputResult::Command(*cmd), true); + } + + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &selected_cmd, + ) + { + return (InputResult::None, true); + } + + if let Some(completed_text) = + selected_command_completion(&first_line, &selected_cmd) + { + self.draft + .textarea + .set_text_clearing_elements(&completed_text); + if !self.draft.textarea.text().is_empty() { + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); + } + return (InputResult::None, true); + } + } + if self.is_task_running { + return self.handle_submission(/*should_queue*/ true); + } + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Char('/'), + modifiers: KeyModifiers::NONE, + .. + } => { + // Treat "/" as accepting the highlighted command as text completion + // while the slash-command popup is active. + let text = self.draft.textarea.text(); + let first_line = text.lines().next().unwrap_or("").to_owned(); + let cursor = self.draft.textarea.cursor(); + let filter_text = command_popup_filter_text(&first_line, cursor) + .unwrap_or_else(|| first_line.clone()); + popup.on_composer_text_change(filter_text); + if let Some(selected_cmd) = popup.selected_item() { + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &selected_cmd, + ) + { + return (InputResult::None, true); + } + + if let Some(completed_text) = + selected_command_completion(&first_line, &selected_cmd) + { + self.draft + .textarea + .set_text_clearing_elements(&completed_text); + self.draft.is_bash_mode = false; + } + if !self.draft.textarea.text().is_empty() { + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); + } + } + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + if let Some(sel) = popup.selected_item() { + if self.blocks_direct_input { + let command_is_allowed = match &sel { + CommandItem::Builtin(cmd) => { + parse_slash_name(self.draft.textarea.text()).is_some_and( + |(_, args, _)| parent_owned_command_is_allowed(*cmd, args), + ) + } + CommandItem::ServiceTier(_) => false, + }; + if !command_is_allowed { + return (InputResult::ParentOwnedInputBlocked, true); + } + } + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &sel, + ) + && let Some(result) = self.try_dispatch_slash_command_with_args() + { + return (result, true); + } + + self.stage_selected_slash_command_history(&sel); + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; + return ( + match sel { + CommandItem::Builtin(cmd) => InputResult::Command(cmd), + CommandItem::ServiceTier(command) => { + InputResult::ServiceTierCommand(command) + } + }, + true, + ); + } + // Fallback to default newline handling if no command selected. + self.handle_key_event_without_popup(key_event) + } + input => self.handle_input_basic(input), + } + } + + fn complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &mut self, + selected_cmd: &CommandItem, + ) -> bool { + let CommandItem::Builtin(cmd) = selected_cmd else { + return false; + }; + if !cmd.supports_inline_args() { + return false; + } + + let text = self.draft.textarea.text(); + let first_line_end = text.find('\n').unwrap_or(text.len()); + let cursor = self.draft.textarea.cursor(); + if cursor > first_line_end || !text.starts_with('/') || !text.is_char_boundary(cursor) { + return false; + } + + let command_token_end = text[1..first_line_end] + .find(char::is_whitespace) + .map(|idx| 1 + idx) + .unwrap_or(first_line_end); + let typed_command_name = &text[1..command_token_end]; + let rest_after_token_is_empty = text[command_token_end..].trim().is_empty(); + if rest_after_token_is_empty && (cursor <= 1 || cursor >= command_token_end) { + return false; + } + let replace_end = + if cursor <= 1 || (typed_command_name == cmd.command() && rest_after_token_is_empty) { + command_token_end + } else { + cursor + }; + let tail = &text[replace_end..]; + let tail_starts_with_whitespace = tail.chars().next().is_some_and(char::is_whitespace); + let selected_command_text = format!("/{}", cmd.command()); + let replacement = if tail_starts_with_whitespace { + selected_command_text + } else { + format!("{selected_command_text} ") + }; + + let ranges_to_unmark = self + .draft + .textarea + .text_elements() + .into_iter() + .filter_map(|element| { + let range = element.byte_range.start..element.byte_range.end; + (range.start < replace_end && replace_end < range.end).then_some(range) + }) + .collect::>(); + for range in ranges_to_unmark { + self.draft.textarea.remove_element_range(range); + } + self.draft + .textarea + .replace_range(0..replace_end, &replacement); + self.draft.is_bash_mode = false; + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); + true + } + + /// Keep slash command elements aligned with the current first line. + pub(super) fn sync_slash_command_elements(&mut self) { + if !self.slash_commands_enabled() { + return; + } + let text = self.draft.textarea.text(); + let first_line_end = text.find('\n').unwrap_or(text.len()); + let first_line = &text[..first_line_end]; + let cursor = self.draft.textarea.cursor(); + let desired_range = self.slash_input().command_element_range(first_line, cursor); + // Slash commands are only valid at byte 0 of the first line. + // Any slash-shaped element not matching the current desired prefix is stale. + let mut has_desired = false; + let mut stale_ranges = Vec::new(); + for elem in self.draft.textarea.text_elements() { + let Some(payload) = elem.placeholder(text) else { + continue; + }; + if payload.strip_prefix('/').is_none() { + continue; + } + let range = elem.byte_range.start..elem.byte_range.end; + if desired_range.as_ref() == Some(&range) { + has_desired = true; + } else { + stale_ranges.push(range); + } + } + + for range in stale_ranges { + self.draft.textarea.remove_element_range(range); + } + + if let Some(range) = desired_range + && !has_desired + { + self.draft.textarea.add_element_range(range); + } + } +} + +pub(super) fn selected_command_dispatches_immediately_on_tab(command: &CommandItem) -> bool { + matches!(command, CommandItem::Builtin(SlashCommand::Skills)) +} + +pub(super) fn selected_command_completion( + first_line: &str, + command: &CommandItem, +) -> Option { + let selected_command_text = format!("/{}", command.command()); + (!first_line.trim_start().starts_with(&selected_command_text)) + .then(|| format!("{selected_command_text} ")) +} + +pub(super) fn prepared_args(prepared_text: &str) -> Option<(&str, usize)> { + let (_, prepared_rest, prepared_rest_offset) = parse_slash_name(prepared_text)?; + Some((prepared_rest, prepared_rest_offset)) +} + +/// Translate full-text element ranges into command-argument ranges. +/// +/// `rest_offset` is the byte offset where `rest` begins in the full text. +pub(super) fn args_elements( + rest: &str, + rest_offset: usize, + text_elements: &[TextElement], +) -> Vec { + if rest.is_empty() || text_elements.is_empty() { + return Vec::new(); + } + text_elements + .iter() + .filter_map(|elem| { + if elem.byte_range.end <= rest_offset { + return None; + } + let start = elem.byte_range.start.saturating_sub(rest_offset); + let mut end = elem.byte_range.end.saturating_sub(rest_offset); + if start >= rest.len() { + return None; + } + end = end.min(rest.len()); + (start < end).then_some(elem.map_range(|_| ByteRange { start, end })) + }) + .collect() +} + +pub(super) fn command_popup_filter_text(first_line: &str, cursor: usize) -> Option { + let (name, _rest) = command_under_cursor(first_line, cursor)?; + Some(format!("/{name}")) +} + +/// If the cursor is currently within a slash command on the first line, +/// extract the command fragment before the cursor and the rest of the line after it. +fn command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> { + if !first_line.starts_with('/') { + return None; + } + if cursor > first_line.len() || !first_line.is_char_boundary(cursor) { + return None; + } + + let name_start = 1usize; + let name_end = first_line[name_start..] + .find(char::is_whitespace) + .map(|idx| name_start + idx) + .unwrap_or_else(|| first_line.len()); + + let cursor = if cursor <= name_start { + name_end + } else { + cursor + }; + if cursor > name_end { + return None; + } + + let name = &first_line[name_start..cursor]; + let rest = &first_line[cursor..]; + + Some((name, rest)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::bottom_pane::AppEventSender; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn test_composer() -> ChatComposer { + let (tx, _rx) = unbounded_channel::(); + ChatComposer::new( + /*has_input_focus*/ true, + AppEventSender::new(tx), + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ) + } + + fn press(composer: &mut ChatComposer, code: KeyCode) -> InputResult { + composer + .handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)) + .0 + } + + fn composer_with_text_at_cursor(text: &str, cursor: usize) -> ChatComposer { + let mut composer = test_composer(); + composer.draft.textarea.set_text_clearing_elements(text); + composer.draft.textarea.set_cursor(cursor); + composer.sync_popups(); + composer + } + + fn composer_with_draft_tail(prefix: &str, draft: &str) -> ChatComposer { + composer_with_text_at_cursor(&format!("{prefix}{draft}"), prefix.len()) + } + + #[test] + fn esc_dismisses_slash_popup_while_idle() { + let mut composer = composer_with_text_at_cursor("/rev", "/rev".len()); + assert!(composer.popup_active()); + + assert_eq!(press(&mut composer, KeyCode::Esc), InputResult::None); + + assert!(!composer.popup_active()); + assert_eq!(composer.draft.textarea.text(), "/rev"); + } + + #[test] + fn slash_completion_preserves_existing_draft_tail_for_inline_arg_commands() { + let draft = "view the diff"; + let expected_text = "/review view the diff"; + + let mut composer = composer_with_draft_tail("/re", draft); + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), expected_text); + assert_eq!(composer.draft.textarea.cursor(), expected_text.len()); + + let mut composer = composer_with_draft_tail("/re", draft); + assert_eq!( + press(&mut composer, KeyCode::Enter), + InputResult::CommandWithArgs(SlashCommand::Review, draft.to_string(), Vec::new()) + ); + assert_eq!(composer.draft.textarea.text(), expected_text); + } + + #[test] + fn slash_completion_does_not_preserve_existing_draft_tail_for_other_commands() { + let mut composer = composer_with_draft_tail( + "/mo", + "preserve this draft only for opted-in slash commands", + ); + + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!(composer.draft.textarea.cursor(), "/model ".len()); + } + + #[test] + fn slash_completion_does_not_turn_command_suffix_into_args() { + let mut composer = composer_with_text_at_cursor("/review", "/re".len()); + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), "/review "); + + let mut composer = composer_with_text_at_cursor("/review", "/re".len()); + assert_eq!( + press(&mut composer, KeyCode::Enter), + InputResult::Command(SlashCommand::Review) + ); + assert!(composer.draft.textarea.is_empty()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshot_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/snapshot_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..900f14a219aa81dca1969bfe6e9305a7e801bc07 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshot_tests.rs @@ -0,0 +1,71 @@ +//! Snapshot coverage for draft text and live voice controls in the composer. + +use super::tests::new_test_composer; +use crate::render::renderable::Renderable; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; + +#[test] +fn draft_and_voice_composer_snapshots() { + use crate::bottom_pane::voice_strip::VoiceStripPhase; + use crate::bottom_pane::voice_strip::VoiceStripState; + use crate::tui::FrameRequester; + + crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (230, 216, 255), + bg: (36, 27, 53), + }, + || { + for voice_active in [false, true] { + let (mut composer, _rx) = new_test_composer(); + if voice_active { + composer.set_voice_strip( + Some(VoiceStripState { + mute_hint: None, + phase: VoiceStripPhase::Active, + microphone_live: true, + microphone_muted: false, + microphone_history: vec![0, 37, 73, 110, 146, 255], + speaker_history: vec![255, 146, 110, 73, 37, 0], + activity: "listening", + animations: false, + }), + FrameRequester::test_dummy(), + ); + } else { + composer.set_text_content( + "Explore the night sky".to_string(), + Vec::new(), + Vec::new(), + ); + } + let width = 60; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + composer.desired_height(width), + ); + let mut buffer = Buffer::empty(area); + composer.render(area, &mut buffer); + let rows = buffer + .content + .chunks(usize::from(width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .collect::>() + .join("\n"); + let name = if voice_active { + "voice_composer" + } else { + "draft_composer" + }; + insta::assert_snapshot!(name, rows); + } + }, + ); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search.rs new file mode 100644 index 0000000000000000000000000000000000000000..35d5a5d18d315a7ad9062624d1516838001d4093 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search.rs @@ -0,0 +1,27 @@ +//! Composer UI bridge for textarea-owned Vim queries, independent of prompt-history recall. + +use super::ChatComposer; +use super::inset_footer_hint_area; +use ratatui::layout::Rect; + +impl ChatComposer { + pub(crate) fn cancel_vim_search(&mut self) -> bool { + let canceled = self.draft.textarea.cancel_vim_search(); + if canceled { + self.finish_vim_edit(); + } + canceled + } + + pub(super) fn vim_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let query = self.draft.textarea.vim_query()?; + let [_, _, _, mut footer] = self.layout_areas(area); + footer.y = footer.bottom().saturating_sub(1); + footer.height = footer.height.min(1); + query.cursor_pos(inset_footer_hint_area(footer)) + } +} + +#[cfg(test)] +#[path = "vim_search_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_effort_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer_effort_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..aaae8c2d4ff90526059decd1fb06a8608c28909a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer_effort_tests.rs @@ -0,0 +1,172 @@ +use super::tests::new_test_composer; +use super::tests::snapshot_composer_state_with_width; +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn effort_composer_baseline_repeat_and_lowering_do_not_replay() { + let (mut composer, _rx) = new_test_composer(); + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from("gpt-5.4 high · main"))); + assert!(composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Ultra), + /*animations_enabled*/ true, + )); + assert!(composer.effort_ignition.is_none()); + assert!(!composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Ultra), + /*animations_enabled*/ true, + )); + + assert!(composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Max), + /*animations_enabled*/ true, + )); + assert!(composer.effort_ignition.is_some()); + assert!(composer.effort_animation_style.is_some()); + assert!(composer.effort_status_line_transition.is_some()); + assert!(composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Ultra), + /*animations_enabled*/ true, + )); + assert!(composer.effort_ignition.is_some()); + assert!(composer.effort_status_line_transition.is_some()); + assert!(composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Medium), + /*animations_enabled*/ true, + )); + assert!(composer.effort_ignition.is_none()); + assert!(composer.effort_status_line_transition.is_none()); +} + +#[test] +fn effort_transition_does_not_queue_a_missing_outgoing_status_line() { + let (mut composer, _rx) = new_test_composer(); + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from("gpt-5.4 high · main"))); + composer.set_task_running(/*running*/ true); + composer.set_text_content("queued draft".to_string(), Vec::new(), Vec::new()); + composer.set_active_reasoning_effort( + Some(&ReasoningEffort::High), + /*animations_enabled*/ true, + ); + + assert!(composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Ultra), + /*animations_enabled*/ true, + )); + assert!(composer.effort_ignition.is_some()); + assert!(composer.effort_status_line_transition.is_none()); +} + +#[test] +fn effort_composer_restored_baseline_and_reduced_motion_do_not_start() { + let (mut composer, _rx) = new_test_composer(); + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from("gpt-5.4 high · main"))); + composer.set_active_reasoning_effort( + Some(&ReasoningEffort::High), + /*animations_enabled*/ false, + ); + assert!(composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Ultra), + /*animations_enabled*/ false, + )); + assert_eq!(composer.effort_tier, Some(EffortTier::Ultra)); + assert!(composer.effort_ignition.is_none()); + assert_eq!(composer.effort_animation_style, None); + assert!(composer.effort_status_line_transition.is_none()); + + assert!(composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Max), + /*animations_enabled*/ true, + )); + assert!(composer.effort_ignition.is_some()); + assert!(composer.effort_status_line_transition.is_some()); + composer.set_active_reasoning_effort_baseline(Some(&ReasoningEffort::Max)); + assert_eq!(composer.effort_tier, Some(EffortTier::Max)); + assert!(composer.effort_ignition.is_none()); + assert!(composer.effort_status_line_transition.is_none()); + assert!(!composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Max), + /*animations_enabled*/ true, + )); + assert!(composer.effort_ignition.is_none()); + assert!(composer.effort_status_line_transition.is_none()); +} + +#[test] +fn effort_transition_never_replaces_a_footer_flash() { + let (mut composer, _rx) = new_test_composer(); + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from("gpt-5.4 high · main"))); + composer.set_active_reasoning_effort( + Some(&ReasoningEffort::High), + /*animations_enabled*/ true, + ); + composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Ultra), + /*animations_enabled*/ true, + ); + composer.set_status_line(Some(Line::from("gpt-5.4 ultra · main"))); + composer + .footer + .show_flash(Line::from("saved"), Duration::from_secs(/*secs*/ 1)); + + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 6, + ); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + let footer = (0..area.width) + .map(|column| buf[(column, area.bottom() - 1)].symbol()) + .collect::(); + + assert!(footer.contains("saved")); + assert!(!footer.contains("U L T R A")); +} + +#[test] +fn effort_transition_keeps_the_full_footer_row() { + let (mut composer, _rx) = new_test_composer(); + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_collaboration_modes_enabled(/*enabled*/ true); + composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan)); + composer.set_status_line(Some(Line::from("gpt-5.4 high · feature-branch"))); + composer.set_active_reasoning_effort( + Some(&ReasoningEffort::High), + /*animations_enabled*/ true, + ); + composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Ultra), + /*animations_enabled*/ true, + ); + + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 36, /*height*/ 6, + ); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + let footer = (0..area.width) + .map(|column| buf[(column, area.bottom() - 1)].symbol()) + .collect::(); + + assert!(footer.contains("gpt-5.4 high · feature-branch")); + assert!(!footer.contains("Plan mode")); + insta::assert_snapshot!("effort_transition_keeps_the_full_footer_row", footer); +} + +#[test] +fn ultra_accent_upgrades_prompt_glyph() { + snapshot_composer_state_with_width( + "ultra_accent_upgrades_prompt_glyph", + /*width*/ 60, + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Ultra), + /*animations_enabled*/ true, + ); + }, + ); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs new file mode 100644 index 0000000000000000000000000000000000000000..6ec2145e7ecac0fdada13b152847104beccb582f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -0,0 +1,1628 @@ +//! The chat composer history module owns shell-style recall and incremental search traversal. +//! +//! It combines persistent cross-session entries with local in-session entries into one offset +//! space. Normal navigation fetches persistent entries individually through +//! [`ChatComposerHistory::on_entry_response`]. Reverse search switches to bounded, +//! query-independent batches through [`ChatComposerHistory::on_batch_response`] after probing the +//! newest entry. Batch responses populate the shared cache even when the active search has moved +//! on, but only the awaited cursor resumes a search; stale log IDs are ignored, and batch read +//! failures follow a bounded retry path. Local entries are already available with full draft +//! metadata. +//! +//! Ctrl+R search is modeled separately from normal Up/Down navigation because it has different +//! guarantees: query edits restart from the newest match, repeated Older/Newer keys move through +//! unique matching text, pending persistent fetches continue the same scan after the response +//! arrives, and boundary hits must not advance hidden cursor state. Search deduplication is scoped +//! to a single active search session and uses exact prompt text; it does not mutate stored history +//! or change normal history browsing. +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::MentionBinding; +use crate::mention_codec::decode_history_mentions_with_at_mentions; +use codex_message_history::HistoryBatchCursor; +use codex_protocol::ThreadId; +use codex_protocol::user_input::TextElement; + +#[path = "chat_composer_history/search_batch.rs"] +mod search_batch; +#[cfg(test)] +#[path = "chat_composer_history/search_batch_tests.rs"] +mod search_batch_tests; + +const MAX_BATCH_READ_RETRIES: u8 = 2; + +/// A composer history entry that can rehydrate draft state. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct HistoryEntry { + /// Raw text stored in history (may include placeholder strings). + pub(crate) text: String, + /// Text element ranges for placeholders inside `text`. + pub(crate) text_elements: Vec, + /// Local image paths captured alongside `text_elements`. + pub(crate) local_image_paths: Vec, + /// Remote image URLs restored with this draft. + pub(crate) remote_image_urls: Vec, + /// Mention bindings for tool/app/skill references inside `text`. + pub(crate) mention_bindings: Vec, + /// Placeholder-to-payload pairs used to restore large paste content. + pub(crate) pending_pastes: Vec<(String, String)>, +} + +impl HistoryEntry { + /// Creates a text-only history entry and decodes persisted mention bindings. + /// + /// Persistent history does not store attachments or original element metadata; selected task + /// elements are recovered from encoded links. Local in-session submissions should be + /// recorded with the full `HistoryEntry` value built by the composer; using `new` for a local + /// image or paste submission would make recall lose placeholder ownership. + pub(crate) fn new(text: String) -> Self { + Self::new_with_at_mentions(text, /*at_mentions_enabled*/ true) + } + + pub(crate) fn new_with_at_mentions(text: String, at_mentions_enabled: bool) -> Self { + let decoded = decode_history_mentions_with_at_mentions(&text, at_mentions_enabled); + let text_elements = decoded + .task_mention_ranges + .into_iter() + .map(|range| TextElement::new(range.into(), /*placeholder*/ None)) + .collect(); + Self { + text: decoded.text, + text_elements, + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: decoded + .mentions + .into_iter() + .map(|mention| MentionBinding { + sigil: mention.sigil, + mention: mention.mention, + path: mention.path, + }) + .collect(), + pending_pastes: Vec::new(), + } + } + + #[cfg(test)] + pub(crate) fn with_pending( + text: String, + text_elements: Vec, + local_image_paths: Vec, + pending_pastes: Vec<(String, String)>, + ) -> Self { + Self { + text, + text_elements, + local_image_paths, + remote_image_urls: Vec::new(), + mention_bindings: Vec::new(), + pending_pastes, + } + } + + #[cfg(test)] + pub(crate) fn with_pending_and_remote( + text: String, + text_elements: Vec, + local_image_paths: Vec, + pending_pastes: Vec<(String, String)>, + remote_image_urls: Vec, + ) -> Self { + Self { + text, + text_elements, + local_image_paths, + remote_image_urls, + mention_bindings: Vec::new(), + pending_pastes, + } + } +} + +/// State machine that manages shell-style history navigation (Up/Down) inside +/// the chat composer. This struct is intentionally decoupled from the +/// rendering widget so the logic remains isolated and easier to test. +#[derive(Clone)] +pub(crate) struct ChatComposerHistory { + /// Thread that owns persistent lookup responses for this metadata snapshot. + thread_id: Option, + /// Identifier of the persistent history log used for stale lookup rejection. + persistent_log_id: Option, + /// Number of entries already present in the persistent cross-session + /// history file when the session started. + persistent_entry_count: usize, + + /// Messages submitted by the user *during this UI session* (newest at END). + /// Local entries retain full draft state (text elements, image paths, pending pastes, remote image URLs). + local_history: Vec, + /// Local entries seeded from resumed transcript replay. + replay_seeded_history: Vec, + + /// Persistent offsets fetched on demand, with `None` for malformed batch rows. + fetched_history: HashMap>, + + /// Current cursor within the combined (persistent + local) history. `None` + /// indicates the user is *not* currently browsing history. + history_cursor: Option, + pending_navigation_direction: Option, + + /// The text that was last inserted into the composer as a result of + /// history navigation. Used to decide if further Up/Down presses should be + /// treated as navigation versus normal cursor movement, together with the + /// "cursor at line boundary" check in [`Self::should_handle_navigation`]. + last_history_text: Option, + + /// Active incremental history search, if Ctrl+R search mode is open. + search: Option, + /// Whether persistent history restore should rehydrate `@` tool mentions. + at_mention_restore_enabled: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HistorySearchDirection { + /// Traverse toward older history offsets. + Older, + /// Traverse toward newer history offsets. + Newer, +} + +/// Result of a single incremental history search step. +/// +/// `Pending` means a persistent entry lookup has been requested and the caller should keep the +/// visible search session open until [`ChatComposerHistory::on_entry_response`] supplies the next +/// result. `AtBoundary` means the current selected match is still valid but the requested direction +/// has no further unique match; callers should avoid treating it like a query miss. `Unavailable` +/// ends a failed lookup without claiming the query has no matching history. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum HistorySearchResult { + Found(HistoryEntry), + Pending, + AtBoundary, + NotFound, + Unavailable, +} + +/// Result of integrating an asynchronous persistent history response. +/// +/// A response can satisfy normal Up/Down navigation, resume a pending Ctrl+R search scan, or be +/// ignored if it belongs to a stale log or an offset the composer no longer needs. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum HistoryEntryResponse { + Found(HistoryEntry), + Search(HistorySearchResult), + Ignored, +} + +/// State for one active Ctrl+R search query. +/// +/// The state keeps two cursors: `selected_offset` is the raw combined-history offset used to +/// continue scanning, while `selected_match_index` points into `unique_matches` so already +/// discovered unique results can be revisited without rescanning duplicate offsets. `seen_texts` +/// intentionally keys on exact prompt text because the UI previews and accepts text, not the +/// storage identity of each historical record. `next_older_cursor` retains the query-independent +/// batch boundary so a match does not force the next Older search back onto the prefix-scan path. +#[derive(Clone, Debug)] +struct HistorySearchState { + query: String, + query_lower: String, + selected_offset: Option, + unique_matches: Vec, + selected_match_index: Option, + seen_texts: HashSet, + awaiting: Option, + next_older_cursor: Option, + exhausted_older: bool, + exhausted_newer: bool, +} + +/// A unique search match cached with enough draft state to be selected again. +/// +/// The vector of these matches is kept in newest-to-oldest offset order. Storing the entry beside +/// the offset avoids depending on later cache lookups when the user moves Newer/Older among matches +/// that have already been discovered. +#[derive(Clone, Debug)] +struct UniqueHistoryMatch { + offset: usize, + entry: HistoryEntry, +} + +/// Persistent-history lookup currently blocking an incremental search scan. +/// +/// The pending request records the boundary behavior that was active when the fetch was issued so +/// the response can either return a unique match or continue scanning as if no async gap had +/// occurred. Single-entry requests retain their direction; batch requests are older-only by +/// construction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PendingHistorySearch { + Entry { + offset: usize, + direction: HistorySearchDirection, + boundary_if_exhausted: bool, + }, + Batch { + cursor: HistoryBatchCursor, + boundary_if_exhausted: bool, + read_failures: u8, + }, +} + +impl ChatComposerHistory { + /// Creates an empty history state machine with no persistent metadata. + /// + /// The caller must provide session metadata before cross-session history can be fetched, but + /// local in-session entries can still be recorded and traversed. Keeping construction cheap and + /// metadata-free lets the composer reset and reuse this helper across session lifecycles. + pub fn new() -> Self { + Self { + thread_id: None, + persistent_log_id: None, + persistent_entry_count: 0, + local_history: Vec::new(), + replay_seeded_history: Vec::new(), + fetched_history: HashMap::new(), + history_cursor: None, + pending_navigation_direction: None, + last_history_text: None, + search: None, + at_mention_restore_enabled: false, + } + } + + pub fn set_at_mention_restore_enabled(&mut self, enabled: bool) { + if self.at_mention_restore_enabled == enabled { + return; + } + self.at_mention_restore_enabled = enabled; + self.fetched_history.clear(); + self.history_cursor = None; + self.last_history_text = None; + self.search = None; + } + + /// Updates persistent history metadata when a new session is configured. + /// + /// Startup-local entries survive the first session configuration because they were recorded + /// before a thread existed. Later configurations clear local history, while every configuration + /// resets fetched entries, navigation cursors, and search state tied to the old history log. + pub fn set_metadata(&mut self, thread_id: ThreadId, log_id: u64, entry_count: usize) { + let had_configured_thread = self.thread_id.replace(thread_id).is_some(); + self.persistent_log_id = Some(log_id); + self.persistent_entry_count = entry_count; + self.fetched_history.clear(); + if had_configured_thread { + self.local_history.clear(); + } + self.replay_seeded_history.clear(); + self.history_cursor = None; + self.pending_navigation_direction = None; + self.last_history_text = None; + self.search = None; + } + + /// Return draft history recorded before the composer became associated with a thread. + pub(crate) fn startup_local_history(&self) -> &[HistoryEntry] { + if self.thread_id.is_none() { + &self.local_history + } else { + &[] + } + } + + /// Records a current-session submission so it can be recalled with full draft metadata. + /// + /// Empty submissions are ignored, adjacent duplicates are collapsed, and active navigation or + /// search state is reset because a new newest entry changes the combined history offset space. + pub fn record_local_submission(&mut self, entry: HistoryEntry) { + self.record_local_submission_inner(entry); + } + + pub fn record_replayed_submission(&mut self, entry: HistoryEntry) { + if self.record_local_submission_inner(entry.clone()) { + self.replay_seeded_history.push(entry); + } + } + + fn record_local_submission_inner(&mut self, entry: HistoryEntry) -> bool { + if entry.text.is_empty() + && entry.text_elements.is_empty() + && entry.local_image_paths.is_empty() + && entry.remote_image_urls.is_empty() + && entry.mention_bindings.is_empty() + && entry.pending_pastes.is_empty() + { + return false; + } + self.history_cursor = None; + self.pending_navigation_direction = None; + self.last_history_text = None; + self.search = None; + + // Avoid inserting a duplicate if identical to the previous entry. + if self.local_history.last().is_some_and(|prev| prev == &entry) { + return false; + } + + self.local_history.push(entry); + true + } + + /// Track the displayed text when a composer normalizes a recalled entry. + pub(super) fn record_recalled_text(&mut self, text: String) { + self.last_history_text = Some(text); + } + + pub(super) fn is_navigating(&self) -> bool { + self.history_cursor.is_some() + } + + /// Resets normal history navigation so the next Up key resumes from the newest entry. + /// + /// This also clears any active incremental search, since normal browsing and Ctrl+R search + /// maintain different cursor semantics. Failing to clear search here would let an old query + /// influence later Up/Down recall. + pub fn reset_navigation(&mut self) { + self.history_cursor = None; + self.pending_navigation_direction = None; + self.last_history_text = None; + self.search = None; + } + + /// Clears only the active incremental search state. + /// + /// The normal Up/Down navigation cursor and cached persistent entries are left intact. Composer + /// search mode calls this when it accepts a match or returns to an empty query so the next + /// search starts with a fresh unique-result cache. + pub fn reset_search(&mut self) { + self.search = None; + } + + /// Returns whether Up/Down should navigate history for the current textarea state. + /// + /// Empty text always enables history traversal. For non-empty text, this requires both: + /// + /// - the current text exactly matching the last recalled history entry, and + /// - the cursor being at a line boundary (start or end). + /// + /// This boundary gate keeps multiline cursor movement usable while preserving shell-like + /// history recall. If callers moved the cursor into the middle of a recalled entry and still + /// forced navigation, users would lose normal vertical movement within the draft. + pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { + if self.persistent_entry_count == 0 && self.local_history.is_empty() { + return false; + } + + if text.is_empty() { + return true; + } + + // Textarea is not empty – only navigate when text matches the last + // recalled history entry and the cursor is at a line boundary. This + // keeps shell-like Up/Down recall working while still allowing normal + // multiline cursor movement from interior positions. + if cursor != 0 && cursor != text.len() { + return false; + } + + matches!(&self.last_history_text, Some(prev) if prev == text) + } + + /// Handles Up by moving toward older entries in the combined history space. + /// + /// Local entries can be returned immediately, while missing persistent entries emit a + /// `LookupMessageHistoryEntry` and return `None` until the response arrives. Calling this while + /// Ctrl+R search is active intentionally exits search traversal. + pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { + self.search = None; + let total_entries = self.persistent_entry_count + self.local_history.len(); + if total_entries == 0 { + return None; + } + + let next_idx = match self.history_cursor { + None => (total_entries as isize) - 1, + Some(0) => return None, // already at oldest + Some(idx) => idx - 1, + }; + + self.history_cursor = Some(next_idx); + self.populate_history_at_index( + next_idx as usize, + HistorySearchDirection::Older, + app_event_tx, + ) + } + + /// Handles Down by moving toward newer entries or clearing the composer past the newest entry. + /// + /// Returning an empty `HistoryEntry` means the user moved past the newest known entry and the + /// caller should clear the composer draft. As with Up, invoking this during Ctrl+R search clears + /// search state and resumes normal shell-style browsing. + pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { + self.search = None; + let total_entries = self.persistent_entry_count + self.local_history.len(); + if total_entries == 0 { + return None; + } + + let next_idx_opt = match self.history_cursor { + None => return None, // not browsing + Some(idx) if (idx as usize) + 1 >= total_entries => None, + Some(idx) => Some(idx + 1), + }; + + match next_idx_opt { + Some(idx) => { + self.history_cursor = Some(idx); + self.populate_history_at_index( + idx as usize, + HistorySearchDirection::Newer, + app_event_tx, + ) + } + None => { + // Past newest – clear and exit browsing mode. + self.history_cursor = None; + self.pending_navigation_direction = None; + self.last_history_text = None; + Some(HistoryEntry::new(String::new())) + } + } + } + + /// Integrates a persistent history entry response into navigation or active search. + /// + /// Responses with a stale log id are ignored, matching responses update the persistent cache, + /// and pending Ctrl+R searches resume their scan from the returned offset. The caller should + /// route `HistoryEntryResponse::Search` back to the composer search session rather than normal + /// history recall; otherwise an async search hit could be accepted without updating footer + /// status or match highlighting. + pub fn on_entry_response( + &mut self, + log_id: u64, + offset: usize, + entry: Option, + app_event_tx: &AppEventSender, + ) -> HistoryEntryResponse { + if self.persistent_log_id != Some(log_id) { + return HistoryEntryResponse::Ignored; + } + + let entry = entry.map(|entry| { + HistoryEntry::new_with_at_mentions(entry, self.at_mention_restore_enabled) + }); + if let Some(entry) = entry.clone() { + self.fetched_history.insert(offset, Some(entry)); + } + + if self + .search + .as_ref() + .and_then(|search| search.awaiting) + .is_some_and(|pending| { + matches!(pending, PendingHistorySearch::Entry { offset: awaited, .. } if awaited == offset) + }) + { + let pending = self + .search + .as_ref() + .and_then(|search| search.awaiting) + .unwrap_or(PendingHistorySearch::Entry { + offset, + direction: HistorySearchDirection::Older, + boundary_if_exhausted: false, + }); + let PendingHistorySearch::Entry { + direction, + boundary_if_exhausted, + .. + } = pending + else { + return HistoryEntryResponse::Ignored; + }; + if let Some(entry) = entry + && self.search_matches(&entry) + && self.search_result_is_unique(&entry) + { + return HistoryEntryResponse::Search(self.search_match(offset, entry)); + } + let result = match direction { + HistorySearchDirection::Older => self.advance_older_search_after_entry_miss( + offset, + boundary_if_exhausted, + app_event_tx, + ), + HistorySearchDirection::Newer => self.advance_search_after( + offset, + direction, + boundary_if_exhausted, + app_event_tx, + ), + }; + return HistoryEntryResponse::Search(result); + } + + if self.history_cursor == Some(offset as isize) { + let direction = self.pending_navigation_direction.take(); + let Some(entry) = entry else { + return HistoryEntryResponse::Ignored; + }; + if self.persistent_entry_duplicates_local(&entry) + && let Some(direction) = direction + { + let Some(offset) = self.next_history_offset(offset, direction) else { + return HistoryEntryResponse::Ignored; + }; + self.history_cursor = Some(offset as isize); + return self + .populate_history_at_index(offset, direction, app_event_tx) + .map(HistoryEntryResponse::Found) + .unwrap_or(HistoryEntryResponse::Ignored); + } + self.record_recalled_text(entry.text.clone()); + return HistoryEntryResponse::Found(entry); + } + + HistoryEntryResponse::Ignored + } + + /// Advance the active Ctrl+R search and return the next visible search state. + /// + /// Callers pass `restart` after opening search or editing the query; that clears the unique + /// match cache and starts from the end of combined history. Repeated calls with the same query + /// and `restart == false` move relative to the current unique match, preserving the selected + /// entry at boundaries. Calling this while a previous persistent lookup is still pending will + /// keep returning `Pending`; otherwise a stale response could race with a newer user action and + /// replace the composer with an unexpected entry. + pub fn search( + &mut self, + query: &str, + direction: HistorySearchDirection, + restart: bool, + app_event_tx: &AppEventSender, + ) -> HistorySearchResult { + let total_entries = self.total_entries(); + if total_entries == 0 { + self.search = Some(HistorySearchState::new(query)); + return HistorySearchResult::NotFound; + } + + let query_changed = self + .search + .as_ref() + .is_none_or(|search| search.query != query); + if !query_changed + && !restart + && self + .search + .as_ref() + .and_then(|search| search.awaiting) + .is_some() + { + return HistorySearchResult::Pending; + } + + if query_changed || restart || self.search.is_none() { + self.search = Some(HistorySearchState::new(query)); + } else if let Some(search) = self.search.as_mut() { + search.awaiting = None; + } + + let boundary_if_exhausted = !restart + && self + .search + .as_ref() + .and_then(|search| search.selected_offset) + .is_some(); + if !restart + && !query_changed + && let Some(result) = self.select_cached_unique_match(direction) + { + return result; + } + if boundary_if_exhausted + && self + .search + .as_ref() + .is_some_and(|search| search.is_exhausted(direction)) + { + return HistorySearchResult::AtBoundary; + } + + let start_offset = + self.search_start_offset(total_entries, direction, query_changed || restart); + let Some(start_offset) = start_offset else { + return self.exhausted_search_result(direction, boundary_if_exhausted); + }; + + let result = + self.advance_search_from(start_offset, direction, boundary_if_exhausted, app_event_tx); + if matches!(result, HistorySearchResult::NotFound) { + self.exhausted_search_result(direction, boundary_if_exhausted) + } else { + result + } + } + + // --------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------- + + fn total_entries(&self) -> usize { + self.persistent_entry_count + self.local_history.len() + } + + fn search_start_offset( + &self, + total_entries: usize, + direction: HistorySearchDirection, + restart: bool, + ) -> Option { + let selected = self + .search + .as_ref() + .and_then(|search| search.selected_offset); + match direction { + HistorySearchDirection::Older => { + if restart { + total_entries.checked_sub(1) + } else { + selected.and_then(|offset| offset.checked_sub(1)) + } + } + HistorySearchDirection::Newer => { + if restart { + Some(0) + } else { + selected + .and_then(|offset| offset.checked_add(1)) + .filter(|offset| *offset < total_entries) + } + } + } + } + + fn advance_search_after( + &mut self, + offset: usize, + direction: HistorySearchDirection, + boundary_if_exhausted: bool, + app_event_tx: &AppEventSender, + ) -> HistorySearchResult { + let next_offset = match direction { + HistorySearchDirection::Older => offset.checked_sub(1), + HistorySearchDirection::Newer => offset + .checked_add(1) + .filter(|next| *next < self.total_entries()), + }; + let Some(next_offset) = next_offset else { + return self.exhausted_search_result(direction, boundary_if_exhausted); + }; + let result = + self.advance_search_from(next_offset, direction, boundary_if_exhausted, app_event_tx); + if matches!(result, HistorySearchResult::NotFound) { + self.exhausted_search_result(direction, boundary_if_exhausted) + } else { + result + } + } + + fn advance_search_from( + &mut self, + mut offset: usize, + direction: HistorySearchDirection, + boundary_if_exhausted: bool, + app_event_tx: &AppEventSender, + ) -> HistorySearchResult { + let total_entries = self.total_entries(); + while offset < total_entries { + if let Some(entry) = self.entry_at_cached_offset(offset) { + if self.search_matches(&entry) && self.search_result_is_unique(&entry) { + return self.search_match(offset, entry); + } + } else if !self.fetched_history.contains_key(&offset) + && offset < self.persistent_entry_count + { + if direction == HistorySearchDirection::Older + && let Some(cursor) = self + .search + .as_ref() + .and_then(|search| search.next_older_cursor) + && cursor.end_offset() == offset + { + return self.request_older_search_batch( + cursor, + boundary_if_exhausted, + app_event_tx, + ); + } + if let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { + if let Some(search) = self.search.as_mut() { + search.awaiting = Some(PendingHistorySearch::Entry { + offset, + direction, + boundary_if_exhausted, + }); + } + app_event_tx.send(AppEvent::LookupMessageHistoryEntry { + thread_id, + offset, + log_id, + }); + return HistorySearchResult::Pending; + } + } + + let next_offset = match direction { + HistorySearchDirection::Older => offset.checked_sub(1), + HistorySearchDirection::Newer => { + offset.checked_add(1).filter(|next| *next < total_entries) + } + }; + let Some(next_offset) = next_offset else { + return HistorySearchResult::NotFound; + }; + offset = next_offset; + } + + HistorySearchResult::NotFound + } + + fn entry_at_cached_offset(&self, offset: usize) -> Option { + if offset >= self.persistent_entry_count { + self.local_history + .get(offset - self.persistent_entry_count) + .cloned() + } else { + self.fetched_history.get(&offset).cloned().flatten() + } + } + + fn search_matches(&self, entry: &HistoryEntry) -> bool { + let Some(search) = self.search.as_ref() else { + return false; + }; + search.query.is_empty() || entry.text.to_lowercase().contains(&search.query_lower) + } + + fn search_result_is_unique(&self, entry: &HistoryEntry) -> bool { + self.search + .as_ref() + .is_none_or(|search| !search.seen_texts.contains(entry.text.as_str())) + } + + fn search_match(&mut self, offset: usize, entry: HistoryEntry) -> HistorySearchResult { + self.history_cursor = Some(offset as isize); + self.record_recalled_text(entry.text.clone()); + if let Some(search) = self.search.as_mut() { + search.selected_offset = Some(offset); + search.record_match(offset, &entry); + search.awaiting = None; + search.exhausted_older = false; + search.exhausted_newer = false; + } + HistorySearchResult::Found(entry) + } + + fn select_cached_unique_match( + &mut self, + direction: HistorySearchDirection, + ) -> Option { + let next_index = { + let search = self.search.as_ref()?; + let selected_index = search.selected_match_index?; + match direction { + HistorySearchDirection::Older => { + let next_index = selected_index + 1; + (next_index < search.unique_matches.len()).then_some(next_index)? + } + HistorySearchDirection::Newer => selected_index.checked_sub(1)?, + } + }; + + let history_match = self.search.as_ref()?.unique_matches[next_index].clone(); + self.history_cursor = Some(history_match.offset as isize); + self.record_recalled_text(history_match.entry.text.clone()); + if let Some(search) = self.search.as_mut() { + search.select_match(next_index); + } + Some(HistorySearchResult::Found(history_match.entry)) + } + + fn exhausted_search_result( + &mut self, + direction: HistorySearchDirection, + boundary_if_exhausted: bool, + ) -> HistorySearchResult { + if let Some(search) = self.search.as_mut() { + search.awaiting = None; + if boundary_if_exhausted { + search.mark_exhausted(direction); + } + } + + if boundary_if_exhausted { + HistorySearchResult::AtBoundary + } else { + HistorySearchResult::NotFound + } + } + + fn populate_history_at_index( + &mut self, + global_idx: usize, + direction: HistorySearchDirection, + app_event_tx: &AppEventSender, + ) -> Option { + let mut global_idx = global_idx; + loop { + if let Some(entry) = self.entry_at_cached_offset(global_idx) { + if global_idx < self.persistent_entry_count + && self.persistent_entry_duplicates_local(&entry) + { + let Some(next_idx) = self.next_history_offset(global_idx, direction) else { + self.pending_navigation_direction = None; + return None; + }; + self.history_cursor = Some(next_idx as isize); + global_idx = next_idx; + continue; + } + self.pending_navigation_direction = None; + self.record_recalled_text(entry.text.clone()); + return Some(entry); + } + + if global_idx >= self.persistent_entry_count { + return None; + } + + if let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { + self.pending_navigation_direction = Some(direction); + app_event_tx.send(AppEvent::LookupMessageHistoryEntry { + thread_id, + offset: global_idx, + log_id, + }); + } + return None; + } + } + + fn next_history_offset( + &self, + offset: usize, + direction: HistorySearchDirection, + ) -> Option { + match direction { + HistorySearchDirection::Older => offset.checked_sub(1), + HistorySearchDirection::Newer => offset + .checked_add(1) + .filter(|next| *next < self.total_entries()), + } + } + + fn persistent_entry_duplicates_local(&self, entry: &HistoryEntry) -> bool { + self.replay_seeded_history.iter().any(|local_entry| { + local_entry.text == entry.text && local_entry.mention_bindings == entry.mention_bindings + }) + } +} + +impl HistorySearchState { + fn new(query: &str) -> Self { + Self { + query: query.to_string(), + query_lower: query.to_lowercase(), + selected_offset: None, + unique_matches: Vec::new(), + selected_match_index: None, + seen_texts: HashSet::new(), + awaiting: None, + next_older_cursor: None, + exhausted_older: false, + exhausted_newer: false, + } + } + + fn is_exhausted(&self, direction: HistorySearchDirection) -> bool { + match direction { + HistorySearchDirection::Older => self.exhausted_older, + HistorySearchDirection::Newer => self.exhausted_newer, + } + } + + fn mark_exhausted(&mut self, direction: HistorySearchDirection) { + match direction { + HistorySearchDirection::Older => self.exhausted_older = true, + HistorySearchDirection::Newer => self.exhausted_newer = true, + } + } + + fn record_match(&mut self, offset: usize, entry: &HistoryEntry) { + if let Some(index) = self + .unique_matches + .iter() + .position(|history_match| history_match.offset == offset) + { + self.select_match(index); + return; + } + + self.seen_texts.insert(entry.text.clone()); + let insert_index = self + .unique_matches + .partition_point(|history_match| history_match.offset > offset); + self.unique_matches.insert( + insert_index, + UniqueHistoryMatch { + offset, + entry: entry.clone(), + }, + ); + self.select_match(insert_index); + } + + fn select_match(&mut self, index: usize) { + let Some(history_match) = self.unique_matches.get(index) else { + return; + }; + self.selected_offset = Some(history_match.offset); + self.selected_match_index = Some(index); + self.awaiting = None; + self.exhausted_older = false; + self.exhausted_newer = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event::HistoryBatchEntryResponse; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn test_thread_id() -> ThreadId { + ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") + .expect("thread id should parse") + } + + fn batch_entry(offset: usize, entry: &str) -> HistoryBatchEntryResponse { + HistoryBatchEntryResponse { + offset, + entry: Some(entry.to_string()), + } + } + + #[test] + fn duplicate_submissions_are_not_recorded() { + let mut history = ChatComposerHistory::new(); + + // Empty submissions are ignored. + history.record_local_submission(HistoryEntry::new(String::new())); + assert_eq!(history.local_history.len(), 0); + + // First entry is recorded. + history.record_local_submission(HistoryEntry::new("hello".to_string())); + assert_eq!(history.local_history.len(), 1); + assert_eq!( + history.local_history.last().unwrap(), + &HistoryEntry::new("hello".to_string()) + ); + + // Identical consecutive entry is skipped. + history.record_local_submission(HistoryEntry::new("hello".to_string())); + assert_eq!(history.local_history.len(), 1); + + // Different entry is recorded. + history.record_local_submission(HistoryEntry::new("world".to_string())); + assert_eq!(history.local_history.len(), 2); + assert_eq!( + history.local_history.last().unwrap(), + &HistoryEntry::new("world".to_string()) + ); + } + + #[test] + fn initial_metadata_preserves_startup_history_but_session_changes_clear_it() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut history = ChatComposerHistory::new(); + let startup_entry = HistoryEntry::new("cleared during startup".to_string()); + history.record_local_submission(startup_entry.clone()); + + assert_eq!( + history.startup_local_history(), + std::slice::from_ref(&startup_entry) + ); + + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 0); + + assert!(history.startup_local_history().is_empty()); + assert_eq!(history.navigate_up(&tx), Some(startup_entry)); + + history.record_local_submission(HistoryEntry::new("thread-owned draft".to_string())); + history.set_metadata(ThreadId::new(), /*log_id*/ 2, /*entry_count*/ 0); + + assert_eq!(history.navigate_up(&tx), None); + } + + #[test] + fn persistent_restore_gates_at_mentions() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut history = ChatComposerHistory::new(); + history.set_metadata(test_thread_id(), /*log_id*/ 42, /*entry_count*/ 1); + + assert!(history.navigate_up(&tx).is_none()); + let disabled = history.on_entry_response( + /*log_id*/ 42, + /*offset*/ 0, + Some("[@sample](plugin://sample@test) and [$figma](app://figma)".to_string()), + &tx, + ); + assert_eq!( + disabled, + HistoryEntryResponse::Found(HistoryEntry { + text: "$sample and $figma".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: vec![ + MentionBinding { + sigil: '$', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, + MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }, + ], + pending_pastes: Vec::new(), + }) + ); + + history.set_at_mention_restore_enabled(/*enabled*/ true); + assert!(history.navigate_up(&tx).is_none()); + let enabled = history.on_entry_response( + /*log_id*/ 42, + /*offset*/ 0, + Some("[@sample](plugin://sample@test) and [$figma](app://figma)".to_string()), + &tx, + ); + assert_eq!( + enabled, + HistoryEntryResponse::Found(HistoryEntry { + text: "@sample and $figma".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: vec![ + MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, + MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }, + ], + pending_pastes: Vec::new(), + }) + ); + } + + #[test] + fn navigation_with_async_fetch() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + // Pretend there are 3 persistent entries. + let thread_id = test_thread_id(); + history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3); + history.record_local_submission(HistoryEntry::new("latest".to_string())); + + // First Up should recall current-session local history. + assert!(history.should_handle_navigation("", /*cursor*/ 0)); + assert_eq!( + Some(HistoryEntry::new("latest".to_string())), + history.navigate_up(&tx) + ); + + // Next Up should request offset 2 and await async data. + assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet + + // Verify that a history lookup request was sent. + let event = rx.try_recv().expect("expected AppEvent to be sent"); + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = event + else { + panic!("unexpected event variant"); + }; + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 2); + assert_eq!(log_id, 1); + + // Inject the async response. + assert_eq!( + HistoryEntryResponse::Found(HistoryEntry::new("latest".to_string())), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 2, + Some("latest".into()), + &tx + ) + ); + + // Next Up should move to offset 1. + assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet + + // Verify second lookup request for offset 1. + let event2 = rx.try_recv().expect("expected second event"); + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = event2 + else { + panic!("unexpected event variant"); + }; + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 1); + assert_eq!(log_id, 1); + + assert_eq!( + HistoryEntryResponse::Found(HistoryEntry::new("older".to_string())), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 1, + Some("older".into()), + &tx + ) + ); + } + + #[test] + fn search_matches_local_history_and_stops_at_boundaries() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.record_local_submission(HistoryEntry::new("git status".to_string())); + history.record_local_submission(HistoryEntry::new("cargo test -p codex-tui".to_string())); + history.record_local_submission(HistoryEntry::new("git diff".to_string())); + + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git status".to_string())), + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::AtBoundary, + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::AtBoundary, + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), + history.search( + "git", + HistorySearchDirection::Newer, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::AtBoundary, + history.search( + "git", + HistorySearchDirection::Newer, + /*restart*/ false, + &tx + ) + ); + } + + #[test] + fn search_skips_duplicate_local_matches() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.record_local_submission(HistoryEntry::new("git status".to_string())); + history.record_local_submission(HistoryEntry::new("cargo test -p codex-tui".to_string())); + history.record_local_submission(HistoryEntry::new("git status".to_string())); + history.record_local_submission(HistoryEntry::new("git diff".to_string())); + + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git status".to_string())), + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::AtBoundary, + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), + history.search( + "git", + HistorySearchDirection::Newer, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git status".to_string())), + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + } + + #[test] + fn repeated_boundary_search_does_not_refetch_persistent_history() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3); + + assert_eq!( + HistorySearchResult::Pending, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + let _ = rx.try_recv().expect("expected latest lookup"); + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( + "needle latest".to_string() + ))), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 2, + Some("needle latest".into()), + &tx, + ) + ); + + assert_eq!( + HistorySearchResult::Pending, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + let _ = rx.try_recv().expect("expected next older lookup"); + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Pending), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 1, + Some("not a match".into()), + &tx, + ) + ); + let AppEvent::LookupMessageHistoryBatch { cursor, .. } = + rx.try_recv().expect("expected oldest batch") + else { + panic!("unexpected event variant"); + }; + assert_eq!(cursor.end_offset(), 0); + assert_eq!( + Some(HistorySearchResult::AtBoundary), + history.on_batch_response( + /*log_id*/ 1, + cursor, + vec![batch_entry(/*offset*/ 0, "also not a match")], + /*next_older_cursor*/ None, + &tx, + ) + ); + assert!(rx.try_recv().is_err()); + + assert_eq!( + HistorySearchResult::AtBoundary, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn search_fetches_persistent_history_until_match() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + let thread_id = test_thread_id(); + history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3); + + assert_eq!( + HistorySearchResult::Pending, + history.search( + "older", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = rx.try_recv().expect("expected latest lookup") + else { + panic!("unexpected event variant"); + }; + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 2); + assert_eq!(log_id, 1); + + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Pending), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 2, + Some("latest".into()), + &tx + ) + ); + let AppEvent::LookupMessageHistoryBatch { + thread_id: response_thread_id, + cursor, + log_id, + } = rx.try_recv().expect("expected next lookup") + else { + panic!("unexpected event variant"); + }; + assert_eq!(response_thread_id, thread_id); + assert_eq!(cursor.end_offset(), 1); + assert_eq!(log_id, 1); + + assert_eq!( + Some(HistorySearchResult::Found(HistoryEntry::new( + "OLDER command".to_string() + ))), + history.on_batch_response( + /*log_id*/ 1, + cursor, + vec![batch_entry(/*offset*/ 1, "OLDER command")], + Some(HistoryBatchCursor::new(/*end_offset*/ 0)), + &tx + ) + ); + } + + #[test] + fn search_skips_duplicate_persistent_matches() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 4); + + assert_eq!( + HistorySearchResult::Pending, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + let _ = rx.try_recv().expect("expected latest lookup"); + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( + "needle same".to_string() + ))), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 3, + Some("needle same".into()), + &tx, + ) + ); + + assert_eq!( + HistorySearchResult::Pending, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + let _ = rx.try_recv().expect("expected duplicate lookup"); + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Pending), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 2, + Some("needle same".into()), + &tx, + ) + ); + let AppEvent::LookupMessageHistoryBatch { cursor, .. } = + rx.try_recv().expect("expected next batch after duplicate") + else { + panic!("unexpected event variant"); + }; + assert_eq!(cursor.end_offset(), 1); + assert_eq!( + Some(HistorySearchResult::Found(HistoryEntry::new( + "needle older".to_string() + ))), + history.on_batch_response( + /*log_id*/ 1, + cursor, + vec![ + batch_entry(/*offset*/ 1, "not a match"), + batch_entry(/*offset*/ 0, "needle older"), + ], + /*next_older_cursor*/ None, + &tx, + ) + ); + assert_eq!( + HistorySearchResult::AtBoundary, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("needle same".to_string())), + history.search( + "needle", + HistorySearchDirection::Newer, + /*restart*/ false, + &tx + ) + ); + } + + #[test] + fn search_is_case_insensitive_and_empty_query_finds_latest() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.record_local_submission(HistoryEntry::new("Build Release".to_string())); + + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("Build Release".to_string())), + history.search( + "release", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("Build Release".to_string())), + history.search( + "", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + } + + #[test] + fn reset_navigation_resets_cursor() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3); + history + .fetched_history + .insert(1, Some(HistoryEntry::new("command2".to_string()))); + history + .fetched_history + .insert(2, Some(HistoryEntry::new("command3".to_string()))); + + assert_eq!( + Some(HistoryEntry::new("command3".to_string())), + history.navigate_up(&tx) + ); + assert_eq!( + Some(HistoryEntry::new("command2".to_string())), + history.navigate_up(&tx) + ); + + history.reset_navigation(); + assert!(!history.is_navigating()); + assert!(history.last_history_text.is_none()); + + assert_eq!( + Some(HistoryEntry::new("command3".to_string())), + history.navigate_up(&tx) + ); + } + + #[test] + fn should_handle_navigation_when_cursor_is_at_line_boundaries() { + let mut history = ChatComposerHistory::new(); + history.record_local_submission(HistoryEntry::new("hello".to_string())); + history.last_history_text = Some("hello".to_string()); + + assert!(history.should_handle_navigation("hello", /*cursor*/ 0)); + assert!(history.should_handle_navigation("hello", "hello".len())); + assert!(!history.should_handle_navigation("hello", /*cursor*/ 1)); + assert!(!history.should_handle_navigation("other", /*cursor*/ 0)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs new file mode 100644 index 0000000000000000000000000000000000000000..26050f7300bb35f4274add77a2f17ae44725aa91 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -0,0 +1,599 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::ColumnWidthConfig; +use super::selection_popup_common::ColumnWidthMode; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height_with_col_width_mode; +use super::selection_popup_common::render_rows_with_col_width_mode; +use super::slash_commands::BuiltinCommandFlags; +use super::slash_commands::ServiceTierCommand; +use super::slash_commands::SlashCommandItem; +use super::slash_commands::commands_for_input; +use crate::render::Insets; +use crate::render::RectExt; +use crate::slash_command::SlashCommand; + +// Hide alias commands in the default popup list so each unique action appears once. +// `quit` is an alias of `exit`, and `btw` is an alias of `side`, so we skip +// those aliases here. +const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Btw]; +const COMMAND_COLUMN_WIDTH: ColumnWidthConfig = ColumnWidthConfig::new( + ColumnWidthMode::AutoAllRows, + /*name_column_width*/ None, +); + +/// A selectable item in the popup. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CommandItem { + Builtin(SlashCommand), + ServiceTier(ServiceTierCommand), +} + +pub(crate) struct CommandPopup { + command_filter: String, + commands: Vec, + state: ScrollState, +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct CommandPopupFlags { + pub(crate) collaboration_modes_enabled: bool, + pub(crate) connectors_enabled: bool, + pub(crate) plugins_command_enabled: bool, + pub(crate) token_activity_command_enabled: bool, + pub(crate) service_tier_commands_enabled: bool, + pub(crate) goal_command_enabled: bool, + pub(crate) voice_command_enabled: bool, + pub(crate) worktrees_enabled: bool, + pub(crate) windows_degraded_sandbox_active: bool, + pub(crate) side_conversation_active: bool, +} + +impl From for BuiltinCommandFlags { + fn from(value: CommandPopupFlags) -> Self { + Self { + collaboration_modes_enabled: value.collaboration_modes_enabled, + connectors_enabled: value.connectors_enabled, + plugins_command_enabled: value.plugins_command_enabled, + token_activity_command_enabled: value.token_activity_command_enabled, + service_tier_commands_enabled: value.service_tier_commands_enabled, + goal_command_enabled: value.goal_command_enabled, + voice_command_enabled: value.voice_command_enabled, + worktrees_enabled: value.worktrees_enabled, + allow_elevate_sandbox: value.windows_degraded_sandbox_active, + side_conversation_active: value.side_conversation_active, + } + } +} + +impl CommandPopup { + pub(crate) fn new( + flags: CommandPopupFlags, + service_tier_commands: Vec, + ) -> Self { + // Keep built-in availability in sync with the composer. + let commands = commands_for_input(flags.into(), &service_tier_commands) + .into_iter() + .filter_map(|command| match command { + SlashCommandItem::Builtin(cmd) => (!cmd.command().starts_with("debug") + && cmd != SlashCommand::Apps) + .then_some(CommandItem::Builtin(cmd)), + SlashCommandItem::ServiceTier(command) => Some(CommandItem::ServiceTier(command)), + }) + .collect(); + Self { + command_filter: String::new(), + commands, + state: ScrollState::new(), + } + } + + /// Update the filter string based on the current composer text. The text + /// passed in is expected to start with a leading '/'. Everything after the + /// *first* '/' on the *first* line becomes the active filter that is used + /// to narrow down the list of available commands. + pub(crate) fn on_composer_text_change(&mut self, text: String) { + let first_line = text.lines().next().unwrap_or(""); + let previous_filter = self.command_filter.clone(); + + if let Some(stripped) = first_line.strip_prefix('/') { + // Extract the *first* token (sequence of non-whitespace + // characters) after the slash so that `/clear something` still + // shows the help for `/clear`. + let token = stripped.trim_start(); + let cmd_token = token.split_whitespace().next().unwrap_or(""); + + // Update the filter keeping the original case (commands are all + // lower-case for now but this may change in the future). + self.command_filter = cmd_token.to_string(); + } else { + // The composer no longer starts with '/'. Reset the filter so the + // popup shows the *full* command list if it is still displayed + // for some reason. + self.command_filter.clear(); + } + + if self.command_filter != previous_filter { + self.state.reset(); + } + + // Reset or clamp selected index based on new filtered list. + let matches_len = self.filtered_items().len(); + self.state.clamp_selection(matches_len); + self.state + .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); + } + + /// Determine the preferred height of the popup for a given width. + /// Accounts for wrapped descriptions so that long tooltips don't overflow. + pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { + let rows = self.rows_from_matches(self.filtered()); + + measure_rows_height_with_col_width_mode( + &rows, + &self.state, + MAX_POPUP_ROWS, + width, + COMMAND_COLUMN_WIDTH, + ) + } + + /// Compute exact/prefix matches over built-in commands and user prompts, + /// paired with optional highlight indices. Preserves the original + /// presentation order for built-ins and prompts. + fn filtered(&self) -> Vec<(CommandItem, Option>)> { + let filter = self.command_filter.trim(); + let mut out: Vec<(CommandItem, Option>)> = Vec::new(); + if filter.is_empty() { + for command in self.commands.iter() { + if matches!(command, CommandItem::Builtin(cmd) if ALIAS_COMMANDS.contains(cmd)) { + continue; + } + out.push((command.clone(), None)); + } + return out; + } + + let filter_lower = filter.to_lowercase(); + let filter_chars = filter.chars().count(); + let mut exact: Vec<(CommandItem, Option>)> = Vec::new(); + let mut prefix: Vec<(CommandItem, Option>)> = Vec::new(); + let indices_for = |offset| Some((offset..offset + filter_chars).collect()); + + let mut push_match = + |item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| { + let display_lower = display.to_lowercase(); + let name_lower = name.map(str::to_lowercase); + let display_exact = display_lower == filter_lower; + let name_exact = name_lower.as_deref() == Some(filter_lower.as_str()); + if display_exact || name_exact { + let offset = if display_exact { 0 } else { name_offset }; + exact.push((item, indices_for(offset))); + return; + } + let display_prefix = display_lower.starts_with(&filter_lower); + let name_prefix = name_lower + .as_ref() + .is_some_and(|name| name.starts_with(&filter_lower)); + if display_prefix || name_prefix { + let offset = if display_prefix { 0 } else { name_offset }; + prefix.push((item, indices_for(offset))); + } + }; + + for command in self.commands.iter() { + let display = command.command(); + push_match(command.clone(), display, None, 0); + } + + out.extend(exact); + out.extend(prefix); + out + } + + fn filtered_items(&self) -> Vec { + self.filtered().into_iter().map(|(c, _)| c).collect() + } + + fn rows_from_matches( + &self, + matches: Vec<(CommandItem, Option>)>, + ) -> Vec { + matches + .into_iter() + .map(|(item, indices)| { + let name = format!("/{}", item.command()); + let description = item.description().to_string(); + GenericDisplayRow { + name, + name_prefix_spans: Vec::new(), + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + display_shortcut: None, + description: Some(description), + category_tag: None, + wrap_indent: None, + is_disabled: false, + disabled_reason: None, + } + }) + .collect() + } + + /// Move the selection cursor one step up. + pub(crate) fn move_up(&mut self) { + let len = self.filtered_items().len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + /// Move the selection cursor one step down. + pub(crate) fn move_down(&mut self) { + let matches_len = self.filtered_items().len(); + self.state.move_down_wrap(matches_len); + self.state + .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); + } + + /// Return currently selected command, if any. + pub(crate) fn selected_item(&self) -> Option { + let matches = self.filtered_items(); + self.state + .selected_idx + .and_then(|idx| matches.get(idx).cloned()) + } +} + +impl CommandItem { + pub(crate) fn command(&self) -> &str { + match self { + Self::Builtin(cmd) => cmd.command(), + Self::ServiceTier(command) => &command.name, + } + } + + fn description(&self) -> &str { + match self { + Self::Builtin(cmd) => cmd.description(), + Self::ServiceTier(command) => &command.description, + } + } +} + +impl WidgetRef for CommandPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let rows = self.rows_from_matches(self.filtered()); + render_rows_with_col_width_mode( + area.inset(Insets::tlbr( + /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, + )), + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + "no matches", + COMMAND_COLUMN_WIDTH, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn filter_includes_init_when_typing_prefix() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + // Simulate the composer line starting with '/in' so the popup filters + // matching commands by prefix. + popup.on_composer_text_change("/in".to_string()); + + // Access the filtered list via the selected command and ensure that + // one of the matches is the new "init" command. + let matches = popup.filtered_items(); + let has_init = matches.iter().any(|item| match item { + CommandItem::Builtin(cmd) => cmd.command() == "init", + CommandItem::ServiceTier(_) => false, + }); + assert!( + has_init, + "expected '/init' to appear among filtered commands" + ); + } + + #[test] + fn selecting_init_by_exact_match() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/init".to_string()); + + // When an exact match exists, the selected command should be that + // command by default. + let selected = popup.selected_item(); + match selected { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), + Some(CommandItem::ServiceTier(command)) => { + panic!("expected init command, got service tier {command:?}") + } + None => panic!("expected a selected command for exact match"), + } + } + + #[test] + fn model_is_first_suggestion_for_mo() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/mo".to_string()); + let matches = popup.filtered_items(); + match matches.first() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), + Some(CommandItem::ServiceTier(command)) => { + panic!("expected model command, got service tier {command:?}") + } + None => panic!("expected at least one match for '/mo'"), + } + } + + #[test] + fn service_tier_command_uses_catalog_name_and_description() { + let mut popup = CommandPopup::new( + CommandPopupFlags { + service_tier_commands_enabled: true, + ..CommandPopupFlags::default() + }, + vec![ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "Fastest inference with increased plan usage".to_string(), + }], + ); + popup.on_composer_text_change("/fa".to_string()); + + match popup.selected_item() { + Some(CommandItem::ServiceTier(command)) => assert_eq!( + command, + ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "Fastest inference with increased plan usage".to_string(), + } + ), + other => panic!("expected fast service tier to be selected, got {other:?}"), + } + let rows = popup.rows_from_matches(popup.filtered()); + assert_eq!( + rows.first().and_then(|row| row.description.as_deref()), + Some("Fastest inference with increased plan usage") + ); + } + + #[test] + fn filtered_commands_keep_presentation_order_for_prefix() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/m".to_string()); + + let cmds: Vec = popup + .filtered_items() + .into_iter() + .map(|item| match item { + CommandItem::Builtin(cmd) => cmd.command().to_string(), + CommandItem::ServiceTier(command) => command.name, + }) + .collect(); + assert_eq!( + cmds, + vec![ + "model".to_string(), + "memories".to_string(), + "mention".to_string(), + "mcp".to_string() + ] + ); + } + + #[cfg(any(target_os = "macos", target_os = "windows"))] + #[test] + fn app_command_popup_snapshot() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/app".to_string()); + + let width = 72; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + popup.calculate_required_height(width), + ); + let mut buf = Buffer::empty(area); + popup.render_ref(area, &mut buf); + + insta::assert_snapshot!("command_popup_app", format!("{buf:?}")); + } + + #[cfg(any(target_os = "macos", target_os = "windows"))] + #[test] + fn voice_command_popup_snapshot() { + let mut popup = CommandPopup::new( + CommandPopupFlags { + voice_command_enabled: true, + ..CommandPopupFlags::default() + }, + Vec::new(), + ); + popup.on_composer_text_change("/voice".to_string()); + + let width = 72; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + popup.calculate_required_height(width), + ); + let mut buf = Buffer::empty(area); + popup.render_ref(area, &mut buf); + + insta::assert_snapshot!("command_popup_voice", format!("{buf:?}")); + } + + #[cfg(target_os = "macos")] + #[test] + fn default_command_popup_items_snapshot() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/".to_string()); + + let commands = popup + .filtered_items() + .into_iter() + .map(|item| { + let command = item.command(); + let description = item.description(); + format!("/{command} - {description}") + }) + .collect::>() + .join("\n"); + + insta::assert_snapshot!("command_popup_default_items", commands); + } + + #[test] + fn prefix_filter_limits_matches_for_ac() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/ac".to_string()); + + let cmds: Vec = popup + .filtered_items() + .into_iter() + .map(|item| match item { + CommandItem::Builtin(cmd) => cmd.command().to_string(), + CommandItem::ServiceTier(command) => command.name, + }) + .collect(); + assert!( + !cmds.iter().any(|cmd| cmd == "compact"), + "expected prefix search for '/ac' to exclude 'compact', got {cmds:?}" + ); + } + + #[test] + fn changing_filter_resets_selection_after_scrolling() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/".to_string()); + + for _ in 0..MAX_POPUP_ROWS { + popup.move_down(); + } + assert!(popup.state.scroll_top > 0); + + popup.on_composer_text_change("/st".to_string()); + + assert_eq!( + popup.selected_item(), + Some(CommandItem::Builtin(SlashCommand::Status)) + ); + assert_eq!(popup.state.scroll_top, 0); + let width = 72; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + popup.calculate_required_height(width), + ); + let mut buf = Buffer::empty(area); + popup.render_ref(area, &mut buf); + insta::assert_snapshot!( + "command_popup_filter_reset_after_scroll", + format!("{buf:?}") + ); + } + + #[test] + fn quit_hidden_in_empty_filter_but_shown_for_prefix() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/".to_string()); + let items = popup.filtered_items(); + assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit))); + + popup.on_composer_text_change("/qu".to_string()); + let items = popup.filtered_items(); + assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit))); + } + + #[test] + fn btw_hidden_in_empty_filter_but_shown_for_prefix() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/".to_string()); + let items = popup.filtered_items(); + assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Btw))); + + popup.on_composer_text_change("/bt".to_string()); + let items = popup.filtered_items(); + assert!(items.contains(&CommandItem::Builtin(SlashCommand::Btw))); + } + + #[test] + fn plan_command_hidden_when_collaboration_modes_disabled() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/".to_string()); + + let cmds: Vec = popup + .filtered_items() + .into_iter() + .map(|item| match item { + CommandItem::Builtin(cmd) => cmd.command().to_string(), + CommandItem::ServiceTier(command) => command.name, + }) + .collect(); + assert!( + !cmds.iter().any(|cmd| cmd == "plan"), + "expected '/plan' to be hidden when collaboration modes are disabled, got {cmds:?}" + ); + } + + #[test] + fn plan_command_visible_when_collaboration_modes_enabled() { + let mut popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + token_activity_command_enabled: false, + service_tier_commands_enabled: false, + goal_command_enabled: false, + voice_command_enabled: false, + worktrees_enabled: true, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + }, + Vec::new(), + ); + popup.on_composer_text_change("/plan".to_string()); + + match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "plan"), + Some(CommandItem::ServiceTier(command)) => { + panic!("expected plan command, got service tier {command:?}") + } + other => panic!("expected plan to be selected for exact match, got {other:?}"), + } + } + + #[test] + fn debug_commands_are_hidden_from_popup() { + let popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + let cmds: Vec = popup + .filtered_items() + .into_iter() + .map(|item| match item { + CommandItem::Builtin(cmd) => cmd.command().to_string(), + CommandItem::ServiceTier(command) => command.name, + }) + .collect(); + + assert!( + !cmds.iter().any(|name| name.starts_with("debug")), + "expected no /debug* command in popup menu, got {cmds:?}" + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..f2c5f953c1f69d3bdb41829b0f7dadaf6c128be7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -0,0 +1,430 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; +use std::cell::RefCell; +use std::time::Instant; +use uuid::Uuid; + +use crate::key_hint; +use crate::key_hint::has_ctrl_or_alt; +use crate::keymap::KeymapContextSet; +use crate::keymap::RuntimeKeymap; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::renderable::Renderable; + +use super::popup_consts::accept_cancel_hint_line; +use super::popup_consts::standard_popup_hint_line; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::bottom_pane_view::ViewCompletion; +use super::footer::max_left_width_for_right; +use super::footer::render_context_right; +use super::paste_burst::PasteBurst; +use super::textarea::TextArea; +use super::textarea::TextAreaState; + +/// Callback invoked when the user submits a custom prompt. +pub(crate) type PromptSubmitted = Box; + +/// Correlates a pending generated prefill with its eventual display label. +struct PendingTextSuggestion { + request_id: Uuid, + applied_label: String, +} + +/// Minimal multi-line text input view to collect custom review instructions. +pub(crate) struct CustomPromptView { + title: String, + placeholder: String, + context_label: Option, + on_submit: PromptSubmitted, + + // UI state + textarea: TextArea, + textarea_state: RefCell, + paste_burst: PasteBurst, + completion: Option, + pending_suggestion: Option, + user_edited: bool, +} + +impl CustomPromptView { + pub(crate) fn new( + title: String, + placeholder: String, + initial_text: String, + context_label: Option, + on_submit: PromptSubmitted, + ) -> Self { + let mut textarea = TextArea::new(); + if !initial_text.is_empty() { + textarea.set_text_clearing_elements(&initial_text); + textarea.set_cursor(initial_text.len()); + } + + Self { + title, + placeholder, + context_label, + on_submit, + textarea, + textarea_state: RefCell::new(TextAreaState::default()), + paste_burst: PasteBurst::default(), + completion: None, + pending_suggestion: None, + user_edited: false, + } + } + + /// Apply the same editor and Vim bindings used by the main composer. + pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + self.textarea.set_keymap_bindings(keymap); + } + + /// Enable Vim editing while keeping the prompt ready for immediate input. + pub(crate) fn enable_vim_in_insert_mode(&mut self) { + self.textarea.set_vim_enabled(/*enabled*/ true); + self.textarea.enter_vim_insert_mode(); + } + + /// Opt this prompt into one correlated background text suggestion. + pub(crate) fn with_text_suggestion( + mut self, + request_id: Uuid, + loading_label: String, + applied_label: String, + ) -> Self { + self.context_label = Some(loading_label); + self.pending_suggestion = Some(PendingTextSuggestion { + request_id, + applied_label, + }); + self + } + + fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) { + let previous_text = self + .pending_suggestion + .as_ref() + .filter(|_| !self.user_edited) + .map(|_| self.textarea.text().to_string()); + + if self.textarea.is_vim_operator_pending() { + self.textarea.input(key_event); + self.user_edited |= previous_text.is_some_and(|text| self.textarea.text() != text); + return; + } + + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + if self.textarea.should_handle_vim_insert_escape(key_event) { + self.textarea.input(key_event); + self.paste_burst.clear_after_explicit_paste(); + } else { + self.on_ctrl_c(); + } + } + KeyEvent { + code: KeyCode::Enter, + modifiers, + .. + } => { + if self.textarea.allows_paste_burst() + && self.paste_burst.direct_insert_newline_should_insert(now) + { + self.paste_burst.extend_window(now); + self.textarea.insert_str("\n"); + self.user_edited = true; + return; + } + if modifiers == KeyModifiers::NONE { + let text = self.textarea.text().trim().to_string(); + if !text.is_empty() { + (self.on_submit)(text); + self.completion = Some(ViewCompletion::Accepted); + } + } else { + self.textarea.input(key_event); + } + } + KeyEvent { + code: KeyCode::Char(_), + modifiers, + .. + } if !has_ctrl_or_alt(modifiers) && self.textarea.allows_paste_burst() => { + let paste_like_burst = self.paste_burst.on_plain_char_no_hold(now).is_some(); + self.textarea.input(key_event); + if paste_like_burst { + self.paste_burst.extend_window(now); + } + } + KeyEvent { + code: KeyCode::Tab, + modifiers, + .. + } if !has_ctrl_or_alt(modifiers) && self.textarea.allows_paste_burst() => { + let in_paste_burst = self.paste_burst.direct_insert_newline_should_insert(now); + self.textarea.input(key_event); + if in_paste_burst { + self.paste_burst.extend_window(now); + } + } + other => { + self.textarea.input(other); + self.paste_burst.clear_after_explicit_paste(); + } + } + + self.user_edited |= previous_text.is_some_and(|text| self.textarea.text() != text); + } +} + +impl BottomPaneView for CustomPromptView { + fn keymap_contexts(&self) -> KeymapContextSet { + KeymapContextSet::new(self.textarea.keymap_context()) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + self.handle_key_event_at(key_event, Instant::now()); + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.completion = Some(ViewCompletion::Cancelled); + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + self.textarea + .should_handle_vim_insert_escape(KeyEvent::from(KeyCode::Esc)) + } + + fn is_complete(&self) -> bool { + self.completion.is_some() + } + + fn completion(&self) -> Option { + self.completion + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + self.textarea.insert_str(&pasted); + self.user_edited = true; + self.paste_burst.clear_after_explicit_paste(); + true + } + + fn apply_text_suggestion(&mut self, request_id: Uuid, suggestion: Option<&str>) -> bool { + if self.completion.is_some() { + return false; + } + + let Some(pending) = self + .pending_suggestion + .take_if(|pending| pending.request_id == request_id) + else { + return false; + }; + + self.context_label = None; + + if !self.user_edited + && let Some(suggestion) = suggestion + { + self.textarea.set_text_clearing_elements(suggestion); + self.textarea.set_cursor(suggestion.len()); + self.context_label = Some(pending.applied_label); + } + + true + } +} + +#[cfg(test)] +#[path = "custom_prompt_view_tests.rs"] +mod tests; + +impl Renderable for CustomPromptView { + fn desired_height(&self, width: u16) -> u16 { + let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 }; + 1u16 + extra_top + self.input_height(width) + 3u16 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let input_height = self.input_height(area.width); + + // Title line + let title_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }; + let title_spans: Vec> = vec![gutter(), self.title.clone().bold()]; + Paragraph::new(Line::from(title_spans)).render(title_area, buf); + + // Optional context line + let mut input_y = area.y.saturating_add(1); + if let Some(context_label) = &self.context_label { + let context_area = Rect { + x: area.x, + y: input_y, + width: area.width, + height: 1, + }; + let spans: Vec> = vec![gutter(), context_label.clone().cyan()]; + Paragraph::new(Line::from(spans)).render(context_area, buf); + input_y = input_y.saturating_add(1); + } + + // Input line + let input_area = Rect { + x: area.x, + y: input_y, + width: area.width, + height: input_height, + }; + if input_area.width >= 2 { + for row in 0..input_area.height { + Paragraph::new(Line::from(vec![gutter()])).render( + Rect { + x: input_area.x, + y: input_area.y.saturating_add(row), + width: 2, + height: 1, + }, + buf, + ); + } + + let text_area_height = input_area.height.saturating_sub(1); + if text_area_height > 0 { + if input_area.width > 2 { + let blank_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y, + width: input_area.width.saturating_sub(2), + height: 1, + }; + Clear.render(blank_rect, buf); + } + let textarea_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y.saturating_add(1), + width: input_area.width.saturating_sub(2), + height: text_area_height, + }; + let mut state = self.textarea_state.borrow_mut(); + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if self.textarea.text().is_empty() { + Paragraph::new(Line::from(self.placeholder.clone().dim())) + .render(textarea_rect, buf); + } + } + } + + let hint_blank_y = input_area.y.saturating_add(input_height); + if hint_blank_y < area.y.saturating_add(area.height) { + let blank_area = Rect { + x: area.x, + y: hint_blank_y, + width: area.width, + height: 1, + }; + Clear.render(blank_area, buf); + } + + let hint_y = hint_blank_y.saturating_add(1); + if hint_y < area.y.saturating_add(area.height) { + let mut hint_line = if self.prefer_esc_to_handle_key_event() { + accept_cancel_hint_line( + Some(key_hint::plain(KeyCode::Enter).into()), + "to confirm", + Some(key_hint::plain(KeyCode::Esc).into()), + "to enter normal mode", + ) + } else { + standard_popup_hint_line() + }; + let hint_area = Rect { + x: area.x, + y: hint_y, + width: area.width, + height: 1, + }; + let mut vim_mode = self.textarea.vim_mode_indicator_span().map(Line::from); + let available_hint_width = vim_mode + .as_ref() + .and_then(|line| max_left_width_for_right(hint_area, line.width() as u16)) + .filter(|width| *width > 0); + if vim_mode.is_some() && available_hint_width.is_none() { + vim_mode = None; + } + if let Some(width) = available_hint_width { + hint_line = truncate_line_with_ellipsis_if_overflow(hint_line, width as usize); + } + Paragraph::new(hint_line).render(hint_area, buf); + if let Some(line) = vim_mode { + render_context_right(hint_area, buf, &line); + } + } + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if area.height < 2 || area.width <= 2 { + return None; + } + let text_area_height = self.input_height(area.width).saturating_sub(1); + if text_area_height == 0 { + return None; + } + let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 }; + let top_line_count = 1u16 + extra_offset; + let textarea_rect = Rect { + x: area.x.saturating_add(2), + y: area.y.saturating_add(top_line_count).saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } + + fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle { + if self.textarea.uses_vim_insert_cursor() { + crossterm::cursor::SetCursorStyle::SteadyBar + } else { + crossterm::cursor::SetCursorStyle::DefaultUserShape + } + } +} + +impl CustomPromptView { + fn input_height(&self, width: u16) -> u16 { + let usable_width = width.saturating_sub(2); + let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); + text_height.saturating_add(1).min(9) + } +} + +fn gutter() -> Span<'static> { + "▌ ".cyan() +} diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..403841f4138be594eca0b55fd3ca2dc467803920 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs @@ -0,0 +1,335 @@ +use super::*; +use crate::key_hint; +use crate::keymap::KeymapContext; +use crate::keymap::RuntimeKeymap; +use pretty_assertions::assert_eq; +use std::sync::mpsc::Receiver; + +#[test] +fn paste_burst_newline_does_not_submit_short_first_line() { + let now = Instant::now(); + + for (first_line, second_line) in [("x", "rest"), ("id", "body"), ("foo", "bar")] { + let (mut view, submitted_rx) = custom_prompt_view(); + let mut ms = 0; + + for ch in first_line.chars() { + view.handle_key_event_at(KeyEvent::from(KeyCode::Char(ch)), now + elapsed(ms)); + ms += 1; + } + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(ms)); + ms += 1; + for ch in second_line.chars() { + view.handle_key_event_at(KeyEvent::from(KeyCode::Char(ch)), now + elapsed(ms)); + ms += 1; + } + + assert!(submitted_rx.try_recv().is_err()); + assert!(!view.is_complete()); + + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(/*ms*/ 200)); + + assert_eq!( + submitted_rx.try_recv(), + Ok(format!("{first_line}\n{second_line}")) + ); + assert!(view.is_complete()); + } +} + +#[test] +fn paste_burst_newline_after_tab_does_not_submit() { + let (mut view, submitted_rx) = custom_prompt_view(); + let now = Instant::now(); + let mut ms = 0; + + view.handle_key_event_at(KeyEvent::from(KeyCode::Char('x')), now + elapsed(ms)); + ms += 1; + view.handle_key_event_at(KeyEvent::from(KeyCode::Tab), now + elapsed(ms)); + ms += 1; + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(ms)); + ms += 1; + for ch in "rest".chars() { + view.handle_key_event_at(KeyEvent::from(KeyCode::Char(ch)), now + elapsed(ms)); + ms += 1; + } + + assert!(submitted_rx.try_recv().is_err()); + assert!(!view.is_complete()); + + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(/*ms*/ 200)); + + assert_eq!(submitted_rx.try_recv(), Ok("x\nrest".to_string())); + assert!(view.is_complete()); +} + +#[test] +fn delayed_enter_after_typing_submits() { + let (mut view, submitted_rx) = custom_prompt_view(); + let now = Instant::now(); + + for (idx, ch) in "foo".chars().enumerate() { + view.handle_key_event_at(KeyEvent::from(KeyCode::Char(ch)), now + elapsed(idx * 20)); + } + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(/*ms*/ 80)); + + assert_eq!(submitted_rx.try_recv(), Ok("foo".to_string())); + assert!(view.is_complete()); +} + +#[test] +fn vim_insert_escape_clears_paste_burst_before_enter() { + let (mut view, submitted_rx) = custom_prompt_view(); + let now = Instant::now(); + view.enable_vim_in_insert_mode(); + + for (idx, ch) in "abc".chars().enumerate() { + view.handle_key_event_at(KeyEvent::from(KeyCode::Char(ch)), now + elapsed(idx)); + } + view.handle_key_event_at(KeyEvent::from(KeyCode::Esc), now + elapsed(/*ms*/ 3)); + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(/*ms*/ 4)); + + assert_eq!(submitted_rx.try_recv(), Ok("abc".to_string())); + assert_eq!(view.completion(), Some(ViewCompletion::Accepted)); +} + +#[test] +fn vim_prompt_hint_tracks_escape_behavior() { + let (mut view, _submitted_rx) = custom_prompt_view(); + let rendered_hint = |view: &CustomPromptView, width: u16| { + let area = Rect::new(0, 0, width, 5); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + (0..area.width) + .map(|x| buf[(x, area.height - 1)].symbol()) + .collect::() + .trim_end() + .to_string() + }; + let vim_color = |view: &CustomPromptView| { + let area = Rect::new(0, 0, 80, 5); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + buf[(67, 4)].style().fg + }; + + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to go back"); + view.enable_vim_in_insert_mode(); + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to enter normal mode Vim: Insert"); + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 60), @"Press enter to confirm or esc to enter norm… Vim: Insert"); + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 14), @"Press enter to"); + assert_eq!(vim_color(&view), Some(ratatui::style::Color::Green)); + + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to go back Vim: Normal"); + assert_eq!(vim_color(&view), Some(ratatui::style::Color::Magenta)); + view.handle_key_event(KeyEvent::from(KeyCode::Char('R'))); + insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to enter normal mode Vim: Replace"); +} + +#[test] +fn vim_insert_cursor_tracks_mode_and_normal_mode_commands() { + let (mut view, _submitted_rx) = custom_prompt_view(); + let area = Rect::new(0, 0, 80, 10); + view.textarea.set_text_clearing_elements("rename"); + view.textarea.set_cursor("rename".len()); + let mut config = codex_config::types::TuiKeymap::default(); + config.editor.move_left = Some(codex_config::types::KeybindingsSpec::One( + codex_config::types::KeybindingSpec("f2".to_string()), + )); + let mut keymap = RuntimeKeymap::from_config(&config).expect("valid editor remap"); + keymap.vim_normal.delete_char = vec![key_hint::plain(KeyCode::F(3))]; + view.set_keymap_bindings(&keymap); + view.handle_key_event(KeyEvent::from(KeyCode::F(2))); + assert_eq!(view.textarea.cursor(), "rename".len() - 1); + view.textarea.set_cursor("rename".len()); + + assert!(matches!( + view.cursor_style(area), + crossterm::cursor::SetCursorStyle::DefaultUserShape + )); + + view.enable_vim_in_insert_mode(); + assert!(view.prefer_esc_to_handle_key_event()); + assert!(view.keymap_contexts().contains(KeymapContext::Editor)); + assert!(matches!( + view.cursor_style(area), + crossterm::cursor::SetCursorStyle::SteadyBar + )); + + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + assert!(!view.is_complete()); + assert!(!view.prefer_esc_to_handle_key_event()); + assert!(view.keymap_contexts().contains(KeymapContext::VimNormal)); + assert!(matches!( + view.cursor_style(area), + crossterm::cursor::SetCursorStyle::DefaultUserShape + )); + + for key_code in [KeyCode::Enter, KeyCode::Esc] { + view.handle_key_event(KeyEvent::from(KeyCode::Char('d'))); + assert!(view.prefer_esc_to_handle_key_event()); + assert!(view.keymap_contexts().contains(KeymapContext::VimOperator)); + view.handle_key_event(KeyEvent::from(key_code)); + assert!(!view.is_complete()); + assert!(!view.prefer_esc_to_handle_key_event()); + assert_eq!(view.textarea.text(), "rename"); + } + + view.handle_key_event(KeyEvent::from(KeyCode::Char('d'))); + view.handle_key_event(KeyEvent::from(KeyCode::Char('i'))); + assert!( + view.keymap_contexts() + .contains(KeymapContext::VimTextObject) + ); + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + view.handle_key_event(KeyEvent::from(KeyCode::Char('h'))); + view.handle_key_event(KeyEvent::from(KeyCode::F(3))); + assert_eq!(view.textarea.text(), "renae"); + + view.handle_key_event(KeyEvent::from(KeyCode::Char('i'))); + assert!(matches!( + view.cursor_style(area), + crossterm::cursor::SetCursorStyle::SteadyBar + )); + view.handle_key_event(KeyEvent::from(KeyCode::Char('m'))); + assert_eq!(view.textarea.text(), "rename"); + + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + view.handle_key_event(KeyEvent::from(KeyCode::Char('R'))); + assert!(view.prefer_esc_to_handle_key_event()); + view.handle_key_event(KeyEvent::from(KeyCode::Char('x'))); + assert_eq!(view.textarea.text(), "renaxe"); + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + assert!(!view.is_complete()); + assert!(!view.prefer_esc_to_handle_key_event()); + assert_eq!(view.textarea.text(), "renaxe"); + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + assert!(view.is_complete()); + assert_eq!(view.completion(), Some(ViewCompletion::Cancelled)); +} + +#[test] +fn escape_without_vim_cancels_prompt() { + let (mut view, _submitted_rx) = custom_prompt_view(); + + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert!(view.is_complete()); + assert_eq!(view.completion(), Some(ViewCompletion::Cancelled)); +} + +#[test] +fn background_prefill_requires_a_matching_unedited_prompt() { + let request_id = Uuid::new_v4(); + let (view, submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + + assert!(!view.apply_text_suggestion(Uuid::new_v4(), Some("Stale"))); + assert!(view.apply_text_suggestion(request_id, Some("Suggested text"))); + assert_eq!(view.context_label.as_deref(), Some("Ready")); + + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), Instant::now()); + + assert_eq!(submitted_rx.try_recv(), Ok("Suggested text".to_string())); + assert!(!view.apply_text_suggestion(request_id, Some("Late"))); +} + +#[test] +fn background_prefill_failure_clears_loading_without_changing_text() { + let request_id = Uuid::new_v4(); + let (view, _submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + view.textarea.set_text_clearing_elements("Existing title"); + + assert!(view.apply_text_suggestion(request_id, /*suggestion*/ None)); + assert_eq!( + (view.textarea.text(), view.context_label.as_deref()), + ("Existing title", None) + ); +} + +#[test] +fn background_prefill_preserves_typing_even_after_the_text_is_deleted() { + let request_id = Uuid::new_v4(); + let (view, _submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + + view.handle_key_event_at(KeyEvent::from(KeyCode::Char('x')), Instant::now()); + view.handle_key_event_at(KeyEvent::from(KeyCode::Backspace), Instant::now()); + + assert!(view.apply_text_suggestion(request_id, Some("Generated"))); + assert_eq!( + (view.textarea.text(), view.context_label.as_deref()), + ("", None) + ); +} + +#[test] +fn background_prefill_preserves_pasted_text() { + let request_id = Uuid::new_v4(); + let (view, _submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + + assert!(view.handle_paste("Manual title".to_string())); + assert!(view.apply_text_suggestion(request_id, Some("Generated"))); + assert_eq!( + (view.textarea.text(), view.context_label.as_deref()), + ("Manual title", None) + ); +} + +#[test] +fn background_prefill_survives_cursor_movement() { + let request_id = Uuid::new_v4(); + let (view, _submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + view.textarea.set_text_clearing_elements("Existing"); + view.textarea.set_cursor("Existing".len()); + + view.handle_key_event_at(KeyEvent::from(KeyCode::Left), Instant::now()); + + assert!(view.apply_text_suggestion(request_id, Some("Generated"))); + assert_eq!( + (view.textarea.text(), view.context_label.as_deref()), + ("Generated", Some("Ready")) + ); +} + +#[test] +fn background_prefill_preserves_replace_backspace_recovery() { + let request_id = Uuid::new_v4(); + let (view, _submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + view.enable_vim_in_insert_mode(); + for code in [KeyCode::Esc, KeyCode::Char('R')] { + view.handle_key_event(KeyEvent::from(code)); + } + assert!(view.apply_text_suggestion(request_id, Some("abc"))); + for code in [KeyCode::Left, KeyCode::Char('X'), KeyCode::Backspace] { + view.handle_key_event(KeyEvent::from(code)); + } + assert_eq!( + (view.textarea.text(), view.textarea.vim_mode_label()), + ("abc", Some("Replace")) + ); +} + +fn custom_prompt_view() -> (CustomPromptView, Receiver) { + let (submitted, submitted_rx) = std::sync::mpsc::channel(); + let view = CustomPromptView::new( + "Edit goal".to_string(), + "Type a goal objective and press Enter".to_string(), + String::new(), + /*context_label*/ None, + Box::new(move |text| { + submitted.send(text).expect("send submitted text"); + }), + ); + (view, submitted_rx) +} + +fn elapsed(ms: usize) -> std::time::Duration { + std::time::Duration::from_millis(ms as u64) +} diff --git a/codex-rs/tui/src/bottom_pane/effort_ignition.rs b/codex-rs/tui/src/bottom_pane/effort_ignition.rs new file mode 100644 index 0000000000000000000000000000000000000000..56658fcaf9a5a624f78bf5f50efd3bb618d8f58b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/effort_ignition.rs @@ -0,0 +1,254 @@ +//! One-shot celebration animations shown inside the composer band when the +//! active reasoning effort changes to Max or Ultra, plus the persistent +//! prompt accent that remains while one of those tiers is active. +//! +//! Wave, Aurora, and Pulse are text-safe: backgrounds blend underneath the +//! draft and glyphs only land in empty cells. Real tier changes choose a +//! random style without repeating the previous one. +//! +//! The clock starts on the first rendered frame. Browser-backed and native +//! terminals without reliable ANSI-256/truecolor support, and sessions with +//! animations disabled, remain static. + +use std::cell::Cell; +use std::time::Duration; +use std::time::Instant; + +use codex_protocol::openai_models::ReasoningEffort; +use rand::Rng as _; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Span; + +use crate::color::blend; +use crate::color::is_light; +use crate::style::user_message_bg_rgb; +use crate::terminal_palette::StdoutColorLevel; +use crate::terminal_palette::best_color_for_level; +use crate::terminal_palette::default_bg; +use crate::terminal_palette::default_fg; +use crate::terminal_palette::effective_stdout_color_level; + +#[path = "effort_ignition_styles.rs"] +mod styles; + +use styles::Canvas; +use styles::paint_style; + +const PROMPT_ACCENT_ALPHA: f32 = 0.86; +const CHARGE: Duration = Duration::from_millis(150); + +pub(crate) const IGNITION_FRAME_TICK: Duration = Duration::from_millis(33); + +pub(crate) fn effort_animation_enabled( + animations_enabled: bool, + color_level: StdoutColorLevel, +) -> bool { + animations_enabled + && matches!( + color_level, + StdoutColorLevel::TrueColor | StdoutColorLevel::Ansi256 + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EffortTier { + Max, + Ultra, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum IgnitionStyle { + Wave, + Aurora, + Pulse, +} + +impl IgnitionStyle { + const ALL: [Self; 3] = [Self::Wave, Self::Aurora, Self::Pulse]; + + pub(crate) fn random(previous: Option) -> Self { + let mut rng = rand::rng(); + loop { + let style = Self::ALL[rng.random_range(0..Self::ALL.len())]; + if Some(style) != previous { + return style; + } + } + } + + fn total_duration(self, tier: EffortTier) -> Duration { + let (max, ultra) = match self { + Self::Wave => (1000, 1300), + Self::Aurora => (1300, 1600), + Self::Pulse => (900, 1250), + }; + Duration::from_millis(match tier { + EffortTier::Max => max, + EffortTier::Ultra => ultra, + }) + } +} + +impl EffortTier { + pub(crate) fn from_effort(effort: Option<&ReasoningEffort>) -> Option { + match effort { + Some(ReasoningEffort::Max) => Some(Self::Max), + Some(ReasoningEffort::Ultra) => Some(Self::Ultra), + _ => None, + } + } + + fn prompt_glyph(self) -> &'static str { + match self { + Self::Max => "›", + Self::Ultra => "»", + } + } + + pub(super) fn hues(self, on_light_bg: bool) -> [(u8, u8, u8); 3] { + match (self, on_light_bg) { + (Self::Max, false) => [(255, 178, 66), (255, 214, 120), (255, 120, 60)], + (Self::Max, true) => [(176, 98, 0), (150, 110, 0), (200, 70, 20)], + (Self::Ultra, false) => [(186, 130, 255), (255, 120, 220), (120, 170, 255)], + (Self::Ultra, true) => [(124, 58, 217), (190, 40, 150), (30, 100, 220)], + } + } + + pub(super) fn accent_rgb(self, on_light_bg: bool) -> (u8, u8, u8) { + self.hues(on_light_bg)[0] + } + + fn accent_fallback(self) -> Color { + match self { + Self::Max => Color::Yellow, + Self::Ultra => Color::Magenta, + } + } + + pub(crate) fn prompt(self, charge: f32) -> Span<'static> { + self.prompt_for( + charge, + default_fg(), + default_bg(), + effective_stdout_color_level(), + ) + } + + fn prompt_for( + self, + charge: f32, + terminal_fg: Option<(u8, u8, u8)>, + terminal_bg: Option<(u8, u8, u8)>, + color_level: StdoutColorLevel, + ) -> Span<'static> { + let color = self.accent_color_for(charge, terminal_fg, terminal_bg, color_level); + let mut style = Style::default().add_modifier(Modifier::BOLD); + if let Some(color) = color { + style = style.fg(color); + } + Span::styled(self.prompt_glyph(), style) + } + + fn accent_color_for( + self, + charge: f32, + terminal_fg: Option<(u8, u8, u8)>, + terminal_bg: Option<(u8, u8, u8)>, + color_level: StdoutColorLevel, + ) -> Option { + let on_light_bg = terminal_bg.is_some_and(is_light); + let accent = self.accent_rgb(on_light_bg); + let target = match terminal_fg { + Some(fg) => blend(accent, fg, charge.clamp(0.0, 1.0) * PROMPT_ACCENT_ALPHA), + None => accent, + }; + match color_level { + StdoutColorLevel::TrueColor | StdoutColorLevel::Ansi256 => { + Some(best_color_for_level(target, color_level)) + } + StdoutColorLevel::Ansi16 => Some(self.accent_fallback()), + StdoutColorLevel::Unknown => None, + } + } +} + +pub(crate) struct EffortIgnition { + tier: EffortTier, + style: IgnitionStyle, + started_at: Cell>, + cancelled: Cell, +} + +impl EffortIgnition { + pub(crate) fn new(tier: EffortTier, style: IgnitionStyle) -> Self { + Self { + tier, + style, + started_at: Cell::new(/*value*/ None), + cancelled: Cell::new(/*value*/ false), + } + } + + fn elapsed(&self) -> Option { + self.started_at.get().map(|started| started.elapsed()) + } + + pub(crate) fn is_finished(&self) -> bool { + self.cancelled.get() + || self + .elapsed() + .is_some_and(|elapsed| elapsed >= self.style.total_duration(self.tier)) + } + + pub(crate) fn charge_alpha(&self) -> f32 { + match self.elapsed() { + Some(elapsed) => (elapsed.as_secs_f32() / CHARGE.as_secs_f32()).clamp(0.0, 1.0), + None => 0.0, + } + } + + pub(crate) fn render(&self, area: Rect, protected: Rect, buf: &mut Buffer) -> bool { + if area.is_empty() { + return false; + } + let color_level = effective_stdout_color_level(); + if !effort_animation_enabled(/*animations_enabled*/ true, color_level) { + self.cancelled.set(/*val*/ true); + return false; + } + let Some(term_bg) = default_bg() else { + self.cancelled.set(/*val*/ true); + return false; + }; + let elapsed = match self.started_at.get() { + Some(started) => started.elapsed(), + None => { + self.started_at.set(Some(Instant::now())); + Duration::ZERO + } + }; + let mut canvas = Canvas { + area, + protected, + buf, + band_rgb: user_message_bg_rgb(term_bg), + color_level, + }; + paint_style( + self.tier, + self.style, + elapsed, + self.tier.hues(is_light(term_bg)), + &mut canvas, + ); + true + } +} + +#[cfg(test)] +#[path = "effort_ignition_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/effort_ignition_styles.rs b/codex-rs/tui/src/bottom_pane/effort_ignition_styles.rs new file mode 100644 index 0000000000000000000000000000000000000000..86801cd34a390a80d44df6a4a3c0ceca24daaf31 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/effort_ignition_styles.rs @@ -0,0 +1,238 @@ +use std::time::Duration; + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; + +use crate::color::blend; +use crate::terminal_palette::StdoutColorLevel; +use crate::terminal_palette::best_color_for_level; + +use super::EffortTier; +use super::IgnitionStyle; + +const WAVE_HALF_WIDTH: f32 = 9.0; +const PULSE_HALF_WIDTH: f32 = 4.5; +const SPARK_START: Duration = Duration::from_millis(900); +const SPARK_FRAME: Duration = Duration::from_millis(100); +const SPARK_GLYPHS: &[&str] = &["·", "✦", "✧"]; + +/// Band entries are `(launch_or_speed, travel_or_phase, strength_or_hue)`. +type Band = (f32, f32, f32); + +fn bands(style: IgnitionStyle, tier: EffortTier) -> &'static [Band] { + match (style, tier) { + (IgnitionStyle::Wave, EffortTier::Max) => &[(0.10, 0.75, 1.0)], + (IgnitionStyle::Wave, EffortTier::Ultra) => &[(0.10, 0.70, 1.0), (0.35, 0.55, 1.0)], + (IgnitionStyle::Aurora, EffortTier::Max) => &[(0.35, 0.15, 0.0), (-0.50, 0.60, 1.0)], + (IgnitionStyle::Aurora, EffortTier::Ultra) => { + &[(0.35, 0.15, 0.0), (-0.50, 0.60, 1.0), (0.75, 0.35, 2.0)] + } + (IgnitionStyle::Pulse, EffortTier::Max) => &[(0.10, 0.60, 1.0)], + (IgnitionStyle::Pulse, EffortTier::Ultra) => &[(0.10, 0.55, 0.8), (0.45, 0.55, 1.1)], + } +} + +/// Paint target clipped to the composer band. Backgrounds blend from the band +/// tint; glyphs only land outside the protected draft and attachment rows. +pub(super) struct Canvas<'a> { + pub(super) area: Rect, + pub(super) protected: Rect, + pub(super) buf: &'a mut Buffer, + pub(super) band_rgb: (u8, u8, u8), + pub(super) color_level: StdoutColorLevel, +} + +impl Canvas<'_> { + fn tint(&mut self, x: u16, y: u16, hue: (u8, u8, u8), alpha: f32) { + if alpha < 0.02 || x >= self.area.width || y >= self.area.height { + return; + } + let color = best_color_for_level( + blend(hue, self.band_rgb, alpha.clamp(0.0, 0.6)), + self.color_level, + ); + if color != Color::default() { + self.buf[(self.area.x + x, self.area.y + y)].set_bg(color); + } + } + + fn tint_column(&mut self, x: u16, hue: (u8, u8, u8), alpha: f32) { + for y in 0..self.area.height { + self.tint(x, y, hue, alpha); + } + } + + fn glyph(&mut self, x: u16, y: u16, glyph: &'static str, hue: (u8, u8, u8), strength: f32) { + if x >= self.area.width || y >= self.area.height { + return; + } + let x = self.area.x + x; + let y = self.area.y + y; + if self.protected.contains((x, y).into()) || self.buf[(x, y)].symbol() != " " { + return; + } + let color = best_color_for_level( + blend(hue, self.band_rgb, strength.clamp(0.25, 1.0)), + self.color_level, + ); + if color != Color::default() { + self.buf[(x, y)] + .set_symbol(glyph) + .set_style(Style::default().fg(color).add_modifier(Modifier::BOLD)); + } + } +} + +fn crest(distance: f32) -> f32 { + if distance >= 1.0 { + 0.0 + } else { + 0.5 * (1.0 + (std::f32::consts::PI * distance).cos()) + } +} + +fn ease_in_out(progress: f32) -> f32 { + let progress = progress.clamp(0.0, 1.0); + if progress < 0.5 { + 4.0 * progress * progress * progress + } else { + let inverse = -2.0 * progress + 2.0; + 1.0 - inverse * inverse * inverse / 2.0 + } +} + +pub(super) fn envelope(elapsed: f32, total: f32, fade_in: f32, fade_out: f32) -> f32 { + if elapsed <= 0.0 || elapsed >= total { + return 0.0; + } + (elapsed / fade_in.max(f32::EPSILON)) + .min((total - elapsed) / fade_out.max(f32::EPSILON)) + .clamp(0.0, 1.0) +} + +fn band_sample( + style: IgnitionStyle, + band: &Band, + elapsed: f32, + column: u16, + width: u16, +) -> (usize, f32) { + let column = f32::from(column); + let width = f32::from(width); + let (first, second, third) = *band; + match style { + IgnitionStyle::Wave => { + let (launch, travel) = (first, second); + let progress = (elapsed - launch) / travel; + if !(0.0..=1.0).contains(&progress) { + return (0, 0.0); + } + let center = ease_in_out(progress) * (width + 2.0 * WAVE_HALF_WIDTH) - WAVE_HALF_WIDTH; + (0, crest((column - center).abs() / WAVE_HALF_WIDTH)) + } + IgnitionStyle::Aurora => { + let (speed, phase, hue) = (first, second, third as usize); + let center = + (0.5 + 0.38 * (std::f32::consts::TAU * (speed * elapsed + phase)).sin()) * width; + let half_width = (width * 0.22).max(4.0); + (hue, crest((column - center).abs() / half_width)) + } + IgnitionStyle::Pulse => { + let (launch, travel, strength) = (first, second, third); + let progress = (elapsed - launch) / travel; + if !(0.0..=1.0).contains(&progress) { + return (0, 0.0); + } + let inverse = 1.0 - progress; + let radius = + (1.0 - inverse * inverse * inverse) * (width / 2.0 + 2.0 * PULSE_HALF_WIDTH); + let distance = (column - width / 2.0).abs(); + ( + 0, + crest((distance - radius).abs() / PULSE_HALF_WIDTH) + * strength + * (1.0 - 0.6 * progress), + ) + } + } +} + +pub(super) fn spark_frame(elapsed: Duration, start: Duration) -> Option<&'static str> { + let frame = elapsed.checked_sub(start)?.as_millis() / SPARK_FRAME.as_millis(); + SPARK_GLYPHS.get(frame as usize).copied() +} + +fn paint_bands( + tier: EffortTier, + style: IgnitionStyle, + elapsed: Duration, + hues: [(u8, u8, u8); 3], + canvas: &mut Canvas<'_>, +) { + let elapsed = elapsed.as_secs_f32(); + let total = style.total_duration(tier).as_secs_f32(); + let fade = if style == IgnitionStyle::Aurora { + envelope( + elapsed, total, /*fade_in*/ 0.25, /*fade_out*/ 0.40, + ) + } else { + 1.0 + }; + for column in 0..canvas.area.width { + let mut weights = [0.0_f32; 3]; + for band in bands(style, tier) { + let (hue, strength) = band_sample(style, band, elapsed, column, canvas.area.width); + weights[hue] = if style == IgnitionStyle::Aurora { + weights[hue] + strength + } else { + weights[hue].max(strength) + }; + } + let weight = weights.iter().sum::(); + if weight <= 0.01 { + continue; + } + let mut rgb = [0.0_f32; 3]; + for (weight, (red, green, blue)) in weights.into_iter().zip(hues) { + rgb[0] += weight * f32::from(red); + rgb[1] += weight * f32::from(green); + rgb[2] += weight * f32::from(blue); + } + let hue = ( + (rgb[0] / weight) as u8, + (rgb[1] / weight) as u8, + (rgb[2] / weight) as u8, + ); + let alpha = if style == IgnitionStyle::Aurora { + (weight * 0.40).min(0.50) * fade + } else { + weight * 0.55 + }; + canvas.tint_column(column, hue, alpha); + } +} + +pub(super) fn paint_style( + tier: EffortTier, + style: IgnitionStyle, + elapsed: Duration, + hues: [(u8, u8, u8); 3], + canvas: &mut Canvas<'_>, +) { + paint_bands(tier, style, elapsed, hues, canvas); + if style == IgnitionStyle::Wave + && tier == EffortTier::Ultra + && let Some(glyph) = spark_frame(elapsed, SPARK_START) + { + canvas.glyph( + canvas.area.width.saturating_sub(2), + /*y*/ 0, + glyph, + hues[0], + /*strength*/ 1.0, + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/effort_ignition_tests.rs b/codex-rs/tui/src/bottom_pane/effort_ignition_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..49b626017fd9b1fcc773a6269459344d6817feaa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/effort_ignition_tests.rs @@ -0,0 +1,344 @@ +use super::styles::envelope; +use super::styles::paint_style; +use super::styles::spark_frame; +use super::*; +use crate::terminal_palette::rgb_color; +use pretty_assertions::assert_eq; +use ratatui::widgets::Widget; + +const WIDTH: u16 = 44; +const HEIGHT: u16 = 3; +const DRAFT: &str = " > keep my draft exactly as typed"; + +fn test_buffer(area: Rect) -> Buffer { + let mut buf = Buffer::empty(area); + if area.height > 1 { + for (column, glyph) in DRAFT.chars().take(usize::from(area.width)).enumerate() { + buf[(area.x + column as u16, area.y + 1)].set_symbol(&glyph.to_string()); + } + } + buf +} + +fn frame(area: Rect, buf: &Buffer) -> Vec { + let symbols = (area.y..area.bottom()).map(|row| { + (area.x..area.right()) + .map(|column| buf[(column, row)].symbol()) + .collect::() + }); + let tint = (area.y..area.bottom()).map(|row| { + (area.x..area.right()) + .map(|column| { + if buf[(column, row)].bg == Color::Reset { + '.' + } else { + '#' + } + }) + .collect::() + }); + symbols.chain(tint).collect() +} + +fn style_name(style: IgnitionStyle) -> &'static str { + match style { + IgnitionStyle::Wave => "wave", + IgnitionStyle::Aurora => "aurora", + IgnitionStyle::Pulse => "pulse", + } +} + +fn paint(tier: EffortTier, style: IgnitionStyle, elapsed: Duration, area: Rect, buf: &mut Buffer) { + let term_bg = (18, 22, 28); + let mut canvas = Canvas { + area, + protected: Rect::new( + area.x, + area.y.saturating_add(1), + area.width, + area.height.saturating_sub(1).min(1), + ), + buf, + band_rgb: user_message_bg_rgb(term_bg), + color_level: StdoutColorLevel::TrueColor, + }; + paint_style( + tier, + style, + elapsed, + tier.hues(/*on_light_bg*/ false), + &mut canvas, + ); +} + +#[test] +fn effort_tier_maps_only_max_and_ultra() { + assert_eq!( + [ + Some(&ReasoningEffort::Max), + Some(&ReasoningEffort::Ultra), + Some(&ReasoningEffort::XHigh), + None, + ] + .map(EffortTier::from_effort), + [Some(EffortTier::Max), Some(EffortTier::Ultra), None, None] + ); +} + +#[test] +fn effort_animation_requires_motion_and_a_reliable_palette() { + for (color_level, supported) in [ + (StdoutColorLevel::TrueColor, true), + (StdoutColorLevel::Ansi256, true), + (StdoutColorLevel::Ansi16, false), + (StdoutColorLevel::Unknown, false), + ] { + assert_eq!( + effort_animation_enabled(/*animations_enabled*/ true, color_level), + supported + ); + assert!(!effort_animation_enabled( + /*animations_enabled*/ false, + color_level, + )); + } +} + +#[test] +fn prompt_accent_blends_with_the_terminal_foreground() { + for (tier, fg, bg, expected) in [ + ( + EffortTier::Ultra, + (224, 220, 214), + (18, 22, 28), + (191, 142, 249), + ), + (EffortTier::Max, (30, 32, 36), (250, 248, 244), (155, 88, 5)), + ] { + assert_eq!( + tier.accent_color_for( + /*charge*/ 1.0, + Some(fg), + Some(bg), + StdoutColorLevel::TrueColor, + ), + Some(rgb_color(expected)) + ); + } +} + +#[test] +fn prompt_accent_degrades_with_terminal_color_support() { + let fg = (224, 220, 214); + let bg = (18, 22, 28); + + assert!(matches!( + EffortTier::Ultra.accent_color_for( + /*charge*/ 1.0, + Some(fg), + Some(bg), + StdoutColorLevel::Ansi256, + ), + Some(Color::Indexed(_)) + )); + for (tier, color_level, expected) in [ + ( + EffortTier::Max, + StdoutColorLevel::Ansi16, + Some(Color::Yellow), + ), + ( + EffortTier::Ultra, + StdoutColorLevel::Ansi16, + Some(Color::Magenta), + ), + (EffortTier::Ultra, StdoutColorLevel::Unknown, None), + ] { + assert_eq!( + tier.accent_color_for(/*charge*/ 1.0, Some(fg), Some(bg), color_level), + expected + ); + } +} + +#[test] +fn max_and_ultra_prompts_render_their_accent_and_glyph() { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 1, /*height*/ 1, + ); + for (tier, glyph, color) in [ + (EffortTier::Max, "›", Color::Yellow), + (EffortTier::Ultra, "»", Color::Magenta), + ] { + let mut buf = Buffer::empty(area); + tier.prompt_for( + /*charge*/ 1.0, + Some((224, 220, 214)), + Some((18, 22, 28)), + StdoutColorLevel::Ansi16, + ) + .render(area, &mut buf); + let prompt = &buf[(0, 0)]; + assert_eq!(prompt.symbol(), glyph); + assert_eq!(prompt.style().fg, Some(color)); + assert!(prompt.style().add_modifier.contains(Modifier::BOLD)); + } +} + +#[test] +fn effort_ignition_clock_waits_for_its_first_visible_frame() { + let ignition = EffortIgnition::new(EffortTier::Ultra, IgnitionStyle::Wave); + let area = Rect::default(); + let mut buf = Buffer::empty(area); + assert!(!ignition.render(area, area, &mut buf)); + + assert_eq!(ignition.started_at.get(), None); + assert!(!ignition.is_finished()); + assert_eq!(ignition.charge_alpha(), 0.0); +} + +#[cfg(unix)] +#[test] +fn effort_ignition_finishes_when_palette_is_unavailable() { + let ignition = EffortIgnition::new(EffortTier::Ultra, IgnitionStyle::Wave); + let area = Rect { + width: 1, + height: 1, + ..Rect::default() + }; + let mut buf = Buffer::empty(area); + assert!(!ignition.render(area, area, &mut buf)); + + assert_eq!(ignition.started_at.get(), None); + assert!(ignition.is_finished()); +} + +#[test] +fn effort_ignition_random_styles_do_not_repeat_immediately() { + let mut previous = None; + for _ in 0..100 { + let style = IgnitionStyle::random(previous); + assert_ne!(Some(style), previous); + previous = Some(style); + } +} + +#[test] +fn envelope_is_zero_outside_and_full_in_the_middle() { + assert_eq!( + [0.0, 1.0, 0.5].map(|elapsed| { + envelope( + elapsed, /*total*/ 1.0, /*fade_in*/ 0.2, /*fade_out*/ 0.2, + ) + }), + [0.0, 0.0, 1.0] + ); +} + +#[test] +fn wave_only_paints_while_a_sweep_is_crossing_the_composer() { + let area = Rect::new(/*x*/ 0, /*y*/ 0, WIDTH, HEIGHT); + for (millis, expected) in [(0, false), (475, true), (3000, false)] { + let mut buf = test_buffer(area); + paint( + EffortTier::Max, + IgnitionStyle::Wave, + Duration::from_millis(millis), + area, + &mut buf, + ); + let painted = frame(area, &buf)[HEIGHT as usize..] + .iter() + .any(|row| row.contains('#')); + assert_eq!(painted, expected); + } +} + +#[test] +fn spark_only_fires_after_landing() { + let start = Duration::from_millis(900); + assert_eq!( + [850, 950, 1050, 1150, 1250] + .map(|millis| spark_frame(Duration::from_millis(millis), start)), + [None, Some("·"), Some("✦"), Some("✧"), None] + ); +} + +#[test] +fn effort_ignition_styles_preserve_draft_and_paint_expected_content() { + let area = Rect::new(/*x*/ 0, /*y*/ 0, WIDTH, HEIGHT); + for style in IgnitionStyle::ALL { + let mut painted = false; + for millis in [180, 420, 720, 1100, 1580] { + let mut buf = test_buffer(area); + paint( + EffortTier::Ultra, + style, + Duration::from_millis(millis), + area, + &mut buf, + ); + let rendered = frame(area, &buf); + assert!(rendered[1].starts_with(DRAFT)); + painted |= rendered[HEIGHT as usize..] + .iter() + .any(|row| row.contains('#')) + || rendered[..HEIGHT as usize] + .iter() + .any(|row| row.contains(['✦', '✧', '·'])); + } + assert!( + painted, + "{} never painted visible content", + style_name(style) + ); + } +} + +#[test] +fn effort_ignition_styles_are_safe_on_tiny_and_offset_areas() { + for style in IgnitionStyle::ALL { + for width in 0..=8 { + for height in 0..=3 { + let area = Rect::new(/*x*/ 2, /*y*/ 4, width, height); + let mut buf = Buffer::empty(Rect::new( + /*x*/ 0, + /*y*/ 0, + width.saturating_add(4), + height.saturating_add(8), + )); + for millis in [0, 180, 720, 1450, 2350] { + paint( + EffortTier::Max, + style, + Duration::from_millis(millis), + area, + &mut buf, + ); + } + } + } + } +} + +#[test] +fn effort_ignition_animation_gallery_snapshot() { + let area = Rect::new(/*x*/ 0, /*y*/ 0, WIDTH, HEIGHT); + let mut frames = Vec::new(); + for style in IgnitionStyle::ALL { + for tier in [EffortTier::Max, EffortTier::Ultra] { + let tier_name = match tier { + EffortTier::Max => "MAX", + EffortTier::Ultra => "ULTRA", + }; + for millis in [0, 180, 420, 720, 1100, 1580, 2050] { + let mut buf = test_buffer(area); + paint(tier, style, Duration::from_millis(millis), area, &mut buf); + let name = style_name(style); + let rendered = frame(area, &buf).join("│"); + frames.push(format!("{name:8} {tier_name:5} {millis:4}ms │{rendered}│")); + } + } + } + insta::assert_snapshot!("effort_ignition_animation_gallery", frames.join("\n")); +} diff --git a/codex-rs/tui/src/bottom_pane/effort_status_line.rs b/codex-rs/tui/src/bottom_pane/effort_status_line.rs new file mode 100644 index 0000000000000000000000000000000000000000..d47605d3c7d7a1b2c1904d96009b3ad43e27f276 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/effort_status_line.rs @@ -0,0 +1,254 @@ +//! One-shot status-line transition shown when reasoning effort changes to Max +//! or Ultra. +//! +//! The previous status line slides to the right while it picks up the tier +//! accent and fades away. The tier letters then appear across the row with +//! wider peripheral gaps, converge into a centered `M A X` or `U L T R A`, and +//! fade out before the refreshed status line fades in. The clock starts only +//! when the passive footer row is rendered, so a picker, flash, or +//! instructional footer cannot consume the animation before it becomes +//! visible. ANSI-16 and unknown-color terminals skip the transition and keep +//! the normal status line; browser-backed and native terminals with ANSI-256 +//! or truecolor can show the full effect. + +use std::cell::Cell; +use std::time::Duration; +use std::time::Instant; + +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; + +use crate::color::blend; +use crate::color::is_light; +use crate::line_truncation::truncate_line_to_width; +use crate::terminal_palette::best_color; +use crate::terminal_palette::default_bg; +use crate::terminal_palette::default_fg; + +use super::effort_ignition::EffortTier; + +const SCROLL_OUT: Duration = Duration::from_millis(620); +const LABEL_ASSEMBLE: Duration = Duration::from_millis(700); +const LABEL_HOLD: Duration = Duration::from_millis(360); +const LABEL_FADE_OUT: Duration = Duration::from_millis(340); +const STATUS_FADE_IN: Duration = Duration::from_millis(480); +const TOTAL: Duration = SCROLL_OUT + .saturating_add(LABEL_ASSEMBLE) + .saturating_add(LABEL_HOLD) + .saturating_add(LABEL_FADE_OUT) + .saturating_add(STATUS_FADE_IN); + +pub(crate) const EFFORT_STATUS_LINE_FRAME_TICK: Duration = Duration::from_millis(33); + +impl EffortTier { + fn label(self) -> &'static str { + match self { + Self::Max => "MAX", + Self::Ultra => "ULTRA", + } + } + + fn fallback_color(self) -> Color { + match self { + Self::Max => Color::Yellow, + Self::Ultra => Color::Magenta, + } + } +} + +pub(crate) struct EffortStatusLineTransition { + tier: EffortTier, + previous: Line<'static>, + started_at: Cell>, +} + +impl EffortStatusLineTransition { + pub(crate) fn new(tier: EffortTier, previous: Line<'static>) -> Self { + Self { + tier, + previous, + started_at: Cell::new(None), + } + } + + pub(crate) fn is_finished(&self) -> bool { + self.started_at + .get() + .is_some_and(|started| started.elapsed() >= TOTAL) + } + + pub(crate) fn render_line( + &self, + current: Option<&Line<'static>>, + width: u16, + ) -> Option> { + let elapsed = match self.started_at.get() { + Some(started) => started.elapsed(), + None => { + self.started_at.set(Some(Instant::now())); + Duration::ZERO + } + }; + transition_line_at(self.tier, Some(&self.previous), current, elapsed, width) + } +} + +fn transition_line_at( + tier: EffortTier, + previous: Option<&Line<'static>>, + current: Option<&Line<'static>>, + elapsed: Duration, + width: u16, +) -> Option> { + let width = usize::from(width); + if width == 0 { + return None; + } + + if elapsed < SCROLL_OUT { + let progress = elapsed.as_secs_f32() / SCROLL_OUT.as_secs_f32(); + let offset = (width as f32 * ease_in_cubic(progress)).round() as usize; + let mut line = previous.cloned()?; + let tint = 0.12 + 0.88 * progress; + style_line( + &mut line, + tier, + /*opacity*/ 1.0 - progress * progress, + tint, + ); + line.spans + .insert(/*index*/ 0, Span::raw(" ".repeat(offset))); + return Some(truncate_line_to_width(line, width)); + } + + let after_scroll = elapsed.saturating_sub(SCROLL_OUT); + let label_duration = LABEL_ASSEMBLE + .saturating_add(LABEL_HOLD) + .saturating_add(LABEL_FADE_OUT); + if after_scroll < label_duration { + let (assemble, opacity) = if after_scroll < LABEL_ASSEMBLE { + let progress = after_scroll.as_secs_f32() / LABEL_ASSEMBLE.as_secs_f32(); + (ease_out_cubic(progress), (progress / 0.55).clamp(0.0, 1.0)) + } else if after_scroll < LABEL_ASSEMBLE.saturating_add(LABEL_HOLD) { + (1.0, 1.0) + } else { + let fade = after_scroll.saturating_sub(LABEL_ASSEMBLE.saturating_add(LABEL_HOLD)); + (1.0, 1.0 - fade.as_secs_f32() / LABEL_FADE_OUT.as_secs_f32()) + }; + return Some(tier_label_line(tier, width, assemble, opacity)); + } + + let fade_elapsed = after_scroll.saturating_sub(label_duration); + let opacity = (fade_elapsed.as_secs_f32() / STATUS_FADE_IN.as_secs_f32()).clamp(0.0, 1.0); + let mut line = current.cloned()?; + style_line(&mut line, tier, opacity, /*tint*/ 0.0); + Some(truncate_line_to_width(line, width)) +} + +fn ease_in_cubic(progress: f32) -> f32 { + let progress = progress.clamp(0.0, 1.0); + progress * progress * progress +} + +fn ease_out_cubic(progress: f32) -> f32 { + let progress = progress.clamp(0.0, 1.0); + let inverse = 1.0 - progress; + 1.0 - inverse * inverse * inverse +} + +fn tier_label_line(tier: EffortTier, width: usize, assemble: f32, opacity: f32) -> Line<'static> { + let letters = tier.label().chars().collect::>(); + let gap_count = letters.len().saturating_sub(1); + let compact_width = letters.len().saturating_add(gap_count); + let max_extra = width.saturating_sub(compact_width); + let spread = (max_extra as f32 * (1.0 - assemble.clamp(0.0, 1.0))).round() as usize; + let gap_weights = (0..gap_count) + .map(|index| { + let position = index.saturating_mul(2).saturating_add(1); + position.abs_diff(gap_count).saturating_add(1) + }) + .collect::>(); + let weight_total = gap_weights.iter().sum::().max(1); + let gaps = gap_weights + .iter() + .map(|weight| 1 + spread.saturating_mul(*weight) / weight_total) + .collect::>(); + let label_width = letters.len().saturating_add(gaps.iter().sum::()); + let left_padding = width.saturating_sub(label_width) / 2; + let mut spans = vec![Span::raw(" ".repeat(left_padding))]; + + for (index, letter) in letters.iter().enumerate() { + let mut style = Style::default().add_modifier(Modifier::BOLD); + let center = letters.len().saturating_sub(1); + let edge = if center == 0 { + 0.0 + } else { + index.saturating_mul(2).abs_diff(center) as f32 / center as f32 + }; + let stagger = 0.22 * edge; + let letter_opacity = ((opacity - stagger) / (1.0 - stagger)).clamp(0.0, 1.0); + apply_fade(&mut style, tier, letter_opacity, /*tint*/ 1.0); + spans.push(Span::styled(letter.to_string(), style)); + if let Some(gap) = gaps.get(index) { + spans.push(Span::raw(" ".repeat(*gap))); + } + } + + truncate_line_to_width(Line::from(spans), width) +} + +fn style_line(line: &mut Line<'static>, tier: EffortTier, opacity: f32, tint: f32) { + apply_fade(&mut line.style, tier, opacity, tint); + for span in &mut line.spans { + apply_fade(&mut span.style, tier, opacity, tint); + } +} + +fn apply_fade(style: &mut Style, tier: EffortTier, opacity: f32, tint: f32) { + let opacity = opacity.clamp(0.0, 1.0); + let tint = tint.clamp(0.0, 1.0); + if matches!(style.fg, Some(color) if !matches!(color, Color::Rgb(..))) { + if opacity < 0.7 { + style.add_modifier |= Modifier::DIM; + } + return; + } + let Some(background) = default_bg() else { + if tint > 0.5 { + style.fg = Some(tier.fallback_color()); + } + if opacity < 0.7 { + style.add_modifier |= Modifier::DIM; + } + return; + }; + let foreground = match style.fg { + Some(Color::Rgb(r, g, b)) => (r, g, b), + _ => default_fg().unwrap_or_else(|| { + if is_light(background) { + (32, 32, 32) + } else { + (224, 224, 224) + } + }), + }; + let accent = tier.accent_rgb(is_light(background)); + let tinted = blend(accent, foreground, tint); + let faded = blend(tinted, background, opacity); + let color = best_color(faded); + style.fg = Some(if color == Color::default() { + tier.fallback_color() + } else { + color + }); + if opacity < 0.7 { + style.add_modifier |= Modifier::DIM; + } +} + +#[cfg(test)] +#[path = "effort_status_line_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/effort_status_line_tests.rs b/codex-rs/tui/src/bottom_pane/effort_status_line_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..03991e224469b080036a1e8667e3b0539f8ebaba --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/effort_status_line_tests.rs @@ -0,0 +1,188 @@ +use super::*; + +use crate::terminal_palette::indexed_color; +use pretty_assertions::assert_eq; +use ratatui::style::Stylize; + +fn text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +#[test] +fn clock_waits_for_the_first_visible_frame() { + let transition = + EffortStatusLineTransition::new(EffortTier::Ultra, Line::from("gpt-5.4 high · main")); + assert!(!transition.is_finished()); + assert_eq!(transition.started_at.get(), None); + + let current = Line::from("gpt-5.4 ultra · main"); + let line = transition + .render_line(Some(¤t), /*width*/ 40) + .expect("first frame should render the outgoing line"); + assert_eq!(text(&line), "gpt-5.4 high · main"); + assert!(transition.started_at.get().is_some()); +} + +#[test] +fn outgoing_status_line_moves_right_and_clips_at_the_edge() { + let previous = Line::from("gpt-5.4 high · main"); + let current = Line::from("gpt-5.4 ultra · main"); + + let first = transition_line_at( + EffortTier::Ultra, + Some(&previous), + Some(¤t), + Duration::ZERO, + /*width*/ 28, + ) + .expect("outgoing line should be visible"); + let middle = transition_line_at( + EffortTier::Ultra, + Some(&previous), + Some(¤t), + Duration::from_millis(300), + /*width*/ 28, + ) + .expect("outgoing line should be visible"); + + assert_eq!(text(&first), "gpt-5.4 high · main"); + let first_offset = text(&first) + .find("gpt-5.4") + .expect("first frame should contain the outgoing model"); + let middle_offset = text(&middle) + .find("gpt-5.4") + .expect("middle frame should contain the outgoing model"); + assert!(middle_offset > first_offset); + assert!(middle.width() <= 28); +} + +#[test] +fn label_and_refreshed_line_appear_in_order() { + let previous = Line::from("gpt-5.4 high · main"); + let current = Line::from("gpt-5.4 max · main"); + + let label = transition_line_at( + EffortTier::Max, + Some(&previous), + Some(¤t), + Duration::from_millis(1500), + /*width*/ 32, + ) + .expect("label should be visible"); + let refreshed = transition_line_at( + EffortTier::Max, + Some(&previous), + Some(¤t), + Duration::from_millis(2200), + /*width*/ 32, + ) + .expect("refreshed status line should be visible"); + + assert_eq!(text(&label), " M A X"); + assert_eq!(text(&refreshed), "gpt-5.4 max · main"); +} + +#[test] +fn unicode_and_span_boundaries_are_safe_on_narrow_rows() { + let previous = Line::from(vec!["模型 ".cyan(), "👩‍💻 main".underlined()]); + let current = Line::from(vec!["模型 ".cyan(), "ultra · main".underlined()]); + + for width in 0..=12 { + for elapsed in [ + Duration::ZERO, + Duration::from_millis(300), + Duration::from_millis(700), + Duration::from_millis(1450), + Duration::from_millis(2250), + Duration::from_millis(2500), + ] { + let line = transition_line_at( + EffortTier::Ultra, + Some(&previous), + Some(¤t), + elapsed, + width, + ); + assert!(line.is_none_or(|line| line.width() <= usize::from(width))); + } + } +} + +#[test] +fn palette_status_line_colors_survive_the_fade() { + for color in [ + Color::Cyan, + Color::Green, + Color::Magenta, + indexed_color(/*index*/ 123), + ] { + let mut style = Style::default().fg(color); + apply_fade( + &mut style, + EffortTier::Ultra, + /*opacity*/ 0.4, + /*tint*/ 1.0, + ); + assert_eq!(style.fg, Some(color)); + assert!(style.add_modifier.contains(Modifier::DIM)); + } +} + +#[test] +fn ultra_letters_start_wide_at_the_edges_and_converge_to_the_center() { + let scattered = tier_label_line( + EffortTier::Ultra, + /*width*/ 40, + /*assemble*/ 0.0, + /*opacity*/ 1.0, + ); + let settled = tier_label_line( + EffortTier::Ultra, + /*width*/ 40, + /*assemble*/ 1.0, + /*opacity*/ 1.0, + ); + let positions = text(&scattered) + .char_indices() + .filter_map(|(index, letter)| letter.is_ascii_uppercase().then_some(index)) + .collect::>(); + let gaps = positions + .windows(2) + .map(|pair| pair[1].saturating_sub(pair[0])) + .collect::>(); + + assert_eq!(positions.len(), 5); + assert!(gaps[0] > gaps[1]); + assert!(gaps[3] > gaps[2]); + assert_eq!(text(&settled), " U L T R A"); +} + +#[test] +fn transition_frames_snapshot() { + let previous = Line::from(vec!["gpt-5.4 high".cyan(), " · main".dim()]); + let max = Line::from(vec!["gpt-5.4 max".cyan(), " · main".dim()]); + let ultra = Line::from(vec!["gpt-5.4 ultra".cyan(), " · main".dim()]); + let mut frames = Vec::new(); + + for (tier, current) in [(EffortTier::Max, &max), (EffortTier::Ultra, &ultra)] { + for millis in [ + 0, 300, 520, 680, 820, 1050, 1320, 1550, 1800, 2020, 2250, 2500, + ] { + let line = transition_line_at( + tier, + Some(&previous), + Some(current), + Duration::from_millis(millis), + /*width*/ 32, + ) + .expect("frame should render"); + let label = tier.label(); + frames.push(format!("{label:5} {millis:4}ms │{:<32}│", text(&line))); + } + } + + insta::assert_snapshot!("effort_status_line_transition_frames", frames.join("\n")); +} diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..dbd24a12574fc7e42151384433b19472f76a76bc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -0,0 +1,504 @@ +//! Experimental controls with popup-owned discovery and configured enablement. +//! Failed saves retain intent for explicit retry; cancellation remains available. +//! Server voice discovery is gated by the client package's native runtime. + +use crate::experimental_features::FeatureWriteResult; +use codex_app_server_protocol::ExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureStage; +use codex_features::FEATURES; +use codex_features::Feature; +use codex_protocol::ThreadId; +use std::time::Duration; +use std::time::Instant; +use tokio::sync::oneshot; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::Widget; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::style::user_message_style; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; + +pub(crate) struct ExperimentalFeatureItem { + pub key: String, + pub name: String, + pub description: String, + pub enabled: bool, + pub writable: bool, +} + +pub(crate) struct ExperimentalFeaturesView { + features: Vec, + voice_supported: bool, + initial_enabled: Vec, + unconfirmed: Vec, + catalog_rx: Option, String>>>, + discovery_status: String, + thread_id: ThreadId, + write_rx: Option>>, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, + footer_hint: Line<'static>, + keymap: ListKeymap, +} + +impl ExperimentalFeaturesView { + pub(crate) fn new( + features: Vec, + thread_id: ThreadId, + catalog_rx: Option, String>>>, + app_event_tx: AppEventSender, + keymap: ListKeymap, + ) -> Self { + let mut view = Self { + discovery_status: if catalog_rx.is_some() { + "Loading server experiments…" + } else { + "" + } + .to_string(), + thread_id, + voice_supported: codex_realtime_webrtc::RealtimeWebrtcSession::is_supported(), + write_rx: None, + catalog_rx, + unconfirmed: Vec::new(), + initial_enabled: features.iter().map(|item| item.enabled).collect(), + features, + state: ScrollState::new(), + complete: false, + app_event_tx, + footer_hint: experimental_popup_hint_line(&keymap), + keymap, + }; + view.initialize_selection(); + view + } + + fn header(&self, width: u16) -> impl Renderable { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Experimental features".bold())); + for text in [ + "Checked features are configured on. Some experimental features take effect only in new tasks or after restarting the Codex server.", + self.discovery_status.as_str(), + ].into_iter().filter(|text| !text.is_empty()) { + for line in textwrap::wrap(text, usize::from(width.max(1))) { + header.push(Line::from(line.into_owned().dim())); + } + } + header + } + + fn initialize_selection(&mut self) { + if self.visible_len() == 0 { + self.state.selected_idx = None; + } else if self.state.selected_idx.is_none() { + self.state.selected_idx = Some(0); + } + } + + fn visible_len(&self) -> usize { + self.features.len() + } + + fn build_rows(&self) -> Vec { + let mut rows = Vec::with_capacity(self.features.len()); + let selected_idx = self.state.selected_idx; + for (idx, item) in self.features.iter().enumerate() { + let prefix = if selected_idx == Some(idx) { + '›' + } else { + ' ' + }; + let marker = if item.enabled { 'x' } else { ' ' }; + let read_only = if item.writable { "" } else { " (read-only)" }; + let name = format!("{prefix} [{marker}] {}{read_only}", item.name); + rows.push(GenericDisplayRow { + name, + description: Some(item.description.clone()), + is_disabled: !item.writable || self.write_rx.is_some(), + ..Default::default() + }); + } + + rows + } + + fn move_up(&mut self) { + let len = self.visible_len(); + if len == 0 { + return; + } + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn move_down(&mut self) { + let len = self.visible_len(); + if len == 0 { + return; + } + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn page_up(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.state.page_up_clamped(len, visible); + } + + fn page_down(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.state.page_down_clamped(len, visible); + } + + fn jump_top(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.state.jump_top(len, visible); + } + + fn jump_bottom(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.state.jump_bottom(len, visible); + } + + fn toggle_selected(&mut self) { + let Some(selected_idx) = self.state.selected_idx else { + return; + }; + + if self.write_rx.is_none() + && let Some(item) = self.features.get_mut(selected_idx) + && item.writable + { + item.enabled = !item.enabled; + } + } + + fn save_special_controls(&mut self) { + // Keep the two unmigrated controls on their existing writer, including + // its runtime and permission side effects. Never retry them generically. + let mut updates = Vec::new(); + for (item, initial) in self.features.iter().zip(&mut self.initial_enabled) { + if item.writable + && item.enabled != *initial + && let Some(feature) = [Feature::PreventIdleSleep, Feature::GuardianApproval] + .into_iter() + .find(|feature| feature.key() == item.key) + { + updates.push((feature, item.enabled)); + *initial = item.enabled; + } + } + if !updates.is_empty() { + self.app_event_tx + .send(AppEvent::UpdateFeatureFlags { updates }); + } + } + + fn save_changes(&mut self) { + if self.write_rx.is_some() { + self.complete = true; + return; + } + self.save_special_controls(); + let updates: Vec<_> = self + .features + .iter() + .zip(&self.initial_enabled) + .filter(|(item, initial)| { + item.writable && (item.enabled != **initial || self.unconfirmed.contains(&item.key)) + }) + .map(|(item, _)| (item.key.clone(), item.enabled)) + .collect(); + if !updates.is_empty() { + let (tx, rx) = oneshot::channel(); + self.write_rx = Some(rx); + // A failed response can follow a committed write. Keep these keys dirty + // so reverting to the old baseline still sends a corrective write. + self.unconfirmed = updates.iter().map(|(key, _)| key.clone()).collect(); + self.discovery_status = "Saving experimental features…".to_string(); + self.app_event_tx.send(AppEvent::SaveExperimentalFeatures { + thread_id: self.thread_id, + updates, + response_tx: tx, + }); + } else { + self.complete = true; + } + } + + fn rows_width(total_width: u16) -> u16 { + total_width.saturating_sub(2) + } +} + +impl BottomPaneView for ExperimentalFeaturesView { + fn pre_draw_tick(&mut self, _now: Instant) -> bool { + if let Some(receiver) = self.write_rx.as_mut() { + let result = match receiver.try_recv() { + Ok(result) => result, + Err(oneshot::error::TryRecvError::Empty) => return false, + Err(oneshot::error::TryRecvError::Closed) => Err( + "Saving was interrupted. Reopen /experimental to check configured values." + .to_string(), + ), + }; + self.write_rx = None; + match result { + Ok(result) => { + for item in &mut self.features { + if matches!( + item.key.as_str(), + "prevent_idle_sleep" | "guardian_approval" + ) { + continue; + } + if let Some(feature) = result + .features + .iter() + .find(|feature| feature.name == item.key) + { + item.enabled = feature.enabled; + } + } + self.unconfirmed.clear(); + self.initial_enabled = self.features.iter().map(|item| item.enabled).collect(); + self.complete = result.warning.is_none(); + self.discovery_status = result.warning.unwrap_or_default(); + } + Err(error) => self.discovery_status = error, + } + return true; + } + let Some(receiver) = self.catalog_rx.as_mut() else { + return false; + }; + let result = match receiver.try_recv() { + Ok(result) => result, + Err(oneshot::error::TryRecvError::Empty) => return false, + Err(oneshot::error::TryRecvError::Closed) => { + Err("Discovery was interrupted".to_string()) + } + }; + self.catalog_rx = None; + match result { + Ok(features) => { + let mut count = 0; + for feature in features { + if feature.stage != ExperimentalFeatureStage::Beta + || (feature.name == Feature::RealtimeConversation.key() + && !self.voice_supported) + { + continue; + } + self.initial_enabled.push(feature.enabled); + self.features.push(ExperimentalFeatureItem { + // Preserve the existing metadata gate for unmigrated controls. + writable: !matches!( + feature.name.as_str(), + "prevent_idle_sleep" | "guardian_approval" + ) || FEATURES.iter().any(|spec| { + spec.key == feature.name + && spec.stage.experimental_menu_name().is_some() + && spec.default_enabled == feature.default_enabled + }), + key: feature.name.clone(), + name: feature.display_name.unwrap_or(feature.name), + description: feature.description.unwrap_or_default(), + enabled: feature.enabled, + }); + count += 1; + } + self.discovery_status = if count == 0 { + "No server experiments available." + } else { + "" + } + .to_string(); + self.initialize_selection(); + } + Err(error) => { + tracing::warn!(%error, "experimental feature discovery failed"); + self.discovery_status = "Server experiments unavailable. Reopen /experimental to retry; restart this Codex client if requests remain unanswered.".to_string(); + } + } + true + } + + fn next_frame_delay(&self) -> Option { + (self.catalog_rx.is_some() || self.write_rx.is_some()) + .then(|| Duration::from_millis(/*millis*/ 100)) + } + + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + _ if self.keymap.move_up.is_pressed(key_event) => self.move_up(), + _ if self.keymap.move_down.is_pressed(key_event) => self.move_down(), + _ if self.keymap.page_up.is_pressed(key_event) => self.page_up(), + _ if self.keymap.page_down.is_pressed(key_event) => self.page_down(), + _ if self.keymap.jump_top.is_pressed(key_event) => self.jump_top(), + _ if self.keymap.jump_bottom.is_pressed(key_event) => self.jump_bottom(), + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } => self.toggle_selected(), + _ if self.keymap.accept.is_pressed(key_event) => { + self.save_changes(); + } + _ if self.keymap.cancel.is_pressed(key_event) => { + self.on_ctrl_c(); + } + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + // After an attempted save, cancellation must remain possible even if the + // server rejects retries immediately. Explicit accept retries dirty keys. + if !self.unconfirmed.is_empty() { + self.save_special_controls(); + } else { + self.save_changes(); + } + self.complete = true; + CancellationEvent::Handled + } +} + +impl Renderable for ExperimentalFeaturesView { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + + Block::default() + .style(user_message_style()) + .render(content_area, buf); + + let header = self.header(content_area.width.saturating_sub(4)); + let header_height = header.desired_height(content_area.width.saturating_sub(4)); + let rows = self.build_rows(); + let rows_width = Self::rows_width(content_area.width); + let rows_height = measure_rows_height( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + let [header_area, _, list_area] = Layout::vertical([ + Constraint::Max(header_height), + Constraint::Max(1), + Constraint::Length(rows_height), + ]) + .areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2))); + + header.render(header_area, buf); + + if list_area.height > 0 && (!rows.is_empty() || self.catalog_rx.is_none()) { + let render_area = Rect { + x: list_area.x.saturating_sub(2), + y: list_area.y, + width: rows_width.max(1), + height: list_area.height, + }; + render_rows( + render_area, + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + " No experimental features available for now", + ); + } + + let hint_area = Rect { + x: footer_area.x + 2, + y: footer_area.y, + width: footer_area.width.saturating_sub(2), + height: footer_area.height, + }; + let hint = if self.write_rx.is_some() { + Line::from("Saving… Closing this popup will not cancel the write.") + } else if !self.unconfirmed.is_empty() { + Line::from("Selections retained. Save to retry, or cancel to close.") + } else { + self.footer_hint.clone() + }; + hint.dim().render(hint_area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let rows = self.build_rows(); + let rows_width = Self::rows_width(width); + let rows_height = measure_rows_height( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + + let mut height = self + .header(width.saturating_sub(4)) + .desired_height(width.saturating_sub(4)); + height = height.saturating_add(rows_height + 3); + height.saturating_add(1) + } +} + +fn experimental_popup_hint_line(keymap: &ListKeymap) -> Line<'static> { + let mut spans = vec![ + "Press ".into(), + key_hint::plain(KeyCode::Char(' ')).into(), + " to select".into(), + ]; + if let Some(accept) = keymap.primary_hint(ListAction::Accept) { + spans.extend([" or ".into(), accept.into(), " to save".into()]); + } + Line::from(spans) +} + +#[cfg(test)] +#[path = "experimental_features_view_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..bd8660426e8bca0c543287637869b08f2e191b41 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs @@ -0,0 +1,259 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn experimental_features_analytics_plan_history() { + let feature = Feature::AnalyticsPlanHistory; + let stage = feature.stage(); + let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel(); + let view = ExperimentalFeaturesView::new( + vec![ExperimentalFeatureItem { + key: feature.key().to_string(), + name: stage.experimental_menu_name().unwrap().to_string(), + description: stage.experimental_menu_description().unwrap().to_string(), + enabled: feature.default_enabled(), + writable: true, + }], + ThreadId::new(), + /*catalog_rx*/ None, + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + snapshot_view("experimental_features_analytics_plan_history", &view); +} + +fn server_feature(name: &str) -> ExperimentalFeature { + ExperimentalFeature { + name: name.to_string(), + stage: ExperimentalFeatureStage::Beta, + display_name: Some(format!("Server {name}")), + description: Some("Description supplied by the server.".to_string()), + announcement: None, + enabled: true, + default_enabled: false, + } +} + +#[test] +fn experimental_features_discovery_preserves_server_order_and_accepts_new_keys() { + let (app_tx, mut app_rx) = tokio::sync::mpsc::unbounded_channel(); + let (catalog_tx, catalog_rx) = oneshot::channel(); + let mut view = ExperimentalFeaturesView::new( + Vec::new(), + ThreadId::new(), + Some(catalog_rx), + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + view.toggle_selected(); + let mut stable = server_feature("stable_feature"); + stable.stage = ExperimentalFeatureStage::Stable; + catalog_tx + .send(Ok(vec![ + server_feature("network_proxy"), + server_feature("future_feature"), + stable, + server_feature("prevent_idle_sleep"), + ])) + .unwrap(); + assert!(view.pre_draw_tick(Instant::now())); + assert_eq!( + view.features + .iter() + .map(|item| (item.key.as_str(), item.enabled)) + .collect::>(), + vec![ + ("network_proxy", true), + ("future_feature", true), + ("prevent_idle_sleep", true) + ] + ); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 100, + view.desired_height(/*width*/ 100), + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + insta::assert_snapshot!( + "experimental_features_server_discovery", + buffer_text(&buffer) + ); + // New server features are writable without a local Feature enum variant. + view.state.selected_idx = Some(1); + view.toggle_selected(); + assert!(!view.features[1].enabled); + view.state.selected_idx = Some(0); + view.toggle_selected(); + view.features[2].enabled = false; + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!( + matches!(app_rx.try_recv().unwrap(), AppEvent::UpdateFeatureFlags { updates } + if updates == vec![(Feature::PreventIdleSleep, false)]) + ); + let AppEvent::SaveExperimentalFeatures { + updates, + response_tx, + .. + } = app_rx.try_recv().unwrap() + else { + panic!("expected feature edits"); + }; + assert_eq!( + updates, + vec![ + ("network_proxy".to_string(), false), + ("future_feature".to_string(), false) + ] + ); + snapshot_view("experimental_features_saving", &view); + response_tx + .send(Err("Saved but readback failed".to_string())) + .unwrap(); + view.pre_draw_tick(Instant::now()); + snapshot_view("experimental_features_save_failed", &view); + + // Reverting uncertain saves to their original values must still write them. + view.features[0].enabled = true; + view.features[1].enabled = true; + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let AppEvent::SaveExperimentalFeatures { + updates, + response_tx, + .. + } = app_rx.try_recv().unwrap() + else { + panic!("expected corrective save without repeating special controls"); + }; + assert_eq!( + updates, + vec![ + ("network_proxy".to_string(), true), + ("future_feature".to_string(), true) + ] + ); + response_tx + .send(Err("Persistent write rejection".to_string())) + .unwrap(); + view.pre_draw_tick(Instant::now()); + // Cancel saves new special edits without retrying the failed generic write. + view.features[2].enabled = true; + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + assert!(view.is_complete()); + assert!( + matches!(app_rx.try_recv().unwrap(), AppEvent::UpdateFeatureFlags { updates } + if updates == vec![(Feature::PreventIdleSleep, true)]) + ); + assert!(app_rx.try_recv().is_err()); +} + +#[test] +fn experimental_features_empty_error_and_cancel_remain_usable() { + let mut changed_default = server_feature("network_proxy"); + changed_default.default_enabled = true; + for (snapshot, result) in [ + ("experimental_features_empty", Ok(Vec::new())), + ( + "experimental_features_unavailable", + Err("method not found".to_string()), + ), + ( + "experimental_features_changed_default", + Ok(vec![changed_default]), + ), + ] { + let (app_tx, mut app_rx) = tokio::sync::mpsc::unbounded_channel(); + let (catalog_tx, catalog_rx) = oneshot::channel(); + let mut view = ExperimentalFeaturesView::new( + Vec::new(), + ThreadId::new(), + Some(catalog_rx), + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + assert!(view.next_frame_delay().is_some()); + catalog_tx.send(result).unwrap(); + assert!(view.pre_draw_tick(Instant::now())); + assert_eq!(view.next_frame_delay(), None); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 80, + view.desired_height(/*width*/ 80), + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + insta::assert_snapshot!(snapshot, buffer_text(&buffer)); + view.on_ctrl_c(); + assert!(view.is_complete()); + assert!(app_rx.try_recv().is_err()); + } + let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel(); + let (catalog_tx, catalog_rx) = oneshot::channel(); + let view = ExperimentalFeaturesView::new( + Vec::new(), + ThreadId::new(), + Some(catalog_rx), + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + drop(view); + assert!(catalog_tx.send(Ok(Vec::new())).is_err()); +} + +fn buffer_text(buffer: &Buffer) -> String { + buffer + .content + .chunks(usize::from(buffer.area.width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") +} + +fn snapshot_view(name: &str, view: &ExperimentalFeaturesView) { + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 80, + view.desired_height(/*width*/ 80), + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + insta::assert_snapshot!(name, buffer_text(&buffer)); +} + +#[test] +fn voice_discovery_requires_the_client_runtime() { + for supported in [false, true] { + let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel(); + let (catalog_tx, catalog_rx) = oneshot::channel(); + let mut view = ExperimentalFeaturesView::new( + Vec::new(), + ThreadId::new(), + Some(catalog_rx), + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + view.voice_supported = supported; + catalog_tx + .send(Ok(vec![server_feature("realtime_conversation")])) + .unwrap(); + assert!(view.pre_draw_tick(Instant::now())); + assert_eq!(view.features.len(), usize::from(supported)); + snapshot_view( + if supported { + "voice_runtime_available" + } else { + "voice_runtime_unavailable" + }, + &view, + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/feedback_note_view.rs b/codex-rs/tui/src/bottom_pane/feedback_note_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..2e91a3a605cb5cc28c6f65bb8d3c1156e32cdaa3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/feedback_note_view.rs @@ -0,0 +1,437 @@ +//! Collects a feedback note with audience-specific disclosure and independent log consent. +//! The editor and submission controls remain visible when the disclosure must scroll. +//! Unbracketed paste bursts insert newlines; Enter submits after the burst ends. + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; +use std::cell::Cell; +use std::cell::RefCell; +use std::time::Instant; + +use crate::app_event::AppEvent; +use crate::app_event::FeedbackCategory; +use crate::app_event_sender::AppEventSender; +use crate::key_hint; +use crate::key_hint::has_ctrl_or_alt; +use crate::render::renderable::Renderable; +use crate::terminal_hyperlinks::mark_underlined_hyperlink; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::feedback_view::FeedbackAudience; +use super::paste_burst::PasteBurst; +use super::popup_consts::accept_cancel_hint_line; +use super::textarea::TextArea; +use super::textarea::TextAreaState; + +const FEEDBACK_POLICY_URL: &str = "https://help.openai.com/en/articles/5722486-how-your-data-is-used-to-improve-model-performance"; +const EMPLOYEE_FEEDBACK_FAQ_URL: &str = "https://go/codex-employee-feedback-faq"; + +enum IntroContent { + Full, + DisclosureOnly, +} + +/// Minimal input overlay to collect an optional feedback note, then submit it +/// through the app-server-managed feedback flow. +pub(crate) struct FeedbackNoteView { + category: FeedbackCategory, + turn_id: Option, + app_event_tx: AppEventSender, + include_logs: bool, + feedback_audience: FeedbackAudience, + + // UI state + textarea: TextArea, + textarea_state: RefCell, + paste_burst: PasteBurst, + intro_scroll: Cell, + intro_page_height: Cell, + complete: bool, +} + +impl FeedbackNoteView { + pub(crate) fn new( + category: FeedbackCategory, + turn_id: Option, + app_event_tx: AppEventSender, + include_logs: bool, + feedback_audience: FeedbackAudience, + ) -> Self { + Self { + category, + turn_id, + app_event_tx, + include_logs, + feedback_audience, + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + paste_burst: PasteBurst::default(), + intro_scroll: Cell::new(0), + intro_page_height: Cell::new(1), + complete: false, + } + } + + fn submit(&mut self) { + let note = self.textarea.text().trim().to_string(); + let reason = if note.is_empty() { None } else { Some(note) }; + self.app_event_tx.send(AppEvent::SubmitFeedback { + category: self.category, + reason, + turn_id: self.turn_id.clone(), + include_logs: self.include_logs, + }); + self.complete = true; + } + + fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) { + match key_event { + KeyEvent { + code: KeyCode::PageUp, + .. + } => { + self.intro_scroll.set( + self.intro_scroll + .get() + .saturating_sub(self.intro_page_height.get()), + ); + } + KeyEvent { + code: KeyCode::PageDown, + .. + } => { + self.intro_scroll.set( + self.intro_scroll + .get() + .saturating_add(self.intro_page_height.get()), + ); + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Enter, + modifiers, + .. + } => { + if self.paste_burst.direct_insert_newline_should_insert(now) { + self.paste_burst.extend_window(now); + self.textarea.insert_str("\n"); + } else if modifiers == KeyModifiers::NONE { + self.submit(); + } else { + self.textarea.input(key_event); + } + } + KeyEvent { + code: KeyCode::Char(_), + modifiers, + .. + } if !has_ctrl_or_alt(modifiers) => { + let paste_like_burst = self.paste_burst.on_plain_char_no_hold(now).is_some(); + self.textarea.input(key_event); + if paste_like_burst { + self.paste_burst.extend_window(now); + } + } + KeyEvent { + code: KeyCode::Tab, + modifiers, + .. + } if !has_ctrl_or_alt(modifiers) => { + let in_paste_burst = self.paste_burst.direct_insert_newline_should_insert(now); + self.textarea.input(key_event); + if in_paste_burst { + self.paste_burst.extend_window(now); + } + } + other => { + self.textarea.input(other); + self.paste_burst.clear_after_explicit_paste(); + } + } + } +} + +impl BottomPaneView for FeedbackNoteView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + self.handle_key_event_at(key_event, Instant::now()); + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + self.textarea.insert_str(&pasted); + self.paste_burst.clear_after_explicit_paste(); + true + } +} + +impl Renderable for FeedbackNoteView { + fn desired_height(&self, width: u16) -> u16 { + self.intro_lines(width, IntroContent::Full).len() as u16 + self.input_height(width) + 2u16 + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if area.height < 2 || area.width <= 2 { + return None; + } + let input_area = self.input_area(area); + let text_area_height = input_area.height.saturating_sub(1); + if text_area_height == 0 { + return None; + } + let textarea_rect = Rect { + x: area.x.saturating_add(2), + y: input_area.y.saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + // Four rows leave room for one intro line; show the disclosure before submission. + let intro_content = if area.height == 4 { + IntroContent::DisclosureOnly + } else { + IntroContent::Full + }; + let intro_lines = self.intro_lines(area.width, intro_content); + let input_area = self.input_area(area); + let intro_area = Rect { + height: input_area.y.saturating_sub(area.y), + ..area + }; + let (_, placeholder) = feedback_title_and_placeholder(self.category); + let input_height = input_area.height; + let max_scroll = intro_lines + .len() + .saturating_sub(usize::from(intro_area.height)); + self.intro_scroll + .set(self.intro_scroll.get().min(max_scroll)); + self.intro_page_height + .set(usize::from(intro_area.height.max(1))); + + for (offset, line) in intro_lines + .iter() + .skip(self.intro_scroll.get()) + .take(usize::from(intro_area.height)) + .enumerate() + { + Paragraph::new(line.clone()).render( + Rect { + x: area.x, + y: area.y.saturating_add(offset as u16), + width: area.width, + height: 1, + }, + buf, + ); + } + let disclosure_url = match self.feedback_audience { + FeedbackAudience::OpenAiEmployee => EMPLOYEE_FEEDBACK_FAQ_URL, + FeedbackAudience::External => FEEDBACK_POLICY_URL, + }; + mark_underlined_hyperlink(buf, intro_area, disclosure_url); + + // Input line + if input_area.width >= 2 { + for row in 0..input_area.height { + Paragraph::new(Line::from(vec![gutter()])).render( + Rect { + x: input_area.x, + y: input_area.y.saturating_add(row), + width: 2, + height: 1, + }, + buf, + ); + } + + let text_area_height = input_area.height.saturating_sub(1); + if text_area_height > 0 { + if input_area.width > 2 { + let blank_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y, + width: input_area.width.saturating_sub(2), + height: 1, + }; + Clear.render(blank_rect, buf); + } + let textarea_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y.saturating_add(1), + width: input_area.width.saturating_sub(2), + height: text_area_height, + }; + let mut state = self.textarea_state.borrow_mut(); + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if self.textarea.text().is_empty() { + Paragraph::new(Line::from(placeholder.dim())).render(textarea_rect, buf); + } + } + } + + let hint_blank_y = input_area.y.saturating_add(input_height); + let hint_spacing = u16::from(area.height > 4); + if hint_spacing > 0 && hint_blank_y < area.y.saturating_add(area.height) { + let blank_area = Rect { + x: area.x, + y: hint_blank_y, + width: area.width, + height: 1, + }; + Clear.render(blank_area, buf); + if max_scroll > 0 { + Paragraph::new(Line::from(vec![ + key_hint::plain(KeyCode::PageUp).into(), + "/".into(), + key_hint::plain(KeyCode::PageDown).into(), + " scroll disclosure".into(), + ])) + .render(blank_area, buf); + } + } + + let hint_y = hint_blank_y.saturating_add(hint_spacing); + if hint_y < area.y.saturating_add(area.height) { + Paragraph::new(accept_cancel_hint_line( + Some(key_hint::plain(KeyCode::Enter).into()), + "to submit", + Some(key_hint::plain(KeyCode::Esc).into()), + "to cancel", + )) + .render( + Rect { + x: area.x, + y: hint_y, + width: area.width, + height: 1, + }, + buf, + ); + } + } +} + +impl FeedbackNoteView { + fn input_area(&self, area: Rect) -> Rect { + // Reserve the editor and submit/cancel hints before allocating disclosure rows. + let footer_height = if area.height > 4 { 2 } else { 1 }; + let input_height = self + .input_height(area.width) + .min(area.height.saturating_sub(footer_height + 1).max(2)) + .min(area.height); + let intro_height = (self.intro_lines(area.width, IntroContent::Full).len() as u16) + .min(area.height.saturating_sub(input_height + footer_height)); + Rect { + y: area.y.saturating_add(intro_height), + height: input_height, + ..area + } + } + + fn input_height(&self, width: u16) -> u16 { + let usable_width = width.saturating_sub(2); + let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); + text_height.saturating_add(1).min(9) + } + + fn intro_lines(&self, width: u16, content: IntroContent) -> Vec> { + let (title, _) = feedback_title_and_placeholder(self.category); + let mut lines = match content { + IntroContent::Full => vec![title.bold().into(), "".into()], + IntroContent::DisclosureOnly => Vec::new(), + }; + match self.feedback_audience { + FeedbackAudience::OpenAiEmployee => { + lines.push( + "Your data may be used to improve our models and products" + .bold() + .into(), + ); + lines.push(Line::from(vec![ + "By submitting feedback, you agree that OpenAI can use your feedback for safety purposes and internal model training, as explained in more detail ".dim(), + "here".cyan().underlined(), + ". Please avoid sharing tented information under NDA, or privileged, HR-related, or sensitive personal information.".dim(), + ])); + } + FeedbackAudience::External => lines.push(Line::from(vec![ + "Your feedback can be used to improve ChatGPT. ".dim(), + "Learn more".cyan().underlined(), + ".".dim(), + ])), + } + word_wrap_lines( + lines, + RtOptions::new(usize::from(width.max(1))) + .initial_indent(Line::from(gutter())) + .subsequent_indent(Line::from(gutter())), + ) + } +} + +fn gutter() -> Span<'static> { + "▌ ".cyan() +} + +fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) { + match category { + FeedbackCategory::BadResult => ( + "Tell us more (bad result)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::GoodResult => ( + "Tell us more (good result)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::Bug => ( + "Tell us more (bug)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::SafetyCheck => ( + "Tell us more (safety check)".to_string(), + "(optional) Share what was refused and why it should have been allowed".to_string(), + ), + FeedbackCategory::Other => ( + "Tell us more (other)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + } +} + +#[cfg(test)] +#[path = "feedback_note_view_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/feedback_note_view_tests.rs b/codex-rs/tui/src/bottom_pane/feedback_note_view_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..094de651b9744bd64a41beda36b3a203355fccae --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/feedback_note_view_tests.rs @@ -0,0 +1,394 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::time::Duration; + +fn render(view: &FeedbackNoteView, 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); + render_buffer(area, &buf) +} + +fn render_buffer(area: Rect, buf: &Buffer) -> String { + let mut 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(&crate::terminal_hyperlinks::strip_osc8(symbol)); + } + } + line.trim_end().to_string() + }) + .collect(); + + while lines.first().is_some_and(|l| l.trim().is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|l| l.trim().is_empty()) { + lines.pop(); + } + lines.join("\n") +} + +fn make_view(category: FeedbackCategory) -> FeedbackNoteView { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + FeedbackNoteView::new( + category, + /*turn_id*/ None, + tx, + /*include_logs*/ true, + FeedbackAudience::External, + ) +} + +#[test] +fn feedback_view_bad_result() { + let view = make_view(FeedbackCategory::BadResult); + let rendered = render(&view, /*width*/ 60); + insta::assert_snapshot!("feedback_view_bad_result", rendered); +} + +#[test] +fn feedback_view_good_result() { + let view = make_view(FeedbackCategory::GoodResult); + let rendered = render(&view, /*width*/ 60); + insta::assert_snapshot!("feedback_view_good_result", rendered); +} + +#[test] +fn feedback_view_bug() { + let view = make_view(FeedbackCategory::Bug); + let rendered = render(&view, /*width*/ 60); + insta::assert_snapshot!("feedback_view_bug", rendered); +} + +#[test] +fn feedback_view_other() { + let view = make_view(FeedbackCategory::Other); + let rendered = render(&view, /*width*/ 60); + insta::assert_snapshot!("feedback_view_other", rendered); +} + +#[test] +fn feedback_view_safety_check() { + let view = make_view(FeedbackCategory::SafetyCheck); + let rendered = render(&view, /*width*/ 60); + insta::assert_snapshot!("feedback_view_safety_check", rendered); +} + +#[test] +fn feedback_view_with_connectivity_diagnostics() { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = FeedbackNoteView::new( + FeedbackCategory::Bug, + /*turn_id*/ None, + tx, + /*include_logs*/ false, + FeedbackAudience::External, + ); + let rendered = render(&view, /*width*/ 60); + + insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered); +} + +#[test] +fn feedback_disclosure_matches_audience_at_normal_and_narrow_widths() { + let mut rendered = Vec::new(); + for (audience, name, url, link_label) in [ + ( + FeedbackAudience::External, + "external", + FEEDBACK_POLICY_URL, + "Learnmore", + ), + ( + FeedbackAudience::OpenAiEmployee, + "employee", + EMPLOYEE_FEEDBACK_FAQ_URL, + "here", + ), + ] { + let mut view = make_view(FeedbackCategory::Bug); + view.feedback_audience = audience; + for width in [80, 40] { + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + view.desired_height(width), + ); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let linked_text = buf + .content + .iter() + .filter(|cell| cell.symbol().contains(&format!("\x1b]8;;{url}\x07"))) + .map(|cell| crate::terminal_hyperlinks::strip_osc8(cell.symbol())) + .collect::(); + assert_eq!(linked_text, link_label); + rendered.push(format!( + "{name} ({width} columns)\n{}", + render_buffer(area, &buf) + )); + } + } + insta::assert_snapshot!("feedback_disclosures", rendered.join("\n\n")); +} + +#[test] +fn feedback_disclosure_scrolls_without_hiding_input_in_short_panes() { + let mut rendered = Vec::new(); + for (width, height) in [(40, 12), (80, 8)] { + let mut view = make_view(FeedbackCategory::Bug); + view.feedback_audience = FeedbackAudience::OpenAiEmployee; + view.handle_paste("A visible note.".to_string()); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + for (stage, key) in [ + ("initial", None), + ("scrolled", Some(KeyCode::PageDown)), + ("restored", Some(KeyCode::PageUp)), + ] { + if let Some(key) = key { + view.handle_key_event(KeyEvent::from(key)); + } + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + let text = render_buffer(area, &buf); + assert!(text.contains("A visible note.")); + assert!(text.contains("enter to submit")); + assert!( + view.cursor_pos(area) + .is_some_and(|(x, y)| x < width && y < height) + ); + assert_eq!(view.is_complete(), false); + if stage == "scrolled" { + assert!(text.contains("sensitive personal")); + } + rendered.push(format!("{width}x{height} {stage}\n{text}")); + } + } + insta::assert_snapshot!("feedback_disclosure_short_panes", rendered.join("\n\n")); +} + +#[test] +fn feedback_disclosure_keeps_editor_visible_in_four_rows() { + let mut view = make_view(FeedbackCategory::Bug); + view.feedback_audience = FeedbackAudience::OpenAiEmployee; + view.handle_paste("A visible note.".to_string()); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4, + ); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + assert!( + view.cursor_pos(area) + .is_some_and(|(x, y)| area.contains((x, y).into())) + ); + let initial = render_buffer(area, &buf); + assert!(initial.contains("Your data may be used to improve our models and products")); + for _ in view.intro_lines(area.width, IntroContent::Full) { + view.handle_key_event(KeyEvent::from(KeyCode::PageDown)); + buf = Buffer::empty(area); + view.render(area, &mut buf); + } + let scrolled = render_buffer(area, &buf); + assert!(scrolled.contains("personal information.")); + insta::assert_snapshot!( + "feedback_disclosure_four_rows", + format!("{initial}\n\n{scrolled}") + ); + + let narrow_area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 4, + ); + for (audience, disclosure) in [ + (FeedbackAudience::OpenAiEmployee, "Your data"), + (FeedbackAudience::External, "Your feedback"), + ] { + let mut view = make_view(FeedbackCategory::SafetyCheck); + view.feedback_audience = audience; + let mut buf = Buffer::empty(narrow_area); + view.render(narrow_area, &mut buf); + assert!(render_buffer(narrow_area, &buf).contains(disclosure)); + } +} + +#[test] +fn feedback_paste_burst_preserves_multiline_note_until_submit() { + for include_logs in [false, true] { + for note in ["x\nrest", "id\n\nbody", "foo\nbar", "x\t\nrest"] { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let mut view = FeedbackNoteView::new( + FeedbackCategory::Bug, + /*turn_id*/ None, + AppEventSender::new(tx), + include_logs, + FeedbackAudience::External, + ); + let now = Instant::now(); + for (index, ch) in note.chars().enumerate() { + let code = match ch { + '\n' => KeyCode::Enter, + '\t' => KeyCode::Tab, + ch => KeyCode::Char(ch), + }; + view.handle_key_event_at( + KeyEvent::from(code), + now + Duration::from_millis(index as u64), + ); + assert!(!view.is_complete()); + assert!(rx.try_recv().is_err()); + } + let expected_note = note.replace('\t', ""); + assert_eq!(view.textarea.text(), expected_note); + if !include_logs && note == "foo\nbar" { + insta::assert_snapshot!("feedback_multiline_paste", render(&view, /*width*/ 60)); + } + + view.handle_key_event_at( + KeyEvent::from(KeyCode::Enter), + now + Duration::from_millis(200), + ); + + let AppEvent::SubmitFeedback { + category, + reason, + turn_id, + include_logs: submitted_logs, + } = rx.try_recv().expect("submit feedback event") + else { + panic!("expected feedback submission"); + }; + assert_eq!( + (category, reason, turn_id, submitted_logs), + ( + FeedbackCategory::Bug, + Some(expected_note), + None, + include_logs + ) + ); + assert!(view.is_complete()); + assert!(rx.try_recv().is_err()); + } + } +} + +#[test] +fn feedback_bracketed_paste_does_not_reach_chat_composer() { + for include_logs in [false, true] { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let tx = AppEventSender::new(tx); + let mut pane = super::super::tests::test_pane(tx.clone()); + pane.show_view(Box::new(FeedbackNoteView::new( + FeedbackCategory::Bug, + /*turn_id*/ None, + tx, + include_logs, + FeedbackAudience::External, + ))); + // An explicit paste ends any preceding keystroke burst. + pane.handle_key_event(KeyEvent::from(KeyCode::Char('x'))); + pane.handle_paste("\nrest".to_string()); + assert!(rx.try_recv().is_err()); + assert!(matches!( + pane.handle_key_event(KeyEvent::from(KeyCode::Enter)), + super::super::InputResult::None + )); + let AppEvent::SubmitFeedback { + reason, + include_logs: submitted_logs, + .. + } = rx.try_recv().expect("submit feedback event") + else { + panic!("expected feedback submission"); + }; + assert_eq!( + (reason, submitted_logs), + (Some("x\nrest".into()), include_logs) + ); + assert!(pane.composer_is_empty()); + assert!(rx.try_recv().is_err()); + } +} + +#[test] +fn submit_feedback_emits_submit_event_with_trimmed_note() { + let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = FeedbackNoteView::new( + FeedbackCategory::Bug, + Some("turn-123".to_string()), + tx, + /*include_logs*/ true, + FeedbackAudience::OpenAiEmployee, + ); + view.textarea.insert_str(" something broke "); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let event = rx.try_recv().expect("submit feedback event"); + assert!(matches!( + event, + AppEvent::SubmitFeedback { + category: FeedbackCategory::Bug, + reason: Some(reason), + turn_id: Some(turn_id), + include_logs: true, + } if reason == "something broke" && turn_id == "turn-123" + )); + assert_eq!(view.is_complete(), true); +} + +#[test] +fn submit_feedback_omits_empty_note() { + let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = FeedbackNoteView::new( + FeedbackCategory::GoodResult, + /*turn_id*/ None, + tx, + /*include_logs*/ false, + FeedbackAudience::External, + ); + + view.submit(); + + let event = rx.try_recv().expect("submit feedback event"); + assert!(matches!( + event, + AppEvent::SubmitFeedback { + category: FeedbackCategory::GoodResult, + reason: None, + turn_id: None, + include_logs: false, + } + )); +} + +#[test] +fn feedback_note_cancel_does_not_submit() { + let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut view = FeedbackNoteView::new( + FeedbackCategory::Bug, + /*turn_id*/ None, + AppEventSender::new(tx_raw), + /*include_logs*/ false, + FeedbackAudience::OpenAiEmployee, + ); + view.textarea.insert_str("unfinished feedback"); + + view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(view.is_complete(), true); + assert!(rx.try_recv().is_err()); +} diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..0a0716c5d915fbd3992cf4b299e08370b86e7acc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -0,0 +1,567 @@ +use codex_feedback::CODEX_APP_DIRECTORY_CACHE_ATTACHMENT_FILENAME; +use codex_feedback::CODEX_APPS_TOOLS_CACHE_ATTACHMENT_FILENAME; +use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME; +use codex_feedback::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; +use codex_feedback::FeedbackDiagnostics; +use codex_feedback::WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME; +use ratatui::style::Stylize; +use ratatui::text::Line; + +use crate::app_event::AppEvent; +use crate::app_event::FeedbackCategory; +use crate::app_event_sender::AppEventSender; +use crate::history_cell; + +use super::popup_consts::standard_popup_hint_line; + +const BASE_CLI_BUG_ISSUE_URL: &str = + "https://github.com/openai/codex/issues/new?template=3-cli.yml"; +/// Internal routing link for employee feedback follow-ups. This must not be shown to external users. +const CODEX_FEEDBACK_INTERNAL_URL: &str = "http://go/codex-feedback-internal"; + +/// The target audience for feedback disclosure and follow-up instructions. +/// +/// This controls displayed copy and links, not feedback upload behavior. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FeedbackAudience { + OpenAiEmployee, + External, +} + +pub(crate) fn should_show_feedback_connectivity_details( + category: FeedbackCategory, + diagnostics: &FeedbackDiagnostics, +) -> bool { + category != FeedbackCategory::GoodResult && !diagnostics.is_empty() +} + +pub(crate) fn feedback_classification(category: FeedbackCategory) -> &'static str { + match category { + FeedbackCategory::BadResult => "bad_result", + FeedbackCategory::GoodResult => "good_result", + FeedbackCategory::Bug => "bug", + FeedbackCategory::SafetyCheck => "safety_check", + FeedbackCategory::Other => "other", + } +} + +pub(crate) fn feedback_success_cell( + category: FeedbackCategory, + include_logs: bool, + thread_id: &str, + feedback_audience: FeedbackAudience, +) -> history_cell::WebHyperlinkHistoryCell { + let prefix = if include_logs { + "• Feedback uploaded." + } else { + "• Feedback recorded (no logs)." + }; + let issue_url = issue_url_for_category(category, thread_id, feedback_audience); + let mut lines = vec![Line::from(match issue_url.as_ref() { + Some(_) if feedback_audience == FeedbackAudience::OpenAiEmployee => { + format!("{prefix} You can share this in #codex-feedback:") + } + Some(_) => format!("{prefix} Please open an issue using the following URL:"), + None => format!("{prefix} Thanks for the feedback!"), + })]; + match issue_url { + Some(url) if feedback_audience == FeedbackAudience::OpenAiEmployee => { + lines.extend([ + "".into(), + Line::from(vec![" ".into(), url.cyan().underlined()]), + "".into(), + Line::from(vec![ + " Sentry Feedback ID: ".into(), + thread_id.to_string().bold(), + ]), + Line::from(vec![ + " Sentry URL: ".into(), + format!("https://go/codex-feedback/{thread_id}") + .cyan() + .underlined(), + ]), + ]); + } + Some(url) => { + lines.extend([ + "".into(), + Line::from(vec![" ".into(), url.cyan().underlined()]), + "".into(), + Line::from(vec![ + " Or mention your thread ID ".into(), + thread_id.to_string().bold(), + " in an existing issue.".into(), + ]), + ]); + } + None => { + lines.extend([ + "".into(), + Line::from(vec![" Thread ID: ".into(), thread_id.to_string().bold()]), + ]); + } + } + history_cell::WebHyperlinkHistoryCell::new(lines) +} + +fn issue_url_for_category( + category: FeedbackCategory, + thread_id: &str, + feedback_audience: FeedbackAudience, +) -> Option { + // Only certain categories provide a follow-up link. We intentionally keep + // the external GitHub behavior identical while routing internal users to + // the internal go link. + match category { + FeedbackCategory::Bug + | FeedbackCategory::BadResult + | FeedbackCategory::SafetyCheck + | FeedbackCategory::Other => Some(match feedback_audience { + FeedbackAudience::OpenAiEmployee => slack_feedback_url(thread_id), + FeedbackAudience::External => { + format!("{BASE_CLI_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}") + } + }), + FeedbackCategory::GoodResult => None, + } +} + +/// Build the internal follow-up URL. +/// +/// We accept a `thread_id` so the call site stays symmetric with the external +/// path, but we currently point to a fixed channel without prefilling text. +fn slack_feedback_url(_thread_id: &str) -> String { + CODEX_FEEDBACK_INTERNAL_URL.to_string() +} + +// Build the selection popup params for feedback categories. +pub(crate) fn feedback_selection_params( + app_event_tx: AppEventSender, +) -> super::SelectionViewParams { + super::SelectionViewParams { + title: Some("How was this?".to_string()), + items: vec![ + make_feedback_item( + app_event_tx.clone(), + "bug", + "Crash, error message, hang, or broken UI/behavior.", + FeedbackCategory::Bug, + ), + make_feedback_item( + app_event_tx.clone(), + "bad result", + "Output was off-target, incorrect, incomplete, or unhelpful.", + FeedbackCategory::BadResult, + ), + make_feedback_item( + app_event_tx.clone(), + "good result", + "Helpful, correct, high‑quality, or delightful result worth celebrating.", + FeedbackCategory::GoodResult, + ), + make_feedback_item( + app_event_tx.clone(), + "safety check", + "Benign usage blocked due to safety checks or refusals.", + FeedbackCategory::SafetyCheck, + ), + make_feedback_item( + app_event_tx, + "other", + "Slowness, feature suggestion, UX feedback, or anything else.", + FeedbackCategory::Other, + ), + ], + ..Default::default() + } +} + +/// Build the selection popup params shown when feedback is disabled. +pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams { + super::SelectionViewParams { + title: Some("Sending feedback is disabled".to_string()), + subtitle: Some("This action is disabled by configuration.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![super::SelectionItem { + name: "Close".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + ..Default::default() + } +} + +fn make_feedback_item( + app_event_tx: AppEventSender, + name: &str, + description: &str, + category: FeedbackCategory, +) -> super::SelectionItem { + let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| { + app_event_tx.send(AppEvent::OpenFeedbackConsent { category }); + }); + super::SelectionItem { + name: name.to_string(), + description: Some(description.to_string()), + actions: vec![action], + dismiss_on_select: true, + ..Default::default() + } +} + +/// Build the upload consent popup params for a given feedback category. +pub(crate) fn feedback_upload_consent_params( + app_event_tx: AppEventSender, + category: FeedbackCategory, + rollout_path: Option, + auto_review_rollout_filename: Option, + include_windows_sandbox_log: bool, + feedback_diagnostics: &FeedbackDiagnostics, +) -> super::SelectionViewParams { + use super::popup_consts::standard_popup_hint_line; + let yes_action: super::SelectionAction = Box::new({ + let tx = app_event_tx.clone(); + move |sender: &AppEventSender| { + let _ = sender; + tx.send(AppEvent::OpenFeedbackNote { + category, + include_logs: true, + }); + } + }); + + let no_action: super::SelectionAction = Box::new({ + let tx = app_event_tx; + move |sender: &AppEventSender| { + let _ = sender; + tx.send(AppEvent::OpenFeedbackNote { + category, + include_logs: false, + }); + } + }); + + // Build header listing files that would be sent if user consents. + let mut header_lines: Vec> = vec![ + Line::from("Upload logs?".bold()).into(), + Line::from("").into(), + Line::from("The following files will be sent:".dim()).into(), + Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(), + Line::from(vec![ + " • ".into(), + DOCTOR_REPORT_ATTACHMENT_FILENAME.into(), + ]) + .into(), + Line::from(vec![ + " • ".into(), + format!("{CODEX_APPS_TOOLS_CACHE_ATTACHMENT_FILENAME} (if available)").into(), + ]) + .into(), + Line::from(vec![ + " • ".into(), + format!("{CODEX_APP_DIRECTORY_CACHE_ATTACHMENT_FILENAME} (if available)").into(), + ]) + .into(), + ]; + if include_windows_sandbox_log { + header_lines.push( + Line::from(vec![ + " • ".into(), + WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.into(), + ]) + .into(), + ); + } + if let Some(path) = rollout_path.as_deref() + && let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string()) + { + header_lines.push(Line::from(vec![" • ".into(), name.into()]).into()); + } + if let Some(filename) = auto_review_rollout_filename { + header_lines.push(Line::from(vec![" • ".into(), filename.into()]).into()); + } + if !feedback_diagnostics.is_empty() { + header_lines.push( + Line::from(vec![ + " • ".into(), + FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME.into(), + ]) + .into(), + ); + } + if should_show_feedback_connectivity_details(category, feedback_diagnostics) { + header_lines.push(Line::from("").into()); + header_lines.push(Line::from("Connectivity diagnostics".bold()).into()); + for diagnostic in feedback_diagnostics.diagnostics() { + header_lines + .push(Line::from(vec![" - ".into(), diagnostic.headline.clone().into()]).into()); + for detail in &diagnostic.details { + header_lines.push(Line::from(vec![" - ".dim(), detail.clone().into()]).into()); + } + } + } + + super::SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + super::SelectionItem { + name: "Yes".to_string(), + description: Some( + "Share the current Codex session logs and diagnostics with the team for troubleshooting." + .to_string(), + ), + actions: vec![yes_action], + dismiss_on_select: true, + ..Default::default() + }, + super::SelectionItem { + name: "No".to_string(), + actions: vec![no_action], + dismiss_on_select: true, + ..Default::default() + }, + ], + header: Box::new(crate::render::renderable::ColumnRenderable::with( + header_lines, + )), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::render::renderable::Renderable; + use codex_feedback::FeedbackDiagnostic; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + fn render_renderable(renderable: &dyn Renderable, width: u16) -> String { + let height = renderable.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + renderable.render(area, &mut buf); + render_buffer(area, &buf) + } + + fn render_buffer(area: Rect, buf: &Buffer) -> String { + let mut 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(&crate::terminal_hyperlinks::strip_osc8(symbol)); + } + } + line.trim_end().to_string() + }) + .collect(); + + while lines.first().is_some_and(|l| l.trim().is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|l| l.trim().is_empty()) { + lines.pop(); + } + lines.join("\n") + } + + fn render_cell(cell: &impl history_cell::HistoryCell, width: u16) -> String { + cell.display_lines(width) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") + } + + #[test] + fn feedback_upload_consent_lists_doctor_report() { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let params = feedback_upload_consent_params( + tx, + FeedbackCategory::Bug, + Some(std::path::PathBuf::from("rollout.jsonl")), + Some("auto-review-rollout.jsonl".to_string()), + /*include_windows_sandbox_log*/ false, + &FeedbackDiagnostics::default(), + ); + + let rendered = render_renderable(params.header.as_ref(), /*width*/ 60); + + insta::assert_snapshot!("feedback_upload_consent_lists_doctor_report", rendered); + } + + #[test] + fn feedback_upload_consent_lists_windows_sandbox_log_when_included() { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let params = feedback_upload_consent_params( + tx, + FeedbackCategory::Bug, + Some(std::path::PathBuf::from("rollout.jsonl")), + Some("auto-review-rollout.jsonl".to_string()), + /*include_windows_sandbox_log*/ true, + &FeedbackDiagnostics::default(), + ); + + let rendered = render_renderable(params.header.as_ref(), /*width*/ 60); + + insta::assert_snapshot!( + "feedback_upload_consent_lists_windows_sandbox_log_when_included", + rendered + ); + } + + #[test] + fn should_show_feedback_connectivity_details_only_for_non_good_result_with_diagnostics() { + let diagnostics = FeedbackDiagnostics::new(vec![FeedbackDiagnostic { + headline: "Proxy environment variables are set and may affect connectivity." + .to_string(), + details: vec!["HTTP_PROXY = http://proxy.example.com:8080".to_string()], + }]); + + assert_eq!( + should_show_feedback_connectivity_details(FeedbackCategory::Bug, &diagnostics), + true + ); + assert_eq!( + should_show_feedback_connectivity_details(FeedbackCategory::GoodResult, &diagnostics), + false + ); + assert_eq!( + should_show_feedback_connectivity_details( + FeedbackCategory::BadResult, + &FeedbackDiagnostics::default() + ), + false + ); + } + + #[test] + fn issue_url_available_for_bug_bad_result_safety_check_and_other() { + let bug_url = issue_url_for_category( + FeedbackCategory::Bug, + "thread-1", + FeedbackAudience::OpenAiEmployee, + ); + let expected_slack_url = "http://go/codex-feedback-internal".to_string(); + assert_eq!(bug_url.as_deref(), Some(expected_slack_url.as_str())); + + let bad_result_url = issue_url_for_category( + FeedbackCategory::BadResult, + "thread-2", + FeedbackAudience::OpenAiEmployee, + ); + assert!(bad_result_url.is_some()); + + let other_url = issue_url_for_category( + FeedbackCategory::Other, + "thread-3", + FeedbackAudience::OpenAiEmployee, + ); + assert!(other_url.is_some()); + + let safety_check_url = issue_url_for_category( + FeedbackCategory::SafetyCheck, + "thread-4", + FeedbackAudience::OpenAiEmployee, + ); + assert!(safety_check_url.is_some()); + + assert!( + issue_url_for_category( + FeedbackCategory::GoodResult, + "t", + FeedbackAudience::OpenAiEmployee + ) + .is_none() + ); + let bug_url_non_employee = + issue_url_for_category(FeedbackCategory::Bug, "t", FeedbackAudience::External); + let expected_external_url = "https://github.com/openai/codex/issues/new?template=3-cli.yml&steps=Uploaded%20thread:%20t"; + assert_eq!(bug_url_non_employee.as_deref(), Some(expected_external_url)); + } + + #[test] + fn feedback_success_cell_matches_external_bug_copy() { + let rendered = render_cell( + &feedback_success_cell( + FeedbackCategory::Bug, + /*include_logs*/ true, + "thread-1", + FeedbackAudience::External, + ), + /*width*/ 120, + ); + assert_eq!( + rendered, + "• Feedback uploaded. Please open an issue using the following URL:\n\n https://github.com/openai/codex/issues/new?template=3-cli.yml&steps=Uploaded%20thread:%20thread-1\n\n Or mention your thread ID thread-1 in an existing issue." + ); + } + + #[test] + fn feedback_success_cell_matches_employee_bug_copy() { + let rendered = render_cell( + &feedback_success_cell( + FeedbackCategory::Bug, + /*include_logs*/ true, + "thread-2", + FeedbackAudience::OpenAiEmployee, + ), + /*width*/ 120, + ); + insta::assert_snapshot!("feedback_success_employee", rendered); + } + + #[test] + fn feedback_success_cell_matches_good_result_copy() { + let rendered = render_cell( + &feedback_success_cell( + FeedbackCategory::GoodResult, + /*include_logs*/ false, + "thread-3", + FeedbackAudience::External, + ), + /*width*/ 120, + ); + assert_eq!( + rendered, + "• Feedback recorded (no logs). Thanks for the feedback!\n\n Thread ID: thread-3" + ); + } + + #[test] + fn feedback_success_cell_uses_issue_links_for_remaining_categories() { + for category in [ + FeedbackCategory::BadResult, + FeedbackCategory::SafetyCheck, + FeedbackCategory::Other, + ] { + let rendered = render_cell( + &feedback_success_cell( + category, + /*include_logs*/ false, + "thread-4", + FeedbackAudience::External, + ), + /*width*/ 120, + ); + assert!(rendered.contains("Please open an issue using the following URL:")); + assert!(rendered.contains("thread-4")); + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs new file mode 100644 index 0000000000000000000000000000000000000000..fe903a0b37e4497fb5a5d6b0a11a1eea1cd95abd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -0,0 +1,184 @@ +use std::path::PathBuf; + +use codex_file_search::FileMatch; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use crate::render::Insets; +use crate::render::RectExt; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::render_rows; + +/// Visual state for the file-search popup. +pub(crate) struct FileSearchPopup { + /// Query corresponding to the `matches` currently shown. + display_query: String, + /// Latest query typed by the user. May differ from `display_query` when + /// a search is still in-flight. + pending_query: String, + /// When `true` we are still waiting for results for `pending_query`. + waiting: bool, + /// Cached matches; paths relative to the search dir. + matches: Vec, + /// Shared selection/scroll state. + state: ScrollState, +} + +impl FileSearchPopup { + pub(crate) fn new() -> Self { + Self { + display_query: String::new(), + pending_query: String::new(), + waiting: true, + matches: Vec::new(), + state: ScrollState::new(), + } + } + + /// Update the query and reset state to *waiting*. + pub(crate) fn set_query(&mut self, query: &str) { + if query == self.pending_query { + return; + } + + self.pending_query.clear(); + self.pending_query.push_str(query); + + self.waiting = true; // waiting for new results + } + + /// Put the popup into an "idle" state used for an empty query (just "@"). + /// Shows a hint instead of matches until the user types more characters. + pub(crate) fn set_empty_prompt(&mut self) { + self.display_query.clear(); + self.pending_query.clear(); + self.waiting = false; + self.matches.clear(); + // Reset selection/scroll state when showing the empty prompt. + self.state.reset(); + } + + /// Replace matches when a `FileSearchResult` arrives. + /// Replace matches. Only applied when `query` matches `pending_query`. + pub(crate) fn set_matches(&mut self, query: &str, matches: Vec) { + if query != self.pending_query { + return; // stale + } + + self.display_query = query.to_string(); + self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect(); + self.waiting = false; + let len = self.matches.len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); + } + + /// Move selection cursor up. + pub(crate) fn move_up(&mut self) { + let len = self.matches.len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); + } + + /// Move selection cursor down. + pub(crate) fn move_down(&mut self) { + let len = self.matches.len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); + } + + pub(crate) fn selected_match(&self) -> Option<&PathBuf> { + self.state + .selected_idx + .and_then(|idx| self.matches.get(idx)) + .map(|file_match| &file_match.path) + } + + pub(crate) fn calculate_required_height(&self) -> u16 { + // Row count depends on whether we already have matches. If no matches + // yet (e.g. initial search or query with no results) reserve a single + // row so the popup is still visible. When matches are present we show + // up to MAX_RESULTS regardless of the waiting flag so the list + // remains stable while a newer search is in-flight. + + self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16 + } +} + +impl WidgetRef for &FileSearchPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + // Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary. + let rows_all: Vec = if self.matches.is_empty() { + Vec::new() + } else { + self.matches + .iter() + .map(|m| GenericDisplayRow { + name: m.path.to_string_lossy().to_string(), + name_prefix_spans: Vec::new(), + match_indices: m + .indices + .as_ref() + .map(|v| v.iter().map(|&i| i as usize).collect()), + display_shortcut: None, + description: None, + category_tag: None, + wrap_indent: None, + is_disabled: false, + disabled_reason: None, + }) + .collect() + }; + + let empty_message = if self.waiting { + "loading..." + } else { + "no matches" + }; + + render_rows( + area.inset(Insets::tlbr( + /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, + )), + buf, + &rows_all, + &self.state, + MAX_POPUP_ROWS, + empty_message, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_file_search::MatchType; + use pretty_assertions::assert_eq; + + fn file_match(index: usize) -> FileMatch { + FileMatch { + score: index as u32, + path: PathBuf::from(format!("src/file_{index:02}.rs")), + match_type: MatchType::File, + root: PathBuf::from("/tmp/repo"), + indices: None, + } + } + + #[test] + fn set_matches_keeps_only_the_first_page_of_results() { + let mut popup = FileSearchPopup::new(); + popup.set_query("file"); + popup.set_matches("file", (0..(MAX_POPUP_ROWS + 2)).map(file_match).collect()); + + assert_eq!( + popup.matches, + (0..MAX_POPUP_ROWS).map(file_match).collect::>() + ); + assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16); + } +} diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs new file mode 100644 index 0000000000000000000000000000000000000000..d5cbd0906794e2d37ae972488c6e28ae3e6b10bd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -0,0 +1,2154 @@ +//! The bottom-pane footer renders transient hints and context indicators. +//! +//! The footer is pure rendering: it formats `FooterProps` into `Line`s without mutating any state. +//! It intentionally does not decide *which* footer content should be shown; that is owned by the +//! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like +//! `ChatWidget` (which decides when quit/interrupt is allowed). +//! +//! Some footer content is time-based rather than event-based, such as the "press again to quit" +//! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is +//! otherwise idle. +//! +//! Terminology used in this module: +//! - "status line" means the configurable contextual row built from `/statusline` items such as +//! model, git branch, and context usage. +//! - "instructional footer" means a row that tells the user what to do next, such as quit +//! confirmation, shortcut help, or queue hints. +//! - "contextual footer" means the footer is free to show ambient context instead of an +//! instruction. In that state, the footer may render the configured status line, the active +//! agent label, side-conversation state, or some combination of those. +//! +//! Single-line collapse overview: +//! 1. The composer decides the current `FooterMode` and hint flags, then calls +//! `single_line_footer_layout` for the base single-line modes. +//! 2. `single_line_footer_layout` applies the width-based fallback rules: +//! (If this description is hard to follow, just try it out by resizing +//! your terminal width; these rules were built out of trial and error.) +//! - Start with the fullest left-side hint plus the right-side context. +//! - When the queue hint is active, prefer keeping that queue hint visible, +//! even if it means dropping the right-side context earlier; the queue +//! hint may also be shortened before it is removed. +//! - When the queue hint is not active but the mode cycle hint is applicable, +//! drop "? for shortcuts" before dropping "(shift+tab to cycle)". +//! - If "(shift+tab to cycle)" cannot fit, also hide the right-side +//! context to avoid too many state transitions in quick succession. +//! - Finally, try a mode-only line (with and without context), and fall +//! back to no left-side footer if nothing can fit. +//! 3. When collapse chooses a specific line, callers render it via +//! `render_footer_line`. Otherwise, callers render the straightforward +//! mode-to-text mapping via `render_footer_from_props`. +//! +//! In short: `single_line_footer_layout` chooses *what* best fits, and the two +//! render helpers choose whether to draw the chosen line or the default +//! `FooterProps` mapping. +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::key_hint::ShortcutHint; +use crate::render::line_utils::prefix_lines; +use crate::status::format_tokens_compact; +use crate::ui_consts::FOOTER_INDENT_COLS; +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +/// The rendering inputs for the footer area under the composer. +/// +/// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`, +/// `BottomPane`, and `ChatWidget`) and pass it to the footer render helpers +/// (`render_footer_from_props` or the single-line collapse logic). The footer +/// treats these values as authoritative and does not attempt to infer missing +/// state (for example, it does not query whether a task is running). +#[derive(Clone, Debug)] +pub(crate) struct FooterProps { + pub(crate) mode: FooterMode, + pub(crate) esc_backtrack_hint: bool, + pub(crate) use_shift_enter_hint: bool, + pub(crate) is_task_running: bool, + pub(crate) queue_submissions: bool, + pub(crate) collaboration_modes_enabled: bool, + pub(crate) is_wsl: bool, + /// Which key the user must press again to quit. + /// + /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. + pub(crate) quit_shortcut_key: KeyBinding, + pub(crate) status_line_value: Option>, + pub(crate) status_line_enabled: bool, + pub(crate) key_hints: FooterKeyHints, + /// Active thread label shown when the footer is rendering contextual information instead of an + /// instructional hint. + /// + /// When both this label and the configured status line are available, they are rendered on the + /// same row separated by ` · `. + pub(crate) active_agent_label: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CollaborationModeIndicator { + Plan, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum GoalStatusIndicator { + Active { usage: Option }, + Paused, + Blocked, + UsageLimited, + BudgetLimited { usage: Option }, + Complete { usage: Option }, +} + +const MODE_CYCLE_HINT: &str = "shift+tab to cycle"; +const FOOTER_CONTEXT_GAP_COLS: u16 = 1; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct FooterKeyHints { + pub(crate) agents: Option, + pub(crate) toggle_shortcuts: Option, + pub(crate) queue: Option, + pub(crate) insert_newline: Option, + pub(crate) external_editor: Option, + pub(crate) edit_previous: Option, + pub(crate) show_transcript: Option, + pub(crate) history_search: Option, + pub(crate) reasoning_down: Option, + pub(crate) reasoning_up: Option, +} + +impl FooterKeyHints { + #[cfg(test)] + pub(crate) fn default_bindings() -> Self { + Self { + agents: None, + toggle_shortcuts: Some(key_hint::plain(KeyCode::Char('?')).into()), + queue: Some(key_hint::plain(KeyCode::Tab).into()), + insert_newline: Some(key_hint::ctrl(KeyCode::Char('j')).into()), + external_editor: Some(key_hint::ctrl(KeyCode::Char('g')).into()), + edit_previous: Some(key_hint::plain(KeyCode::Esc).into()), + show_transcript: Some(key_hint::ctrl(KeyCode::Char('t')).into()), + history_search: Some(key_hint::ctrl(KeyCode::Char('r')).into()), + reasoning_down: Some(key_hint::alt(KeyCode::Char(',')).into()), + reasoning_up: Some(key_hint::alt(KeyCode::Char('.')).into()), + } + } +} + +impl CollaborationModeIndicator { + fn label(self, show_cycle_hint: bool) -> String { + let suffix = if show_cycle_hint { + format!(" ({MODE_CYCLE_HINT})") + } else { + String::new() + }; + match self { + CollaborationModeIndicator::Plan => format!("Plan mode{suffix}"), + } + } + + fn styled_span(self, show_cycle_hint: bool) -> Span<'static> { + let label = self.label(show_cycle_hint); + match self { + CollaborationModeIndicator::Plan => Span::from(label).magenta(), + } + } +} + +/// Selects which footer content is rendered. +/// +/// The current mode is owned by `ChatComposer`, which may override it based on transient state +/// (for example, showing `QuitShortcutReminder` only while its timer is active). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum FooterMode { + /// Single-line query prompt for history recall or Vim buffer search. + HistorySearch, + /// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D). + QuitShortcutReminder, + /// Multi-line shortcut overlay shown after pressing `?`. + ShortcutOverlay, + /// Transient "press Esc again" hint shown after the first Esc while idle. + EscHint, + /// Base single-line footer when the composer is empty. + ComposerEmpty, + /// Base single-line footer when the composer contains a draft. + /// + /// The shortcuts hint is suppressed here; when a task is running, this + /// mode can show the queue hint instead. + ComposerHasDraft, +} + +pub(crate) fn toggle_shortcut_mode( + current: FooterMode, + ctrl_c_hint: bool, + is_empty: bool, +) -> FooterMode { + if ctrl_c_hint && matches!(current, FooterMode::QuitShortcutReminder) { + return current; + } + + let base_mode = if is_empty { + FooterMode::ComposerEmpty + } else { + FooterMode::ComposerHasDraft + }; + + match current { + FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder => base_mode, + _ => FooterMode::ShortcutOverlay, + } +} + +pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode { + if is_task_running { + current + } else { + FooterMode::EscHint + } +} + +pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { + match current { + FooterMode::EscHint + | FooterMode::ShortcutOverlay + | FooterMode::QuitShortcutReminder + | FooterMode::HistorySearch + | FooterMode::ComposerHasDraft => FooterMode::ComposerEmpty, + other => other, + } +} + +pub(crate) fn footer_height(props: &FooterProps) -> u16 { + let show_shortcuts_hint = match props.mode { + FooterMode::ComposerEmpty => true, + FooterMode::ComposerHasDraft => false, + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, + }; + let show_queue_hint = match props.mode { + FooterMode::ComposerHasDraft => props.is_task_running, + FooterMode::QuitShortcutReminder + | FooterMode::HistorySearch + | FooterMode::ComposerEmpty + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, + }; + footer_from_props_lines( + props, + /*collaboration_mode_indicator*/ None, + /*show_cycle_hint*/ false, + show_shortcuts_hint, + show_queue_hint, + ) + .len() as u16 +} + +/// Render a single precomputed footer line. +pub(crate) fn render_footer_line(area: Rect, buf: &mut Buffer, line: Line<'static>) { + Paragraph::new(prefix_lines( + vec![line], + " ".repeat(FOOTER_INDENT_COLS).into(), + " ".repeat(FOOTER_INDENT_COLS).into(), + )) + .render(area, buf); +} + +/// Render footer content directly from `FooterProps`. +/// +/// This is intentionally not part of the width-based collapse/fallback logic. +/// Transient instructional states (shortcut overlay, Esc hint, quit reminder) +/// prioritize "what to do next" instructions and currently suppress the +/// collaboration mode label entirely. When collapse logic has already chosen a +/// specific single line, prefer `render_footer_line`. +pub(crate) fn render_footer_from_props( + area: Rect, + buf: &mut Buffer, + props: &FooterProps, + collaboration_mode_indicator: Option, + show_cycle_hint: bool, + show_shortcuts_hint: bool, + show_queue_hint: bool, +) { + Paragraph::new(prefix_lines( + footer_from_props_lines( + props, + collaboration_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ), + " ".repeat(FOOTER_INDENT_COLS).into(), + " ".repeat(FOOTER_INDENT_COLS).into(), + )) + .render(area, buf); +} + +pub(crate) fn left_fits(area: Rect, left_width: u16) -> bool { + let max_width = area.width.saturating_sub(FOOTER_INDENT_COLS as u16); + left_width <= max_width +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SummaryHintKind { + None, + Shortcuts, + QueueMessage, + QueueShort, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct LeftSideState { + hint: SummaryHintKind, + show_cycle_hint: bool, +} + +fn left_side_line( + collaboration_mode_indicator: Option, + state: LeftSideState, + key_hints: FooterKeyHints, +) -> Line<'static> { + let mut line = Line::from(""); + match state.hint { + SummaryHintKind::None => {} + SummaryHintKind::Shortcuts => { + if let Some(key) = key_hints.agents { + line.push_span(key); + line.push_span(" for agents".dim()); + if key_hints.toggle_shortcuts.is_some() { + line.push_span(" · ".dim()); + } + } + if let Some(key) = key_hints.toggle_shortcuts { + line.push_span(key); + line.push_span(" for shortcuts".dim()); + } + } + SummaryHintKind::QueueMessage => { + if let Some(key) = key_hints.queue { + line.push_span(key); + line.push_span(" to queue message".dim()); + } + } + SummaryHintKind::QueueShort => { + if let Some(key) = key_hints.queue { + line.push_span(key); + line.push_span(" to queue".dim()); + } + } + }; + + if let Some(collaboration_mode_indicator) = collaboration_mode_indicator { + if !matches!(state.hint, SummaryHintKind::None) { + line.push_span(" · ".dim()); + } + line.push_span(collaboration_mode_indicator.styled_span(state.show_cycle_hint)); + } + + line +} + +pub(crate) enum SummaryLeft { + Default, + Custom(Line<'static>), + None, +} + +/// Compute the single-line footer layout and whether the right-side context +/// indicator can be shown alongside it. +pub(crate) fn single_line_footer_layout( + area: Rect, + context_width: u16, + collaboration_mode_indicator: Option, + show_cycle_hint: bool, + show_shortcuts_hint: bool, + show_queue_hint: bool, + key_hints: FooterKeyHints, +) -> (SummaryLeft, bool) { + let hint_kind = if show_queue_hint { + SummaryHintKind::QueueMessage + } else if show_shortcuts_hint { + SummaryHintKind::Shortcuts + } else { + SummaryHintKind::None + }; + let default_state = LeftSideState { + hint: hint_kind, + show_cycle_hint, + }; + let default_line = left_side_line(collaboration_mode_indicator, default_state, key_hints); + let default_width = default_line.width() as u16; + if default_width > 0 && can_show_left_with_context(area, default_width, context_width) { + return (SummaryLeft::Default, true); + } + + let state_line = |state: LeftSideState| -> Line<'static> { + if state == default_state { + default_line.clone() + } else { + left_side_line(collaboration_mode_indicator, state, key_hints) + } + }; + let state_width = |state: LeftSideState| -> u16 { state_line(state).width() as u16 }; + if show_shortcuts_hint && key_hints.agents.is_some() { + let compact_line = left_side_line( + collaboration_mode_indicator, + default_state, + FooterKeyHints { + toggle_shortcuts: None, + ..key_hints + }, + ); + if can_show_left_with_context(area, compact_line.width() as u16, context_width) { + return (SummaryLeft::Custom(compact_line), true); + } + } + // When the mode cycle hint is applicable (idle, non-queue mode), only show + // the right-side context indicator if the "(shift+tab to cycle)" variant + // can also fit. + let context_requires_cycle_hint = show_cycle_hint && !show_queue_hint; + + if show_queue_hint { + // In queue mode, prefer dropping context before dropping the queue hint. + let queue_states = [ + default_state, + LeftSideState { + hint: SummaryHintKind::QueueMessage, + show_cycle_hint: false, + }, + LeftSideState { + hint: SummaryHintKind::QueueShort, + show_cycle_hint: false, + }, + ]; + + // Pass 1: keep the right-side context indicator if any queue variant + // can fit alongside it. We skip adjacent duplicates because + // `default_state` can already be the no-cycle queue variant. + let mut previous_state: Option = None; + for state in queue_states { + if previous_state == Some(state) { + continue; + } + previous_state = Some(state); + let width = state_width(state); + if width > 0 && can_show_left_with_context(area, width, context_width) { + if state == default_state { + return (SummaryLeft::Default, true); + } + return (SummaryLeft::Custom(state_line(state)), true); + } + } + + // Pass 2: if context cannot fit, drop it before dropping the queue + // hint. Reuse the same dedupe so we do not try equivalent states twice. + let mut previous_state: Option = None; + for state in queue_states { + if previous_state == Some(state) { + continue; + } + previous_state = Some(state); + let width = state_width(state); + if width > 0 && left_fits(area, width) { + if state == default_state { + return (SummaryLeft::Default, false); + } + return (SummaryLeft::Custom(state_line(state)), false); + } + } + } else if collaboration_mode_indicator.is_some() { + if show_cycle_hint { + // First fallback: drop shortcut hint but keep the cycle + // hint on the mode label if it can fit. + let cycle_state = LeftSideState { + hint: SummaryHintKind::None, + show_cycle_hint: true, + }; + let cycle_width = state_width(cycle_state); + if cycle_width > 0 && can_show_left_with_context(area, cycle_width, context_width) { + return (SummaryLeft::Custom(state_line(cycle_state)), true); + } + if cycle_width > 0 && left_fits(area, cycle_width) { + return (SummaryLeft::Custom(state_line(cycle_state)), false); + } + } + + // Next fallback: mode label only. If the cycle hint is applicable but + // cannot fit, we also suppress context so the right side does not + // outlive "(shift+tab to cycle)" on the left. + let mode_only_state = LeftSideState { + hint: SummaryHintKind::None, + show_cycle_hint: false, + }; + let mode_only_width = state_width(mode_only_state); + if !context_requires_cycle_hint + && mode_only_width > 0 + && can_show_left_with_context(area, mode_only_width, context_width) + { + return ( + SummaryLeft::Custom(state_line(mode_only_state)), + true, // show_context + ); + } + if mode_only_width > 0 && left_fits(area, mode_only_width) { + return ( + SummaryLeft::Custom(state_line(mode_only_state)), + false, // show_context + ); + } + } + + // Final fallback: if queue variants (or other earlier states) could not fit + // at all, drop every hint and try to show just the mode label. + if let Some(collaboration_mode_indicator) = collaboration_mode_indicator { + let mode_only_state = LeftSideState { + hint: SummaryHintKind::None, + show_cycle_hint: false, + }; + // Compute the width without going through `state_line` so we do not + // depend on `default_state` (which may still be a queue variant). + let mode_only_width = left_side_line( + Some(collaboration_mode_indicator), + mode_only_state, + key_hints, + ) + .width() as u16; + if !context_requires_cycle_hint + && can_show_left_with_context(area, mode_only_width, context_width) + { + return ( + SummaryLeft::Custom(left_side_line( + Some(collaboration_mode_indicator), + mode_only_state, + key_hints, + )), + true, // show_context + ); + } + if left_fits(area, mode_only_width) { + return ( + SummaryLeft::Custom(left_side_line( + Some(collaboration_mode_indicator), + mode_only_state, + key_hints, + )), + false, // show_context + ); + } + } + + (SummaryLeft::None, true) +} + +pub(crate) fn mode_indicator_line( + indicator: Option, + show_cycle_hint: bool, +) -> Option> { + indicator.map(|indicator| Line::from(vec![indicator.styled_span(show_cycle_hint)])) +} + +pub(crate) fn goal_status_indicator_line( + indicator: Option<&GoalStatusIndicator>, +) -> Option> { + let indicator = indicator?; + let label = match indicator { + GoalStatusIndicator::Active { usage } => { + if let Some(usage) = usage { + format!("Pursuing goal ({usage})") + } else { + "Pursuing goal".to_string() + } + } + GoalStatusIndicator::Paused => "Goal paused (/goal resume)".to_string(), + GoalStatusIndicator::Blocked => "Goal stalled (/goal resume)".to_string(), + GoalStatusIndicator::UsageLimited => "Goal hit usage limits (/goal resume)".to_string(), + GoalStatusIndicator::BudgetLimited { usage } => { + if let Some(usage) = usage { + format!("Goal unmet ({usage})") + } else { + "Goal abandoned".to_string() + } + } + GoalStatusIndicator::Complete { usage } => { + if let Some(usage) = usage { + format!("Goal achieved ({usage})") + } else { + "Goal achieved".to_string() + } + } + }; + + Some(Line::from(vec![Span::from(label).magenta()])) +} + +pub(crate) fn status_line_right_indicator_line( + collaboration_mode_indicator: Option, + goal_status_indicator: Option<&GoalStatusIndicator>, + ide_context_active: bool, + show_cycle_hint: bool, +) -> Option> { + let primary_indicator = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint) + .or_else(|| goal_status_indicator_line(goal_status_indicator)); + let ide_context_indicator = ide_context_active.then(|| Line::from(vec!["IDE context".cyan()])); + let mut line: Option> = None; + + for indicator in [primary_indicator, ide_context_indicator] + .into_iter() + .flatten() + { + if let Some(line) = line.as_mut() { + line.push_span(" · ".dim()); + for span in indicator.spans { + line.push_span(span); + } + } else { + line = Some(indicator); + } + } + + line +} + +pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> { + if let Some(rest) = label.strip_prefix("Side ") { + Line::from(vec!["Side".magenta().bold(), format!(" {rest}").magenta()]) + } else { + Line::from(vec![Span::from(label.to_string()).magenta()]) + } +} + +fn right_aligned_x(area: Rect, content_width: u16) -> Option { + if area.is_empty() { + return None; + } + + let right_padding = FOOTER_INDENT_COLS as u16; + let max_width = area.width.saturating_sub(right_padding); + if content_width == 0 || max_width == 0 { + return None; + } + + if content_width >= max_width { + return Some(area.x.saturating_add(right_padding)); + } + + Some( + area.x + .saturating_add(area.width) + .saturating_sub(content_width) + .saturating_sub(right_padding), + ) +} + +pub(crate) fn max_left_width_for_right(area: Rect, right_width: u16) -> Option { + let context_x = right_aligned_x(area, right_width)?; + let left_start = area.x + FOOTER_INDENT_COLS as u16; + + // minimal one column gap between left and right + let gap = FOOTER_CONTEXT_GAP_COLS; + + if context_x <= left_start + gap { + return Some(0); + } + + Some(context_x.saturating_sub(left_start + gap)) +} + +pub(crate) fn can_show_left_with_context(area: Rect, left_width: u16, context_width: u16) -> bool { + let Some(context_x) = right_aligned_x(area, context_width) else { + return true; + }; + if left_width == 0 { + return true; + } + let left_extent = FOOTER_INDENT_COLS as u16 + left_width + FOOTER_CONTEXT_GAP_COLS; + left_extent <= context_x.saturating_sub(area.x) +} + +pub(crate) fn render_context_right(area: Rect, buf: &mut Buffer, line: &Line<'static>) { + if area.is_empty() { + return; + } + + let context_width = line.width() as u16; + let Some(mut x) = right_aligned_x(area, context_width) else { + return; + }; + let y = area.y + area.height.saturating_sub(1); + let max_x = area.x.saturating_add(area.width); + + for span in &line.spans { + if x >= max_x { + break; + } + let span_width = span.width() as u16; + if span_width == 0 { + continue; + } + let remaining = max_x.saturating_sub(x); + let draw_width = span_width.min(remaining); + buf.set_span(x, y, span, draw_width); + x = x.saturating_add(span_width); + } +} + +pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { + if area.width > 2 { + area.x += 2; + area.width = area.width.saturating_sub(2); + } + area +} + +pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(String, String)]) { + if items.is_empty() { + return; + } + + footer_hint_items_line(items).render(inset_footer_hint_area(area), buf); +} + +/// Map `FooterProps` to footer lines without width-based collapse. +/// +/// This is the canonical FooterMode-to-text mapping. It powers transient, +/// instructional states (shortcut overlay, Esc hint, quit reminder) and also +/// the default rendering for base states when collapse is not applied (or when +/// `single_line_footer_layout` returns `SummaryLeft::Default`). Collapse and +/// fallback decisions live in `single_line_footer_layout`; this function only +/// formats the chosen/default content. +fn footer_from_props_lines( + props: &FooterProps, + collaboration_mode_indicator: Option, + show_cycle_hint: bool, + show_shortcuts_hint: bool, + show_queue_hint: bool, +) -> Vec> { + let key_hints = props.key_hints; + // Passive footer context can come from the configurable status line, the + // active agent label, or both combined. + if let Some(status_line) = passive_footer_status_line(props) { + return vec![status_line]; + } + match props.mode { + FooterMode::QuitShortcutReminder => { + vec![quit_shortcut_reminder_line(props.quit_shortcut_key)] + } + FooterMode::HistorySearch => vec![Line::from("reverse-i-search: ").dim()], + FooterMode::ComposerEmpty => { + let state = LeftSideState { + hint: if show_shortcuts_hint { + SummaryHintKind::Shortcuts + } else { + SummaryHintKind::None + }, + show_cycle_hint, + }; + vec![left_side_line( + collaboration_mode_indicator, + state, + key_hints, + )] + } + FooterMode::ShortcutOverlay => { + let state = ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + is_task_running: props.is_task_running, + queue_submissions: props.queue_submissions, + is_wsl: props.is_wsl, + collaboration_modes_enabled: props.collaboration_modes_enabled, + key_hints, + }; + shortcut_overlay_lines(state) + } + FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], + FooterMode::ComposerHasDraft => { + let state = LeftSideState { + hint: if show_queue_hint { + SummaryHintKind::QueueMessage + } else if show_shortcuts_hint { + SummaryHintKind::Shortcuts + } else { + SummaryHintKind::None + }, + show_cycle_hint, + }; + vec![left_side_line( + collaboration_mode_indicator, + state, + key_hints, + )] + } + } +} + +/// Returns the contextual footer row when the footer is not busy showing an instructional hint. +/// +/// The returned line may contain the configured status line, the currently viewed agent label, or +/// both combined. Active instructional states such as quit reminders, shortcut overlays, and queue +/// prompts deliberately return `None` so those call-to-action hints stay visible. +pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option> { + if !shows_passive_footer_line(props) { + return None; + } + + let mut line = if props.status_line_enabled { + props.status_line_value.clone() + } else { + None + }; + + if let Some(active_agent_label) = props.active_agent_label.as_ref() { + if let Some(existing) = line.as_mut() { + existing.spans.push(" · ".dim()); + existing.spans.push(active_agent_label.clone().dim()); + } else { + line = Some(Line::from(active_agent_label.clone()).dim()); + } + } + + if props.mode == FooterMode::ComposerEmpty + && let Some(key) = props.key_hints.agents + && let Some(line) = line.as_mut() + { + line.extend(vec![" · ".dim(), key.into(), " for agents".dim()]); + } + + line +} + +/// Whether the current footer mode allows contextual information to replace instructional hints. +/// +/// In practice this means the composer is idle, or it has a draft but is not currently running a +/// task, so the footer can spend the row on ambient context instead of "what to do next" text. +pub(crate) fn shows_passive_footer_line(props: &FooterProps) -> bool { + match props.mode { + FooterMode::ComposerEmpty => true, + FooterMode::ComposerHasDraft => !props.is_task_running, + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, + } +} + +/// Whether callers should reserve the dedicated status-line layout for a contextual footer row. +/// +/// The dedicated layout exists for the configurable `/statusline` row. An agent label by itself +/// can be rendered by the standard footer flow, so this only becomes `true` when the status line +/// feature is enabled and the current mode allows contextual footer content. +pub(crate) fn uses_passive_footer_status_layout(props: &FooterProps) -> bool { + props.status_line_enabled && shows_passive_footer_line(props) +} + +pub(crate) fn footer_line_width( + props: &FooterProps, + collaboration_mode_indicator: Option, + show_cycle_hint: bool, + show_shortcuts_hint: bool, + show_queue_hint: bool, +) -> u16 { + footer_from_props_lines( + props, + collaboration_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ) + .last() + .map(|line| line.width() as u16) + .unwrap_or(0) +} + +pub(crate) fn footer_hint_items_width(items: &[(String, String)]) -> u16 { + if items.is_empty() { + return 0; + } + footer_hint_items_line(items).width() as u16 +} + +pub(crate) fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static> { + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(key.clone().bold()); + if idx == 0 + && key == "voice" + && let Some(label) = label.strip_prefix("● ") + { + spans.push(" ●".red()); + spans.push(format!(" {label}").into()); + } else { + spans.push(format!(" {label}").into()); + } + if idx + 1 != items.len() { + spans.push((if items[0].0 == "voice" { " " } else { " " }).into()); + } + } + Line::from(spans) +} + +#[derive(Clone, Copy, Debug)] +struct ShortcutsState { + use_shift_enter_hint: bool, + esc_backtrack_hint: bool, + is_task_running: bool, + queue_submissions: bool, + is_wsl: bool, + collaboration_modes_enabled: bool, + key_hints: FooterKeyHints, +} + +fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { + Line::from(vec![key.into(), " again to quit".into()]).dim() +} + +fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { + let esc = key_hint::plain(KeyCode::Esc); + if esc_backtrack_hint { + Line::from(vec![esc.into(), " again to edit previous message".into()]).dim() + } else { + Line::from(vec![ + esc.into(), + " ".into(), + esc.into(), + " to edit previous message".into(), + ]) + .dim() + } +} + +fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { + let mut commands = Line::from(""); + let mut shell_commands = Line::from(""); + let mut newline = Line::from(""); + let mut queue_message_tab = Line::from(""); + let mut file_paths = Line::from(""); + let mut paste_image = Line::from(""); + let mut external_editor = Line::from(""); + let mut edit_previous = Line::from(""); + let mut history_search = Line::from(""); + let mut quit = Line::from(""); + let mut show_transcript = Line::from(""); + let mut change_mode = Line::from(""); + let mut reasoning_down = Line::from(""); + let mut reasoning_up = Line::from(""); + + for descriptor in SHORTCUTS { + if let Some(text) = descriptor.overlay_entry(state) { + match descriptor.id { + ShortcutId::Commands => commands = text, + ShortcutId::ShellCommands => shell_commands = text, + ShortcutId::InsertNewline => newline = text, + ShortcutId::QueueMessageTab => queue_message_tab = text, + ShortcutId::FilePaths => file_paths = text, + ShortcutId::PasteImage => paste_image = text, + ShortcutId::ExternalEditor => external_editor = text, + ShortcutId::EditPrevious => edit_previous = text, + ShortcutId::HistorySearch => history_search = text, + ShortcutId::Quit => quit = text, + ShortcutId::ShowTranscript => show_transcript = text, + ShortcutId::ChangeMode => change_mode = text, + ShortcutId::ReasoningDown => reasoning_down = text, + ShortcutId::ReasoningUp => reasoning_up = text, + } + } + } + + let mut ordered = vec![ + commands, + shell_commands, + newline, + queue_message_tab, + file_paths, + paste_image, + external_editor, + edit_previous, + history_search, + quit, + reasoning_down, + reasoning_up, + ]; + if change_mode.width() > 0 { + ordered.push(change_mode); + } + ordered.push(show_transcript); + let mut lines = build_columns(ordered); + if let Some(key) = state.key_hints.agents { + lines.push(Line::from(vec![ + key.into(), + " for agents (empty prompt)".into(), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(vec![ + "customize shortcuts with ".into(), + "/keymap".cyan(), + ])); + lines +} + +fn build_columns(entries: Vec>) -> Vec> { + if entries.is_empty() { + return Vec::new(); + } + + const COLUMNS: usize = 2; + const COLUMN_PADDING: [usize; COLUMNS] = [4, 4]; + const COLUMN_GAP: usize = 4; + + let rows = entries.len().div_ceil(COLUMNS); + let target_len = rows * COLUMNS; + let mut entries = entries; + if entries.len() < target_len { + entries.extend(std::iter::repeat_n( + Line::from(""), + target_len - entries.len(), + )); + } + + let mut column_widths = [0usize; COLUMNS]; + + for (idx, entry) in entries.iter().enumerate() { + let column = idx % COLUMNS; + column_widths[column] = column_widths[column].max(entry.width()); + } + + for (idx, width) in column_widths.iter_mut().enumerate() { + *width += COLUMN_PADDING[idx]; + } + + entries + .chunks(COLUMNS) + .map(|chunk| { + let mut line = Line::from(""); + for (col, entry) in chunk.iter().enumerate() { + line.extend(entry.spans.clone()); + if col < COLUMNS - 1 { + let target_width = column_widths[col]; + let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP; + line.push_span(Span::from(" ".repeat(padding))); + } + } + line.dim() + }) + .collect() +} + +pub(crate) fn context_window_line(percent: Option, used_tokens: Option) -> Line<'static> { + if let Some(percent) = percent { + let percent = percent.clamp(0, 100); + return Line::from(vec![Span::from(format!("{percent}% context left")).dim()]); + } + + if let Some(tokens) = used_tokens { + let used_fmt = format_tokens_compact(tokens); + return Line::from(vec![Span::from(format!("{used_fmt} used")).dim()]); + } + + Line::from(vec![Span::from("100% context left").dim()]) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShortcutId { + Commands, + ShellCommands, + InsertNewline, + QueueMessageTab, + FilePaths, + PasteImage, + ExternalEditor, + EditPrevious, + HistorySearch, + Quit, + ShowTranscript, + ChangeMode, + ReasoningDown, + ReasoningUp, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ShortcutBinding { + key: KeyBinding, + condition: DisplayCondition, +} + +impl ShortcutBinding { + fn matches(&self, state: ShortcutsState) -> bool { + self.condition.matches(state) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DisplayCondition { + Always, + WhenShiftEnterHint, + WhenNotShiftEnterHint, + WhenUnderWSL, + WhenCollaborationModesEnabled, +} + +impl DisplayCondition { + fn matches(self, state: ShortcutsState) -> bool { + match self { + DisplayCondition::Always => true, + DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, + DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, + DisplayCondition::WhenUnderWSL => state.is_wsl, + DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled, + } + } +} + +struct ShortcutDescriptor { + id: ShortcutId, + bindings: &'static [ShortcutBinding], + prefix: &'static str, + label: &'static str, +} + +impl ShortcutDescriptor { + fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { + self.bindings.iter().find(|binding| binding.matches(state)) + } + + fn overlay_entry(&self, state: ShortcutsState) -> Option> { + let key = match self.id { + ShortcutId::InsertNewline => state.key_hints.insert_newline, + ShortcutId::QueueMessageTab => state.key_hints.queue, + ShortcutId::ExternalEditor => state.key_hints.external_editor, + ShortcutId::EditPrevious => state.key_hints.edit_previous, + ShortcutId::ShowTranscript => state.key_hints.show_transcript, + ShortcutId::HistorySearch => state.key_hints.history_search, + ShortcutId::ReasoningDown => state.key_hints.reasoning_down, + ShortcutId::ReasoningUp => state.key_hints.reasoning_up, + ShortcutId::Commands + | ShortcutId::ShellCommands + | ShortcutId::FilePaths + | ShortcutId::PasteImage + | ShortcutId::Quit + | ShortcutId::ChangeMode => self + .binding_for(state) + .map(|binding| ShortcutHint::Single(binding.key)), + }?; + let mut line = Line::from(vec![self.prefix.into(), key.into()]); + match self.id { + ShortcutId::QueueMessageTab => { + if state.is_task_running || state.queue_submissions { + line.push_span(" to queue message"); + } else { + line.push_span(" to submit message"); + } + } + ShortcutId::EditPrevious => { + if state.esc_backtrack_hint { + line.push_span(" again to edit previous message"); + } else { + line.extend(vec![ + " ".into(), + key.into(), + " to edit previous message".into(), + ]); + } + } + ShortcutId::Quit => { + if state.is_task_running { + line.push_span(" to interrupt"); + } else { + line.push_span(" to exit"); + } + } + _ => line.push_span(self.label), + }; + Some(line) + } +} + +const SHORTCUTS: &[ShortcutDescriptor] = &[ + ShortcutDescriptor { + id: ShortcutId::Commands, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Char('/')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for commands", + }, + ShortcutDescriptor { + id: ShortcutId::ShellCommands, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Char('!')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for shell commands", + }, + ShortcutDescriptor { + id: ShortcutId::InsertNewline, + bindings: &[ + ShortcutBinding { + key: key_hint::shift(KeyCode::Enter), + condition: DisplayCondition::WhenShiftEnterHint, + }, + ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('j')), + condition: DisplayCondition::WhenNotShiftEnterHint, + }, + ], + prefix: "", + label: " for newline", + }, + ShortcutDescriptor { + id: ShortcutId::QueueMessageTab, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Tab), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to queue message", + }, + ShortcutDescriptor { + id: ShortcutId::FilePaths, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Char('@')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for file paths", + }, + ShortcutDescriptor { + id: ShortcutId::PasteImage, + // Show Ctrl+Alt+V when running under WSL (terminals often intercept plain + // Ctrl+V); otherwise fall back to Ctrl+V. + bindings: &[ + ShortcutBinding { + key: key_hint::ctrl_alt(KeyCode::Char('v')), + condition: DisplayCondition::WhenUnderWSL, + }, + ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('v')), + condition: DisplayCondition::Always, + }, + ], + prefix: "", + label: " to paste images", + }, + ShortcutDescriptor { + id: ShortcutId::ExternalEditor, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('g')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to edit in external editor", + }, + ShortcutDescriptor { + id: ShortcutId::EditPrevious, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Esc), + condition: DisplayCondition::Always, + }], + prefix: "", + label: "", + }, + ShortcutDescriptor { + id: ShortcutId::HistorySearch, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('r')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " search history", + }, + ShortcutDescriptor { + id: ShortcutId::Quit, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('c')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to exit", + }, + ShortcutDescriptor { + id: ShortcutId::ShowTranscript, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('t')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to view transcript", + }, + ShortcutDescriptor { + id: ShortcutId::ChangeMode, + bindings: &[ShortcutBinding { + key: key_hint::shift(KeyCode::Tab), + condition: DisplayCondition::WhenCollaborationModesEnabled, + }], + prefix: "", + label: " to change mode", + }, + ShortcutDescriptor { + id: ShortcutId::ReasoningDown, + bindings: &[ShortcutBinding { + key: key_hint::alt(KeyCode::Char(',')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " reasoning down", + }, + ShortcutDescriptor { + id: ShortcutId::ReasoningUp, + bindings: &[ShortcutBinding { + key: key_hint::alt(KeyCode::Char('.')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " reasoning up", + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; + use crate::test_backend::VT100Backend; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::Terminal; + use ratatui::backend::Backend; + use ratatui::backend::TestBackend; + + #[test] + fn voice_live_microphone_indicator_is_red() { + let line = footer_hint_items_line(&[("voice".into(), "● listen".into())]); + assert_eq!(line.spans[2].style.fg, Some(ratatui::style::Color::Red)); + } + + #[test] + fn voice_footer_rendering_preserves_text_and_styles() { + let items = [ + ("voice".into(), "● listen".into()), + ("ctrl+m".into(), "mute".into()), + ]; + let mut terminal = + Terminal::new(TestBackend::new(/*width*/ 40, /*height*/ 1)).expect("create terminal"); + terminal + .draw(|frame| render_footer_hint_items(frame.area(), frame.buffer_mut(), &items)) + .expect("render voice footer"); + let backend = terminal.backend(); + let mut previous_style = None; + let style_runs = (0..40) + .filter_map(|x| { + let cell = backend.buffer().cell((x, 0))?; + let style = cell.style(); + if previous_style == Some(style) { + None + } else { + previous_style = Some(style); + Some((x, style)) + } + }) + .collect::>(); + insta::assert_debug_snapshot!( + "voice_footer_rendered_styles", + (backend.to_string(), style_runs) + ); + } + + fn snapshot_footer(name: &str, props: FooterProps) { + snapshot_footer_with_mode_indicator( + name, /*width*/ 80, &props, /*collaboration_mode_indicator*/ None, + ); + } + + fn snapshot_footer_with_context( + name: &str, + props: FooterProps, + percent: Option, + used_tokens: Option, + ) { + snapshot_footer_with_mode_indicator_and_context( + name, + /*width*/ 80, + &props, + /*collaboration_mode_indicator*/ None, + context_window_line(percent, used_tokens), + ); + } + + fn draw_footer_frame( + terminal: &mut Terminal, + height: u16, + props: &FooterProps, + collaboration_mode_indicator: Option, + ide_context_active: bool, + context_line: Line<'static>, + ) { + terminal + .draw(|f| { + let area = Rect::new(0, 0, f.area().width, height); + let show_cycle_hint = !props.is_task_running; + let show_shortcuts_hint = match props.mode { + FooterMode::ComposerEmpty => true, + FooterMode::ComposerHasDraft => false, + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, + }; + let show_queue_hint = match props.mode { + FooterMode::ComposerHasDraft => props.is_task_running, + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ComposerEmpty + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, + }; + let status_line_active = uses_passive_footer_status_layout(props); + let passive_status_line = if status_line_active { + passive_footer_status_line(props) + } else { + None + }; + let left_mode_indicator = if status_line_active { + None + } else { + collaboration_mode_indicator + }; + let available_width = area.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; + let mut truncated_status_line = if status_line_active + && matches!( + props.mode, + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft + ) { + passive_status_line.as_ref().map(|line| { + truncate_line_with_ellipsis_if_overflow(line.clone(), available_width) + }) + } else { + None + }; + let mut left_width = if status_line_active { + truncated_status_line + .as_ref() + .map(|line| line.width() as u16) + .unwrap_or(0) + } else { + footer_line_width( + props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ) + }; + let right_line = if status_line_active { + let full = status_line_right_indicator_line( + collaboration_mode_indicator, + /*goal_status_indicator*/ None, + ide_context_active, + show_cycle_hint, + ); + let compact = status_line_right_indicator_line( + collaboration_mode_indicator, + /*goal_status_indicator*/ None, + ide_context_active, + /*show_cycle_hint*/ false, + ); + let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0); + if can_show_left_with_context(area, left_width, full_width) { + full + } else { + compact + } + } else { + Some(context_line.clone()) + }; + let right_width = right_line + .as_ref() + .map(|line| line.width() as u16) + .unwrap_or(0); + if status_line_active + && let Some(max_left) = max_left_width_for_right(area, right_width) + && left_width > max_left + && let Some(line) = passive_status_line.as_ref().map(|line| { + truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize) + }) + { + left_width = line.width() as u16; + truncated_status_line = Some(line); + } + let can_show_left_and_context = + can_show_left_with_context(area, left_width, right_width); + if matches!( + props.mode, + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft + ) { + if status_line_active { + if let Some(line) = truncated_status_line.clone() { + render_footer_line(area, f.buffer_mut(), line); + } + if can_show_left_and_context && let Some(line) = &right_line { + render_context_right(area, f.buffer_mut(), line); + } + } else { + let (summary_left, show_context) = single_line_footer_layout( + area, + right_width, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + props.key_hints, + ); + match summary_left { + SummaryLeft::Default => { + render_footer_from_props( + area, + f.buffer_mut(), + props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); + } + SummaryLeft::Custom(line) => { + render_footer_line(area, f.buffer_mut(), line); + } + SummaryLeft::None => {} + } + if show_context && let Some(line) = &right_line { + render_context_right(area, f.buffer_mut(), line); + } + } + } else { + render_footer_from_props( + area, + f.buffer_mut(), + props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); + let show_context = can_show_left_and_context + && !matches!( + props.mode, + FooterMode::EscHint + | FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + ); + if show_context && let Some(line) = &right_line { + render_context_right(area, f.buffer_mut(), line); + } + } + }) + .unwrap(); + } + + fn snapshot_footer_with_mode_indicator( + name: &str, + width: u16, + props: &FooterProps, + collaboration_mode_indicator: Option, + ) { + snapshot_footer_with_mode_indicator_and_context( + name, + width, + props, + collaboration_mode_indicator, + context_window_line(/*percent*/ None, /*used_tokens*/ None), + ); + } + + fn snapshot_footer_with_mode_indicator_and_context( + name: &str, + width: u16, + props: &FooterProps, + collaboration_mode_indicator: Option, + context_line: Line<'static>, + ) { + let height = footer_height(props).max(1); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + draw_footer_frame( + &mut terminal, + height, + props, + collaboration_mode_indicator, + /*ide_context_active*/ false, + context_line, + ); + assert_snapshot!(name, terminal.backend()); + } + + fn render_footer_with_mode_indicator_and_context( + width: u16, + props: &FooterProps, + collaboration_mode_indicator: Option, + context_line: Line<'static>, + ) -> String { + let height = footer_height(props).max(1); + let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal"); + draw_footer_frame( + &mut terminal, + height, + props, + collaboration_mode_indicator, + /*ide_context_active*/ false, + context_line, + ); + terminal.backend().vt100().screen().contents() + } + + fn snapshot_footer_with_indicators( + name: &str, + width: u16, + props: &FooterProps, + collaboration_mode_indicator: Option, + ide_context_active: bool, + ) { + let height = footer_height(props).max(1); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + draw_footer_frame( + &mut terminal, + height, + props, + collaboration_mode_indicator, + ide_context_active, + context_window_line(/*percent*/ None, /*used_tokens*/ None), + ); + assert_snapshot!(name, terminal.backend()); + } + + #[test] + fn footer_snapshots() { + snapshot_footer( + "footer_shortcuts_default", + FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_shift_and_esc", + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: true, + use_shift_enter_hint: true, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints { + insert_newline: Some(key_hint::shift(KeyCode::Enter).into()), + ..FooterKeyHints::default_bindings() + }, + active_agent_label: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_collaboration_modes_enabled", + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_running", + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_idle", + FooterProps { + mode: FooterMode::QuitShortcutReminder, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_running", + FooterProps { + mode: FooterMode::QuitShortcutReminder, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }, + ); + + snapshot_footer( + "footer_esc_hint_idle", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }, + ); + + snapshot_footer( + "footer_esc_hint_primed", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: true, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }, + ); + + snapshot_footer_with_context( + "footer_shortcuts_context_running", + FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }, + Some(72), + /*used_tokens*/ None, + ); + + snapshot_footer_with_context( + "footer_context_tokens_used", + FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }, + /*percent*/ None, + Some(123_456), + ); + + snapshot_footer( + "footer_composer_has_draft_queue_hint_enabled", + FooterProps { + mode: FooterMode::ComposerHasDraft, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }, + ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }; + + snapshot_footer_with_mode_indicator( + "footer_mode_indicator_wide", + /*width*/ 120, + &props, + Some(CollaborationModeIndicator::Plan), + ); + + snapshot_footer_with_mode_indicator( + "footer_mode_indicator_narrow_overlap_hides", + /*width*/ 50, + &props, + Some(CollaborationModeIndicator::Plan), + ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + queue_submissions: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }; + + snapshot_footer_with_mode_indicator( + "footer_mode_indicator_running_hides_hint", + /*width*/ 120, + &props, + Some(CollaborationModeIndicator::Plan), + ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: Some(Line::from("Status line content".to_string())), + status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }; + + snapshot_footer("footer_status_line_overrides_shortcuts", props); + + let props = FooterProps { + mode: FooterMode::ComposerHasDraft, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: Some(Line::from("Status line content".to_string())), + status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }; + + snapshot_footer("footer_status_line_yields_to_queue_hint", props); + + let props = FooterProps { + mode: FooterMode::ComposerHasDraft, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: Some(Line::from("Status line content".to_string())), + status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }; + + snapshot_footer("footer_status_line_overrides_draft_idle", props); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, // command timed out / empty + status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }; + + snapshot_footer_with_mode_indicator_and_context( + "footer_status_line_enabled_mode_right", + /*width*/ 120, + &props, + Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), + ); + + snapshot_footer_with_indicators( + "footer_status_line_enabled_mode_and_ide_context_right", + /*width*/ 120, + &props, + Some(CollaborationModeIndicator::Plan), + /*ide_context_active*/ true, + ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }; + + snapshot_footer_with_mode_indicator_and_context( + "footer_status_line_disabled_context_right", + /*width*/ 120, + &props, + Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), + ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }; + + // has status line and no collaboration mode + snapshot_footer_with_mode_indicator_and_context( + "footer_status_line_enabled_no_mode_right", + /*width*/ 120, + &props, + /*collaboration_mode_indicator*/ None, + context_window_line(Some(50), /*used_tokens*/ None), + ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: Some(Line::from( + "Status line content that should truncate before the mode indicator".to_string(), + )), + status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }; + + snapshot_footer_with_mode_indicator_and_context( + "footer_status_line_truncated_with_gap", + /*width*/ 40, + &props, + Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), + ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: Some("Robie [explorer]".to_string()), + }; + + snapshot_footer("footer_active_agent_label", props); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: Some(Line::from("Status line content".to_string())), + status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: Some("Robie [explorer]".to_string()), + }; + + snapshot_footer("footer_status_line_with_active_agent_label", props); + } + + #[test] + fn footer_status_line_truncates_to_keep_mode_indicator() { + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + queue_submissions: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: Some(Line::from( + "Status line content that is definitely too long to fit alongside the mode label" + .to_string(), + )), + status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), + active_agent_label: None, + }; + + let screen = render_footer_with_mode_indicator_and_context( + /*width*/ 80, + &props, + Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), + ); + let collapsed = screen.split_whitespace().collect::>().join(" "); + assert!( + collapsed.contains("Plan mode"), + "mode indicator should remain visible" + ); + assert!( + !collapsed.contains("shift+tab to cycle"), + "compact mode indicator should be used when space is tight" + ); + assert!( + screen.contains('…'), + "status line should be truncated with ellipsis to keep mode indicator" + ); + } + + #[test] + fn paste_image_shortcut_prefers_ctrl_alt_v_under_wsl() { + let descriptor = SHORTCUTS + .iter() + .find(|descriptor| descriptor.id == ShortcutId::PasteImage) + .expect("paste image shortcut"); + + let is_wsl = { + #[cfg(target_os = "linux")] + { + crate::clipboard_paste::is_probably_wsl() + } + #[cfg(not(target_os = "linux"))] + { + false + } + }; + + let expected_key = if is_wsl { + key_hint::ctrl_alt(KeyCode::Char('v')) + } else { + key_hint::ctrl(KeyCode::Char('v')) + }; + + let actual_key = descriptor + .binding_for(ShortcutsState { + use_shift_enter_hint: false, + esc_backtrack_hint: false, + is_task_running: false, + queue_submissions: false, + is_wsl, + collaboration_modes_enabled: false, + key_hints: FooterKeyHints::default_bindings(), + }) + .expect("shortcut binding") + .key; + + assert_eq!(actual_key, expected_key); + } +} diff --git a/codex-rs/tui/src/bottom_pane/hook_status.rs b/codex-rs/tui/src/bottom_pane/hook_status.rs new file mode 100644 index 0000000000000000000000000000000000000000..2552ea3bc9f8f72d710f6ccaafc47b4b359a1cac --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/hook_status.rs @@ -0,0 +1,32 @@ +//! Transient hook activity, independent of whether an agent turn owns a status row. + +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::motion::MotionMode; +use crate::motion::shimmer_text; +use crate::render::renderable::Renderable; +use crate::text_formatting::capitalize_first; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; + +pub(super) struct HookStatus<'a> { + pub message: &'a str, + pub animations_enabled: bool, +} + +impl Renderable for HookStatus<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + let mut spans = vec!["• ".dim()]; + spans.extend(shimmer_text( + &capitalize_first(self.message), + MotionMode::from_animations_enabled(self.animations_enabled), + )); + truncate_line_with_ellipsis_if_overflow(Line::from(spans).dim(), usize::from(area.width)) + .render(area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + 1 + } +} diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..88318fb08adad1165eb1f64201802152a1608a32 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -0,0 +1,1752 @@ +use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::HookHandlerMetadata; +use codex_app_server_protocol::HookMetadata; +use codex_app_server_protocol::HookSource; +use codex_app_server_protocol::HookTrustStatus; +use codex_app_server_protocol::HooksListEntry; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use strum::IntoEnumIterator; +use unicode_width::UnicodeWidthStr; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::render_menu_surface; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::hooks_rpc::HookTrustUpdate; +use crate::hooks_rpc::hook_needs_review; +use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::renderable::Renderable; +use crate::status::format_directory_display; +use crate::style::accent_style; + +const EVENT_COLUMN_WIDTH: usize = 22; +const COUNT_COLUMN_WIDTH: usize = 12; +const MAX_COMMAND_DETAIL_LINES: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HooksBrowserPage { + Events, + Handlers(HookEventName), +} + +pub(crate) struct HooksBrowserView { + entry: HooksListEntry, + page: HooksBrowserPage, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, + keymap: ListKeymap, +} + +impl HooksBrowserView { + #[cfg(test)] + pub(crate) fn new( + hooks: Vec, + warnings: Vec, + errors: Vec, + app_event_tx: AppEventSender, + ) -> Self { + Self::from_entry( + HooksListEntry { + cwd: std::path::PathBuf::new(), + hooks, + warnings, + errors, + }, + app_event_tx, + crate::keymap::RuntimeKeymap::defaults().list, + ) + } + + pub(crate) fn from_entry( + mut entry: HooksListEntry, + app_event_tx: AppEventSender, + keymap: ListKeymap, + ) -> Self { + entry.hooks.sort_by_key(|hook| hook.display_order); + let mut view = Self { + entry, + page: HooksBrowserPage::Events, + state: ScrollState::new(), + complete: false, + app_event_tx, + keymap, + }; + if view.page_len() > 0 { + view.state.selected_idx = Some( + view.event_rows() + .iter() + .position(|row| row.needs_review > 0) + .unwrap_or(0), + ); + } + view + } + + fn event_rows(&self) -> Vec { + codex_protocol::protocol::HookEventName::iter() + .map(|event_name| { + let event_name: HookEventName = event_name.into(); + let installed = self + .entry + .hooks + .iter() + .filter(|hook| hook.event_name == event_name) + .count(); + let active = self + .entry + .hooks + .iter() + .filter(|hook| hook.event_name == event_name && hook_is_active(hook)) + .count(); + let needs_review = self + .entry + .hooks + .iter() + .filter(|hook| hook.event_name == event_name && hook_needs_review(hook)) + .count(); + EventRow { + event_name, + installed, + active, + needs_review, + } + }) + .collect() + } + + fn handlers_for_event(&self, event_name: HookEventName) -> impl Iterator { + self.entry + .hooks + .iter() + .filter(move |hook| hook.event_name == event_name) + } + + fn selected_event(&self) -> Option { + self.state + .selected_idx + .and_then(|idx| codex_protocol::protocol::HookEventName::iter().nth(idx)) + .map(Into::into) + } + + fn selected_hook_index(&self, event_name: HookEventName) -> Option { + let selected_visible_idx = self.state.selected_idx?; + self.entry + .hooks + .iter() + .enumerate() + .filter(|(_, hook)| hook.event_name == event_name) + .nth(selected_visible_idx) + .map(|(idx, _)| idx) + } + + fn selected_hook(&self, event_name: HookEventName) -> Option<&HookMetadata> { + self.selected_hook_index(event_name) + .and_then(|idx| self.entry.hooks.get(idx)) + } + + fn move_up(&mut self) { + let len = self.page_len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, self.max_visible_rows()); + } + + fn move_down(&mut self) { + let len = self.page_len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, self.max_visible_rows()); + } + + fn page_up(&mut self) { + let len = self.page_len(); + self.state.page_up_clamped(len, self.max_visible_rows()); + } + + fn page_down(&mut self) { + let len = self.page_len(); + self.state.page_down_clamped(len, self.max_visible_rows()); + } + + fn jump_top(&mut self) { + let len = self.page_len(); + self.state.jump_top(len, self.max_visible_rows()); + } + + fn jump_bottom(&mut self) { + let len = self.page_len(); + self.state.jump_bottom(len, self.max_visible_rows()); + } + + fn page_len(&self) -> usize { + match self.page { + HooksBrowserPage::Events => codex_protocol::protocol::HookEventName::iter().count(), + HooksBrowserPage::Handlers(event_name) => self.handlers_for_event(event_name).count(), + } + } + + fn max_visible_rows(&self) -> usize { + MAX_POPUP_ROWS.min(self.page_len().max(1)) + } + + fn open_selected_event(&mut self) { + let Some(event_name) = self.selected_event() else { + return; + }; + self.page = HooksBrowserPage::Handlers(event_name); + self.state = ScrollState::new(); + if self.page_len() > 0 { + self.state.selected_idx = Some(0); + } + } + + fn toggle_selected_hook(&mut self, event_name: HookEventName) { + let Some(idx) = self.selected_hook_index(event_name) else { + return; + }; + let Some(hook) = self.entry.hooks.get_mut(idx) else { + return; + }; + if hook.is_managed { + return; + } + if hook_needs_review(hook) { + return; + } + + hook.enabled = !hook.enabled; + self.app_event_tx.send(AppEvent::SetHookEnabled { + key: hook.key.clone(), + enabled: hook.enabled, + }); + } + + fn trust_selected_hook(&mut self, event_name: HookEventName) { + let Some(idx) = self.selected_hook_index(event_name) else { + return; + }; + let Some(hook) = self.entry.hooks.get_mut(idx) else { + return; + }; + if !hook_needs_review(hook) { + return; + } + + hook.trust_status = HookTrustStatus::Trusted; + self.app_event_tx.send(AppEvent::TrustHook { + key: hook.key.clone(), + current_hash: hook.current_hash.clone(), + }); + } + + fn trust_all_hooks(&mut self) { + let mut updates = Vec::new(); + for hook in &mut self.entry.hooks { + if !hook_needs_review(hook) { + continue; + } + + hook.trust_status = HookTrustStatus::Trusted; + updates.push(HookTrustUpdate { + key: hook.key.clone(), + current_hash: hook.current_hash.clone(), + }); + } + if !updates.is_empty() { + self.app_event_tx.send(AppEvent::TrustHooks { updates }); + } + } + + fn close(&mut self) { + self.complete = true; + } + + fn return_to_events(&mut self) { + let selected_event_name = match self.page { + HooksBrowserPage::Events => None, + HooksBrowserPage::Handlers(event_name) => Some(event_name), + }; + self.page = HooksBrowserPage::Events; + self.state = ScrollState::new(); + self.state.selected_idx = selected_event_name + .and_then(|event_name| { + codex_protocol::protocol::HookEventName::iter() + .position(|candidate| HookEventName::from(candidate) == event_name) + }) + .or_else(|| (self.page_len() > 0).then_some(0)); + } + + fn event_header_lines() -> Vec> { + vec![ + "Hooks".bold().into(), + "Lifecycle hooks from config and enabled plugins." + .dim() + .into(), + ] + } + + fn review_needed_total_count(&self) -> usize { + self.entry + .hooks + .iter() + .filter(|hook| hook_needs_review(hook)) + .count() + } + + #[allow(clippy::disallowed_methods)] + fn handler_header_lines( + event_name: HookEventName, + review_needed_count: usize, + ) -> Vec> { + let mut lines = vec![format!("{} hooks", event_label(event_name)).bold().into()]; + match review_needed_message(review_needed_count) { + None => lines.push( + "Turn hooks on or off. Your changes are saved automatically." + .dim() + .into(), + ), + Some(message) => lines.push(message.yellow().into()), + } + lines + } + + fn review_needed_count(&self, event_name: HookEventName) -> usize { + self.handlers_for_event(event_name) + .filter(|hook| hook_needs_review(hook)) + .count() + } + + #[allow(clippy::disallowed_methods)] + fn event_table_lines(&self) -> Vec> { + let rows = self.event_rows(); + let show_review = rows.iter().any(|row| row.needs_review > 0); + let mut lines = Vec::new(); + let mut header = vec![ + format!("{: 0; + let mut row_line = vec![ + Span::from(format!( + "{: Vec> { + let mut lines = Vec::new(); + if self.entry.warnings.is_empty() && self.entry.errors.is_empty() { + return lines; + } + + lines.push("Issues".bold().into()); + lines.extend( + self.entry + .warnings + .iter() + .map(|warning| format!("⚠ {warning}").into()), + ); + lines.extend(self.entry.errors.iter().map(|error| { + format!("■ {}: {}", error.path.display(), error.message) + .red() + .into() + })); + lines + } + + #[allow(clippy::disallowed_methods)] + fn event_page_lines(&self) -> Vec> { + let mut lines = Self::event_header_lines(); + lines.push(Line::default()); + + if let Some(message) = review_needed_message(self.review_needed_total_count()) { + lines.push(format!("⚠ {message}").yellow().into()); + lines.push(Line::default()); + } + + let issue_lines = self.event_issue_lines(); + if !issue_lines.is_empty() { + lines.extend(issue_lines); + lines.push(Line::default()); + } + + lines.extend(self.event_table_lines()); + lines + } + + #[allow(clippy::disallowed_methods)] + fn handler_row_lines(&self, event_name: HookEventName, width: usize) -> Vec> { + self.handlers_for_event(event_name) + .enumerate() + .map(|(idx, hook)| { + let marker = if hook_needs_review(hook) { + '!' + } else if hook_is_active(hook) { + 'x' + } else { + ' ' + }; + let row = match hook.trust_status { + HookTrustStatus::Modified => { + format!("[{marker}] {} · modified", hook_title(idx)) + } + HookTrustStatus::Untrusted => format!("[{marker}] {} · new", hook_title(idx)), + HookTrustStatus::Managed | HookTrustStatus::Trusted => { + format!("[{marker}] {}", hook_title(idx)) + } + }; + let mut line = Line::from(row); + line = truncate_line_with_ellipsis_if_overflow(line, width); + let needs_review = hook_needs_review(hook); + if self.state.selected_idx == Some(idx) { + if needs_review { + line = line.yellow().bold(); + } else { + line = line.patch_style(accent_style()); + } + } else if needs_review { + line = line.yellow(); + } else if hook.is_managed { + line = line.dim(); + } + line + }) + .collect() + } + + fn detail_lines(&self, event_name: HookEventName, width: usize) -> Vec> { + let Some(hook) = self.selected_hook(event_name) else { + return vec!["No hooks installed for this event.".dim().into()]; + }; + + let mut lines = vec![detail_line("Event", event_label(event_name))]; + if let Some(matcher) = hook.matcher.as_deref() { + lines.extend(detail_wrapped_lines( + "Matcher", matcher, width, /*max_lines*/ None, + )); + } + lines.extend(detail_wrapped_lines( + "Source", + &detail_source_value(hook), + width, + /*max_lines*/ None, + )); + match &hook.handler { + HookHandlerMetadata::Command { command, r#async } => { + lines.extend(detail_wrapped_lines( + "Command", + command, + width, + Some(MAX_COMMAND_DETAIL_LINES), + )); + lines.push(detail_line("Mode", if *r#async { "Async" } else { "Sync" })); + } + HookHandlerMetadata::McpTool { server, tool } => { + lines.extend(detail_wrapped_lines( + "MCP Server", + server, + width, + /*max_lines*/ None, + )); + lines.extend(detail_wrapped_lines( + "MCP Tool", tool, width, /*max_lines*/ None, + )); + } + HookHandlerMetadata::Prompt {} => { + lines.push(detail_line("Handler", "Prompt")); + } + HookHandlerMetadata::Agent {} => { + lines.push(detail_line("Handler", "Agent")); + } + } + lines.push(detail_line("Timeout", &format!("{}s", hook.timeout_sec))); + if let Some(limit) = hook.additional_context_limit { + let value = if limit == 0 { + "unlimited".to_string() + } else { + format!("limit: {limit} approximate tokens") + }; + lines.push(detail_line("Context", &value)); + } + lines.push(detail_line("Trust", hook_trust_label(hook.trust_status))); + lines + } + + fn render_footer(&self, area: Rect, buf: &mut Buffer) { + let hint_area = Rect { + x: area.x + 2, + y: area.y, + width: area.width.saturating_sub(2), + height: area.height, + }; + let accept = self.keymap.primary_hint(ListAction::Accept); + let cancel = self + .keymap + .primary_hint(ListAction::Cancel) + .unwrap_or_else(|| key_hint::plain(KeyCode::Esc).into()); + let footer = match self.page { + HooksBrowserPage::Events if self.review_needed_total_count() > 0 => { + let mut spans = vec![ + "Press ".into(), + key_hint::plain(KeyCode::Char('t')).into(), + " to trust all; ".into(), + ]; + if let Some(accept) = accept { + spans.extend([accept.into(), " to review hooks; ".into()]); + } + spans.extend([cancel.into(), " to close".into()]); + Line::from(spans) + } + HooksBrowserPage::Events => { + let mut spans = vec!["Press ".into()]; + if let Some(accept) = accept { + spans.extend([accept.into(), " to view hooks; ".into()]); + } + spans.extend([cancel.into(), " to close".into()]); + Line::from(spans) + } + HooksBrowserPage::Handlers(event_name) => { + let selected_hook = self.selected_hook(event_name); + if selected_hook.is_none() { + Line::from(vec!["Press ".into(), cancel.into(), " to go back".into()]) + } else if selected_hook.is_some_and(|hook| hook.is_managed) { + Line::from(vec![ + "Managed hooks are always on; press ".into(), + cancel.into(), + " to go back".into(), + ]) + } else if selected_hook.is_some_and(hook_needs_review) { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Char('t')).into(), + " to trust; ".into(), + cancel.into(), + " to go back".into(), + ]) + } else { + let mut spans = + vec!["Press ".into(), key_hint::plain(KeyCode::Char(' ')).into()]; + if let Some(accept) = accept { + spans.extend([" or ".into(), accept.into()]); + } + spans.extend([" to toggle; ".into(), cancel.into(), " to go back".into()]); + Line::from(spans) + } + } + }; + footer.dim().render(hint_area, buf); + } +} + +impl BottomPaneView for HooksBrowserView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + _ if self.keymap.move_up.is_pressed(key_event) => self.move_up(), + _ if self.keymap.move_down.is_pressed(key_event) => self.move_down(), + _ if self.keymap.page_up.is_pressed(key_event) => self.page_up(), + _ if self.keymap.page_down.is_pressed(key_event) => self.page_down(), + _ if self.keymap.jump_top.is_pressed(key_event) => self.jump_top(), + _ if self.keymap.jump_bottom.is_pressed(key_event) => self.jump_bottom(), + _ if self.keymap.accept.is_pressed(key_event) + && self.page == HooksBrowserPage::Events => + { + self.open_selected_event() + } + _ if self.keymap.accept.is_pressed(key_event) => { + if let HooksBrowserPage::Handlers(event_name) = self.page { + self.toggle_selected_hook(event_name); + } + } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } => { + if let HooksBrowserPage::Handlers(event_name) = self.page { + self.toggle_selected_hook(event_name); + } + } + KeyEvent { + code: KeyCode::Char('t'), + modifiers: KeyModifiers::NONE, + .. + } => match self.page { + HooksBrowserPage::Events => self.trust_all_hooks(), + HooksBrowserPage::Handlers(event_name) => self.trust_selected_hook(event_name), + }, + _ if self.keymap.cancel.is_pressed(key_event) => match self.page { + HooksBrowserPage::Events => self.close(), + HooksBrowserPage::Handlers(_) => self.return_to_events(), + }, + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.close(); + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } +} + +impl Renderable for HooksBrowserView { + fn desired_height(&self, width: u16) -> u16 { + let content_width = width.saturating_sub(4) as usize; + let height = match self.page { + HooksBrowserPage::Events => self.event_page_lines().len(), + HooksBrowserPage::Handlers(event_name) => { + let row_count = self.handler_row_lines(event_name, content_width).len(); + let header_line_count = + Self::handler_header_lines(event_name, self.review_needed_count(event_name)) + .len(); + if row_count == 0 { + header_line_count + 2 + } else { + let visible_row_count = row_count.min(MAX_POPUP_ROWS); + header_line_count + + 1 + + visible_row_count + + 1 + + self.detail_lines(event_name, content_width).len() + } + } + }; + (height + 3).try_into().unwrap_or(u16::MAX) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + let content_area = render_menu_surface(content_area, buf); + let width = content_area.width as usize; + let lines = match self.page { + HooksBrowserPage::Events => self.event_page_lines(), + HooksBrowserPage::Handlers(event_name) => { + let mut lines = + Self::handler_header_lines(event_name, self.review_needed_count(event_name)); + let rows = self.handler_row_lines(event_name, width); + if rows.is_empty() { + lines.push(Line::default()); + lines.push(Line::from( + "No hooks installed for this event.".dim().italic(), + )); + lines.push(Line::default()); + Paragraph::new(lines).render(content_area, buf); + self.render_footer(footer_area, buf); + return; + } + let list_height = rows.len().clamp(1, MAX_POPUP_ROWS) as u16; + lines.push(Line::default()); + let header_height = lines.len() as u16; + let [header_area, list_area, detail_area] = Layout::vertical([ + Constraint::Length(header_height), + Constraint::Length(list_height), + Constraint::Fill(1), + ]) + .areas(content_area); + Paragraph::new(lines.clone()).render(header_area, buf); + let visible_rows = rows + .into_iter() + .skip(self.state.scroll_top) + .take(list_height as usize) + .collect::>(); + Paragraph::new(visible_rows).render(list_area, buf); + let mut detail_lines = vec![Line::default()]; + detail_lines.extend(self.detail_lines(event_name, width)); + Paragraph::new(detail_lines).render(detail_area, buf); + self.render_footer(footer_area, buf); + return; + } + }; + Paragraph::new(lines).render(content_area, buf); + self.render_footer(footer_area, buf); + } +} + +fn hook_is_active(hook: &HookMetadata) -> bool { + hook.enabled + && matches!( + hook.trust_status, + HookTrustStatus::Managed | HookTrustStatus::Trusted + ) +} + +fn review_needed_message(count: usize) -> Option { + match count { + 0 => None, + 1 => Some("1 hook needs review before it can run.".to_string()), + count => Some(format!("{count} hooks need review before they can run.")), + } +} + +struct EventRow { + event_name: HookEventName, + installed: usize, + active: usize, + needs_review: usize, +} + +fn hook_trust_label(status: HookTrustStatus) -> &'static str { + match status { + HookTrustStatus::Managed => "Managed", + HookTrustStatus::Trusted => "Trusted", + HookTrustStatus::Untrusted => "New hook - review required", + HookTrustStatus::Modified => "Modified since last trusted - review required", + } +} + +fn event_label(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", + HookEventName::PostToolUse => "PostToolUse", + HookEventName::PreCompact => "PreCompact", + HookEventName::PostCompact => "PostCompact", + HookEventName::SessionStart => "SessionStart", + HookEventName::SessionEnd => "SessionEnd", + HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", + HookEventName::SubagentStop => "SubagentStop", + HookEventName::Stop => "Stop", + HookEventName::Interrupt => "Interrupt", + } +} + +fn event_description(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "Before a tool executes", + HookEventName::PermissionRequest => "When permission is requested", + HookEventName::PostToolUse => "After a tool executes", + HookEventName::PreCompact => "Before context compaction", + HookEventName::PostCompact => "After context compaction", + HookEventName::SessionStart => "When a new session starts", + HookEventName::SessionEnd => "Right before a session ends", + HookEventName::UserPromptSubmit => "When the user submits a prompt", + HookEventName::SubagentStart => "When a subagent is created", + HookEventName::SubagentStop => "Right before a subagent ends its turn", + HookEventName::Stop => "Right before Codex ends its turn", + HookEventName::Interrupt => "Right before an interrupted turn is aborted", + } +} + +fn hook_title(idx: usize) -> String { + format!("Hook {}", idx + 1) +} + +fn hook_source_summary(hook: &HookMetadata) -> String { + match hook.source { + HookSource::Plugin => hook + .plugin_id + .as_deref() + .map(|plugin_id| format!("Plugin - {plugin_id}")) + .unwrap_or_else(|| "Plugin".to_string()), + _ => config_source_label(hook.source).to_string(), + } +} + +fn detail_source_value(hook: &HookMetadata) -> String { + match hook.source { + HookSource::Plugin => hook_source_summary(hook), + HookSource::System + | HookSource::Mdm + | HookSource::CloudRequirements + | HookSource::CloudManagedConfig + | HookSource::LegacyManagedConfigFile + | HookSource::LegacyManagedConfigMdm => config_source_label(hook.source).to_string(), + _ => format!( + "{} - {}", + config_source_label(hook.source), + format_directory_display(&hook.source_path, /*max_width*/ None) + ), + } +} + +fn config_source_label(source: HookSource) -> &'static str { + match source { + HookSource::System => "Admin config", + HookSource::User => "User config", + HookSource::Project => "Project config", + HookSource::Mdm => "Admin config", + HookSource::SessionFlags => "Session flags", + HookSource::Plugin => unreachable!("plugin hooks are handled by summary_source"), + HookSource::CloudRequirements => "Admin config", + HookSource::CloudManagedConfig => "Cloud-managed config", + HookSource::LegacyManagedConfigFile => "Admin config", + HookSource::LegacyManagedConfigMdm => "Admin config", + HookSource::Unknown => "Unknown source", + } +} + +fn detail_line(label: &str, value: &str) -> Line<'static> { + Line::from(vec![format!("{label:<10}").into(), value.to_string().dim()]) +} + +fn detail_wrapped_lines( + label: &str, + value: &str, + width: usize, + max_lines: Option, +) -> Vec> { + let label_width = label.width().saturating_add(1).max(10); + let prefix = format!("{label:(); + let max_width = width.saturating_sub(prefix_width); + let Some(last_span) = last_line.spans.last_mut() else { + return lines; + }; + let truncated = truncate_line_with_ellipsis_if_overflow( + Line::from(format!("{}…", last_span.content)), + max_width, + ); + let content = truncated + .spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::(); + last_span.content = content.into(); + } + lines +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::bottom_pane_view::BottomPaneView; + use crate::render::renderable::Renderable; + use crate::test_support::PathBufExt; + use crate::test_support::test_path_buf; + use crate::test_support::test_path_display; + use codex_app_server_protocol::HookErrorInfo; + use codex_app_server_protocol::HookEventName; + use codex_app_server_protocol::HookHandlerMetadata; + use codex_app_server_protocol::HookMetadata; + use codex_app_server_protocol::HookSource; + use codex_app_server_protocol::HookTrustStatus; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Color; + use ratatui::style::Modifier; + use tokio::sync::mpsc::unbounded_channel; + + fn render_lines(view: &HooksBrowserView, 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 rendered = (0..area.width) + .map(|col| { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + " ".to_string() + } else { + symbol.to_string() + } + }) + .collect::(); + let normalized = rendered + .replace(&test_path_display("/tmp/hooks.json"), "/tmp/hooks.json") + .replace(&test_path_display("/tmp/h.json"), "/tmp/h.json"); + format!("{normalized:width$}", width = area.width as usize) + }) + .collect::>() + .join("\n") + } + + fn render_buffer(view: &HooksBrowserView, width: u16) -> Buffer { + 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); + buf + } + + #[allow(clippy::too_many_arguments)] + fn hook( + key: &str, + event_name: HookEventName, + source: HookSource, + plugin_id: Option<&str>, + command: &str, + enabled: bool, + is_managed: bool, + display_order: i64, + ) -> HookMetadata { + let current_hash = "sha256:current".to_string(); + HookMetadata { + key: key.to_string(), + event_name, + handler: HookHandlerMetadata::Command { + command: command.to_string(), + r#async: false, + }, + is_managed, + matcher: Some("Bash".to_string()), + timeout_sec: 30, + status_message: None, + additional_context_limit: None, + source_path: test_path_buf("/tmp/hooks.json").abs(), + source, + plugin_id: plugin_id.map(str::to_string), + display_order, + enabled, + current_hash, + trust_status: if is_managed { + HookTrustStatus::Managed + } else { + HookTrustStatus::Trusted + }, + } + } + + fn view() -> HooksBrowserView { + let (tx_raw, _rx) = unbounded_channel::(); + HooksBrowserView::new( + vec![ + hook( + "plugin:superpowers", + HookEventName::PreToolUse, + HookSource::Plugin, + Some("superpowers@openai-curated"), + "${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ), + hook( + "path:user-config", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "~/bin/check-shell-with-a-command-that-is-way-too-long-for-the-summary-column.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 1, + ), + hook( + "path:managed", + HookEventName::PermissionRequest, + HookSource::System, + /*plugin_id*/ None, + "/enterprise/hooks/permission-check.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 2, + ), + ], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ) + } + + #[test] + fn renders_event_browser() { + let mut view = view(); + assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112)); + view.keymap.accept.clear(); + assert!(!render_lines(&view, /*width*/ 112).contains("enter to view hooks")); + } + + #[test] + fn selected_event_rows_use_the_shared_accent_style() { + let view = view(); + let buf = render_buffer(&view, /*width*/ 112); + let expected = accent_style(); + + let selected_cell = buf + .content + .iter() + .find(|cell| { + let style = cell.style(); + cell.symbol() == "P" + && style.fg == expected.fg + && style.add_modifier.contains(Modifier::BOLD) + }) + .expect("selected event row should use the shared accent style"); + + assert_eq!(selected_cell.style().fg, expected.fg); + } + + #[test] + fn renders_event_browser_with_review_column_when_needed() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + assert_snapshot!( + "hooks_browser_events_with_review_column", + render_lines(&view, /*width*/ 112) + ); + assert_eq!( + view.event_table_lines()[1].spans[3].style.fg, + Some(Color::Cyan) + ); + assert!( + view.event_table_lines()[1].spans[3] + .style + .add_modifier + .contains(ratatui::style::Modifier::BOLD) + ); + } + + #[test] + fn renders_event_browser_with_issues() { + let (tx_raw, _rx) = unbounded_channel::(); + let view = HooksBrowserView::new( + Vec::new(), + vec!["skipped invalid matcher for PreToolUse".to_string()], + vec![HookErrorInfo { + path: test_path_buf("/tmp/hooks.json"), + message: "failed to parse hooks config".to_string(), + }], + AppEventSender::new(tx_raw), + ); + + assert_snapshot!( + "hooks_browser_events_with_issues", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_handler_browser_with_details() { + let mut view = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!("hooks_browser_handlers", render_lines(&view, /*width*/ 112)); + } + + #[test] + fn renders_mcp_tool_handler_with_server_and_tool_details() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut configured_hook = hook( + "plugin:security-scanner", + HookEventName::PreToolUse, + HookSource::Plugin, + Some("security-tools@openai-curated"), + "", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ); + configured_hook.handler = HookHandlerMetadata::McpTool { + server: "scanner".to_string(), + tool: "scan_file".to_string(), + }; + let mut view = HooksBrowserView::new( + vec![configured_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_snapshot!( + "hooks_browser_mcp_tool_handler", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_handler_additional_context_limit() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut configured_hook = hook( + "path:context-limit", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ); + configured_hook.additional_context_limit = Some(0); + let mut view = HooksBrowserView::new( + vec![configured_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_snapshot!( + "hooks_browser_additional_context_limit", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_untrusted_enabled_handler_as_inactive() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "~/bin/untrusted.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let HookHandlerMetadata::Command { r#async, .. } = &mut untrusted_hook.handler else { + panic!("expected command hook"); + }; + *r#async = true; + let mut view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_snapshot!( + "hooks_browser_untrusted_enabled_handler", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn review_needed_handler_rows_use_warning_color() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "~/bin/untrusted.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 1, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let mut view = HooksBrowserView::new( + vec![ + hook( + "path:trusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "~/bin/trusted.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ), + untrusted_hook, + ], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_eq!( + view.handler_row_lines(HookEventName::PreToolUse, /*width*/ 112)[1] + .style + .fg, + Some(Color::Yellow) + ); + } + + #[test] + fn review_needed_handler_header_uses_warning_color() { + assert_eq!( + HooksBrowserView::handler_header_lines( + HookEventName::PreToolUse, + /*review_needed_count*/ 1, + )[1] + .spans[0] + .style + .fg, + Some(Color::Yellow) + ); + } + + #[test] + fn renders_managed_handler_without_toggle_hint() { + let mut view = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!( + "hooks_browser_managed_handler", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_selected_managed_handler() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + vec![ + hook( + "path:managed-1", + HookEventName::PreToolUse, + HookSource::System, + /*plugin_id*/ None, + "/enterprise/hooks/pre-tool-use-1.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 0, + ), + hook( + "path:managed-2", + HookEventName::PreToolUse, + HookSource::System, + /*plugin_id*/ None, + "/enterprise/hooks/pre-tool-use-2.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 1, + ), + ], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + assert_snapshot!( + "hooks_browser_selected_managed_handler", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_scrolled_handler_window() { + let (tx_raw, _rx) = unbounded_channel::(); + let hooks = (0..=MAX_POPUP_ROWS) + .map(|idx| { + hook( + &format!("path:hook-{idx}"), + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + &format!("/tmp/hook-{idx}.sh"), + /*enabled*/ true, + /*is_managed*/ false, + idx as i64, + ) + }) + .collect(); + let mut view = + HooksBrowserView::new(hooks, Vec::new(), Vec::new(), AppEventSender::new(tx_raw)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + for _ in 0..MAX_POPUP_ROWS { + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + } + assert_snapshot!( + "hooks_browser_scrolled_handlers", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_command_details_with_three_line_cap() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut capped_command_hook = hook( + "path:long-command", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ); + capped_command_hook.source_path = test_path_buf("/tmp/h.json").abs(); + let mut view = HooksBrowserView::new( + vec![capped_command_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!( + "hooks_browser_capped_command_details", + render_lines(&view, /*width*/ 44) + ); + } + + #[test] + fn renders_empty_handler_browser_message() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + Vec::new(), + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!( + "hooks_browser_empty_handlers", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn managed_hooks_count_as_active() { + let (tx_raw, _rx) = unbounded_channel::(); + let view = HooksBrowserView::new( + vec![hook( + "path:managed", + HookEventName::PreToolUse, + HookSource::System, + /*plugin_id*/ None, + "/enterprise/hooks/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 0, + )], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + let rows = view.event_rows(); + let pre_tool_use = rows + .into_iter() + .find(|row| row.event_name == HookEventName::PreToolUse) + .expect("pre tool use row"); + + assert_eq!(pre_tool_use.installed, 1); + assert_eq!(pre_tool_use.active, 1); + } + + #[test] + fn review_needed_hooks_are_not_active() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + let rows = view.event_rows(); + let pre_tool_use = rows + .into_iter() + .find(|row| row.event_name == HookEventName::PreToolUse) + .expect("pre tool use row"); + + assert_eq!(pre_tool_use.installed, 1); + assert_eq!(pre_tool_use.active, 0); + assert_eq!(pre_tool_use.needs_review, 1); + } + + #[test] + fn review_needed_event_is_selected_by_default() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PermissionRequest, + HookSource::User, + /*plugin_id*/ None, + "/tmp/permission-request-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + assert_eq!( + view.selected_event(), + Some(HookEventName::PermissionRequest) + ); + } + + #[test] + fn renders_review_needed_handler() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let mut view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_snapshot!( + "hooks_browser_review_needed_handler", + render_lines(&view, /*width*/ 112) + ); + } + + fn assert_unmanaged_toggle_key(key_code: KeyCode) { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + vec![hook( + "plugin:superpowers", + HookEventName::PreToolUse, + HookSource::Plugin, + Some("superpowers@openai-curated"), + "hooks/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + )], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(key_code)); + + match rx.try_recv().expect("toggle event") { + AppEvent::SetHookEnabled { key, enabled } => { + assert_eq!(key, "plugin:superpowers"); + assert!(!enabled); + } + other => panic!("expected hook toggle event, got {other:?}"), + } + } + + #[test] + fn toggle_keys_toggle_unmanaged_handler() { + for key_code in [KeyCode::Char(' '), KeyCode::Enter] { + assert_unmanaged_toggle_key(key_code); + } + } + + #[test] + fn space_does_not_toggle_managed_handler() { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + vec![hook( + "path:managed", + HookEventName::PreToolUse, + HookSource::System, + /*plugin_id*/ None, + "/enterprise/hooks/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 0, + )], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Char(' '))); + + assert!(rx.try_recv().is_err()); + } + + #[test] + fn trust_key_trusts_review_needed_handler_without_changing_enablement() { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let current_hash = untrusted_hook.current_hash.clone(); + let mut view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Char('t'))); + + match rx.try_recv().expect("trust event") { + AppEvent::TrustHook { + key, + current_hash: hash_to_trust, + } => { + assert_eq!(key, "path:untrusted"); + assert_eq!(hash_to_trust, current_hash); + } + other => panic!("expected hook trust event, got {other:?}"), + } + } + + #[test] + fn trust_key_preserves_disabled_modified_handler() { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut modified_hook = hook( + "path:modified", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 0, + ); + modified_hook.trust_status = HookTrustStatus::Modified; + let current_hash = modified_hook.current_hash.clone(); + let mut view = HooksBrowserView::new( + vec![modified_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Char('t'))); + + let hook = view.entry.hooks.first().expect("trusted hook"); + assert!(!hook.enabled); + assert_eq!(hook.trust_status, HookTrustStatus::Trusted); + match rx.try_recv().expect("trust event") { + AppEvent::TrustHook { + key, + current_hash: hash_to_trust, + } => { + assert_eq!(key, "path:modified"); + assert_eq!(hash_to_trust, current_hash); + } + other => panic!("expected hook trust event, got {other:?}"), + } + } + + #[test] + fn trust_key_on_event_page_trusts_all_review_needed_hooks() { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let mut modified_hook = hook( + "path:modified", + HookEventName::Stop, + HookSource::User, + /*plugin_id*/ None, + "/tmp/stop-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 1, + ); + modified_hook.trust_status = HookTrustStatus::Modified; + let mut view = HooksBrowserView::new( + vec![ + untrusted_hook, + modified_hook, + hook( + "path:trusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/trusted.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 2, + ), + ], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + view.handle_key_event(KeyEvent::from(KeyCode::Char('t'))); + + assert_eq!( + view.entry + .hooks + .iter() + .map(|hook| hook.trust_status) + .collect::>(), + vec![ + HookTrustStatus::Trusted, + HookTrustStatus::Trusted, + HookTrustStatus::Trusted, + ] + ); + match rx.try_recv().expect("trust event") { + AppEvent::TrustHooks { updates } => assert_eq!( + updates, + vec![ + HookTrustUpdate { + key: "path:untrusted".to_string(), + current_hash: "sha256:current".to_string(), + }, + HookTrustUpdate { + key: "path:modified".to_string(), + current_hash: "sha256:current".to_string(), + }, + ] + ), + other => panic!("expected hook trust event, got {other:?}"), + } + assert!(rx.try_recv().is_err()); + } + + #[test] + fn escape_returns_to_the_selected_event() { + let mut view = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert_eq!(view.page, HooksBrowserPage::Events); + assert_eq!( + view.selected_event(), + Some(HookEventName::PermissionRequest) + ); + } + + #[test] + fn esc_routes_through_the_view() { + assert!(view().prefer_esc_to_handle_key_event()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..57c3e8793e80034a571732bfd00ddc2534fafa41 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -0,0 +1,3001 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use itertools::Itertools as _; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use super::selection_popup_common::render_menu_surface; +use super::selection_popup_common::wrap_styled_line; +use crate::app_event_sender::AppEventSender; +use crate::clipboard_paste::normalize_pasted_search_query; +use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::key_hint::ShortcutHint; +use crate::key_hint::is_plain_text_key_event; +use crate::keymap::ListKeymap; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::bottom_pane_view::ViewCompletion; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::ColumnWidthConfig; +pub(crate) use super::selection_popup_common::ColumnWidthMode; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height_with_col_width_mode; +use super::selection_popup_common::render_rows_single_line_with_col_width_mode; +use super::selection_popup_common::render_rows_with_col_width_mode; +pub(crate) use super::selection_row_layout::SelectionDescriptionLayout; +use super::selection_tabs::SelectionTab; +use super::selection_tabs::render_tab_bar; +use super::selection_tabs::tab_bar_height; +use unicode_width::UnicodeWidthStr; + +/// Minimum list width (in content columns) required before the side-by-side +/// layout is activated. Keeps the list usable even when sharing horizontal +/// space with the side content panel. +const MIN_LIST_WIDTH_FOR_SIDE: u16 = 40; + +/// Horizontal gap (in columns) between the list area and the side content +/// panel when side-by-side layout is active. +const SIDE_CONTENT_GAP: u16 = 2; + +/// Shared menu-surface horizontal inset (2 cells per side) used by selection popups. +const MENU_SURFACE_HORIZONTAL_INSET: u16 = 4; + +/// Controls how the side content panel is sized relative to the popup width. +/// +/// When the computed side width falls below `side_content_min_width` or the +/// remaining list area would be narrower than [`MIN_LIST_WIDTH_FOR_SIDE`], the +/// side-by-side layout is abandoned and the stacked fallback is used instead. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SideContentWidth { + /// Fixed number of columns. `Fixed(0)` disables side content entirely. + Fixed(u16), + /// Exact 50/50 split of the content area (minus the inter-column gap). + Half, +} + +impl Default for SideContentWidth { + fn default() -> Self { + Self::Fixed(0) + } +} + +/// Returns the popup content width after subtracting the shared menu-surface +/// horizontal inset (2 columns on each side). +pub(crate) fn popup_content_width(total_width: u16) -> u16 { + total_width.saturating_sub(MENU_SURFACE_HORIZONTAL_INSET) +} + +/// Returns side-by-side layout widths as `(list_width, side_width)` when the +/// layout can fit. Returns `None` when the side panel is disabled/too narrow or +/// when the remaining list width would become unusably small. +pub(crate) fn side_by_side_layout_widths( + content_width: u16, + side_content_width: SideContentWidth, + side_content_min_width: u16, +) -> Option<(u16, u16)> { + let side_width = match side_content_width { + SideContentWidth::Fixed(0) => return None, + SideContentWidth::Fixed(width) => width, + SideContentWidth::Half => content_width.saturating_sub(SIDE_CONTENT_GAP) / 2, + }; + if side_width < side_content_min_width { + return None; + } + let list_width = content_width.saturating_sub(SIDE_CONTENT_GAP + side_width); + (list_width >= MIN_LIST_WIDTH_FOR_SIDE).then_some((list_width, side_width)) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum SelectionRowDisplay { + #[default] + Wrapped, + SingleLine, +} + +/// One selectable item in the generic selection list. +pub(crate) type SelectionAction = Box; + +/// A second way to accept the highlighted row, sharing its dismissal behavior. +pub(crate) struct SelectionSecondaryAction { + pub key: KeyBinding, + pub action: SelectionAction, + pub footer_hint: Line<'static>, +} + +enum SelectionActionKind { + Primary, + Secondary, +} +pub(crate) type SelectionToggleAction = dyn Fn(bool, &AppEventSender) + Send + Sync; + +pub(crate) struct SelectionToggle { + pub is_on: bool, + pub action: Box, +} + +/// Callback invoked whenever the highlighted item changes (arrow keys, search +/// filter, number-key jump). Receives the *actual* index into the unfiltered +/// `items` list and the event sender. Used by the theme picker for live preview. +pub(crate) type OnSelectionChangedCallback = + Option>; + +/// Callback invoked when the picker is dismissed without accepting (Esc or +/// Ctrl+C). Used by the theme picker to restore the pre-open theme. +pub(crate) type OnCancelCallback = Option>; + +/// One row in a [`ListSelectionView`] selection list. +/// +/// This is the source-of-truth model for row state before filtering and +/// formatting into render rows. A row is treated as disabled when either +/// `is_disabled` is true or `disabled_reason` is present; disabled rows cannot +/// be accepted and are skipped by keyboard navigation. +#[derive(Default)] +pub(crate) struct SelectionItem { + pub name: String, + pub name_prefix_spans: Vec>, + pub toggle: Option, + pub toggle_placeholder: Option<&'static str>, + pub display_shortcut: Option, + pub description: Option, + pub selected_description: Option, + pub is_current: bool, + pub is_default: bool, + pub is_disabled: bool, + pub actions: Vec, + pub secondary_action: Option, + pub dismiss_on_select: bool, + /// Require an explicit accept key after a direct shortcut highlights this sensitive item. + pub require_explicit_confirmation: bool, + pub dismiss_parent_on_child_accept: bool, + pub search_value: Option, + pub disabled_reason: Option, + /// Optional marker rendered in place of the number for a disabled row. + pub disabled_gutter_marker: Option<&'static str>, +} + +/// Construction-time configuration for [`ListSelectionView`]. +/// +/// This config is consumed once by [`ListSelectionView::new`]. After +/// construction, mutable interaction state (filtering, scrolling, and selected +/// row) lives on the view itself. +/// +/// `col_width_mode` controls column width mode in selection lists: +/// `AutoVisible` (default) measures only rows visible in the viewport +/// `AutoAllRows` measures all rows to ensure stable column widths as the user scrolls +/// `Fixed` used a fixed 30/70 split between columns +/// `row_display` controls whether rows can wrap or stay single-line with ellipsis truncation +/// `description_layout` optionally moves descriptions below labels when their +/// column would become too narrow. +pub(crate) struct SelectionViewParams { + pub view_id: Option<&'static str>, + pub title: Option, + pub subtitle: Option, + pub footer_note: Option>, + pub footer_hint: Option>, + pub tab_footer_hints: Vec<(String, Line<'static>)>, + pub items: Vec, + pub tabs: Vec, + pub initial_tab_id: Option, + pub is_searchable: bool, + pub search_placeholder: Option, + pub col_width_mode: ColumnWidthMode, + pub row_display: SelectionRowDisplay, + pub description_layout: SelectionDescriptionLayout, + /// Rendered left-column width to use for auto-sized rows. + pub name_column_width: Option, + pub header: Box, + /// Blank rows after the header; defaults to one. Inline banners use zero. + pub header_gap: u16, + pub initial_selected_idx: Option, + + /// Rich content rendered beside (wide terminals) or below (narrow terminals) + /// the list items, inside the bordered menu surface. Used by the theme picker + /// to show a syntax-highlighted preview. + pub side_content: Box, + + /// Width mode for side content when side-by-side layout is active. + pub side_content_width: SideContentWidth, + + /// Minimum side panel width required before side-by-side layout activates. + pub side_content_min_width: u16, + + /// Optional fallback content rendered when side-by-side does not fit. + /// When absent, `side_content` is reused. + pub stacked_side_content: Option>, + + /// Keep side-content background colors after rendering in side-by-side mode. + /// Disabled by default so existing popups preserve their reset-background look. + pub preserve_side_content_bg: bool, + + /// Called when the highlighted item changes (navigation, filter, number-key). + /// Receives the *actual* item index, not the filtered/visible index. + pub on_selection_changed: OnSelectionChangedCallback, + + /// Whether cancellation keys can dismiss the picker. + pub allow_cancel: bool, + + /// Called when the picker is dismissed via Esc/Ctrl+C without selecting. + pub on_cancel: OnCancelCallback, +} + +impl Default for SelectionViewParams { + fn default() -> Self { + Self { + view_id: None, + title: None, + subtitle: None, + footer_note: None, + footer_hint: None, + tab_footer_hints: Vec::new(), + items: Vec::new(), + tabs: Vec::new(), + initial_tab_id: None, + is_searchable: false, + search_placeholder: None, + col_width_mode: ColumnWidthMode::AutoVisible, + row_display: SelectionRowDisplay::Wrapped, + description_layout: SelectionDescriptionLayout::Columns, + name_column_width: None, + header: Box::new(()), + header_gap: 1, + initial_selected_idx: None, + side_content: Box::new(()), + side_content_width: SideContentWidth::default(), + side_content_min_width: 0, + stacked_side_content: None, + preserve_side_content_bg: false, + on_selection_changed: None, + allow_cancel: true, + on_cancel: None, + } + } +} + +/// Runtime state for rendering and interacting with a list-based selection popup. +/// +/// This type is the single authority for filtered index mapping between +/// visible rows and source items and for preserving selection while filters +/// change. +pub(crate) struct ListSelectionView { + view_id: Option<&'static str>, + footer_note: Option>, + footer_hint: Option>, + tab_footer_hints: Vec<(String, Line<'static>)>, + items: Vec, + tabs: Vec, + active_tab_idx: Option, + state: ScrollState, + completion: Option, + pub(super) dismiss_after_child_accept: bool, + app_event_tx: AppEventSender, + is_searchable: bool, + search_query: String, + search_placeholder: Option, + col_width_mode: ColumnWidthMode, + row_display: SelectionRowDisplay, + description_layout: SelectionDescriptionLayout, + name_column_width: Option, + filtered_indices: Vec, + last_selected_actual_idx: Option, + rendered_item_count: std::cell::Cell, + header: Box, + header_gap: u16, + initial_selected_idx: Option, + side_content: Box, + side_content_width: SideContentWidth, + side_content_min_width: u16, + stacked_side_content: Option>, + preserve_side_content_bg: bool, + + /// Called when the highlighted item changes (navigation, filter, number-key). + on_selection_changed: OnSelectionChangedCallback, + + allow_cancel: bool, + + /// Called when the picker is dismissed via Esc/Ctrl+C without selecting. + on_cancel: OnCancelCallback, + keymap: ListKeymap, +} + +const SELECTION_TOGGLE_ON_PREFIX: &str = "[*] "; +const SELECTION_TOGGLE_OFF_PREFIX: &str = "[ ] "; +pub(crate) const SELECTION_TOGGLE_UNAVAILABLE_PREFIX: &str = "[-] "; +pub(crate) const SELECTION_TOGGLE_BLOCKED_PREFIX: &str = "[!] "; + +fn selection_toggle_prefix(toggle: &SelectionToggle) -> &'static str { + if toggle.is_on { + SELECTION_TOGGLE_ON_PREFIX + } else { + SELECTION_TOGGLE_OFF_PREFIX + } +} + +fn selection_item_toggle_prefix(item: &SelectionItem) -> Option<&'static str> { + item.toggle + .as_ref() + .map(selection_toggle_prefix) + .or(item.toggle_placeholder) +} + +impl ListSelectionView { + fn selected_item_has_toggle(&self) -> bool { + self.selected_actual_idx() + .and_then(|actual_idx| self.active_items().get(actual_idx)) + .is_some_and(|item| item.toggle.is_some() && Self::item_is_enabled(item)) + } + + fn selected_item_has_toggle_placeholder(&self) -> bool { + self.selected_actual_idx() + .and_then(|actual_idx| self.active_items().get(actual_idx)) + .is_some_and(|item| { + item.toggle.is_none() + && item.toggle_placeholder.is_some() + && Self::item_is_enabled(item) + }) + } + + fn toggle_selected(&mut self) { + let Some(actual_idx) = self.selected_actual_idx() else { + return; + }; + let app_event_tx = self.app_event_tx.clone(); + let Some(item) = self.active_items_mut().get_mut(actual_idx) else { + return; + }; + if !Self::item_is_enabled(item) { + return; + } + let Some(toggle) = item.toggle.as_mut() else { + return; + }; + + toggle.is_on = !toggle.is_on; + (toggle.action)(toggle.is_on, &app_event_tx); + } +} + +impl ListSelectionView { + /// Create a selection popup view with filtering, scrolling, and callbacks wired. + /// + /// The constructor normalizes header/title composition and immediately + /// applies filtering so `ScrollState` starts in a valid visible range. + /// When search is enabled, rows without `search_value` will disappear as + /// soon as the query is non-empty, which can look like dropped data unless + /// callers intentionally populate that field. + pub fn new( + params: SelectionViewParams, + app_event_tx: AppEventSender, + keymap: ListKeymap, + ) -> Self { + let mut header = params.header; + if params.title.is_some() || params.subtitle.is_some() { + let title = params.title.map(|title| Line::from(title.bold())); + let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim())); + header = Box::new(ColumnRenderable::with([ + header, + Box::new(title), + Box::new(subtitle), + ])); + } + let active_tab_idx = params.initial_tab_id.as_ref().and_then(|initial_tab_id| { + params + .tabs + .iter() + .position(|tab| tab.id.as_str() == initial_tab_id.as_str()) + }); + let active_tab_idx = if params.tabs.is_empty() { + None + } else { + Some(active_tab_idx.unwrap_or(0)) + }; + let has_initial_selected_idx = params.initial_selected_idx.is_some(); + let mut s = Self { + view_id: params.view_id, + footer_note: params.footer_note, + footer_hint: params.footer_hint, + tab_footer_hints: params.tab_footer_hints, + items: params.items, + tabs: params.tabs, + active_tab_idx, + state: ScrollState::new(), + completion: None, + dismiss_after_child_accept: false, + app_event_tx, + is_searchable: params.is_searchable, + search_query: String::new(), + search_placeholder: if params.is_searchable { + params.search_placeholder + } else { + None + }, + col_width_mode: params.col_width_mode, + row_display: params.row_display, + description_layout: params.description_layout, + name_column_width: params.name_column_width, + filtered_indices: Vec::new(), + last_selected_actual_idx: None, + rendered_item_count: std::cell::Cell::new(0), + header, + header_gap: params.header_gap, + initial_selected_idx: params.initial_selected_idx, + side_content: params.side_content, + side_content_width: params.side_content_width, + side_content_min_width: params.side_content_min_width, + stacked_side_content: params.stacked_side_content, + preserve_side_content_bg: params.preserve_side_content_bg, + on_selection_changed: params.on_selection_changed, + allow_cancel: params.allow_cancel, + on_cancel: params.on_cancel, + keymap, + }; + s.apply_filter(); + if s.tabs_enabled() && !has_initial_selected_idx && s.state.selected_idx.is_none() { + s.select_first_enabled_row(); + } + s + } + + fn visible_len(&self) -> usize { + self.filtered_indices.len() + } + + fn tabs_enabled(&self) -> bool { + self.active_tab_idx.is_some() + } + + fn active_items(&self) -> &[SelectionItem] { + self.active_tab_idx + .and_then(|idx| self.tabs.get(idx)) + .map(|tab| tab.items.as_slice()) + .unwrap_or(self.items.as_slice()) + } + + fn active_items_mut(&mut self) -> &mut [SelectionItem] { + if let Some(idx) = self.active_tab_idx + && let Some(tab) = self.tabs.get_mut(idx) + { + return tab.items.as_mut_slice(); + } + self.items.as_mut_slice() + } + + fn active_header(&self) -> &dyn Renderable { + self.active_tab_idx + .and_then(|idx| self.tabs.get(idx)) + .map(|tab| tab.header.as_ref()) + .unwrap_or(self.header.as_ref()) + } + + fn active_footer_hint(&self) -> Option<&Line<'static>> { + if let Some(action) = self + .selected_actual_idx() + .and_then(|idx| self.active_items().get(idx)) + .and_then(|item| item.secondary_action.as_ref()) + { + return Some(&action.footer_hint); + } + self.active_tab_id() + .and_then(|active_tab_id| { + self.tab_footer_hints + .iter() + .find_map(|(tab_id, hint)| (tab_id.as_str() == active_tab_id).then_some(hint)) + }) + .or(self.footer_hint.as_ref()) + } + + fn active_tab_id(&self) -> Option<&str> { + self.active_tab_idx + .and_then(|idx| self.tabs.get(idx)) + .map(|tab| tab.id.as_str()) + } + + fn max_visible_rows(len: usize) -> usize { + MAX_POPUP_ROWS.min(len.max(1)) + } + + fn selected_actual_idx(&self) -> Option { + self.state + .selected_idx + .and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied()) + } + + fn apply_filter(&mut self) { + let previously_selected = self + .selected_actual_idx() + .filter(|actual_idx| self.enabled_actual_idx(*actual_idx).is_some()) + .or_else(|| { + self.initial_selected_idx + .take() + .filter(|actual_idx| self.enabled_actual_idx(*actual_idx).is_some()) + }) + .or_else(|| { + (!self.is_searchable) + .then(|| { + self.active_items() + .iter() + .position(|item| item.is_current && Self::item_is_enabled(item)) + }) + .flatten() + }); + + if self.is_searchable && !self.search_query.is_empty() { + let query_lower = self.search_query.to_lowercase(); + self.filtered_indices = self + .active_items() + .iter() + .positions(|item| { + item.search_value + .as_ref() + .is_some_and(|v| v.to_lowercase().contains(&query_lower)) + }) + .collect(); + } else { + self.filtered_indices = (0..self.active_items().len()).collect(); + } + + let len = self.filtered_indices.len(); + let selected_visible_idx = self + .state + .selected_idx + .and_then(|visible_idx| { + self.filtered_indices + .get(visible_idx) + .and_then(|idx| self.filtered_indices.iter().position(|cur| cur == idx)) + }) + .or_else(|| { + previously_selected.and_then(|actual_idx| { + self.filtered_indices + .iter() + .position(|idx| *idx == actual_idx) + }) + }); + self.state.selected_idx = selected_visible_idx + .filter(|visible_idx| { + self.filtered_indices + .get(*visible_idx) + .and_then(|actual_idx| self.active_items().get(*actual_idx)) + .is_some_and(Self::item_is_enabled) + }) + .or_else(|| self.first_enabled_visible_idx()) + .or_else(|| (len > 0).then_some(0)); + + let visible = Self::max_visible_rows(len); + self.state.clamp_selection(len); + self.state.ensure_visible(len, visible); + + // Notify the callback when filtering changes the selected actual item + // so live preview stays in sync (e.g. typing in the theme picker). + let new_actual = self.selected_actual_idx(); + if new_actual != previously_selected { + self.fire_selection_changed(); + } + } + + fn build_rows(&self) -> Vec { + let enabled_row_number_width = self + .filtered_indices + .iter() + .filter(|actual_idx| { + self.active_items() + .get(**actual_idx) + .is_some_and(Self::item_is_enabled) + }) + .count() + .max(1) + .to_string() + .len(); + let mut enabled_row_number = 0; + self.filtered_indices + .iter() + .enumerate() + .filter_map(|(visible_idx, actual_idx)| { + self.active_items().get(*actual_idx).map(|item| { + let is_selected = self.state.selected_idx == Some(visible_idx); + let prefix = if is_selected { '›' } else { ' ' }; + let name = item.name.as_str(); + let marker = if item.is_current { + " (current)" + } else if item.is_default { + " (default)" + } else { + "" + }; + let name_with_marker = format!("{name}{marker}"); + let is_disabled = item.is_disabled || item.disabled_reason.is_some(); + let wrap_prefix = if self.is_searchable { + // The number keys don't work when search is enabled (since we let the + // numbers be used for the search query). + format!("{prefix} ") + } else if is_disabled { + if let Some(disabled_gutter_marker) = item.disabled_gutter_marker { + let marker_width = UnicodeWidthStr::width(disabled_gutter_marker); + let marker_padding = + " ".repeat(enabled_row_number_width.saturating_sub(marker_width)); + format!("{prefix} {marker_padding}{disabled_gutter_marker} ") + } else { + format!("{prefix} {}", " ".repeat(enabled_row_number_width + 2)) + } + } else { + enabled_row_number += 1; + let n = enabled_row_number; + format!("{prefix} {n}. ") + }; + let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); + let mut name_prefix_spans = Vec::new(); + name_prefix_spans.push(wrap_prefix.into()); + if let Some(toggle_prefix) = selection_item_toggle_prefix(item) { + name_prefix_spans.push(toggle_prefix.into()); + } + name_prefix_spans.extend(item.name_prefix_spans.clone()); + let description = is_selected + .then(|| item.selected_description.clone()) + .flatten() + .or_else(|| item.description.clone()); + let wrap_indent = description.is_none().then_some(wrap_prefix_width); + GenericDisplayRow { + name: name_with_marker, + name_prefix_spans, + display_shortcut: item.display_shortcut, + match_indices: None, + description, + category_tag: None, + wrap_indent, + is_disabled, + disabled_reason: item.disabled_reason.clone(), + } + }) + }) + .collect() + } + + fn switch_tab(&mut self, step: isize) { + let Some(active_idx) = self.active_tab_idx else { + return; + }; + let len = self.tabs.len(); + if len == 0 { + return; + } + + let next_idx = if step.is_negative() { + active_idx.checked_sub(1).unwrap_or(len - 1) + } else { + (active_idx + 1) % len + }; + self.active_tab_idx = Some(next_idx); + self.search_query.clear(); + self.state.reset(); + self.apply_filter(); + if self.state.selected_idx.is_none() { + self.select_first_enabled_row(); + } + self.fire_selection_changed(); + } + + fn select_first_enabled_row(&mut self) { + let selected_visible_idx = self + .first_enabled_visible_idx() + .or_else(|| (!self.filtered_indices.is_empty()).then_some(0)); + self.state.selected_idx = selected_visible_idx; + self.state.scroll_top = 0; + } + + fn first_enabled_visible_idx(&self) -> Option { + self.filtered_indices.iter().position(|actual_idx| { + self.active_items() + .get(*actual_idx) + .is_some_and(Self::item_is_enabled) + }) + } + + fn enabled_actual_idx(&self, actual_idx: usize) -> Option { + self.active_items() + .get(actual_idx) + .is_some_and(Self::item_is_enabled) + .then_some(actual_idx) + } + + fn item_is_enabled(item: &SelectionItem) -> bool { + item.disabled_reason.is_none() && !item.is_disabled + } + + fn actual_idx_for_enabled_number(&self, number: usize) -> Option { + if number == 0 { + return None; + } + + self.active_items() + .iter() + .enumerate() + .filter(|(_, item)| Self::item_is_enabled(item)) + .nth(number - 1) + .map(|(idx, _)| idx) + } + + fn move_up(&mut self) { + let before = self.selected_actual_idx(); + let len = self.visible_len(); + self.state.move_up_wrap(len); + let visible = Self::max_visible_rows(len); + self.skip_disabled_up(); + self.state.ensure_visible(len, visible); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } + } + + fn move_down(&mut self) { + let before = self.selected_actual_idx(); + let len = self.visible_len(); + self.state.move_down_wrap(len); + let visible = Self::max_visible_rows(len); + self.skip_disabled_down(); + self.state.ensure_visible(len, visible); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } + } + + fn page_up(&mut self) { + let before = self.selected_actual_idx(); + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_up_clamped(len, visible); + self.skip_disabled_up_clamped(); + self.state.ensure_visible(len, visible); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } + } + + fn page_down(&mut self) { + let before = self.selected_actual_idx(); + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_down_clamped(len, visible); + self.skip_disabled_down_clamped(); + self.state.ensure_visible(len, visible); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } + } + + fn jump_top(&mut self) { + let before = self.selected_actual_idx(); + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_top(len, visible); + self.skip_disabled_down_clamped(); + self.state.ensure_visible(len, visible); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } + } + + fn jump_bottom(&mut self) { + let before = self.selected_actual_idx(); + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_bottom(len, visible); + self.skip_disabled_up_clamped(); + self.state.ensure_visible(len, visible); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } + } + + fn fire_selection_changed(&self) { + if let Some(cb) = &self.on_selection_changed + && let Some(actual) = self.selected_actual_idx() + { + cb(actual, &self.app_event_tx); + } + } + + fn accept(&mut self, kind: SelectionActionKind) { + let selected_actual_idx = self.selected_actual_idx(); + if let Some(item) = selected_actual_idx.and_then(|idx| self.active_items().get(idx)) { + if item.is_disabled || item.disabled_reason.is_some() { + return; + } + match kind { + SelectionActionKind::Primary => { + for act in &item.actions { + act(&self.app_event_tx); + } + } + SelectionActionKind::Secondary => { + let Some(action) = &item.secondary_action else { + return; + }; + (action.action)(&self.app_event_tx); + } + } + if item.dismiss_on_select { + self.completion = Some(ViewCompletion::Accepted); + } else if item.dismiss_parent_on_child_accept { + self.dismiss_after_child_accept = true; + } + self.last_selected_actual_idx = selected_actual_idx; + } else if selected_actual_idx.is_none() { + if let Some(cb) = &self.on_cancel { + cb(&self.app_event_tx); + } + self.completion = Some(ViewCompletion::Cancelled); + } + } + + fn select_shortcut(&mut self, actual_idx: usize) { + let previously_selected = self.selected_actual_idx(); + self.state.selected_idx = Some(actual_idx); + if self + .active_items() + .get(actual_idx) + .is_some_and(|item| item.require_explicit_confirmation) + { + if self.selected_actual_idx() != previously_selected { + self.fire_selection_changed(); + } + } else { + self.accept(SelectionActionKind::Primary); + } + } + + #[cfg(test)] + pub(crate) fn set_search_query(&mut self, query: String) { + self.search_query = query; + self.apply_filter(); + } + + pub(crate) fn take_last_selected_index(&mut self) -> Option { + self.last_selected_actual_idx.take() + } + + fn rows_width(total_width: u16) -> u16 { + total_width.saturating_sub(2) + } + + fn clear_to_terminal_bg(buf: &mut Buffer, area: Rect) { + let buf_area = buf.area(); + let min_x = area.x.max(buf_area.x); + let min_y = area.y.max(buf_area.y); + let max_x = area + .x + .saturating_add(area.width) + .min(buf_area.x.saturating_add(buf_area.width)); + let max_y = area + .y + .saturating_add(area.height) + .min(buf_area.y.saturating_add(buf_area.height)); + for y in min_y..max_y { + for x in min_x..max_x { + buf[(x, y)] + .set_symbol(" ") + .set_style(ratatui::style::Style::reset()); + } + } + } + + fn force_bg_to_terminal_bg(buf: &mut Buffer, area: Rect) { + let buf_area = buf.area(); + let min_x = area.x.max(buf_area.x); + let min_y = area.y.max(buf_area.y); + let max_x = area + .x + .saturating_add(area.width) + .min(buf_area.x.saturating_add(buf_area.width)); + let max_y = area + .y + .saturating_add(area.height) + .min(buf_area.y.saturating_add(buf_area.height)); + for y in min_y..max_y { + for x in min_x..max_x { + buf[(x, y)].set_bg(ratatui::style::Color::Reset); + } + } + } + + fn stacked_side_content(&self) -> &dyn Renderable { + self.stacked_side_content + .as_deref() + .unwrap_or_else(|| self.side_content.as_ref()) + } + + /// Returns `Some(side_width)` when the content area is wide enough for a + /// side-by-side layout (list + gap + side panel), `None` otherwise. + fn side_layout_width(&self, content_width: u16) -> Option { + side_by_side_layout_widths( + content_width, + self.side_content_width, + self.side_content_min_width, + ) + .map(|(_, side_width)| side_width) + } + + fn skip_disabled_down(&mut self) { + let len = self.visible_len(); + for _ in 0..len { + if self.selected_visible_idx_is_disabled() { + self.state.move_down_wrap(len); + } else { + break; + } + } + } + + fn skip_disabled_up(&mut self) { + let len = self.visible_len(); + for _ in 0..len { + if self.selected_visible_idx_is_disabled() { + self.state.move_up_wrap(len); + } else { + break; + } + } + } + + fn skip_disabled_down_clamped(&mut self) { + let Some(start) = self.state.selected_idx else { + return; + }; + if !self.visible_idx_is_disabled(start) { + return; + } + + let len = self.visible_len(); + self.state.selected_idx = ((start + 1)..len) + .find(|idx| !self.visible_idx_is_disabled(*idx)) + .or_else(|| { + (0..start) + .rev() + .find(|idx| !self.visible_idx_is_disabled(*idx)) + }) + .or(Some(start)); + } + + fn skip_disabled_up_clamped(&mut self) { + let Some(start) = self.state.selected_idx else { + return; + }; + if !self.visible_idx_is_disabled(start) { + return; + } + + let len = self.visible_len(); + self.state.selected_idx = (0..start) + .rev() + .find(|idx| !self.visible_idx_is_disabled(*idx)) + .or_else(|| ((start + 1)..len).find(|idx| !self.visible_idx_is_disabled(*idx))) + .or(Some(start)); + } + + fn selected_visible_idx_is_disabled(&self) -> bool { + self.state + .selected_idx + .is_some_and(|idx| self.visible_idx_is_disabled(idx)) + } + + fn visible_idx_is_disabled(&self, idx: usize) -> bool { + self.filtered_indices + .get(idx) + .and_then(|actual_idx| self.active_items().get(*actual_idx)) + .is_some_and(|item| item.disabled_reason.is_some() || item.is_disabled) + } +} + +impl BottomPaneView for ListSelectionView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + // Searchable lists reserve printable characters for query input. This + // keeps vim-style plain j/k/h/l useful in non-search lists without + // making those letters impossible to type into a filter. + let allow_plain_char_navigation = + !self.is_searchable || !is_plain_text_key_event(key_event); + + match key_event { + _ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => { + self.move_up() + } + _ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => { + self.move_down() + } + _ if allow_plain_char_navigation && self.keymap.page_up.is_pressed(key_event) => { + self.page_up() + } + _ if allow_plain_char_navigation && self.keymap.page_down.is_pressed(key_event) => { + self.page_down() + } + _ if allow_plain_char_navigation && self.keymap.jump_top.is_pressed(key_event) => { + self.jump_top() + } + _ if allow_plain_char_navigation && self.keymap.jump_bottom.is_pressed(key_event) => { + self.jump_bottom() + } + _ if allow_plain_char_navigation + && self.tabs_enabled() + && self.keymap.move_left.is_pressed(key_event) => + { + self.switch_tab(/*step*/ -1) + } + _ if allow_plain_char_navigation + && self.tabs_enabled() + && self.keymap.move_right.is_pressed(key_event) => + { + self.switch_tab(/*step*/ 1) + } + KeyEvent { + code: KeyCode::Backspace, + .. + } if self.is_searchable => { + self.search_query.pop(); + self.apply_filter(); + } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } if self.selected_item_has_toggle() + && (!self.is_searchable || self.search_query.is_empty()) => + { + self.toggle_selected() + } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } if self.is_searchable + && self.search_query.is_empty() + && self.selected_item_has_toggle_placeholder() => {} + _ if self.allow_cancel && self.keymap.cancel.is_pressed(key_event) => { + self.on_ctrl_c(); + } + _ if self.keymap.accept.is_pressed(key_event) => { + self.accept(SelectionActionKind::Primary) + } + _ if allow_plain_char_navigation + && self + .selected_actual_idx() + .and_then(|idx| self.active_items().get(idx)) + .and_then(|item| item.secondary_action.as_ref()) + .is_some_and(|action| action.key.is_press(key_event)) => + { + self.accept(SelectionActionKind::Secondary); + } + KeyEvent { + code: KeyCode::Char(c), + .. + } if c.is_ascii_control() => {} + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if self.is_searchable + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.search_query.push(c); + self.apply_filter(); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if !self.is_searchable + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + if let Some(idx) = self.items.iter().position(|item| { + item.display_shortcut + .is_some_and(|shortcut| shortcut.is_press(key_event)) + && Self::item_is_enabled(item) + }) { + self.select_shortcut(idx); + return; + } + if let Some(idx) = c + .to_digit(10) + .map(|d| d as usize) + .and_then(|number| self.actual_idx_for_enabled_number(number)) + { + self.select_shortcut(idx); + } + } + _ => {} + } + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if !self.is_searchable { + return false; + } + let Some(pasted) = normalize_pasted_search_query(&pasted) else { + return false; + }; + self.search_query.push_str(&pasted); + self.apply_filter(); + true + } + + fn is_complete(&self) -> bool { + self.completion.is_some() + } + + fn completion(&self) -> Option { + self.completion + } + + fn dismiss_after_child_accept(&self) -> bool { + self.dismiss_after_child_accept + } + + fn clear_dismiss_after_child_accept(&mut self) { + self.dismiss_after_child_accept = false; + } + + fn view_id(&self) -> Option<&'static str> { + self.view_id + } + + fn selected_index(&self) -> Option { + self.selected_actual_idx() + } + + fn active_tab_id(&self) -> Option<&str> { + ListSelectionView::active_tab_id(self) + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + if !self.allow_cancel { + return CancellationEvent::NotHandled; + } + if let Some(cb) = &self.on_cancel { + cb(&self.app_event_tx); + } + self.completion = Some(ViewCompletion::Cancelled); + CancellationEvent::Handled + } +} + +impl Renderable for ListSelectionView { + fn desired_height(&self, width: u16) -> u16 { + // Inner content width after menu surface horizontal insets (2 per side). + let inner_width = popup_content_width(width); + + // When side-by-side is active, measure the list at the reduced width + // that accounts for the gap and side panel. + let effective_rows_width = if let Some(side_w) = self.side_layout_width(inner_width) { + Self::rows_width(width).saturating_sub(SIDE_CONTENT_GAP + side_w) + } else { + Self::rows_width(width) + }; + + // Measure wrapped height for up to MAX_POPUP_ROWS items. + let rows = self.build_rows(); + let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width) + .with_description_layout(self.description_layout); + let rows_height = match self.row_display { + SelectionRowDisplay::Wrapped => measure_rows_height_with_col_width_mode( + &rows, + &self.state, + MAX_POPUP_ROWS, + effective_rows_width.saturating_add(1), + column_width, + ), + SelectionRowDisplay::SingleLine => rows.len().clamp(1, MAX_POPUP_ROWS) as u16, + }; + + let header = self.active_header(); + let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width); + let mut height = header.desired_height(inner_width); + height = height.saturating_add(tab_height + u16::from(tab_height > 0)); + height = height.saturating_add(rows_height + 2 + self.header_gap); + if self.is_searchable { + height = height.saturating_add(1); + } + + // Side content: when the terminal is wide enough the panel sits beside + // the list and shares vertical space; otherwise it stacks below. + if self.side_layout_width(inner_width).is_some() { + // Side-by-side — side content shares list rows vertically so it + // doesn't add to total height. + } else { + let side_h = self.stacked_side_content().desired_height(inner_width); + if side_h > 0 { + height = height.saturating_add(1 + side_h); + } + } + + if let Some(note) = &self.footer_note { + let note_width = width.saturating_sub(2); + let note_lines = wrap_styled_line(note, note_width); + height = height.saturating_add(note_lines.len() as u16); + } + if self.active_footer_hint().is_some() { + height = height.saturating_add(1); + } + height + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.rendered_item_count.set(0); + if area.height == 0 || area.width == 0 { + return; + } + + let note_width = area.width.saturating_sub(2); + let note_lines = self + .footer_note + .as_ref() + .map(|note| wrap_styled_line(note, note_width)); + let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16); + let footer_rows = note_height + u16::from(self.active_footer_hint().is_some()); + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); + + let outer_content_area = content_area; + // Paint the shared menu surface and then layout inside the returned inset. + let content_area = render_menu_surface(outer_content_area, buf); + + let inner_width = popup_content_width(outer_content_area.width); + let side_w = self.side_layout_width(inner_width); + + // When side-by-side is active, shrink the list to make room. + let full_rows_width = Self::rows_width(outer_content_area.width); + let effective_rows_width = if let Some(sw) = side_w { + full_rows_width.saturating_sub(SIDE_CONTENT_GAP + sw) + } else { + full_rows_width + }; + + let header = self.active_header(); + let header_height = header.desired_height(inner_width); + let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width); + let rows = self.build_rows(); + let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width) + .with_description_layout(self.description_layout); + let rows_height = match self.row_display { + SelectionRowDisplay::Wrapped => measure_rows_height_with_col_width_mode( + &rows, + &self.state, + MAX_POPUP_ROWS, + effective_rows_width.saturating_add(1), + column_width, + ), + SelectionRowDisplay::SingleLine => rows.len().clamp(1, MAX_POPUP_ROWS) as u16, + }; + + // Stacked (fallback) side content height — only used when not side-by-side. + let stacked_side_h = if side_w.is_none() { + self.stacked_side_content().desired_height(inner_width) + } else { + 0 + }; + let stacked_gap = if stacked_side_h > 0 { 1 } else { 0 }; + + let [ + header_area, + _, + tabs_area, + _, + search_area, + list_area, + _, + stacked_side_area, + ] = Layout::vertical([ + Constraint::Max(header_height), + Constraint::Max(self.header_gap), + Constraint::Length(tab_height), + Constraint::Length(u16::from(tab_height > 0)), + Constraint::Length(if self.is_searchable { 1 } else { 0 }), + Constraint::Length(rows_height), + Constraint::Length(stacked_gap), + Constraint::Length(stacked_side_h), + ]) + .areas(content_area); + + // -- Header -- + if header_area.height < header_height { + let [header_area, elision_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area); + header.render(header_area, buf); + Paragraph::new(vec![ + Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(), + ]) + .render(elision_area, buf); + } else { + header.render(header_area, buf); + } + + // -- Tabs -- + if tab_height > 0 { + render_tab_bar(&self.tabs, self.active_tab_idx.unwrap_or(0), tabs_area, buf); + } + + // -- Search bar -- + if self.is_searchable { + Line::from(self.search_query.clone()).render(search_area, buf); + let query_span: Span<'static> = if self.search_query.is_empty() { + self.search_placeholder + .as_ref() + .map(|placeholder| placeholder.clone().dim()) + .unwrap_or_else(|| "".into()) + } else { + self.search_query.clone().into() + }; + Line::from(query_span).render(search_area, buf); + } + + // -- List rows -- + if list_area.height > 0 { + let render_area = Rect { + x: if rows.is_empty() { + list_area.x + } else { + list_area.x.saturating_sub(2) + }, + y: list_area.y, + width: effective_rows_width.max(1), + height: list_area.height, + }; + let rendered_rows = match self.row_display { + SelectionRowDisplay::Wrapped => render_rows_with_col_width_mode( + render_area, + buf, + &rows, + &self.state, + render_area.height as usize, + "no matches", + column_width, + ), + SelectionRowDisplay::SingleLine => render_rows_single_line_with_col_width_mode( + render_area, + buf, + &rows, + &self.state, + render_area.height as usize, + "no matches", + column_width, + ), + }; + self.rendered_item_count.set(rendered_rows.items); + } + + // -- Side content (preview panel) -- + if let Some(sw) = side_w { + // Side-by-side: render to the right half of the popup content + // area so preview content can center vertically in that panel. + let side_x = content_area.x + content_area.width - sw; + let side_area = Rect::new(side_x, content_area.y, sw, content_area.height); + + // Clear the menu-surface background behind the side panel so the + // preview appears on the terminal's own background. + let clear_x = side_x.saturating_sub(SIDE_CONTENT_GAP); + let clear_w = outer_content_area + .x + .saturating_add(outer_content_area.width) + .saturating_sub(clear_x); + Self::clear_to_terminal_bg( + buf, + Rect::new( + clear_x, + outer_content_area.y, + clear_w, + outer_content_area.height, + ), + ); + self.side_content.render(side_area, buf); + if !self.preserve_side_content_bg { + Self::force_bg_to_terminal_bg( + buf, + Rect::new( + clear_x, + outer_content_area.y, + clear_w, + outer_content_area.height, + ), + ); + } + } else if stacked_side_area.height > 0 { + // Stacked fallback: render below the list (same as old footer_content). + let clear_height = (outer_content_area.y + outer_content_area.height) + .saturating_sub(stacked_side_area.y); + let clear_area = Rect::new( + outer_content_area.x, + stacked_side_area.y, + outer_content_area.width, + clear_height, + ); + Self::clear_to_terminal_bg(buf, clear_area); + self.stacked_side_content().render(stacked_side_area, buf); + } + + if footer_area.height > 0 { + let [note_area, hint_area] = Layout::vertical([ + Constraint::Length(note_height), + Constraint::Length(if self.active_footer_hint().is_some() { + 1 + } else { + 0 + }), + ]) + .areas(footer_area); + + if let Some(lines) = note_lines { + let note_area = Rect { + x: note_area.x + 2, + y: note_area.y, + width: note_area.width.saturating_sub(2), + height: note_area.height, + }; + for (idx, line) in lines.iter().enumerate() { + if idx as u16 >= note_area.height { + break; + } + let line_area = Rect { + x: note_area.x, + y: note_area.y + idx as u16, + width: note_area.width, + height: 1, + }; + line.clone().render(line_area, buf); + } + } + + if let Some(hint) = self.active_footer_hint() { + let hint_area = Rect { + x: hint_area.x + 2, + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + hint.clone().dim().render(hint_area, buf); + } + } + } +} + +impl ListSelectionView { + pub(crate) fn rendered_item_count(&self) -> usize { + self.rendered_item_count.get() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::bottom_pane::popup_consts::standard_popup_hint_line; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Color; + use ratatui::style::Style; + use tokio::sync::mpsc::unbounded_channel; + + struct MarkerRenderable { + marker: &'static str, + height: u16, + } + + impl Renderable for MarkerRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + for y in area.y..area.y.saturating_add(area.height) { + for x in area.x..area.x.saturating_add(area.width) { + if x < buf.area().width && y < buf.area().height { + buf[(x, y)].set_symbol(self.marker); + } + } + } + } + + fn desired_height(&self, _width: u16) -> u16 { + self.height + } + } + + struct StyledMarkerRenderable { + marker: &'static str, + style: Style, + height: u16, + } + + impl Renderable for StyledMarkerRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + for y in area.y..area.y.saturating_add(area.height) { + for x in area.x..area.x.saturating_add(area.width) { + if x < buf.area().width && y < buf.area().height { + buf[(x, y)].set_symbol(self.marker).set_style(self.style); + } + } + } + } + + fn desired_height(&self, _width: u16) -> u16 { + self.height + } + } + + fn new_view(params: SelectionViewParams, tx: AppEventSender) -> ListSelectionView { + ListSelectionView::new(params, tx, crate::keymap::RuntimeKeymap::defaults().list) + } + + #[test] + fn secondary_action_respects_search_disabled_rows_and_dismissal() { + for (is_searchable, is_disabled, key, expected) in [ + (false, false, KeyCode::Char('s'), Some("secondary")), + (false, false, KeyCode::Enter, Some("primary")), + (false, true, KeyCode::Char('s'), None), + (true, false, KeyCode::Char('s'), None), + ] { + let (tx, mut rx) = unbounded_channel(); + let mut view = new_view( + SelectionViewParams { + is_searchable, + items: vec![SelectionItem { + name: "Selection".into(), + is_disabled, + dismiss_on_select: true, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::UpdateModel("primary".into())) + })], + secondary_action: Some(SelectionSecondaryAction { + key: crate::key_hint::plain(KeyCode::Char('s')), + action: Box::new(|tx| { + tx.send(AppEvent::UpdateModel("secondary".into())) + }), + footer_hint: "s for secondary".into(), + }), + ..Default::default() + }], + ..Default::default() + }, + AppEventSender::new(tx), + ); + view.handle_key_event(KeyEvent::from(key)); + let actual = rx.try_recv().ok().map(|event| match event { + AppEvent::UpdateModel(model) => model, + event => panic!("unexpected event: {event:?}"), + }); + assert_eq!(actual.as_deref(), expected); + assert_eq!(view.completion, expected.map(|_| ViewCompletion::Accepted)); + assert_eq!(view.search_query, if is_searchable { "s" } else { "" }); + } + } + + fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "Read Only".to_string(), + description: Some("Codex can read files".to_string()), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Full Access".to_string(), + description: Some("Codex can edit files".to_string()), + is_current: false, + dismiss_on_select: true, + ..Default::default() + }, + ]; + new_view( + SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + subtitle: subtitle.map(str::to_string), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }, + tx, + ) + } + + fn render_lines(view: &ListSelectionView) -> String { + render_lines_with_width(view, /*width*/ 48) + } + + fn render_lines_with_width(view: &ListSelectionView, width: u16) -> String { + render_lines_in_area(view, width, view.desired_height(width)) + } + + fn render_lines_in_area(view: &ListSelectionView, width: u16, height: u16) -> String { + 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") + } + + fn description_col(rendered: &str, item_marker: &str, description: &str) -> usize { + let line = rendered + .lines() + .find(|line| line.contains(item_marker) && line.contains(description)) + .expect("expected rendered line to contain row marker and description"); + line.find(description) + .expect("expected rendered line to contain description") + } + + fn make_scrolling_width_items() -> Vec { + let mut items: Vec = (1..=8) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + description: Some(format!("desc {idx}")), + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + items.push(SelectionItem { + name: "Item 9 with an intentionally much longer name".to_string(), + description: Some("desc 9".to_string()), + dismiss_on_select: true, + ..Default::default() + }); + items + } + + fn render_before_after_scroll_snapshot(col_width_mode: ColumnWidthMode, width: u16) -> String { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: make_scrolling_width_items(), + col_width_mode, + ..Default::default() + }, + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + let before_scroll = render_lines_with_width(&view, width); + for _ in 0..8 { + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + } + let after_scroll = render_lines_with_width(&view, width); + + format!("before scroll:\n{before_scroll}\n\nafter scroll:\n{after_scroll}") + } + + #[test] + fn renders_blank_line_between_title_and_items_without_subtitle() { + let view = make_selection_view(/*subtitle*/ None); + assert_snapshot!( + "list_selection_spacing_without_subtitle", + render_lines(&view) + ); + } + + #[test] + fn renders_blank_line_between_subtitle_and_items() { + let view = make_selection_view(Some("Switch between Codex approval presets")); + assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); + } + + #[test] + fn theme_picker_subtitle_uses_fallback_text_in_94x35_terminal() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let home = dirs::home_dir().expect("home directory should be available"); + let codex_home = home.join(".codex"); + let params = crate::theme_picker::build_theme_picker_params( + /*current_name*/ None, + Some(&codex_home), + Some(94), + ); + let view = new_view(params, tx); + + let rendered = render_lines_in_area(&view, /*width*/ 94, /*height*/ 35); + assert!(rendered.contains("Move up/down to live preview themes")); + } + + #[test] + fn theme_picker_enables_side_content_background_preservation() { + let params = crate::theme_picker::build_theme_picker_params( + /*current_name*/ None, + /*codex_home*/ None, + Some(120), + ); + assert!( + params.preserve_side_content_bg, + "theme picker should preserve side-content backgrounds to keep diff preview styling", + ); + } + + #[test] + fn preserve_side_content_bg_keeps_rendered_background_colors() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = new_view( + SelectionViewParams { + title: Some("Debug".to_string()), + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(StyledMarkerRenderable { + marker: "+", + style: Style::default().bg(Color::Blue), + height: 1, + }), + side_content_width: SideContentWidth::Half, + side_content_min_width: 10, + preserve_side_content_bg: true, + ..Default::default() + }, + tx, + ); + let area = Rect::new(0, 0, 120, 35); + let mut buf = Buffer::empty(area); + + view.render(area, &mut buf); + + let plus_bg = (0..area.height) + .flat_map(|y| (0..area.width).map(move |x| (x, y))) + .find_map(|(x, y)| { + let cell = &buf[(x, y)]; + (cell.symbol() == "+").then(|| cell.style().bg) + }) + .expect("expected side content to render at least one '+' marker"); + assert_eq!( + plus_bg, + Some(Color::Blue), + "expected side-content marker to preserve custom background styling", + ); + } + + #[test] + fn snapshot_footer_note_wraps() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![SelectionItem { + name: "Read Only".to_string(), + description: Some("Codex can read files".to_string()), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }]; + let footer_note = Line::from(vec![ + "Note: ".dim(), + "Use /setup-default-sandbox".cyan(), + " to allow network access.".dim(), + ]); + let view = new_view( + SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + footer_note: Some(footer_note), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }, + tx, + ); + assert_snapshot!( + "list_selection_footer_note_wraps", + render_lines_with_width(&view, /*width*/ 40) + ); + } + + #[test] + fn renders_search_query_line_when_enabled() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![SelectionItem { + name: "Read Only".to_string(), + description: Some("Codex can read files".to_string()), + is_current: false, + dismiss_on_select: true, + ..Default::default() + }]; + let mut view = new_view( + SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search branches".to_string()), + ..Default::default() + }, + tx, + ); + view.set_search_query("filters".to_string()); + + let lines = render_lines(&view); + assert!( + lines.contains("filters"), + "expected search query line to include rendered query, got {lines:?}" + ); + } + + #[test] + fn empty_searchable_list_aligns_message_with_search() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = new_view( + SelectionViewParams { + title: Some("Select a base branch".to_string()), + footer_hint: Some(standard_popup_hint_line()), + is_searchable: true, + search_placeholder: Some("Type to search branches".to_string()), + ..Default::default() + }, + tx, + ); + + let rendered = render_lines_with_width(&view, /*width*/ 48) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + assert_snapshot!("list_selection_empty_searchable", rendered); + } + + #[test] + fn paste_appends_to_search_query_and_filters_items() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = new_view( + SelectionViewParams { + items: vec![ + SelectionItem { + name: "main -> feature/other".to_string(), + search_value: Some("feature/other".to_string()), + ..Default::default() + }, + SelectionItem { + name: "main -> feature/paste-support".to_string(), + search_value: Some("feature/paste-support".to_string()), + ..Default::default() + }, + ], + is_searchable: true, + ..Default::default() + }, + tx, + ); + view.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + + assert!(view.handle_paste("eature/paste-support\n".to_string())); + + assert_eq!(view.search_query, "feature/paste-support"); + assert_eq!(view.filtered_indices, vec![1]); + assert_eq!(view.selected_actual_idx(), Some(1)); + } + + #[test] + fn whitespace_only_paste_is_ignored() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = new_view( + SelectionViewParams { + items: vec![SelectionItem { + name: "main".to_string(), + search_value: Some("main".to_string()), + ..Default::default() + }], + is_searchable: true, + ..Default::default() + }, + tx, + ); + + assert!(!view.handle_paste(" \n\t ".to_string())); + + assert_eq!(view.search_query, ""); + assert_eq!(view.filtered_indices, vec![0]); + } + + #[test] + fn switching_tabs_changes_visible_items_and_clears_search() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + tabs: vec![ + SelectionTab { + id: "alpha".to_string(), + label: "Alpha".to_string(), + header: Box::new(()), + items: vec![SelectionItem { + name: "Alpha Item".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + }, + SelectionTab { + id: "beta".to_string(), + label: "Beta".to_string(), + header: Box::new(()), + items: vec![SelectionItem { + name: "Beta Item".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + }, + ], + initial_tab_id: Some("beta".to_string()), + is_searchable: true, + ..Default::default() + }, + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + view.set_search_query("beta".to_string()); + + view.handle_key_event(KeyEvent::from(KeyCode::Left)); + + assert_eq!(view.active_tab_id(), Some("alpha")); + assert_eq!(view.search_query, ""); + view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert_eq!(view.active_tab_id(), Some("beta")); + view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(view.active_tab_id(), Some("alpha")); + let rendered = render_lines(&view); + assert!( + rendered.contains("Alpha Item") && !rendered.contains("Beta Item"), + "expected switched tab to render the alpha items, got:\n{rendered}" + ); + } + + #[test] + fn tabbed_view_preserves_current_row_on_initial_selection_and_tab_switch() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + tabs: vec![ + SelectionTab { + id: "alpha".to_string(), + label: "Alpha".to_string(), + header: Box::new(()), + items: vec![ + SelectionItem { + name: "Alpha First".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Alpha Current".to_string(), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }, + ], + }, + SelectionTab { + id: "beta".to_string(), + label: "Beta".to_string(), + header: Box::new(()), + items: vec![ + SelectionItem { + name: "Beta First".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Beta Current".to_string(), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }, + ], + }, + ], + initial_tab_id: Some("beta".to_string()), + ..Default::default() + }, + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + assert_eq!(view.active_tab_id(), Some("beta")); + assert_eq!(view.selected_actual_idx(), Some(1)); + + view.handle_key_event(KeyEvent::from(KeyCode::Left)); + + assert_eq!(view.active_tab_id(), Some("alpha")); + assert_eq!(view.selected_actual_idx(), Some(1)); + } + + #[test] + fn space_appends_to_active_search_instead_of_toggling_selected_item() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![SelectionItem { + name: "Plugin".to_string(), + toggle: Some(SelectionToggle { + is_on: false, + action: Box::new(|_enabled, tx: &_| { + tx.send(AppEvent::OpenApprovalsPopup); + }), + }), + ..Default::default() + }], + is_searchable: true, + ..Default::default() + }, + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + view.set_search_query("plugin".to_string()); + + view.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + assert_eq!(view.search_query, "plugin "); + assert!( + !view.active_items()[0] + .toggle + .as_ref() + .is_some_and(|toggle| toggle.is_on), + "expected Space to leave the toggle state unchanged while search is active" + ); + assert!( + rx.try_recv().is_err(), + "expected Space with an active search query to avoid firing the toggle action" + ); + } + + #[test] + fn single_line_row_display_truncates_instead_of_wrapping() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let single_line_view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: vec![SelectionItem { + name: "A very long plugin name".to_string(), + description: Some( + "A very long description that would normally wrap onto another line." + .to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }], + row_display: SelectionRowDisplay::SingleLine, + ..Default::default() + }, + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + let (wrapped_tx_raw, _wrapped_rx) = unbounded_channel::(); + let wrapped_tx = AppEventSender::new(wrapped_tx_raw); + let wrapped_view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: vec![SelectionItem { + name: "A very long plugin name".to_string(), + description: Some( + "A very long description that would normally wrap onto another line." + .to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }], + ..Default::default() + }, + wrapped_tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + let rendered = render_lines_with_width(&single_line_view, /*width*/ 36); + assert!( + rendered.contains("…"), + "expected single-line rendering to truncate with an ellipsis, got:\n{rendered}" + ); + assert!( + single_line_view.desired_height(/*width*/ 36) + < wrapped_view.desired_height(/*width*/ 36), + "expected single-line rendering to reserve less height than wrapped rendering:\nsingle-line:\n{rendered}\n\nwrapped:\n{}", + render_lines_with_width(&wrapped_view, /*width*/ 36) + ); + } + + #[test] + fn name_column_width_override_moves_description_column_right() { + let auto_items = vec![ + SelectionItem { + name: "Short".to_string(), + description: Some("desc".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Longer".to_string(), + description: Some("desc".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let widened_items = vec![ + SelectionItem { + name: "Short".to_string(), + description: Some("desc".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Longer".to_string(), + description: Some("desc".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let (auto_tx_raw, _auto_rx) = unbounded_channel::(); + let auto_tx = AppEventSender::new(auto_tx_raw); + let auto_view = ListSelectionView::new( + SelectionViewParams { + items: auto_items, + row_display: SelectionRowDisplay::SingleLine, + col_width_mode: ColumnWidthMode::AutoVisible, + ..Default::default() + }, + auto_tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + let (widened_tx_raw, _widened_rx) = unbounded_channel::(); + let widened_tx = AppEventSender::new(widened_tx_raw); + let widened_view = ListSelectionView::new( + SelectionViewParams { + items: widened_items, + row_display: SelectionRowDisplay::SingleLine, + col_width_mode: ColumnWidthMode::AutoVisible, + name_column_width: Some(18), + ..Default::default() + }, + widened_tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + let auto_rendered = render_lines_with_width(&auto_view, /*width*/ 48); + let widened_rendered = render_lines_with_width(&widened_view, /*width*/ 48); + let auto_col = description_col(&auto_rendered, "1. Short", "desc"); + let widened_col = description_col(&widened_rendered, "1. Short", "desc"); + + assert!( + widened_col > auto_col, + "expected name column override to push the description right:\nauto:\n{auto_rendered}\n\nwidened:\n{widened_rendered}" + ); + } + + #[test] + fn enter_with_no_matches_triggers_cancel_callback() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = new_view( + SelectionViewParams { + items: vec![SelectionItem { + name: "Read Only".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + is_searchable: true, + on_cancel: Some(Box::new(|tx: &_| { + tx.send(AppEvent::OpenApprovalsPopup); + })), + ..Default::default() + }, + tx, + ); + view.set_search_query("no-matches".to_string()); + + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert!(view.is_complete()); + match rx.try_recv() { + Ok(AppEvent::OpenApprovalsPopup) => {} + Ok(other) => panic!("expected OpenApprovalsPopup cancel event, got {other:?}"), + Err(err) => panic!("expected cancel callback event, got {err}"), + } + } + + #[test] + fn move_down_without_selection_change_does_not_fire_callback() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = new_view( + SelectionViewParams { + items: vec![SelectionItem { + name: "Only choice".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + on_selection_changed: Some(Box::new(|_idx, tx: &_| { + tx.send(AppEvent::OpenApprovalsPopup); + })), + ..Default::default() + }, + tx, + ); + + while rx.try_recv().is_ok() {} + + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + + assert!( + rx.try_recv().is_err(), + "moving down in a single-item list should not fire on_selection_changed", + ); + } + + #[test] + fn disabled_current_rows_skip_default_selection_and_number_shortcuts() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![ + SelectionItem { + name: "Unavailable".to_string(), + description: Some("Not available right now.".to_string()), + is_current: true, + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Alpha".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Busy".to_string(), + description: Some("Still disabled.".to_string()), + disabled_reason: Some("Try again later.".to_string()), + ..Default::default() + }, + SelectionItem { + name: "Beta".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }, + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + assert_eq!(view.selected_actual_idx(), Some(1)); + + let rendered = render_lines_with_width(&view, /*width*/ 60); + assert!( + rendered.contains("› 1. Alpha"), + "expected first enabled row to be selected and numbered 1, got:\n{rendered}" + ); + assert!( + rendered.contains(" 2. Beta"), + "expected second enabled row to be numbered 2, got:\n{rendered}" + ); + assert!( + !rendered.contains("1. Unavailable") && !rendered.contains("3. Beta"), + "expected disabled rows to be skipped by numbering, got:\n{rendered}" + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + + assert_eq!(view.take_last_selected_index(), Some(3)); + } + + #[test] + fn c0_ctrl_p_respects_unbound_list_move_up() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; + keymap.move_up.clear(); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![ + SelectionItem { + name: "First".to_string(), + ..Default::default() + }, + SelectionItem { + name: "Second".to_string(), + ..Default::default() + }, + ], + initial_selected_idx: Some(1), + is_searchable: true, + ..Default::default() + }, + tx, + keymap, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE)); + + assert_eq!(view.selected_actual_idx(), Some(1)); + assert_eq!(view.search_query, ""); + } + + #[test] + fn c0_ctrl_n_respects_unbound_list_move_down() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; + keymap.move_down.clear(); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![ + SelectionItem { + name: "First".to_string(), + ..Default::default() + }, + SelectionItem { + name: "Second".to_string(), + ..Default::default() + }, + ], + is_searchable: true, + ..Default::default() + }, + tx, + keymap, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{000e}'), KeyModifiers::NONE)); + + assert_eq!(view.selected_actual_idx(), Some(0)); + assert_eq!(view.search_query, ""); + } + + #[test] + fn c0_ctrl_p_respects_remapped_list_move_down() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; + keymap.move_up.clear(); + keymap.move_down = vec![crate::key_hint::ctrl(KeyCode::Char('p'))]; + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![ + SelectionItem { + name: "First".to_string(), + ..Default::default() + }, + SelectionItem { + name: "Second".to_string(), + ..Default::default() + }, + ], + is_searchable: true, + ..Default::default() + }, + tx, + keymap, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE)); + + assert_eq!(view.selected_actual_idx(), Some(1)); + } + + #[test] + fn page_and_jump_navigation_use_list_keymap() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; + keymap.page_down = vec![crate::key_hint::ctrl(KeyCode::Char('d'))]; + keymap.page_up = vec![crate::key_hint::ctrl(KeyCode::Char('u'))]; + keymap.jump_bottom = vec![crate::key_hint::ctrl(KeyCode::Char('e'))]; + keymap.jump_top = vec![crate::key_hint::ctrl(KeyCode::Char('a'))]; + let mut view = ListSelectionView::new( + SelectionViewParams { + items: (0..12) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + tx, + keymap, + ); + + view.handle_key_event(KeyEvent::from(KeyCode::PageDown)); + assert_eq!(view.selected_actual_idx(), Some(0)); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_actual_idx(), Some(8)); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_actual_idx(), Some(0)); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_actual_idx(), Some(11)); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_actual_idx(), Some(0)); + } + + #[test] + fn page_and_jump_navigation_skip_trailing_disabled_rows_without_wrapping() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: (0..12) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + is_disabled: idx >= 8, + ..Default::default() + }) + .collect(), + ..Default::default() + }, + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + view.handle_key_event(KeyEvent::from(KeyCode::PageDown)); + assert_eq!(view.selected_actual_idx(), Some(7)); + let selected = view.state.selected_idx.expect("selection should be set"); + assert!(view.state.scroll_top <= selected); + assert!(selected < view.state.scroll_top + ListSelectionView::max_visible_rows(/*len*/ 12)); + + view.handle_key_event(KeyEvent::from(KeyCode::End)); + assert_eq!(view.selected_actual_idx(), Some(7)); + let selected = view.state.selected_idx.expect("selection should be set"); + assert!(view.state.scroll_top <= selected); + assert!(selected < view.state.scroll_top + ListSelectionView::max_visible_rows(/*len*/ 12)); + } + + #[test] + fn wraps_long_option_without_overflowing_columns() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "Yes, proceed".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, and don't ask again for commands that start with `python -mpre_commit run --files eslint-plugin/no-mixed-const-enum-exports.js`".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = new_view( + SelectionViewParams { + title: Some("Approval".to_string()), + items, + ..Default::default() + }, + tx, + ); + + let rendered = render_lines_with_width(&view, /*width*/ 60); + let command_line = rendered + .lines() + .find(|line| line.contains("python -mpre_commit run")) + .expect("rendered lines should include wrapped command"); + assert!( + command_line.starts_with(" `python -mpre_commit run"), + "wrapped command line should align under the numbered prefix:\n{rendered}" + ); + assert!( + rendered.contains("eslint-plugin/no-") + && rendered.contains("mixed-const-enum-exports.js"), + "long command should not be truncated even when wrapped:\n{rendered}" + ); + } + + #[test] + fn width_changes_do_not_hide_rows() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "gpt-5.1-codex".to_string(), + description: Some( + "Optimized for Codex. Balance of reasoning quality and coding ability." + .to_string(), + ), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-5.1-codex-mini".to_string(), + description: Some( + "Optimized for Codex. Cheaper, faster, but less capable.".to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-4.1-codex".to_string(), + description: Some( + "Legacy model. Use when you need compatibility with older automations." + .to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = new_view( + SelectionViewParams { + title: Some("Select Model and Effort".to_string()), + items, + ..Default::default() + }, + tx, + ); + let mut missing: Vec = Vec::new(); + for width in 60..=90 { + let rendered = render_lines_with_width(&view, width); + if !rendered.contains("3.") { + missing.push(width); + } + } + assert!( + missing.is_empty(), + "third option missing at widths {missing:?}" + ); + } + + #[test] + fn narrow_width_keeps_all_rows_visible() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let desc = "x".repeat(10); + let items: Vec = (1..=3) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + description: Some(desc.clone()), + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + let view = new_view( + SelectionViewParams { + title: Some("Debug".to_string()), + items, + ..Default::default() + }, + tx, + ); + let rendered = render_lines_with_width(&view, /*width*/ 24); + assert!( + rendered.contains("3."), + "third option missing for width 24:\n{rendered}" + ); + } + + #[test] + fn snapshot_model_picker_width_80() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "gpt-5.1-codex".to_string(), + description: Some( + "Optimized for Codex. Balance of reasoning quality and coding ability." + .to_string(), + ), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-5.1-codex-mini".to_string(), + description: Some( + "Optimized for Codex. Cheaper, faster, but less capable.".to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-4.1-codex".to_string(), + description: Some( + "Legacy model. Use when you need compatibility with older automations." + .to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = new_view( + SelectionViewParams { + title: Some("Select Model and Effort".to_string()), + items, + ..Default::default() + }, + tx, + ); + assert_snapshot!( + "list_selection_model_picker_width_80", + render_lines_with_width(&view, /*width*/ 80) + ); + } + + #[test] + fn snapshot_narrow_width_preserves_third_option() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let desc = "x".repeat(10); + let items: Vec = (1..=3) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + description: Some(desc.clone()), + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + let view = new_view( + SelectionViewParams { + title: Some("Debug".to_string()), + items, + ..Default::default() + }, + tx, + ); + assert_snapshot!( + "list_selection_narrow_width_preserves_rows", + render_lines_with_width(&view, /*width*/ 24) + ); + } + + #[test] + fn snapshot_narrow_width_counts_halfwidth_sound_marks() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "abガc".to_string(), + description: Some("dakuten description".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "aパc".to_string(), + description: Some("handakuten description".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = new_view( + SelectionViewParams { + title: Some("Halfwidth sound marks".to_string()), + items, + ..Default::default() + }, + tx, + ); + + let rendered = format!( + "width 20:\n{}\n\nwidth 24:\n{}", + render_lines_with_width(&view, /*width*/ 20), + render_lines_with_width(&view, /*width*/ 24) + ); + assert_snapshot!("list_selection_halfwidth_sound_marks_narrow", rendered); + } + + #[test] + fn snapshot_auto_visible_col_width_mode_scroll_behavior() { + assert_snapshot!( + "list_selection_col_width_mode_auto_visible_scroll", + render_before_after_scroll_snapshot(ColumnWidthMode::AutoVisible, /*width*/ 96) + ); + } + + #[test] + fn snapshot_auto_all_rows_col_width_mode_scroll_behavior() { + assert_snapshot!( + "list_selection_col_width_mode_auto_all_rows_scroll", + render_before_after_scroll_snapshot(ColumnWidthMode::AutoAllRows, /*width*/ 96) + ); + } + + #[test] + fn snapshot_fixed_col_width_mode_scroll_behavior() { + assert_snapshot!( + "list_selection_col_width_mode_fixed_scroll", + render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, /*width*/ 96) + ); + } + + #[test] + fn auto_all_rows_col_width_does_not_shift_when_scrolling() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + + let mut view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: make_scrolling_width_items(), + col_width_mode: ColumnWidthMode::AutoAllRows, + ..Default::default() + }, + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + let before_scroll = render_lines_with_width(&view, /*width*/ 96); + for _ in 0..8 { + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + } + let after_scroll = render_lines_with_width(&view, /*width*/ 96); + + assert!( + after_scroll.contains("9. Item 9 with an intentionally much longer name"), + "expected the scrolled view to include the longer row:\n{after_scroll}" + ); + + let before_col = description_col(&before_scroll, "8. Item 8", "desc 8"); + let after_col = description_col(&after_scroll, "8. Item 8", "desc 8"); + assert_eq!( + before_col, after_col, + "description column changed across scroll:\nbefore:\n{before_scroll}\nafter:\n{after_scroll}" + ); + } + + #[test] + fn fixed_col_width_is_30_70_and_does_not_shift_when_scrolling() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let width = 96; + let mut view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: make_scrolling_width_items(), + col_width_mode: ColumnWidthMode::Fixed, + ..Default::default() + }, + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + let before_scroll = render_lines_with_width(&view, width); + let before_col = description_col(&before_scroll, "8. Item 8", "desc 8"); + let expected_desc_col = ((width.saturating_sub(2) as usize) * 3) / 10; + assert_eq!( + before_col, expected_desc_col, + "fixed mode should place description column at a 30/70 split:\n{before_scroll}" + ); + + for _ in 0..8 { + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + } + let after_scroll = render_lines_with_width(&view, width); + let after_col = description_col(&after_scroll, "8. Item 8", "desc 8"); + assert_eq!( + before_col, after_col, + "fixed description column changed across scroll:\nbefore:\n{before_scroll}\nafter:\n{after_scroll}" + ); + } + + #[test] + fn side_layout_width_half_uses_exact_split() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = new_view( + SelectionViewParams { + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(MarkerRenderable { + marker: "W", + height: 1, + }), + side_content_width: SideContentWidth::Half, + side_content_min_width: 10, + ..Default::default() + }, + tx, + ); + + let content_width: u16 = 120; + let expected = content_width.saturating_sub(SIDE_CONTENT_GAP) / 2; + assert_eq!(view.side_layout_width(content_width), Some(expected)); + } + + #[test] + fn side_layout_width_half_falls_back_when_list_would_be_too_narrow() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = new_view( + SelectionViewParams { + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(MarkerRenderable { + marker: "W", + height: 1, + }), + side_content_width: SideContentWidth::Half, + side_content_min_width: 50, + ..Default::default() + }, + tx, + ); + + assert_eq!(view.side_layout_width(/*content_width*/ 80), None); + } + + #[test] + fn stacked_side_content_is_used_when_side_by_side_does_not_fit() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = new_view( + SelectionViewParams { + title: Some("Debug".to_string()), + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(MarkerRenderable { + marker: "W", + height: 1, + }), + stacked_side_content: Some(Box::new(MarkerRenderable { + marker: "N", + height: 1, + })), + side_content_width: SideContentWidth::Half, + side_content_min_width: 60, + ..Default::default() + }, + tx, + ); + + let rendered = render_lines_with_width(&view, /*width*/ 70); + assert!( + rendered.contains('N'), + "expected stacked marker to be rendered:\n{rendered}" + ); + assert!( + !rendered.contains('W'), + "wide marker should not render in stacked mode:\n{rendered}" + ); + } + + #[test] + fn side_content_clearing_resets_symbols_and_style() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = new_view( + SelectionViewParams { + title: Some("Debug".to_string()), + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(MarkerRenderable { + marker: "W", + height: 1, + }), + side_content_width: SideContentWidth::Half, + side_content_min_width: 10, + ..Default::default() + }, + tx, + ); + + let width = 120; + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + for y in 0..height { + for x in 0..width { + buf[(x, y)] + .set_symbol("X") + .set_style(Style::default().bg(Color::Red)); + } + } + view.render(area, &mut buf); + + let cell = &buf[(width - 1, 0)]; + assert_eq!(cell.symbol(), " "); + let style = cell.style(); + assert_eq!(style.fg, Some(Color::Reset)); + assert_eq!(style.bg, Some(Color::Reset)); + assert_eq!(style.underline_color, Some(Color::Reset)); + + let mut saw_marker = false; + for y in 0..height { + for x in 0..width { + let cell = &buf[(x, y)]; + if cell.symbol() == "W" { + saw_marker = true; + assert_eq!(cell.style().bg, Some(Color::Reset)); + } + } + } + assert!( + saw_marker, + "expected side marker renderable to draw into buffer" + ); + } + + #[test] + fn side_content_clearing_handles_non_zero_buffer_origin() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = new_view( + SelectionViewParams { + title: Some("Debug".to_string()), + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(MarkerRenderable { + marker: "W", + height: 1, + }), + side_content_width: SideContentWidth::Half, + side_content_min_width: 10, + ..Default::default() + }, + tx, + ); + + let width = 120; + let height = view.desired_height(width); + let area = Rect::new(0, 20, width, height); + let mut buf = Buffer::empty(area); + for y in area.y..area.y + height { + for x in area.x..area.x + width { + buf[(x, y)] + .set_symbol("X") + .set_style(Style::default().bg(Color::Red)); + } + } + view.render(area, &mut buf); + + let cell = &buf[(area.x + width - 1, area.y)]; + assert_eq!(cell.symbol(), " "); + assert_eq!(cell.style().bg, Some(Color::Reset)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs new file mode 100644 index 0000000000000000000000000000000000000000..a433df5e503a03d83c2a7f81ac0d75b52a9067f3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -0,0 +1,2820 @@ +use std::collections::HashSet; +use std::collections::VecDeque; +use std::path::PathBuf; + +#[cfg(test)] +use crate::app_command::AppCommand as Op; +use codex_app_server_protocol::McpElicitationEnumSchema; +use codex_app_server_protocol::McpElicitationPrimitiveSchema; +use codex_app_server_protocol::McpElicitationSingleSelectEnumSchema; +use codex_app_server_protocol::McpServerElicitationAction; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_app_server_protocol::RequestId as AppServerRequestId; +use codex_protocol::ThreadId; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as APPROVAL_META_KIND_KEY; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as APPROVAL_META_KIND_MCP_TOOL_CALL; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as APPROVAL_META_KIND_TOOL_SUGGESTION; +use codex_protocol::mcp_approval_meta::PERSIST_ALWAYS as APPROVAL_PERSIST_ALWAYS_VALUE; +use codex_protocol::mcp_approval_meta::PERSIST_KEY as APPROVAL_PERSIST_KEY; +use codex_protocol::mcp_approval_meta::PERSIST_SESSION as APPROVAL_PERSIST_SESSION_VALUE; +use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY; +use codex_protocol::mcp_approval_meta::TOOL_PARAMS_DISPLAY_KEY as APPROVAL_TOOL_PARAMS_DISPLAY_KEY; +use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as APPROVAL_TOOL_PARAMS_KEY; +use codex_protocol::user_input::TextElement; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use serde_json::Value; +use unicode_width::UnicodeWidthStr; + +use crate::app::app_server_requests::ResolvedAppServerRequest; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::ChatComposerConfig; +use crate::bottom_pane::InputResult; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::scroll_state::ScrollState; +use crate::bottom_pane::selection_popup_common::GenericDisplayRow; +use crate::bottom_pane::selection_popup_common::measure_rows_height; +use crate::bottom_pane::selection_popup_common::menu_surface_inset; +use crate::bottom_pane::selection_popup_common::menu_surface_padding_height; +use crate::bottom_pane::selection_popup_common::render_menu_surface; +use crate::bottom_pane::selection_popup_common::render_rows; +use crate::key_hint::ShortcutHint; +use crate::keymap::KeymapContext; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::keymap::RuntimeKeymap; +use crate::render::renderable::Renderable; +use crate::text_formatting::format_json_compact; +use crate::text_formatting::truncate_text; + +const ANSWER_PLACEHOLDER: &str = "Type your answer"; +const OPTIONAL_ANSWER_PLACEHOLDER: &str = "Type your answer (optional)"; +const FOOTER_SEPARATOR: &str = " | "; +const MIN_COMPOSER_HEIGHT: u16 = 3; +const MIN_OVERLAY_HEIGHT: u16 = 8; +const APPROVAL_FIELD_ID: &str = "__approval"; +const APPROVAL_ACCEPT_ONCE_VALUE: &str = "accept"; +const APPROVAL_ACCEPT_SESSION_VALUE: &str = "accept_session"; +const APPROVAL_ACCEPT_ALWAYS_VALUE: &str = "accept_always"; +const APPROVAL_DECLINE_VALUE: &str = "decline"; +const APPROVAL_CANCEL_VALUE: &str = "cancel"; +const APPROVAL_TOOL_PARAM_DISPLAY_LIMIT: usize = 3; +const APPROVAL_TOOL_PARAM_VALUE_TRUNCATE_GRAPHEMES: usize = 60; +const TOOL_TYPE_KEY: &str = "tool_type"; +const TOOL_ID_KEY: &str = "tool_id"; +const TOOL_SUGGEST_SUGGEST_TYPE_KEY: &str = "suggest_type"; +const TOOL_SUGGEST_REASON_KEY: &str = "suggest_reason"; +const TOOL_SUGGEST_INSTALL_URL_KEY: &str = "install_url"; + +#[derive(Clone, PartialEq, Default)] +struct ComposerDraft { + text: String, + text_elements: Vec, + local_image_paths: Vec, + pending_pastes: Vec<(String, String)>, +} + +impl ComposerDraft { + fn text_with_pending(&self) -> String { + if self.pending_pastes.is_empty() { + return self.text.clone(); + } + debug_assert!( + !self.text_elements.is_empty(), + "pending pastes should always have matching text elements" + ); + let (expanded, _) = ChatComposer::expand_pending_pastes( + &self.text, + self.text_elements.clone(), + &self.pending_pastes, + ); + expanded + } +} + +#[derive(Clone, Debug, PartialEq)] +struct McpServerElicitationOption { + label: String, + description: Option, + value: Value, +} + +#[derive(Clone, Debug, PartialEq)] +enum McpServerElicitationFieldInput { + Select { + options: Vec, + default_idx: Option, + }, + Text { + secret: bool, + }, +} + +#[derive(Clone, Debug, PartialEq)] +struct McpServerElicitationField { + id: String, + label: String, + prompt: String, + required: bool, + input: McpServerElicitationFieldInput, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum McpServerElicitationResponseMode { + FormContent, + ApprovalAction, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolSuggestionToolType { + Connector, + Plugin, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolSuggestionType { + Install, + Enable, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ToolSuggestionRequest { + pub(crate) tool_type: ToolSuggestionToolType, + pub(crate) suggest_type: ToolSuggestionType, + pub(crate) suggest_reason: String, + pub(crate) tool_id: String, + pub(crate) tool_name: String, + pub(crate) install_url: Option, +} + +#[derive(Clone, Debug, PartialEq)] +struct McpToolApprovalDisplayParam { + name: String, + value: Value, + display_name: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct McpServerElicitationFormRequest { + thread_id: ThreadId, + server_name: String, + request_id: AppServerRequestId, + message: String, + approval_display_params: Vec, + response_mode: McpServerElicitationResponseMode, + fields: Vec, + tool_suggestion: Option, +} + +#[derive(Default)] +struct McpServerElicitationAnswerState { + selection: ScrollState, + draft: ComposerDraft, + answer_committed: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FooterTip { + text: String, + highlight: bool, +} + +impl FooterTip { + fn new(text: impl Into) -> Self { + Self { + text: text.into(), + highlight: false, + } + } + + fn highlighted(text: impl Into) -> Self { + Self { + text: text.into(), + highlight: true, + } + } +} + +impl McpServerElicitationFormRequest { + pub(crate) fn from_app_server_request( + thread_id: ThreadId, + request_id: AppServerRequestId, + request: &McpServerElicitationRequestParams, + ) -> Option { + let McpServerElicitationRequestParams { + server_name, + request, + .. + } = request; + let McpServerElicitationRequest::Form { + meta, + message, + requested_schema, + } = request + else { + return None; + }; + + let requested_schema = serde_json::to_value(requested_schema).ok()?; + Self::from_parts( + thread_id, + server_name.clone(), + request_id, + meta.as_ref(), + message.clone(), + requested_schema, + ) + } + + fn from_parts( + thread_id: ThreadId, + server_name: String, + request_id: AppServerRequestId, + meta: Option<&Value>, + message: String, + requested_schema: Value, + ) -> Option { + let tool_suggestion = parse_tool_suggestion_request(meta); + let is_tool_approval = meta + .and_then(Value::as_object) + .and_then(|meta| meta.get(APPROVAL_META_KIND_KEY)) + .and_then(Value::as_str) + == Some(APPROVAL_META_KIND_MCP_TOOL_CALL); + let is_empty_object_schema = requested_schema.as_object().is_some_and(|schema| { + schema.get("type").and_then(Value::as_str) == Some("object") + && schema + .get("properties") + .and_then(Value::as_object) + .is_some_and(serde_json::Map::is_empty) + }); + let is_message_only_schema = requested_schema.is_null() || is_empty_object_schema; + let is_tool_approval_action = is_tool_approval && is_message_only_schema; + let approval_display_params = if is_tool_approval_action { + parse_tool_approval_display_params(meta) + } else { + Vec::new() + }; + + let (response_mode, fields) = if tool_suggestion.is_some() && is_message_only_schema { + (McpServerElicitationResponseMode::FormContent, Vec::new()) + } else if is_message_only_schema { + let allow_description = if is_tool_approval_action { + "Run the tool and continue." + } else { + "Allow this request and continue." + }; + let mut options = vec![McpServerElicitationOption { + label: "Allow".to_string(), + description: Some(allow_description.to_string()), + value: Value::String(APPROVAL_ACCEPT_ONCE_VALUE.to_string()), + }]; + if approval_supports_persist_mode(meta, APPROVAL_PERSIST_SESSION_VALUE) { + let description = if is_tool_approval_action { + "Run the tool and remember this choice for this session." + } else { + "Allow this request and remember this choice for this session." + }; + options.push(McpServerElicitationOption { + label: "Allow for this session".to_string(), + description: Some(description.to_string()), + value: Value::String(APPROVAL_ACCEPT_SESSION_VALUE.to_string()), + }); + } + if approval_supports_persist_mode(meta, APPROVAL_PERSIST_ALWAYS_VALUE) { + let description = if is_tool_approval_action { + "Run the tool and remember this choice for future tool calls." + } else { + "Allow this request and remember this choice for future requests." + }; + options.push(McpServerElicitationOption { + label: "Always allow".to_string(), + description: Some(description.to_string()), + value: Value::String(APPROVAL_ACCEPT_ALWAYS_VALUE.to_string()), + }); + } + if is_tool_approval_action { + options.push(McpServerElicitationOption { + label: "Cancel".to_string(), + description: Some("Cancel this tool call".to_string()), + value: Value::String(APPROVAL_CANCEL_VALUE.to_string()), + }); + } else { + options.extend([ + McpServerElicitationOption { + label: "Deny".to_string(), + description: Some("Decline this request and continue.".to_string()), + value: Value::String(APPROVAL_DECLINE_VALUE.to_string()), + }, + McpServerElicitationOption { + label: "Cancel".to_string(), + description: Some("Cancel this request".to_string()), + value: Value::String(APPROVAL_CANCEL_VALUE.to_string()), + }, + ]); + } + ( + McpServerElicitationResponseMode::ApprovalAction, + vec![McpServerElicitationField { + id: APPROVAL_FIELD_ID.to_string(), + label: String::new(), + prompt: String::new(), + required: true, + input: McpServerElicitationFieldInput::Select { + options, + default_idx: Some(0), + }, + }], + ) + } else { + ( + McpServerElicitationResponseMode::FormContent, + parse_fields_from_schema(&requested_schema)?, + ) + }; + + Some(Self { + thread_id, + server_name, + request_id, + message, + approval_display_params, + response_mode, + fields, + tool_suggestion, + }) + } + + pub(crate) fn tool_suggestion(&self) -> Option<&ToolSuggestionRequest> { + self.tool_suggestion.as_ref() + } + + pub(crate) fn thread_id(&self) -> ThreadId { + self.thread_id + } + + pub(crate) fn server_name(&self) -> &str { + self.server_name.as_str() + } + + pub(crate) fn request_id(&self) -> &AppServerRequestId { + &self.request_id + } +} + +fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option { + let meta = meta?.as_object()?; + if meta.get(APPROVAL_META_KIND_KEY).and_then(Value::as_str) + != Some(APPROVAL_META_KIND_TOOL_SUGGESTION) + { + return None; + } + + let tool_type = match meta.get(TOOL_TYPE_KEY).and_then(Value::as_str) { + Some("connector") => ToolSuggestionToolType::Connector, + Some("plugin") => ToolSuggestionToolType::Plugin, + _ => return None, + }; + let suggest_type = match meta + .get(TOOL_SUGGEST_SUGGEST_TYPE_KEY) + .and_then(Value::as_str) + { + Some("install") => ToolSuggestionType::Install, + Some("enable") => ToolSuggestionType::Enable, + _ => return None, + }; + + Some(ToolSuggestionRequest { + tool_type, + suggest_type, + suggest_reason: meta + .get(TOOL_SUGGEST_REASON_KEY) + .and_then(Value::as_str)? + .to_string(), + tool_id: meta.get(TOOL_ID_KEY).and_then(Value::as_str)?.to_string(), + tool_name: meta.get(TOOL_NAME_KEY).and_then(Value::as_str)?.to_string(), + install_url: meta + .get(TOOL_SUGGEST_INSTALL_URL_KEY) + .and_then(Value::as_str) + .map(ToString::to_string), + }) +} + +fn approval_supports_persist_mode(meta: Option<&Value>, expected_mode: &str) -> bool { + let Some(persist) = meta + .and_then(Value::as_object) + .and_then(|meta| meta.get(APPROVAL_PERSIST_KEY)) + else { + return false; + }; + + match persist { + Value::String(value) => value == expected_mode, + Value::Array(values) => values + .iter() + .filter_map(Value::as_str) + .any(|value| value == expected_mode), + _ => false, + } +} + +fn parse_tool_approval_display_params(meta: Option<&Value>) -> Vec { + let Some(meta) = meta.and_then(Value::as_object) else { + return Vec::new(); + }; + + let display_params = meta + .get(APPROVAL_TOOL_PARAMS_DISPLAY_KEY) + .and_then(Value::as_array) + .map(|display_params| { + display_params + .iter() + .filter_map(parse_tool_approval_display_param) + .collect::>() + }) + .unwrap_or_default(); + if !display_params.is_empty() { + return display_params; + } + + let mut fallback_params = meta + .get(APPROVAL_TOOL_PARAMS_KEY) + .and_then(Value::as_object) + .map(|tool_params| { + tool_params + .iter() + .map(|(name, value)| McpToolApprovalDisplayParam { + name: name.clone(), + value: value.clone(), + display_name: name.clone(), + }) + .collect::>() + }) + .unwrap_or_default(); + fallback_params.sort_by(|left, right| left.name.cmp(&right.name)); + fallback_params +} + +fn parse_tool_approval_display_param(value: &Value) -> Option { + let value = value.as_object()?; + let name = value.get("name")?.as_str()?.trim(); + if name.is_empty() { + return None; + } + let display_name = value + .get("display_name") + .and_then(Value::as_str) + .unwrap_or(name) + .trim(); + if display_name.is_empty() { + return None; + } + Some(McpToolApprovalDisplayParam { + name: name.to_string(), + value: value.get("value")?.clone(), + display_name: display_name.to_string(), + }) +} + +fn format_tool_approval_display_message( + message: &str, + approval_display_params: &[McpToolApprovalDisplayParam], +) -> String { + let message = message.trim(); + if approval_display_params.is_empty() { + return message.to_string(); + } + + let mut sections = Vec::new(); + if !message.is_empty() { + sections.push(message.to_string()); + } + let param_lines = approval_display_params + .iter() + .take(APPROVAL_TOOL_PARAM_DISPLAY_LIMIT) + .map(format_tool_approval_display_param_line) + .collect::>(); + if !param_lines.is_empty() { + sections.push(param_lines.join("\n")); + } + let mut message = sections.join("\n\n"); + message.push('\n'); + message +} + +fn format_tool_approval_display_param_line(param: &McpToolApprovalDisplayParam) -> String { + format!( + "{}: {}", + param.display_name, + format_tool_approval_display_param_value(¶m.value) + ) +} + +fn format_tool_approval_display_param_value(value: &Value) -> String { + let formatted = match value { + Value::String(text) => text.split_whitespace().collect::>().join(" "), + _ => { + let compact_json = value.to_string(); + format_json_compact(&compact_json).unwrap_or(compact_json) + } + }; + truncate_text(&formatted, APPROVAL_TOOL_PARAM_VALUE_TRUNCATE_GRAPHEMES) +} + +fn parse_fields_from_schema(requested_schema: &Value) -> Option> { + let schema = requested_schema.as_object()?; + if schema.get("type").and_then(Value::as_str) != Some("object") { + return None; + } + let required = schema + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect::>(); + let properties = schema.get("properties")?.as_object()?; + let mut fields = Vec::new(); + for (id, property_schema) in properties { + let property = + serde_json::from_value::(property_schema.clone()) + .ok()?; + fields.push(parse_field(id, property, required.contains(id))?); + } + if fields.is_empty() { + return None; + } + Some(fields) +} + +fn parse_field( + id: &str, + property: McpElicitationPrimitiveSchema, + required: bool, +) -> Option { + match property { + McpElicitationPrimitiveSchema::String(schema) => { + let label = schema.title.unwrap_or_else(|| id.to_string()); + let prompt = schema.description.unwrap_or_else(|| label.clone()); + Some(McpServerElicitationField { + id: id.to_string(), + label, + prompt, + required, + input: McpServerElicitationFieldInput::Text { secret: false }, + }) + } + McpElicitationPrimitiveSchema::Boolean(schema) => { + let label = schema.title.unwrap_or_else(|| id.to_string()); + let prompt = schema.description.unwrap_or_else(|| label.clone()); + let default_idx = schema.default.map(|value| if value { 0 } else { 1 }); + let options = [true, false] + .into_iter() + .map(|value| { + let label = if value { "True" } else { "False" }.to_string(); + McpServerElicitationOption { + label, + description: None, + value: Value::Bool(value), + } + }) + .collect(); + Some(McpServerElicitationField { + id: id.to_string(), + label, + prompt, + required, + input: McpServerElicitationFieldInput::Select { + options, + default_idx, + }, + }) + } + McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy(schema)) => { + let label = schema.title.unwrap_or_else(|| id.to_string()); + let prompt = schema.description.unwrap_or_else(|| label.clone()); + let default_idx = schema + .default + .as_ref() + .and_then(|value| schema.enum_.iter().position(|entry| entry == value)); + let enum_names = schema.enum_names.unwrap_or_default(); + let options = schema + .enum_ + .into_iter() + .enumerate() + .map(|(idx, value)| McpServerElicitationOption { + label: enum_names + .get(idx) + .cloned() + .unwrap_or_else(|| value.clone()), + description: None, + value: Value::String(value), + }) + .collect(); + Some(McpServerElicitationField { + id: id.to_string(), + label, + prompt, + required, + input: McpServerElicitationFieldInput::Select { + options, + default_idx, + }, + }) + } + McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::SingleSelect(schema)) => { + parse_single_select_field(id, schema, required) + } + McpElicitationPrimitiveSchema::Number(_) + | McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::MultiSelect(_)) => None, + } +} + +fn parse_single_select_field( + id: &str, + schema: McpElicitationSingleSelectEnumSchema, + required: bool, +) -> Option { + match schema { + McpElicitationSingleSelectEnumSchema::Untitled(schema) => { + let label = schema.title.unwrap_or_else(|| id.to_string()); + let prompt = schema.description.unwrap_or_else(|| label.clone()); + let default_idx = schema + .default + .as_ref() + .and_then(|value| schema.enum_.iter().position(|entry| entry == value)); + let options = schema + .enum_ + .into_iter() + .map(|value| McpServerElicitationOption { + label: value.clone(), + description: None, + value: Value::String(value), + }) + .collect(); + Some(McpServerElicitationField { + id: id.to_string(), + label, + prompt, + required, + input: McpServerElicitationFieldInput::Select { + options, + default_idx, + }, + }) + } + McpElicitationSingleSelectEnumSchema::Titled(schema) => { + let label = schema.title.unwrap_or_else(|| id.to_string()); + let prompt = schema.description.unwrap_or_else(|| label.clone()); + let default_idx = schema.default.as_ref().and_then(|value| { + schema + .one_of + .iter() + .position(|entry| entry.const_.as_str() == value) + }); + let options = schema + .one_of + .into_iter() + .map(|entry| McpServerElicitationOption { + label: entry.title, + description: None, + value: Value::String(entry.const_), + }) + .collect(); + Some(McpServerElicitationField { + id: id.to_string(), + label, + prompt, + required, + input: McpServerElicitationFieldInput::Select { + options, + default_idx, + }, + }) + } + } +} + +pub(crate) struct McpServerElicitationOverlay { + app_event_tx: AppEventSender, + request: McpServerElicitationFormRequest, + queue: VecDeque, + composer: ChatComposer, + answers: Vec, + current_idx: usize, + done: bool, + validation_error: Option, + list_keymap: ListKeymap, + composer_submit_hint: Option, +} + +impl McpServerElicitationOverlay { + #[cfg(test)] + pub(crate) fn new( + request: McpServerElicitationFormRequest, + app_event_tx: AppEventSender, + has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, + ) -> Self { + Self::new_with_keymap( + request, + app_event_tx, + has_input_focus, + enhanced_keys_supported, + disable_paste_burst, + RuntimeKeymap::defaults(), + ) + } + + pub(crate) fn new_with_keymap( + request: McpServerElicitationFormRequest, + app_event_tx: AppEventSender, + has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, + keymap: RuntimeKeymap, + ) -> Self { + let composer_submit_hint = keymap.primary_hint(KeymapContext::Composer, "submit"); + let mut composer = ChatComposer::new_with_config( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + ANSWER_PLACEHOLDER.to_string(), + disable_paste_burst, + ChatComposerConfig::plain_text(), + ); + composer.set_keymap_bindings(&keymap); + composer.set_footer_hint_override(Some(Vec::new())); + let mut overlay = Self { + app_event_tx, + request, + queue: VecDeque::new(), + composer, + answers: Vec::new(), + current_idx: 0, + done: false, + validation_error: None, + list_keymap: keymap.list, + composer_submit_hint, + }; + overlay.reset_for_request(); + overlay.restore_current_draft(); + overlay + } + + fn reset_for_request(&mut self) { + self.answers = self + .request + .fields + .iter() + .map(|field| { + let mut selection = ScrollState::new(); + let (draft, answer_committed) = match &field.input { + McpServerElicitationFieldInput::Select { default_idx, .. } => { + selection.selected_idx = default_idx.or(Some(0)); + (ComposerDraft::default(), default_idx.is_some()) + } + McpServerElicitationFieldInput::Text { .. } => { + (ComposerDraft::default(), false) + } + }; + McpServerElicitationAnswerState { + selection, + draft, + answer_committed, + } + }) + .collect(); + self.current_idx = 0; + self.validation_error = None; + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + } + + fn field_count(&self) -> usize { + self.request.fields.len() + } + + fn current_index(&self) -> usize { + self.current_idx + } + + fn current_field(&self) -> Option<&McpServerElicitationField> { + self.request.fields.get(self.current_index()) + } + + fn current_answer(&self) -> Option<&McpServerElicitationAnswerState> { + self.answers.get(self.current_index()) + } + + fn current_answer_mut(&mut self) -> Option<&mut McpServerElicitationAnswerState> { + let idx = self.current_idx; + self.answers.get_mut(idx) + } + + fn capture_composer_draft(&self) -> ComposerDraft { + ComposerDraft { + text: self.composer.current_text(), + text_elements: self.composer.text_elements(), + local_image_paths: self + .composer + .local_images() + .into_iter() + .map(|img| img.path) + .collect(), + pending_pastes: self.composer.pending_pastes(), + } + } + + fn restore_current_draft(&mut self) { + self.composer + .set_placeholder_text(self.answer_placeholder().to_string()); + self.composer.set_footer_hint_override(Some(Vec::new())); + if self.current_field_is_select() { + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.composer.move_cursor_to_end(); + return; + } + let Some(answer) = self.current_answer() else { + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.composer.move_cursor_to_end(); + return; + }; + let draft = answer.draft.clone(); + self.composer + .set_text_content(draft.text, draft.text_elements, draft.local_image_paths); + self.composer.set_pending_pastes(draft.pending_pastes); + self.composer.move_cursor_to_end(); + } + + fn save_current_draft(&mut self) { + if self.current_field_is_select() { + return; + } + let draft = self.capture_composer_draft(); + if let Some(answer) = self.current_answer_mut() { + if answer.answer_committed && answer.draft != draft { + answer.answer_committed = false; + } + answer.draft = draft; + } + } + + fn clear_current_draft(&mut self) { + if self.current_field_is_select() { + return; + } + if let Some(answer) = self.current_answer_mut() { + answer.draft = ComposerDraft::default(); + answer.answer_committed = false; + } + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.composer.move_cursor_to_end(); + } + + fn answer_placeholder(&self) -> &'static str { + self.current_field().map_or(ANSWER_PLACEHOLDER, |field| { + if field.required { + ANSWER_PLACEHOLDER + } else { + OPTIONAL_ANSWER_PLACEHOLDER + } + }) + } + + fn current_field_is_select(&self) -> bool { + matches!( + self.current_field().map(|field| &field.input), + Some(McpServerElicitationFieldInput::Select { .. }) + ) + } + + fn current_field_is_secret(&self) -> bool { + matches!( + self.current_field().map(|field| &field.input), + Some(McpServerElicitationFieldInput::Text { secret: true }) + ) + } + + fn selected_option_index(&self) -> Option { + self.current_answer() + .and_then(|answer| answer.selection.selected_idx) + } + + fn options_len(&self) -> usize { + self.current_options().len() + } + + fn current_options(&self) -> &[McpServerElicitationOption] { + match self.current_field().map(|field| &field.input) { + Some(McpServerElicitationFieldInput::Select { options, .. }) => options.as_slice(), + _ => &[], + } + } + + fn option_rows(&self) -> Vec { + let selected_idx = self.selected_option_index(); + self.current_options() + .iter() + .enumerate() + .map(|(idx, option)| { + let prefix = if selected_idx.is_some_and(|selected| selected == idx) { + '›' + } else { + ' ' + }; + let number = idx + 1; + let prefix_label = format!("{prefix} {number}. "); + let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str()); + GenericDisplayRow { + name: format!("{prefix_label}{}", option.label), + description: option.description.clone(), + wrap_indent: Some(wrap_indent), + ..Default::default() + } + }) + .collect() + } + + fn wrapped_prompt_lines(&self, width: u16) -> Vec { + textwrap::wrap(&self.current_prompt_text(), width.max(1) as usize) + .into_iter() + .map(|line| line.to_string()) + .collect() + } + + fn current_prompt_text(&self) -> String { + let request_message = format_tool_approval_display_message( + &self.request.message, + &self.request.approval_display_params, + ); + let Some(field) = self.current_field() else { + return request_message; + }; + let mut sections = Vec::new(); + if !request_message.trim().is_empty() { + sections.push(request_message); + } + let field_prompt = if field.label.trim().is_empty() + || field.prompt.trim().is_empty() + || field.label == field.prompt + { + if field.prompt.trim().is_empty() { + field.label.clone() + } else { + field.prompt.clone() + } + } else { + format!("{}\n{}", field.label, field.prompt) + }; + if !field_prompt.trim().is_empty() { + sections.push(field_prompt); + } + sections.join("\n\n") + } + + fn footer_tips(&self) -> Vec { + let mut tips = Vec::new(); + let is_last_field = self.current_index().saturating_add(1) >= self.field_count(); + let submit_hint = if self.current_field_is_select() { + self.list_keymap.primary_hint(ListAction::Accept) + } else { + self.composer_submit_hint + }; + if let Some(submit_hint) = submit_hint.map(ShortcutHint::display_label) { + if self.field_count() == 1 { + tips.push(FooterTip::highlighted(format!("{submit_hint} to submit"))); + } else if is_last_field { + tips.push(FooterTip::highlighted(format!( + "{submit_hint} to submit all" + ))); + } else { + tips.push(FooterTip::new(format!("{submit_hint} to submit answer"))); + } + } + if self.field_count() > 1 { + if self.current_field_is_select() { + tips.push(FooterTip::new("←/→ to navigate fields")); + } else { + tips.push(FooterTip::new("ctrl + p / ctrl + n change field")); + } + } + tips.push(FooterTip::new("esc to cancel")); + tips + } + + fn footer_tip_lines(&self, width: u16) -> Vec> { + let mut tips = Vec::new(); + if let Some(error) = self.validation_error.as_ref() { + tips.push(FooterTip::highlighted(error.clone())); + } + tips.extend(self.footer_tips()); + wrap_footer_tips(width, tips) + } + + fn options_required_height(&self, width: u16) -> u16 { + let rows = self.option_rows(); + if rows.is_empty() { + return 0; + } + let mut state = self + .current_answer() + .map(|answer| answer.selection) + .unwrap_or_default(); + if state.selected_idx.is_none() { + state.selected_idx = Some(0); + } + measure_rows_height(&rows, &state, rows.len(), width.max(1)) + } + + fn input_height(&self, width: u16) -> u16 { + if self.current_field_is_select() { + return self.options_required_height(width); + } + self.composer + .desired_height(width.max(1)) + .clamp(MIN_COMPOSER_HEIGHT, MIN_COMPOSER_HEIGHT.saturating_add(5)) + } + + fn move_field(&mut self, next: bool) { + let len = self.field_count(); + if len == 0 { + return; + } + self.save_current_draft(); + let offset = if next { 1 } else { len.saturating_sub(1) }; + self.current_idx = (self.current_idx + offset) % len; + self.validation_error = None; + self.restore_current_draft(); + } + + fn jump_to_field(&mut self, idx: usize) { + if idx >= self.field_count() { + return; + } + self.save_current_draft(); + self.current_idx = idx; + self.restore_current_draft(); + } + + fn field_value(&self, idx: usize) -> Option { + let field = self.request.fields.get(idx)?; + let answer = self.answers.get(idx)?; + match &field.input { + McpServerElicitationFieldInput::Select { options, .. } => { + if !answer.answer_committed { + return None; + } + let selected_idx = answer.selection.selected_idx?; + options.get(selected_idx).map(|option| option.value.clone()) + } + McpServerElicitationFieldInput::Text { .. } => { + if !answer.answer_committed { + return None; + } + let text = answer.draft.text_with_pending(); + let text = text.trim(); + (!text.is_empty()).then(|| Value::String(text.to_string())) + } + } + } + + fn required_unanswered_count(&self) -> usize { + self.request + .fields + .iter() + .enumerate() + .filter(|(idx, field)| field.required && self.field_value(*idx).is_none()) + .count() + } + + fn first_required_unanswered_index(&self) -> Option { + self.request + .fields + .iter() + .enumerate() + .find(|(idx, field)| field.required && self.field_value(*idx).is_none()) + .map(|(idx, _)| idx) + } + + fn is_current_field_answered(&self) -> bool { + self.field_value(self.current_index()).is_some() + } + + fn option_index_for_digit(&self, ch: char) -> Option { + let digit = ch.to_digit(10)?; + if digit == 0 { + return None; + } + let idx = (digit - 1) as usize; + (idx < self.options_len()).then_some(idx) + } + + fn select_current_option(&mut self, committed: bool) { + let options_len = self.options_len(); + if let Some(answer) = self.current_answer_mut() { + answer.selection.clamp_selection(options_len); + answer.answer_committed = committed; + } + } + + fn clear_selection(&mut self) { + if let Some(answer) = self.current_answer_mut() { + answer.selection.reset(); + answer.answer_committed = false; + } + } + + fn dispatch_cancel(&self) { + self.app_event_tx.resolve_elicitation( + self.request.thread_id, + self.request.server_name.clone(), + self.request.request_id.clone(), + McpServerElicitationAction::Cancel, + /*content*/ None, + /*meta*/ None, + ); + } + + fn advance_queue_or_complete(&mut self) { + if let Some(next) = self.queue.pop_front() { + self.request = next; + self.reset_for_request(); + self.restore_current_draft(); + } else { + self.done = true; + } + } + + fn submit_answers(&mut self) { + self.save_current_draft(); + if let Some(idx) = self.first_required_unanswered_index() { + self.validation_error = Some("Answer required fields before submitting.".to_string()); + self.jump_to_field(idx); + return; + } + self.validation_error = None; + if self.request.response_mode == McpServerElicitationResponseMode::ApprovalAction { + let (decision, meta) = + match self.field_value(/*idx*/ 0).as_ref().and_then(Value::as_str) { + Some(APPROVAL_ACCEPT_ONCE_VALUE) => (McpServerElicitationAction::Accept, None), + Some(APPROVAL_ACCEPT_SESSION_VALUE) => ( + McpServerElicitationAction::Accept, + Some(serde_json::json!({ + APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_SESSION_VALUE, + })), + ), + Some(APPROVAL_ACCEPT_ALWAYS_VALUE) => ( + McpServerElicitationAction::Accept, + Some(serde_json::json!({ + APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_ALWAYS_VALUE, + })), + ), + Some(APPROVAL_DECLINE_VALUE) => (McpServerElicitationAction::Decline, None), + Some(APPROVAL_CANCEL_VALUE) => (McpServerElicitationAction::Cancel, None), + _ => (McpServerElicitationAction::Cancel, None), + }; + self.app_event_tx.resolve_elicitation( + self.request.thread_id, + self.request.server_name.clone(), + self.request.request_id.clone(), + decision, + /*content*/ None, + meta, + ); + self.advance_queue_or_complete(); + return; + } + let content = self + .request + .fields + .iter() + .enumerate() + .filter_map(|(idx, field)| self.field_value(idx).map(|value| (field.id.clone(), value))) + .collect::>(); + self.app_event_tx.resolve_elicitation( + self.request.thread_id, + self.request.server_name.clone(), + self.request.request_id.clone(), + McpServerElicitationAction::Accept, + Some(Value::Object(content)), + /*meta*/ None, + ); + self.advance_queue_or_complete(); + } + + fn dismiss_resolved_request(&mut self, request: &ResolvedAppServerRequest) -> bool { + let ResolvedAppServerRequest::McpElicitation { + server_name, + request_id, + } = request + else { + return false; + }; + + let queue_len = self.queue.len(); + self.queue.retain(|queued_request| { + queued_request.server_name != *server_name || queued_request.request_id != *request_id + }); + if self.request.server_name == *server_name && self.request.request_id == *request_id { + self.advance_queue_or_complete(); + return true; + } + + self.queue.len() != queue_len + } + + fn go_next_or_submit(&mut self) { + if self.current_index() + 1 >= self.field_count() { + self.submit_answers(); + } else { + self.move_field(/*next*/ true); + } + } + + fn apply_submission_to_draft(&mut self, text: String, text_elements: Vec) { + let local_image_paths = self + .composer + .local_images() + .into_iter() + .map(|img| img.path) + .collect::>(); + if let Some(answer) = self.current_answer_mut() { + answer.draft = ComposerDraft { + text: text.clone(), + text_elements: text_elements.clone(), + local_image_paths: local_image_paths.clone(), + pending_pastes: Vec::new(), + }; + answer.answer_committed = !text.trim().is_empty(); + } + self.composer + .set_text_content(text, text_elements, local_image_paths); + self.composer.move_cursor_to_end(); + self.composer.set_footer_hint_override(Some(Vec::new())); + } + + fn handle_composer_input_result(&mut self, result: InputResult) -> bool { + match result { + InputResult::Submitted { + text, + text_elements, + } + | InputResult::Queued { + text, + text_elements, + .. + } => { + self.apply_submission_to_draft(text, text_elements); + self.validation_error = None; + self.go_next_or_submit(); + true + } + _ => false, + } + } + + fn render_prompt(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let answered = self.is_current_field_answered(); + for (offset, line) in self.wrapped_prompt_lines(area.width).iter().enumerate() { + let y = area.y.saturating_add(offset as u16); + if y >= area.y + area.height { + break; + } + let line = if answered { + Line::from(line.clone()) + } else { + Line::from(line.clone()).cyan() + }; + Paragraph::new(line).render( + Rect { + x: area.x, + y, + width: area.width, + height: 1, + }, + buf, + ); + } + } + + fn render_input(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + if self.current_field_is_select() { + let rows = self.option_rows(); + let mut state = self + .current_answer() + .map(|answer| answer.selection) + .unwrap_or_default(); + if state.selected_idx.is_none() && !rows.is_empty() { + state.selected_idx = Some(0); + } + state.ensure_visible(rows.len(), area.height as usize); + render_rows(area, buf, &rows, &state, rows.len().max(1), "No options"); + return; + } + if self.current_field_is_secret() { + self.composer.render_with_mask(area, buf, Some('*')); + } else { + self.composer.render(area, buf); + } + } + + fn render_footer(&self, area: Rect, input_area_height: u16, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let options_hidden = self.current_field_is_select() + && input_area_height > 0 + && self.options_required_height(area.width) > input_area_height; + let option_tip = if options_hidden { + let selected = self.selected_option_index().unwrap_or(0).saturating_add(1); + let total = self.options_len(); + Some(FooterTip::new(format!("option {selected}/{total}"))) + } else { + None + }; + let mut tip_lines = self.footer_tip_lines(area.width); + if let Some(prefix) = option_tip { + let mut tips = vec![prefix]; + if let Some(first_line) = tip_lines.first_mut() { + let mut first = Vec::new(); + std::mem::swap(first_line, &mut first); + tips.extend(first); + *first_line = tips; + } else { + tip_lines.push(tips); + } + } + for (row_idx, tips) in tip_lines.into_iter().take(area.height as usize).enumerate() { + let mut spans = Vec::new(); + for (tip_idx, tip) in tips.into_iter().enumerate() { + if tip_idx > 0 { + spans.push(FOOTER_SEPARATOR.into()); + } + if tip.highlight { + spans.push(tip.text.cyan().bold().not_dim()); + } else { + spans.push(tip.text.into()); + } + } + let line = Line::from(spans).dim(); + Paragraph::new(line).render( + Rect { + x: area.x, + y: area.y.saturating_add(row_idx as u16), + width: area.width, + height: 1, + }, + buf, + ); + } + } +} + +impl Renderable for McpServerElicitationOverlay { + fn desired_height(&self, width: u16) -> u16 { + let outer = Rect::new(0, 0, width, u16::MAX); + let inner = menu_surface_inset(outer); + let inner_width = inner.width.max(1); + let height = 1u16 + .saturating_add(self.wrapped_prompt_lines(inner_width).len() as u16) + .saturating_add(self.input_height(inner_width)) + .saturating_add(self.footer_tip_lines(inner_width).len() as u16) + .saturating_add(menu_surface_padding_height()); + height.max(MIN_OVERLAY_HEIGHT) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let content_area = render_menu_surface(area, buf); + if content_area.width == 0 || content_area.height == 0 { + return; + } + let prompt_lines = self.wrapped_prompt_lines(content_area.width); + let footer_lines = self.footer_tip_lines(content_area.width); + let mut remaining = content_area.height; + + let progress_height = u16::from(remaining > 0); + remaining = remaining.saturating_sub(progress_height); + + let footer_height = (footer_lines.len() as u16).min(remaining.saturating_sub(1)); + remaining = remaining.saturating_sub(footer_height); + + let min_input_height = if self.current_field_is_select() { + u16::from(remaining > 0) + } else { + MIN_COMPOSER_HEIGHT.min(remaining) + }; + let mut input_height = min_input_height; + remaining = remaining.saturating_sub(input_height); + + let prompt_height = (prompt_lines.len() as u16).min(remaining); + remaining = remaining.saturating_sub(prompt_height); + input_height = input_height.saturating_add(remaining); + + let progress_area = Rect { + x: content_area.x, + y: content_area.y, + width: content_area.width, + height: progress_height, + }; + let prompt_area = Rect { + x: content_area.x, + y: progress_area.y.saturating_add(progress_area.height), + width: content_area.width, + height: prompt_height, + }; + let input_area = Rect { + x: content_area.x, + y: prompt_area.y.saturating_add(prompt_area.height), + width: content_area.width, + height: input_height, + }; + let footer_area = Rect { + x: content_area.x, + y: input_area.y.saturating_add(input_area.height), + width: content_area.width, + height: footer_height, + }; + + let unanswered = self.required_unanswered_count(); + let progress_line = if self.field_count() > 0 { + let idx = self.current_index() + 1; + let total = self.field_count(); + let base = format!("Field {idx}/{total}"); + if unanswered > 0 { + Line::from(format!("{base} ({unanswered} required unanswered)").dim()) + } else { + Line::from(base.dim()) + } + } else { + Line::from("No fields".dim()) + }; + Paragraph::new(progress_line).render(progress_area, buf); + self.render_prompt(prompt_area, buf); + self.render_input(input_area, buf); + self.render_footer(footer_area, input_area.height, buf); + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if self.current_field_is_select() { + return None; + } + let content_area = menu_surface_inset(area); + if content_area.width == 0 || content_area.height == 0 { + return None; + } + let prompt_lines = self.wrapped_prompt_lines(content_area.width); + let footer_lines = self.footer_tip_lines(content_area.width); + let mut remaining = content_area.height; + remaining = remaining.saturating_sub(u16::from(remaining > 0)); + let footer_height = (footer_lines.len() as u16).min(remaining.saturating_sub(1)); + remaining = remaining.saturating_sub(footer_height); + let min_input_height = MIN_COMPOSER_HEIGHT.min(remaining); + let mut input_height = min_input_height; + remaining = remaining.saturating_sub(input_height); + let prompt_height = (prompt_lines.len() as u16).min(remaining); + remaining = remaining.saturating_sub(prompt_height); + input_height = input_height.saturating_add(remaining); + let input_area = Rect { + x: content_area.x, + y: content_area + .y + .saturating_add(1) + .saturating_add(prompt_height), + width: content_area.width, + height: input_height, + }; + self.composer.cursor_pos(input_area) + } +} + +impl BottomPaneView for McpServerElicitationOverlay { + fn next_frame_delay(&self) -> Option { + self.composer.footer_flash_delay() + } + + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + if self.current_field_is_select() { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } else { + self.composer.keymap_contexts() + } + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + + if matches!(key_event.code, KeyCode::Esc) + || self.current_field_is_select() + && self.list_keymap.action_for(key_event) == Some(ListAction::Cancel) + { + self.dispatch_cancel(); + self.done = true; + return; + } + + match key_event { + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::PageUp, + modifiers: KeyModifiers::NONE, + .. + } => { + self.move_field(/*next*/ false); + return; + } + KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::PageDown, + modifiers: KeyModifiers::NONE, + .. + } => { + self.move_field(/*next*/ true); + return; + } + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::NONE, + .. + } if self.current_field_is_select() => { + self.move_field(/*next*/ false); + return; + } + _ if self.current_field_is_select() + && self.list_keymap.action_for(key_event) == Some(ListAction::MoveLeft) => + { + self.move_field(/*next*/ false); + return; + } + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::NONE, + .. + } if self.current_field_is_select() => { + self.move_field(/*next*/ true); + return; + } + _ if self.current_field_is_select() + && self.list_keymap.action_for(key_event) == Some(ListAction::MoveRight) => + { + self.move_field(/*next*/ true); + return; + } + _ => {} + } + + if self.current_field_is_select() { + self.validation_error = None; + let options_len = self.options_len(); + match (self.list_keymap.action_for(key_event), key_event.code) { + (Some(ListAction::MoveUp), _) | (_, KeyCode::Up | KeyCode::Char('k')) => { + if let Some(answer) = self.current_answer_mut() { + answer.selection.move_up_wrap(options_len); + answer.answer_committed = false; + } + } + (Some(ListAction::MoveDown), _) | (_, KeyCode::Down | KeyCode::Char('j')) => { + if let Some(answer) = self.current_answer_mut() { + answer.selection.move_down_wrap(options_len); + answer.answer_committed = false; + } + } + (_, KeyCode::Backspace | KeyCode::Delete) => self.clear_selection(), + (_, KeyCode::Char(' ')) => self.select_current_option(/*committed*/ true), + (Some(ListAction::Accept), _) | (_, KeyCode::Enter) => { + if self.selected_option_index().is_some() { + self.select_current_option(/*committed*/ true); + } + self.go_next_or_submit(); + } + (_, KeyCode::Char(ch)) => { + if let Some(option_idx) = self.option_index_for_digit(ch) { + if let Some(answer) = self.current_answer_mut() { + answer.selection.selected_idx = Some(option_idx); + } + self.select_current_option(/*committed*/ true); + self.go_next_or_submit(); + } + } + _ => {} + } + return; + } + + let before = self.capture_composer_draft(); + let (result, _) = self.composer.handle_key_event(key_event); + let submitted = self.handle_composer_input_result(result); + if submitted { + return; + } + let after = self.capture_composer_draft(); + if before != after { + self.validation_error = None; + if let Some(answer) = self.current_answer_mut() { + answer.answer_committed = false; + } + } + } + + fn terminal_title_requires_action(&self) -> bool { + true + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + if !self.current_field_is_select() && !self.composer.current_text_with_pending().is_empty() + { + self.clear_current_draft(); + return CancellationEvent::Handled; + } + self.dispatch_cancel(); + self.done = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.done + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() || self.current_field_is_select() { + return false; + } + self.validation_error = None; + if let Some(answer) = self.current_answer_mut() { + answer.answer_committed = false; + } + self.composer.handle_paste(pasted) + } + + fn flush_paste_burst_if_due(&mut self) -> bool { + self.composer.flush_paste_burst_if_due() + } + + fn is_in_paste_burst(&self) -> bool { + self.composer.is_in_paste_burst() + } + + fn try_consume_mcp_server_elicitation_request( + &mut self, + request: McpServerElicitationFormRequest, + ) -> Option { + self.queue.push_back(request); + None + } + + fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool { + self.dismiss_resolved_request(request) + } +} + +fn wrap_footer_tips(width: u16, tips: Vec) -> Vec> { + crate::footer_hint::wrap_hint_rows( + tips, + width, + UnicodeWidthStr::width(FOOTER_SEPARATOR), + |tip| UnicodeWidthStr::width(tip.text.as_str()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::render::renderable::Renderable; + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::UnboundedReceiver; + use tokio::sync::mpsc::unbounded_channel; + + fn test_sender() -> (AppEventSender, UnboundedReceiver) { + let (tx_raw, rx) = unbounded_channel::(); + (AppEventSender::new(tx_raw), rx) + } + + fn form_request( + message: &str, + requested_schema: Value, + meta: Option, + ) -> McpServerElicitationRequestParams { + McpServerElicitationRequestParams { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + server_name: "server-1".to_string(), + request: McpServerElicitationRequest::Form { + meta, + message: message.to_string(), + requested_schema: serde_json::from_value(requested_schema) + .expect("test schema should deserialize"), + }, + } + } + + fn request_id(value: &str) -> AppServerRequestId { + AppServerRequestId::String(value.to_string()) + } + + fn from_form_request( + thread_id: ThreadId, + request: McpServerElicitationRequestParams, + ) -> Option { + McpServerElicitationFormRequest::from_app_server_request( + thread_id, + request_id("request-1"), + &request, + ) + } + + fn empty_object_schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": {}, + }) + } + + fn tool_approval_meta( + persist_modes: &[&str], + tool_params: Option, + tool_params_display: Option>, + ) -> Option { + let mut meta = serde_json::Map::from_iter([( + APPROVAL_META_KIND_KEY.to_string(), + Value::String(APPROVAL_META_KIND_MCP_TOOL_CALL.to_string()), + )]); + if !persist_modes.is_empty() { + meta.insert( + APPROVAL_PERSIST_KEY.to_string(), + Value::Array( + persist_modes + .iter() + .map(|mode| Value::String((*mode).to_string())) + .collect(), + ), + ); + } + if let Some(tool_params) = tool_params { + meta.insert(APPROVAL_TOOL_PARAMS_KEY.to_string(), tool_params); + } + if let Some(tool_params_display) = tool_params_display { + meta.insert( + APPROVAL_TOOL_PARAMS_DISPLAY_KEY.to_string(), + Value::Array( + tool_params_display + .into_iter() + .map(|(name, value, display_name)| { + serde_json::json!({ + "name": name, + "value": value, + "display_name": display_name, + }) + }) + .collect(), + ), + ); + } + Some(Value::Object(meta)) + } + + fn snapshot_buffer(buf: &Buffer) -> String { + let mut lines = Vec::new(); + for y in 0..buf.area().height { + let mut row = String::new(); + for x in 0..buf.area().width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(row); + } + lines.join("\n") + } + + fn render_snapshot(overlay: &McpServerElicitationOverlay, area: Rect) -> String { + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + snapshot_buffer(&buf) + } + + #[test] + fn parses_boolean_form_request() { + let thread_id = ThreadId::default(); + let request = from_form_request( + thread_id, + form_request( + "Allow this request?", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action.", + } + }, + "required": ["confirmed"], + }), + /*meta*/ None, + ), + ) + .expect("expected supported form"); + + assert_eq!( + request, + McpServerElicitationFormRequest { + thread_id, + server_name: "server-1".to_string(), + request_id: request_id("request-1"), + message: "Allow this request?".to_string(), + approval_display_params: Vec::new(), + response_mode: McpServerElicitationResponseMode::FormContent, + fields: vec![McpServerElicitationField { + id: "confirmed".to_string(), + label: "Confirm".to_string(), + prompt: "Approve the pending action.".to_string(), + required: true, + input: McpServerElicitationFieldInput::Select { + options: vec![ + McpServerElicitationOption { + label: "True".to_string(), + description: None, + value: Value::Bool(true), + }, + McpServerElicitationOption { + label: "False".to_string(), + description: None, + value: Value::Bool(false), + }, + ], + default_idx: None, + }, + }], + tool_suggestion: None, + } + ); + } + + #[test] + fn unsupported_numeric_form_falls_back() { + let request = from_form_request( + ThreadId::default(), + form_request( + "Pick a number", + serde_json::json!({ + "type": "object", + "properties": { + "count": { + "type": "integer", + "title": "Count", + } + }, + }), + /*meta*/ None, + ), + ); + + assert_eq!(request, None); + } + + #[test] + fn empty_object_schema_uses_approval_actions() { + let thread_id = ThreadId::default(); + let request = from_form_request( + thread_id, + form_request( + "Allow this request?", + empty_object_schema(), + /*meta*/ None, + ), + ) + .expect("expected approval fallback"); + + assert_eq!( + request, + McpServerElicitationFormRequest { + thread_id, + server_name: "server-1".to_string(), + request_id: request_id("request-1"), + message: "Allow this request?".to_string(), + approval_display_params: Vec::new(), + response_mode: McpServerElicitationResponseMode::ApprovalAction, + fields: vec![McpServerElicitationField { + id: APPROVAL_FIELD_ID.to_string(), + label: String::new(), + prompt: String::new(), + required: true, + input: McpServerElicitationFieldInput::Select { + options: vec![ + McpServerElicitationOption { + label: "Allow".to_string(), + description: Some("Allow this request and continue.".to_string()), + value: Value::String(APPROVAL_ACCEPT_ONCE_VALUE.to_string()), + }, + McpServerElicitationOption { + label: "Deny".to_string(), + description: Some("Decline this request and continue.".to_string()), + value: Value::String(APPROVAL_DECLINE_VALUE.to_string()), + }, + McpServerElicitationOption { + label: "Cancel".to_string(), + description: Some("Cancel this request".to_string()), + value: Value::String(APPROVAL_CANCEL_VALUE.to_string()), + }, + ], + default_idx: Some(0), + }, + }], + tool_suggestion: None, + } + ); + } + + #[test] + fn empty_tool_approval_schema_uses_approval_actions() { + let thread_id = ThreadId::default(); + let request = from_form_request( + thread_id, + form_request( + "Allow this request?", + empty_object_schema(), + tool_approval_meta( + &[], + /*tool_params*/ None, + /*tool_params_display*/ None, + ), + ), + ) + .expect("expected approval fallback"); + + assert_eq!( + request, + McpServerElicitationFormRequest { + thread_id, + server_name: "server-1".to_string(), + request_id: request_id("request-1"), + message: "Allow this request?".to_string(), + approval_display_params: Vec::new(), + response_mode: McpServerElicitationResponseMode::ApprovalAction, + fields: vec![McpServerElicitationField { + id: APPROVAL_FIELD_ID.to_string(), + label: String::new(), + prompt: String::new(), + required: true, + input: McpServerElicitationFieldInput::Select { + options: vec![ + McpServerElicitationOption { + label: "Allow".to_string(), + description: Some("Run the tool and continue.".to_string()), + value: Value::String(APPROVAL_ACCEPT_ONCE_VALUE.to_string()), + }, + McpServerElicitationOption { + label: "Cancel".to_string(), + description: Some("Cancel this tool call".to_string()), + value: Value::String(APPROVAL_CANCEL_VALUE.to_string()), + }, + ], + default_idx: Some(0), + }, + }], + tool_suggestion: None, + } + ); + } + + #[test] + fn tool_suggestion_meta_is_parsed_into_request_payload() { + let request = from_form_request( + ThreadId::default(), + form_request( + "Suggest Google Calendar", + empty_object_schema(), + Some(serde_json::json!({ + "codex_approval_kind": "tool_suggestion", + "tool_type": "connector", + "suggest_type": "install", + "suggest_reason": "Plan and reference events from your calendar", + "tool_id": "connector_2128aebfecb84f64a069897515042a44", + "tool_name": "Google Calendar", + "install_url": "https://example.test/google-calendar", + })), + ), + ) + .expect("expected tool suggestion form"); + + assert_eq!( + request.tool_suggestion(), + Some(&ToolSuggestionRequest { + tool_type: ToolSuggestionToolType::Connector, + suggest_type: ToolSuggestionType::Install, + suggest_reason: "Plan and reference events from your calendar".to_string(), + tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + tool_name: "Google Calendar".to_string(), + install_url: Some("https://example.test/google-calendar".to_string()), + }) + ); + } + + #[test] + fn plugin_tool_suggestion_meta_without_install_url_is_parsed_into_request_payload() { + let request = from_form_request( + ThreadId::default(), + form_request( + "Suggest Slack", + empty_object_schema(), + Some(serde_json::json!({ + "codex_approval_kind": "tool_suggestion", + "tool_type": "plugin", + "suggest_type": "install", + "suggest_reason": "Install the Slack plugin to search messages", + "tool_id": "slack@openai-curated", + "tool_name": "Slack", + })), + ), + ) + .expect("expected tool suggestion form"); + + assert_eq!( + request.tool_suggestion(), + Some(&ToolSuggestionRequest { + tool_type: ToolSuggestionToolType::Plugin, + suggest_type: ToolSuggestionType::Install, + suggest_reason: "Install the Slack plugin to search messages".to_string(), + tool_id: "slack@openai-curated".to_string(), + tool_name: "Slack".to_string(), + install_url: None, + }) + ); + } + + #[test] + fn tool_approval_display_params_prefer_explicit_display_order() { + let request = from_form_request( + ThreadId::default(), + form_request( + "Allow Calendar to create an event", + empty_object_schema(), + tool_approval_meta( + &[], + Some(serde_json::json!({ + "zeta": 3, + "alpha": 1, + })), + Some(vec![ + ( + "calendar_id", + Value::String("primary".to_string()), + "Calendar", + ), + ( + "title", + Value::String("Roadmap review".to_string()), + "Title", + ), + ]), + ), + ), + ) + .expect("expected approval fallback"); + + assert_eq!( + request.approval_display_params, + vec![ + McpToolApprovalDisplayParam { + name: "calendar_id".to_string(), + value: Value::String("primary".to_string()), + display_name: "Calendar".to_string(), + }, + McpToolApprovalDisplayParam { + name: "title".to_string(), + value: Value::String("Roadmap review".to_string()), + display_name: "Title".to_string(), + }, + ] + ); + } + + #[test] + fn submit_sends_accept_with_typed_content() { + let (tx, mut rx) = test_sender(); + let thread_id = ThreadId::default(); + let request = from_form_request( + thread_id, + form_request( + "Allow this request?", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action.", + } + }, + "required": ["confirmed"], + }), + /*meta*/ None, + ), + ) + .expect("expected supported form"); + let mut overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.select_current_option(/*committed*/ true); + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected resolution"); + let AppEvent::SubmitThreadOp { + thread_id: resolved_thread_id, + op, + } = event + else { + panic!("expected SubmitThreadOp"); + }; + assert_eq!(resolved_thread_id, thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "server-1".to_string(), + request_id: request_id("request-1"), + decision: McpServerElicitationAction::Accept, + content: Some(serde_json::json!({ + "confirmed": true, + })), + meta: None, + } + ); + } + + #[test] + fn horizontal_list_keys_move_between_select_fields() { + let (tx, _rx) = test_sender(); + let request = from_form_request( + ThreadId::default(), + form_request( + "Choose values", + serde_json::json!({ + "type": "object", + "properties": { + "first": { + "type": "boolean", + "title": "First", + }, + "second": { + "type": "boolean", + "title": "Second", + } + }, + "required": ["first", "second"], + }), + /*meta*/ None, + ), + ) + .expect("expected supported form"); + let mut overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + assert_eq!(overlay.current_idx, 0); + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert_eq!(overlay.current_idx, 1); + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(overlay.current_idx, 0); + overlay.list_keymap.accept.clear(); + assert!( + overlay + .footer_tips() + .iter() + .all(|tip| !tip.text.contains("submit")) + ); + } + + #[test] + fn switching_fields_clears_length_validation_flash() { + let (tx, _rx) = test_sender(); + let request = from_form_request( + ThreadId::default(), + form_request( + "Two fields", + serde_json::json!({ + "type": "object", + "properties": {"a": {"type": "string"}, "b": {"type": "string"}}, + "required": ["a", "b"], + }), + /*meta*/ None, + ), + ) + .expect("supported form"); + let mut overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ true, + ); + overlay.handle_paste("x".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS + 1)); + overlay.handle_key_event(KeyCode::Enter.into()); + assert!(overlay.next_frame_delay().is_some()); + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + assert_eq!(overlay.next_frame_delay(), None); + assert!(overlay.composer.current_text().is_empty()); + } + + #[test] + fn text_fields_inherit_composer_chord_context_and_submit_binding() { + let (tx, _rx) = test_sender(); + let thread_id = ThreadId::default(); + let request = from_form_request( + thread_id, + form_request( + "Enter a name", + serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name", + } + }, + "required": ["name"], + }), + /*meta*/ None, + ), + ) + .expect("supported text field"); + let mut config = TuiKeymap::default(); + config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x enter".to_string(), + ))); + let keymap = RuntimeKeymap::from_config(&config).expect("valid MCP composer chord"); + let mut overlay = McpServerElicitationOverlay::new_with_keymap( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ true, keymap, + ); + + let snapshot = render_snapshot( + &overlay, + Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10, + ), + ) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + insta::assert_snapshot!( + "mcp_server_elicitation_text_configured_key_chords", + snapshot + ); + assert!( + overlay + .keymap_contexts() + .contains(crate::keymap::KeymapContext::Composer) + ); + overlay.composer_submit_hint = None; + assert!( + overlay + .footer_tips() + .iter() + .all(|tip| !tip.text.contains("submit")) + ); + } + + #[test] + fn empty_tool_approval_schema_session_choice_sets_persist_meta() { + let (tx, mut rx) = test_sender(); + let thread_id = ThreadId::default(); + let request = from_form_request( + thread_id, + form_request( + "Allow this request?", + empty_object_schema(), + tool_approval_meta( + &[ + APPROVAL_PERSIST_SESSION_VALUE, + APPROVAL_PERSIST_ALWAYS_VALUE, + ], + /*tool_params*/ None, + /*tool_params_display*/ None, + ), + ), + ) + .expect("expected approval fallback"); + let mut overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + if let Some(answer) = overlay.current_answer_mut() { + answer.selection.selected_idx = Some(1); + } + overlay.select_current_option(/*committed*/ true); + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected resolution"); + let AppEvent::SubmitThreadOp { + thread_id: resolved_thread_id, + op, + } = event + else { + panic!("expected SubmitThreadOp"); + }; + assert_eq!(resolved_thread_id, thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "server-1".to_string(), + request_id: request_id("request-1"), + decision: McpServerElicitationAction::Accept, + content: None, + meta: Some(serde_json::json!({ + APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_SESSION_VALUE, + })), + } + ); + } + + #[test] + fn empty_tool_approval_schema_always_allow_sets_persist_meta() { + let (tx, mut rx) = test_sender(); + let thread_id = ThreadId::default(); + let request = from_form_request( + thread_id, + form_request( + "Allow this request?", + empty_object_schema(), + tool_approval_meta( + &[ + APPROVAL_PERSIST_SESSION_VALUE, + APPROVAL_PERSIST_ALWAYS_VALUE, + ], + /*tool_params*/ None, + /*tool_params_display*/ None, + ), + ), + ) + .expect("expected approval fallback"); + let mut overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + if let Some(answer) = overlay.current_answer_mut() { + answer.selection.selected_idx = Some(2); + } + overlay.select_current_option(/*committed*/ true); + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected resolution"); + let AppEvent::SubmitThreadOp { + thread_id: resolved_thread_id, + op, + } = event + else { + panic!("expected SubmitThreadOp"); + }; + assert_eq!(resolved_thread_id, thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "server-1".to_string(), + request_id: request_id("request-1"), + decision: McpServerElicitationAction::Accept, + content: None, + meta: Some(serde_json::json!({ + APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_ALWAYS_VALUE, + })), + } + ); + } + + #[test] + fn ctrl_c_cancels_elicitation() { + let (tx, mut rx) = test_sender(); + let thread_id = ThreadId::default(); + let request = from_form_request( + thread_id, + form_request( + "Allow this request?", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action.", + } + }, + "required": ["confirmed"], + }), + /*meta*/ None, + ), + ) + .expect("expected supported form"); + let mut overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + assert_eq!(overlay.on_ctrl_c(), CancellationEvent::Handled); + + let event = rx.try_recv().expect("expected resolution"); + let AppEvent::SubmitThreadOp { + thread_id: resolved_thread_id, + op, + } = event + else { + panic!("expected SubmitThreadOp"); + }; + assert_eq!(resolved_thread_id, thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "server-1".to_string(), + request_id: request_id("request-1"), + decision: McpServerElicitationAction::Cancel, + content: None, + meta: None, + } + ); + } + + #[test] + fn queues_requests_fifo() { + let (tx, _rx) = test_sender(); + let first = from_form_request( + ThreadId::default(), + form_request( + "First", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + } + }, + }), + /*meta*/ None, + ), + ) + .expect("expected supported form"); + let second = from_form_request( + ThreadId::default(), + form_request( + "Second", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + } + }, + }), + /*meta*/ None, + ), + ) + .expect("expected supported form"); + let third = from_form_request( + ThreadId::default(), + form_request( + "Third", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + } + }, + }), + /*meta*/ None, + ), + ) + .expect("expected supported form"); + let mut overlay = McpServerElicitationOverlay::new( + first, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.try_consume_mcp_server_elicitation_request(second); + overlay.try_consume_mcp_server_elicitation_request(third); + overlay.select_current_option(/*committed*/ true); + overlay.submit_answers(); + + assert_eq!(overlay.request.message, "Second"); + + overlay.select_current_option(/*committed*/ true); + overlay.submit_answers(); + + assert_eq!(overlay.request.message, "Third"); + } + + #[test] + fn resolved_request_dismisses_overlay_without_emitting_events() { + let (tx, mut rx) = test_sender(); + let thread_id = ThreadId::default(); + let supported_form_schema = serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + } + }, + }); + let mut overlay = McpServerElicitationOverlay::new( + from_form_request( + thread_id, + form_request("First", supported_form_schema.clone(), /*meta*/ None), + ) + .expect("expected supported form"), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + overlay.try_consume_mcp_server_elicitation_request( + McpServerElicitationFormRequest::from_app_server_request( + thread_id, + request_id("request-2"), + &McpServerElicitationRequestParams { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-2".to_string()), + server_name: "server-1".to_string(), + request: McpServerElicitationRequest::Form { + meta: None, + message: "Second".to_string(), + requested_schema: serde_json::from_value(supported_form_schema) + .expect("test schema should deserialize"), + }, + }, + ) + .expect("expected supported form"), + ); + + assert!( + overlay.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { + server_name: "server-1".to_string(), + request_id: request_id("request-1"), + }) + ); + assert_eq!(overlay.request.message, "Second"); + assert!(matches!( + rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + + assert!( + overlay.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { + server_name: "server-1".to_string(), + request_id: request_id("request-2"), + }) + ); + assert!(overlay.is_complete()); + assert!(matches!( + rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + } + + #[test] + fn boolean_form_snapshot() { + let (tx, _rx) = test_sender(); + let request = from_form_request( + ThreadId::default(), + form_request( + "Allow this request?", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action.", + } + }, + "required": ["confirmed"], + }), + /*meta*/ None, + ), + ) + .expect("expected supported form"); + let overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + insta::assert_snapshot!( + "mcp_server_elicitation_boolean_form", + render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) + ); + } + + #[test] + fn approval_form_tool_approval_snapshot() { + let (tx, _rx) = test_sender(); + let request = from_form_request( + ThreadId::default(), + form_request( + "Allow this request?", + empty_object_schema(), + tool_approval_meta( + &[], + /*tool_params*/ None, + /*tool_params_display*/ None, + ), + ), + ) + .expect("expected approval fallback"); + let overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + insta::assert_snapshot!( + "mcp_server_elicitation_approval_form_without_schema", + render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) + ); + } + + #[test] + fn message_only_form_snapshot() { + let (tx, _rx) = test_sender(); + let request = from_form_request( + ThreadId::default(), + form_request( + "Boolean elicit MCP example: do you confirm?", + empty_object_schema(), + /*meta*/ None, + ), + ) + .expect("expected message-only form"); + let overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + insta::assert_snapshot!( + "mcp_server_elicitation_message_only_form", + render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) + ); + } + + #[test] + fn message_only_form_with_persist_options_snapshot() { + let (tx, _rx) = test_sender(); + let request = from_form_request( + ThreadId::default(), + form_request( + "Boolean elicit MCP example: do you confirm?", + empty_object_schema(), + Some(serde_json::json!({ + APPROVAL_PERSIST_KEY: [ + APPROVAL_PERSIST_SESSION_VALUE, + APPROVAL_PERSIST_ALWAYS_VALUE, + ], + })), + ), + ) + .expect("expected message-only form"); + let overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + insta::assert_snapshot!( + "mcp_server_elicitation_message_only_form_with_persist_options", + render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) + ); + } + + #[test] + fn approval_form_tool_approval_with_persist_options_snapshot() { + let (tx, _rx) = test_sender(); + let request = from_form_request( + ThreadId::default(), + form_request( + "Allow this request?", + empty_object_schema(), + tool_approval_meta( + &[ + APPROVAL_PERSIST_SESSION_VALUE, + APPROVAL_PERSIST_ALWAYS_VALUE, + ], + /*tool_params*/ None, + /*tool_params_display*/ None, + ), + ), + ) + .expect("expected approval fallback"); + let overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + insta::assert_snapshot!( + "mcp_server_elicitation_approval_form_with_session_persist", + render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) + ); + } + + #[test] + fn approval_form_tool_approval_with_param_summary_snapshot() { + let (tx, _rx) = test_sender(); + let request = from_form_request( + ThreadId::default(), + form_request( + "Allow Calendar to create an event", + empty_object_schema(), + tool_approval_meta( + &[], + Some(serde_json::json!({ + "calendar_id": "primary", + "title": "Roadmap review", + "notes": "This is a deliberately long note that should truncate before it turns the approval body into a giant wall of text in the TUI overlay.", + "ignored_after_limit": "fourth param", + })), + Some(vec![ + ( + "calendar_id", + Value::String("primary".to_string()), + "Calendar", + ), + ( + "title", + Value::String("Roadmap review".to_string()), + "Title", + ), + ( + "notes", + Value::String("This is a deliberately long note that should truncate before it turns the approval body into a giant wall of text in the TUI overlay.".to_string()), + "Notes", + ), + ( + "ignored_after_limit", + Value::String("fourth param".to_string()), + "Ignored", + ), + ]), + ), + ), + ) + .expect("expected approval fallback"); + let overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + insta::assert_snapshot!( + "mcp_server_elicitation_approval_form_with_param_summary", + render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..0dd58b75c97b7b6e7e195e3c95c1030374c00529 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs @@ -0,0 +1,477 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::Widget; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap; +use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::style::user_message_style; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; + +const MEMORIES_DOC_URL: &str = "https://developers.openai.com/codex/memories"; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum MemoriesSetting { + Use, + Generate, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum MemoriesAction { + Reset, +} + +enum MemoriesMenuItem { + Setting { + setting: MemoriesSetting, + name: &'static str, + description: &'static str, + enabled: bool, + }, + Action { + action: MemoriesAction, + name: &'static str, + description: &'static str, + }, +} + +pub(crate) struct MemoriesSettingsView { + items: Vec, + state: ScrollState, + reset_confirmation: Option, + complete: bool, + app_event_tx: AppEventSender, + docs_link: Line<'static>, + keymap: ListKeymap, +} + +impl MemoriesSettingsView { + pub(crate) fn new( + use_memories: bool, + generate_memories: bool, + app_event_tx: AppEventSender, + keymap: ListKeymap, + ) -> Self { + let mut view = Self { + items: vec![ + MemoriesMenuItem::Setting { + setting: MemoriesSetting::Use, + name: "Use memories", + description: "Use memories in the following threads. Applied at next thread.", + enabled: use_memories, + }, + MemoriesMenuItem::Setting { + setting: MemoriesSetting::Generate, + name: "Generate memories", + description: "Generate memories from the following threads. Current thread included.", + enabled: generate_memories, + }, + MemoriesMenuItem::Action { + action: MemoriesAction::Reset, + name: "Reset all memories", + description: "Clear local memory files and summaries. Existing threads stay intact.", + }, + ], + state: ScrollState::new(), + reset_confirmation: None, + complete: false, + app_event_tx, + docs_link: Line::from(vec![ + "Learn more: ".dim(), + MEMORIES_DOC_URL.cyan().underlined(), + ]), + keymap, + }; + view.initialize_selection(); + view + } + + fn initialize_selection(&mut self) { + self.state.selected_idx = (!self.items.is_empty()).then_some(0); + } + + fn settings_header(&self) -> ColumnRenderable<'_> { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Memories".bold())); + header.push(Line::from( + "Choose how Codex uses and creates memories. Changes are saved to config.toml".dim(), + )); + header + } + + fn reset_confirmation_header(&self) -> ColumnRenderable<'_> { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Reset all memories?".bold())); + header.push(Line::from( + "This clears local memory files and rollout summaries for the current Codex home." + .dim(), + )); + header + } + + fn active_state(&self) -> &ScrollState { + self.reset_confirmation.as_ref().unwrap_or(&self.state) + } + + fn active_state_mut(&mut self) -> &mut ScrollState { + self.reset_confirmation.as_mut().unwrap_or(&mut self.state) + } + + fn visible_len(&self) -> usize { + if self.reset_confirmation.is_some() { + 2 + } else { + self.items.len() + } + } + + fn build_rows(&self) -> Vec { + if let Some(state) = self.reset_confirmation.as_ref() { + return ["Reset all memories", "Go back"] + .into_iter() + .enumerate() + .map(|(idx, name)| GenericDisplayRow { + name: if state.selected_idx == Some(idx) { + format!("› {name}") + } else { + format!(" {name}") + }, + description: Some(match idx { + 0 => "Delete local memory files and rollout summaries.".to_string(), + 1 => "Return to memory settings.".to_string(), + _ => unreachable!("reset confirmation only renders two rows"), + }), + ..Default::default() + }) + .collect(); + } + + let selected_idx = self.state.selected_idx; + self.items + .iter() + .enumerate() + .map(|(idx, item)| { + let prefix = if selected_idx == Some(idx) { + '›' + } else { + ' ' + }; + let (name, description) = match item { + MemoriesMenuItem::Setting { + name, + description, + enabled, + .. + } => ( + format!("{prefix} [{}] {name}", if *enabled { 'x' } else { ' ' }), + description, + ), + MemoriesMenuItem::Action { + name, description, .. + } => (format!("{prefix} {name}"), description), + }; + GenericDisplayRow { + name, + description: Some((*description).to_string()), + ..Default::default() + } + }) + .collect() + } + + fn move_up(&mut self) { + let len = self.visible_len(); + if len == 0 { + return; + } + let state = self.active_state_mut(); + state.move_up_wrap(len); + state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn move_down(&mut self) { + let len = self.visible_len(); + if len == 0 { + return; + } + let state = self.active_state_mut(); + state.move_down_wrap(len); + state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn page_up(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.active_state_mut().page_up_clamped(len, visible); + } + + fn page_down(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.active_state_mut().page_down_clamped(len, visible); + } + + fn jump_top(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.active_state_mut().jump_top(len, visible); + } + + fn jump_bottom(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.active_state_mut().jump_bottom(len, visible); + } + + fn toggle_selected(&mut self) { + if self.reset_confirmation.is_some() { + return; + } + + let Some(selected_idx) = self.state.selected_idx else { + return; + }; + + if let Some(MemoriesMenuItem::Setting { enabled, .. }) = self.items.get_mut(selected_idx) { + *enabled = !*enabled; + } + } + + fn rows_width(total_width: u16) -> u16 { + total_width.saturating_sub(2) + } + + fn current_setting(&self, setting: MemoriesSetting) -> bool { + self.items + .iter() + .find_map(|item| match item { + MemoriesMenuItem::Setting { + setting: item_setting, + enabled, + .. + } if *item_setting == setting => Some(*enabled), + _ => None, + }) + .unwrap_or(false) + } + + fn open_reset_confirmation(&mut self) { + let mut state = ScrollState::new(); + state.selected_idx = Some(0); + self.reset_confirmation = Some(state); + } + + fn close_reset_confirmation(&mut self) { + self.reset_confirmation = None; + self.state.selected_idx = self.items.len().checked_sub(1); + } + + fn footer_hint(&self) -> Line<'static> { + if self.reset_confirmation.is_some() { + standard_popup_hint_line_for_keymap(&self.keymap) + } else { + memories_settings_hint_line(&self.keymap) + } + } +} + +impl BottomPaneView for MemoriesSettingsView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + _ if self.keymap.move_up.is_pressed(key_event) => self.move_up(), + _ if self.keymap.move_down.is_pressed(key_event) => self.move_down(), + _ if self.keymap.page_up.is_pressed(key_event) => self.page_up(), + _ if self.keymap.page_down.is_pressed(key_event) => self.page_down(), + _ if self.keymap.jump_top.is_pressed(key_event) => self.jump_top(), + _ if self.keymap.jump_bottom.is_pressed(key_event) => self.jump_bottom(), + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } => self.toggle_selected(), + _ if self.keymap.accept.is_pressed(key_event) => self.save(), + _ if self.keymap.cancel.is_pressed(key_event) => self.cancel(), + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.cancel(); + CancellationEvent::Handled + } +} + +impl MemoriesSettingsView { + fn save(&mut self) { + if let Some(state) = self.reset_confirmation.as_ref() { + match state.selected_idx { + Some(0) => { + self.app_event_tx.send(AppEvent::ResetMemories); + self.complete = true; + } + Some(1) | None => self.close_reset_confirmation(), + Some(other) => unreachable!("unexpected reset confirmation row: {other}"), + } + return; + } + + match self.state.selected_idx.and_then(|idx| self.items.get(idx)) { + Some(MemoriesMenuItem::Action { + action: MemoriesAction::Reset, + .. + }) => self.open_reset_confirmation(), + _ => { + self.app_event_tx.send(AppEvent::UpdateMemorySettings { + use_memories: self.current_setting(MemoriesSetting::Use), + generate_memories: self.current_setting(MemoriesSetting::Generate), + }); + self.complete = true; + } + } + } + + fn cancel(&mut self) { + if self.reset_confirmation.is_some() { + self.close_reset_confirmation(); + } else { + self.complete = true; + } + } +} + +impl Renderable for MemoriesSettingsView { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + + Block::default() + .style(user_message_style()) + .render(content_area, buf); + + let header = if self.reset_confirmation.is_some() { + self.reset_confirmation_header() + } else { + self.settings_header() + }; + let header_height = header.desired_height(content_area.width.saturating_sub(4)); + let rows = self.build_rows(); + let rows_width = Self::rows_width(content_area.width); + let rows_height = measure_rows_height( + &rows, + self.active_state(), + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + let [header_area, _, list_area, _, docs_area] = Layout::vertical([ + Constraint::Max(header_height), + Constraint::Max(1), + Constraint::Length(rows_height), + Constraint::Max(1), + Constraint::Length(1), + ]) + .areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2))); + + header.render(header_area, buf); + + if list_area.height > 0 { + let render_area = Rect { + x: list_area.x.saturating_sub(2), + y: list_area.y, + width: rows_width.max(1), + height: list_area.height, + }; + render_rows( + render_area, + buf, + &rows, + self.active_state(), + MAX_POPUP_ROWS, + " No memory settings available", + ); + } + if self.reset_confirmation.is_none() { + self.docs_link.clone().render(docs_area, buf); + crate::terminal_hyperlinks::mark_url_hyperlink(buf, docs_area, MEMORIES_DOC_URL); + } + + let hint_area = Rect { + x: footer_area.x + 2, + y: footer_area.y, + width: footer_area.width.saturating_sub(2), + height: footer_area.height, + }; + self.footer_hint().render(hint_area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let header = if self.reset_confirmation.is_some() { + self.reset_confirmation_header() + } else { + self.settings_header() + }; + let rows = self.build_rows(); + let rows_width = Self::rows_width(width); + let rows_height = measure_rows_height( + &rows, + self.active_state(), + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + + let docs_height = if self.reset_confirmation.is_some() { + 0 + } else { + 1 + }; + let mut height = header.desired_height(width.saturating_sub(4)); + height = height.saturating_add(rows_height + 4 + docs_height); + height.saturating_add(1) + } +} + +fn memories_settings_hint_line(keymap: &ListKeymap) -> Line<'static> { + let mut spans = vec![ + "Press ".into(), + key_hint::plain(KeyCode::Char(' ')).into(), + " to toggle".into(), + ]; + if let Some(accept) = keymap.primary_hint(ListAction::Accept) { + spans.extend(["; ".into(), accept.into(), " to save or select".into()]); + } + Line::from(spans) +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..3aedb4fc504a1404368bd844f32b45673ad027bd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -0,0 +1,3664 @@ +//! The bottom pane is the interactive footer of the chat UI. +//! +//! The pane owns the [`ChatComposer`] (editable prompt input) and a stack of transient +//! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused +//! interactions like selection lists. +//! +//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs +//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent +//! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active +//! view the first chance to consume Ctrl+C (typically to dismiss itself), then lets an active +//! composer history search consume Ctrl+C as cancellation, and `ChatWidget` may treat an unhandled +//! Ctrl+C as an interrupt or as the first press of a double-press quit shortcut. +//! +//! Some UI is time-based rather than input-based, such as the transient "press again to quit" +//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. +//! Inline banners sit above the composer. Number shortcuts apply only to an empty, idle composer; +//! drafts, paste bursts, and active dialogs keep their normal input routing. +use std::collections::VecDeque; +use std::path::PathBuf; + +use crate::app::app_server_requests::ResolvedAppServerRequest; +use crate::app_event::AppEvent; +use crate::app_event::ConnectorsSnapshot; +use crate::app_event::HistoryLookupResponse; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::pending_input_preview::PendingInputPreview; +use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals; +use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::KeymapContext; +use crate::keymap::KeymapContextSet; +use crate::keymap::RuntimeKeymap; +use crate::render::renderable::FlexRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableItem; +use crate::terminal_palette::effective_stdout_color_level; +use crate::tui::FrameRequester; +pub(crate) use bottom_pane_view::BottomPaneView; +pub(crate) use bottom_pane_view::ViewCompletion; +use codex_app_server_protocol::SkillMetadata; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_features::Features; +use codex_file_search::FileMatch; +use codex_plugin::PluginCapabilitySummary; +use codex_protocol::ThreadId; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::user_input::TextElement; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use std::time::Duration; +use std::time::Instant; + +mod action_required_title; +mod actionable_banner; +mod app_link_view; +mod apply_patch_header; +mod approval_overlay; +mod async_questions; +mod hook_status; +mod mcp_server_elicitation; +mod multi_select_picker; +#[cfg(test)] +#[path = "questions_tests.rs"] +mod question_tests; +mod questions; +mod request_user_input; +mod status_line_setup; +mod status_line_style; +mod status_surface_preview; +mod title_setup; +pub(crate) mod user_verification; +mod voice_strip; +pub(crate) use action_required_title::ACTION_REQUIRED_PREVIEW_PREFIX; +pub(crate) use action_required_title::build_action_required_title_text; +pub(crate) use actionable_banner::ActionableBanner; +pub(crate) use actionable_banner::BannerDismissal; +pub(crate) use app_link_view::AppLinkElicitationTarget; +pub(crate) use app_link_view::AppLinkSuggestionType; +pub(crate) use app_link_view::AppLinkView; +pub(crate) use app_link_view::AppLinkViewParams; +pub(crate) use approval_overlay::ApplyPatchApprovalRequest; +pub(crate) use approval_overlay::ApprovalOverlay; +pub(crate) use approval_overlay::ApprovalRequest; +pub(crate) use approval_overlay::ExecApprovalRequest; +pub(crate) use approval_overlay::McpElicitationApprovalRequest; +pub(crate) use approval_overlay::PermissionsApprovalRequest; +pub(crate) use approval_overlay::format_requested_permissions_rule; +pub(crate) use async_questions::AsyncQuestions; +pub(crate) use async_questions::QuestionState; +pub(crate) use async_questions::QuestionSubmission; +pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest; +pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay; +pub(crate) use request_user_input::RequestUserInputOverlay; +pub(crate) use status_line_style::status_line_from_segments; +pub(crate) use voice_strip::VoiceStripPhase; +pub(crate) use voice_strip::VoiceStripState; +mod bottom_pane_view; +mod effort_ignition; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocalImageAttachment { + pub(crate) placeholder: String, + pub(crate) path: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct MentionBinding { + /// Visible mention sigil (`$` or `@`). + pub(crate) sigil: char, + /// Mention token text without the leading sigil (`$` or `@`). + pub(crate) mention: String, + /// Canonical mention target (for example `app://...` or absolute SKILL.md path). + pub(crate) path: String, +} +mod chat_composer; +mod chat_composer_history; +mod command_popup; +pub(crate) mod custom_prompt_view; +mod effort_status_line; +mod experimental_features_view; +mod file_search_popup; +mod footer; +mod list_selection_view; +mod memories_settings_view; +mod mentions_v2; +pub(crate) mod prompt_args; +mod skill_popup; +mod skills_toggle_view; +pub(crate) mod slash_commands; +pub(crate) use footer::CollaborationModeIndicator; +pub(crate) use footer::GoalStatusIndicator; +#[cfg(test)] +pub(crate) use footer::goal_status_indicator_line; +pub(crate) use list_selection_view::ColumnWidthMode; +pub(crate) use list_selection_view::ListSelectionView; +pub(crate) use list_selection_view::OnSelectionChangedCallback; +pub(crate) use list_selection_view::SelectionDescriptionLayout; +pub(crate) use list_selection_view::SelectionRowDisplay; +pub(crate) use list_selection_view::SelectionToggle; +pub(crate) use list_selection_view::SelectionViewParams; +pub(crate) use list_selection_view::SideContentWidth; +pub(crate) use list_selection_view::popup_content_width; +pub(crate) use list_selection_view::side_by_side_layout_widths; +pub(crate) use memories_settings_view::MemoriesSettingsView; +use slash_commands::ServiceTierCommand; +mod feedback_note_view; +mod feedback_view; +mod hooks_browser_view; +pub(crate) use feedback_view::FeedbackAudience; +pub(crate) use feedback_view::feedback_classification; +pub(crate) use feedback_view::feedback_disabled_params; +pub(crate) use feedback_view::feedback_selection_params; +pub(crate) use feedback_view::feedback_success_cell; +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::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; +pub(crate) mod popup_consts; +mod scroll_state; +mod selection_popup_common; +mod selection_row_layout; +mod selection_tabs; +mod startup; +mod textarea; +mod unified_exec_footer; +pub(crate) use feedback_note_view::FeedbackNoteView; +pub(crate) use hooks_browser_view::HooksBrowserView; +pub(crate) use selection_tabs::SelectionTab; + +/// How long the "press again to quit" hint stays visible. +/// +/// This is shared between: +/// - `ChatWidget`: arming the double-press quit shortcut. +/// - `BottomPane`/`ChatComposer`: rendering and expiring the footer hint. +/// +/// Keeping a single value ensures Ctrl+C and Ctrl+D behave identically. +pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1); + +const APPROVAL_PROMPT_TYPING_IDLE_DELAY: Duration = Duration::from_secs(1); + +/// Whether Ctrl+C/Ctrl+D require a second press to quit. +/// +/// This UX experiment was enabled by default, but requiring a double press to quit feels janky in +/// practice (especially for users accustomed to shells and other TUIs). Disable it for now while we +/// rethink a better quit/interrupt design. +pub(crate) const DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED: bool = false; + +/// The result of offering a cancellation key to a bottom-pane surface. +/// +/// This is primarily used for Ctrl+C routing: active views can consume the key to dismiss +/// themselves, and the caller can decide what higher-level action (if any) to take when the key is +/// not handled locally. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CancellationEvent { + Handled, + NotHandled, +} + +use crate::bottom_pane::prompt_args::parse_slash_name; +pub(crate) use chat_composer::ChatComposer; +pub(crate) use chat_composer::ChatComposerConfig; +pub(crate) use chat_composer::ComposerDraftSnapshot; +pub(crate) use chat_composer::InputResult; +pub(crate) use chat_composer::QueuedInputAction; +pub(crate) use chat_composer_history::HistoryEntry; +pub(crate) use textarea::KillBufferSnapshot; + +use crate::status_indicator_widget::StatusDetailsCapitalization; +use crate::status_indicator_widget::StatusIndicatorWidget; +#[cfg(test)] +pub(crate) use experimental_features_view::ExperimentalFeatureItem; +pub(crate) use experimental_features_view::ExperimentalFeaturesView; +pub(crate) use list_selection_view::SELECTION_TOGGLE_BLOCKED_PREFIX; +pub(crate) use list_selection_view::SELECTION_TOGGLE_UNAVAILABLE_PREFIX; +pub(crate) use list_selection_view::SelectionAction; +pub(crate) use list_selection_view::SelectionItem; +pub(crate) use list_selection_view::SelectionSecondaryAction; + +struct DelayedApprovalRequest { + request: ApprovalRequest, + features: Features, +} + +/// Pane displayed in the lower half of the chat UI. +/// +/// This is the owning container for the prompt input (`ChatComposer`) and the view stack +/// (`BottomPaneView`). It performs local input routing and renders time-based hints, while leaving +/// process-level decisions (quit, interrupt, shutdown) to `ChatWidget`. +pub(crate) struct BottomPane { + /// Composer is retained even when a BottomPaneView is displayed so the + /// input state is retained when the view is closed. + composer: ChatComposer, + + /// Stack of views displayed instead of the composer (e.g. popups/modals). + view_stack: Vec>, + pub(crate) questions: Option>, + delayed_approval_requests: VecDeque, + last_composer_activity_at: Option, + + app_event_tx: AppEventSender, + frame_requester: FrameRequester, + thread_id: Option, + + has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, + is_task_running: bool, + esc_backtrack_hint: bool, + animations_enabled: bool, + + /// Inline status indicator shown above the composer while a task is running. + status: Option, + /// Running-hook summary supplied by the lifecycle owner after its reveal delay. + hook_status_message: Option, + inline_banner: Option, + /// Streaming may drop the row without losing its elapsed time or modal pause. + status_timer: crate::status_indicator_widget::StatusTimer, + /// Unified exec session summary source. + /// + /// When a status row exists, this summary is mirrored inline in that row; + /// when no status row exists, it renders as its own footer row. + unified_exec_footer: UnifiedExecFooter, + /// Preview of pending steers and queued drafts shown above the composer. + pending_input_preview: PendingInputPreview, + /// Inactive threads with pending approval requests. + pending_thread_approvals: PendingThreadApprovals, + context_window_percent: Option, + context_window_used_tokens: Option, + keymap: RuntimeKeymap, +} + +pub(crate) struct BottomPaneParams { + pub(crate) app_event_tx: AppEventSender, + pub(crate) frame_requester: FrameRequester, + pub(crate) has_input_focus: bool, + pub(crate) enhanced_keys_supported: bool, + pub(crate) placeholder_text: String, + pub(crate) disable_paste_burst: bool, + pub(crate) animations_enabled: bool, + pub(crate) skills: Option>, +} + +impl BottomPane { + pub fn new(params: BottomPaneParams) -> Self { + Self::new_with_composer_config(params, ChatComposerConfig::default()) + } + + /// Construct a bottom pane with explicitly restricted composer behavior. + pub(crate) fn new_with_composer_config( + params: BottomPaneParams, + composer_config: ChatComposerConfig, + ) -> Self { + let BottomPaneParams { + app_event_tx, + frame_requester, + has_input_focus, + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + animations_enabled, + skills, + } = params; + let mut composer = ChatComposer::new_with_config( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + composer_config, + ); + composer.set_frame_requester(frame_requester.clone()); + let keymap = RuntimeKeymap::defaults(); + composer.set_keymap_bindings(&keymap); + composer.set_skill_mentions(skills); + Self { + composer, + view_stack: Vec::new(), + questions: None, + delayed_approval_requests: VecDeque::new(), + last_composer_activity_at: None, + app_event_tx, + frame_requester, + thread_id: None, + has_input_focus, + enhanced_keys_supported, + disable_paste_burst, + is_task_running: false, + status: None, + hook_status_message: None, + inline_banner: None, + status_timer: crate::status_indicator_widget::StatusTimer::default(), + unified_exec_footer: UnifiedExecFooter::new(), + pending_input_preview: PendingInputPreview::new(), + pending_thread_approvals: PendingThreadApprovals::new(), + esc_backtrack_hint: false, + animations_enabled, + context_window_percent: None, + context_window_used_tokens: None, + keymap, + } + } + + pub fn set_skills(&mut self, skills: Option>) { + self.composer.set_skill_mentions(skills); + self.request_redraw(); + } + + /// Update image-paste behavior for the active composer and repaint immediately. + /// + /// Callers use this to keep composer affordances aligned with model capabilities. + pub fn set_image_paste_enabled(&mut self, enabled: bool) { + self.composer.set_image_paste_enabled(enabled); + self.request_redraw(); + } + + /// Mirrors the effective reasoning effort into the composer so its next + /// visible frame can play a one-shot Max/Ultra effect. + pub(crate) fn set_active_reasoning_effort(&mut self, effort: Option<&ReasoningEffort>) { + let animations_enabled = effort_ignition::effort_animation_enabled( + self.animations_enabled, + effective_stdout_color_level(), + ); + if self + .composer + .set_active_reasoning_effort(effort, animations_enabled) + { + self.request_redraw(); + } + } + + /// Establishes a restored thread's effort without replaying its one-shot animation. + pub(crate) fn set_active_reasoning_effort_baseline( + &mut self, + effort: Option<&ReasoningEffort>, + ) { + self.composer.set_active_reasoning_effort_baseline(effort); + } + + pub fn set_connectors_snapshot(&mut self, snapshot: Option) { + self.composer.set_connector_mentions(snapshot); + self.request_redraw(); + } + + pub fn set_plugin_mentions(&mut self, plugins: Option>) { + self.composer.set_plugin_mentions(plugins); + self.request_redraw(); + } + + pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) { + self.composer.set_agents_navigation_enabled(enabled); + self.request_redraw(); + } + + pub(crate) fn set_task_mentions_enabled(&mut self, enabled: bool) { + self.composer.set_task_mentions_enabled(enabled); + self.request_redraw(); + } + + pub(crate) fn task_mentions_enabled(&self) -> bool { + self.composer.task_mentions_enabled() + } + + pub(crate) fn on_task_search_result( + &mut self, + query: &str, + matches: Vec, + ) { + self.composer.on_task_search_result(query, matches); + self.request_redraw(); + } + + pub fn set_plugins_command_enabled(&mut self, enabled: bool) { + self.composer.set_plugins_command_enabled(enabled); + self.request_redraw(); + } + + pub fn set_token_activity_command_enabled(&mut self, enabled: bool) { + self.composer.set_token_activity_command_enabled(enabled); + self.request_redraw(); + } + + pub fn set_mentions_v2_enabled(&mut self, enabled: bool) { + self.composer.set_mentions_v2_enabled(enabled); + self.request_redraw(); + } + + pub fn take_mention_bindings(&mut self) -> Vec { + self.composer.take_mention_bindings() + } + + pub fn take_recent_submission_mention_bindings(&mut self) -> Vec { + self.composer.take_recent_submission_mention_bindings() + } + + /// Add a staged slash-command draft to the composer's local recall list. + /// + /// This should be called exactly once after `ChatWidget` dispatches a recognized command. + /// Slash recall records the submitted command text regardless of whether the command succeeds. + pub(crate) fn record_pending_slash_command_history(&mut self) { + self.composer.record_pending_slash_command_history(); + } + + /// Replace all bottom-pane keymap caches from one resolved runtime keymap. + /// + /// The bottom pane owns several input surfaces: composer, overlays, and + /// selection views. Applying one snapshot through this method keeps those + /// surfaces synchronized after config reloads or interactive remaps. Callers + /// should not update the composer directly unless they deliberately want + /// overlays and selection views to continue using the previous bindings. + pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + self.keymap = keymap.clone(); + self.composer.set_keymap_bindings(keymap); + if let Some(questions) = &mut self.questions { + questions.set_keymap(keymap); + } + let interrupt_binding = keymap.primary_hint(KeymapContext::Chat, "interrupt_turn"); + self.pending_input_preview + .set_interrupt_binding(interrupt_binding); + if let Some(status) = self.status.as_mut() { + status.set_interrupt_binding(interrupt_binding); + } + self.request_redraw(); + } + + pub(crate) fn transcript_shortcut_hint(&self) -> Option { + self.keymap + .primary_hint(KeymapContext::Global, "open_transcript") + } + + /// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text. + pub(crate) fn drain_pending_submission_state(&mut self) { + let _ = self.take_recent_submission_images_with_placeholders(); + let _ = self.take_remote_image_urls(); + let _ = self.take_recent_submission_mention_bindings(); + let _ = self.take_mention_bindings(); + } + + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.composer.set_collaboration_modes_enabled(enabled); + self.request_redraw(); + } + + pub fn set_connectors_enabled(&mut self, enabled: bool) { + self.composer.set_connectors_enabled(enabled); + } + + #[cfg(target_os = "windows")] + pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) { + self.composer.set_windows_degraded_sandbox_active(enabled); + self.request_redraw(); + } + + pub fn set_collaboration_mode_indicator( + &mut self, + indicator: Option, + ) { + self.composer.set_collaboration_mode_indicator(indicator); + self.request_redraw(); + } + + pub fn set_goal_status_indicator(&mut self, indicator: Option) { + self.composer.set_goal_status_indicator(indicator); + self.request_redraw(); + } + + pub fn set_ide_context_active(&mut self, active: bool) { + self.composer.set_ide_context_active(active); + self.request_redraw(); + } + + pub fn set_worktrees_enabled(&mut self, enabled: bool) { + self.composer.set_worktrees_enabled(enabled); + self.request_redraw(); + } + + pub fn set_service_tier_commands_enabled(&mut self, enabled: bool) { + self.composer.set_service_tier_commands_enabled(enabled); + self.request_redraw(); + } + + pub fn set_service_tier_commands(&mut self, commands: Vec) { + self.composer.set_service_tier_commands(commands); + self.request_redraw(); + } + + pub fn set_goal_command_enabled(&mut self, enabled: bool) { + self.composer.set_goal_command_enabled(enabled); + self.request_redraw(); + } + + pub fn set_voice_command_enabled(&mut self, enabled: bool) { + self.composer.set_voice_command_enabled(enabled); + self.request_redraw(); + } + + pub(crate) fn set_side_conversation_active(&mut self, active: bool) { + self.composer.set_side_conversation_active(active); + self.request_redraw(); + } + + pub(crate) fn set_placeholder_text(&mut self, placeholder: String) { + self.composer.set_placeholder_text(placeholder); + self.request_redraw(); + } + + pub(crate) fn set_parent_owned_thread(&mut self) { + self.composer.set_parent_owned_thread(); + self.request_redraw(); + } + + /// Update the key hint shown next to queued messages so it matches the + /// binding that `ChatWidget` actually listens for. + pub(crate) fn set_queued_message_edit_binding( + &mut self, + binding: Option, + ) { + self.pending_input_preview.set_edit_binding(binding); + if let Some(questions) = &mut self.questions { + questions.next_hint = binding; + } + self.request_redraw(); + } + + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + self.composer.set_vim_enabled(enabled); + if let Some(questions) = &mut self.questions { + questions.set_vim_enabled(enabled); + } + self.request_redraw(); + } + + pub(crate) fn take_kill_buffer_snapshot(&mut self) -> KillBufferSnapshot { + self.composer.take_kill_buffer_snapshot() + } + + pub(crate) fn restore_kill_buffer_snapshot(&mut self, snapshot: KillBufferSnapshot) { + self.composer.restore_kill_buffer_snapshot(snapshot); + } + + pub(crate) fn toggle_vim_enabled(&mut self) -> bool { + let enabled = self.composer.toggle_vim_enabled(); + if let Some(questions) = &mut self.questions { + questions.set_vim_enabled(enabled); + } + self.request_redraw(); + enabled + } + + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { + self.status.as_ref() + } + + pub(crate) fn status_elapsed(&self) -> Option { + self.is_task_running + .then(|| self.status_timer.elapsed_at(Instant::now())) + } + + pub(crate) fn reset_status_timer(&mut self, elapsed: Duration) { + self.status_timer.reset(elapsed); + } + + pub(crate) fn set_status_timer_origin(&mut self, started_at: Option) { + self.status_timer.display_started_at = started_at; + self.request_redraw(); + } + + pub fn skills(&self) -> Option<&Vec> { + self.composer.skills() + } + + pub fn plugins(&self) -> Option<&Vec> { + self.composer.plugins() + } + + #[cfg(test)] + pub(crate) fn context_window_percent(&self) -> Option { + self.context_window_percent + } + + #[cfg(test)] + pub(crate) fn context_window_used_tokens(&self) -> Option { + self.context_window_used_tokens + } + + fn active_view(&self) -> Option<&dyn BottomPaneView> { + self.view_stack.last().map(std::convert::AsRef::as_ref) + } + + fn push_view(&mut self, view: Box) { + self.view_stack.push(view); + self.schedule_active_view_frame(); + self.request_redraw(); + } + + fn pop_active_view_with_completion(&mut self, completion: Option) { + if self.view_stack.pop().is_some() { + match completion { + Some(ViewCompletion::Accepted) => { + while self + .view_stack + .last() + .is_some_and(|view| view.dismiss_after_child_accept()) + { + self.view_stack.pop(); + } + } + Some(ViewCompletion::Cancelled) => { + if let Some(view) = self.view_stack.last_mut() { + view.clear_dismiss_after_child_accept(); + } + } + None => {} + } + self.on_view_stack_depth_decreased(); + } + } + + fn on_view_stack_depth_decreased(&mut self) { + if self.view_stack.is_empty() { + self.on_active_view_complete(); + } + } + + fn approval_prompt_delay_remaining(&self, now: Instant) -> Option { + self.last_composer_activity_at.and_then(|last_activity_at| { + last_activity_at + .checked_add(APPROVAL_PROMPT_TYPING_IDLE_DELAY) + .and_then(|show_at| show_at.checked_duration_since(now)) + .filter(|delay| !delay.is_zero()) + }) + } + + fn record_composer_activity_at(&mut self, now: Instant) { + self.last_composer_activity_at = Some(now); + if self.has_pending_approval() + && let Some(delay) = self.approval_prompt_delay_remaining(now) + { + self.request_redraw_in(delay); + } + } + + fn maybe_show_delayed_approval_requests_at(&mut self, now: Instant) { + if !self.has_pending_approval() || !self.view_stack.is_empty() { + return; + } + if let Some(delay) = self.approval_prompt_delay_remaining(now) { + self.request_redraw_in(delay); + return; + } + + // Promote the oldest delayed approval once typing has been idle long enough. + // `ApprovalOverlay` advances its internal queue with `pop()`, so drain the + // remaining delayed approvals from the back to preserve FIFO display order. + let Some(first) = self.delayed_approval_requests.pop_front() else { + return; + }; + let mut modal = ApprovalOverlay::new( + first.request, + self.app_event_tx.clone(), + first.features, + self.keymap.approval.clone(), + self.keymap.list.clone(), + ); + while let Some(delayed) = self.delayed_approval_requests.pop_back() { + modal.enqueue_request(delayed.request); + } + self.pause_status_timer_for_modal(); + self.push_view(Box::new(modal)); + } + + /// Edit the draft without invoking popups, submissions, or remote actions. + pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) { + self.view_stack.clear(); + self.delayed_approval_requests.clear(); + self.composer + .set_input_enabled(/*enabled*/ true, /*placeholder*/ None); + self.composer.handle_disconnected_key(key); + self.request_redraw(); + } + + /// Forward a key event to the active view or the composer. + pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { + let records_composer_activity = + matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + && !key_hint::has_ctrl_or_alt(key_event.modifiers) + && matches!( + key_event.code, + KeyCode::Char(_) + | KeyCode::Backspace + | KeyCode::Delete + | KeyCode::Enter + | KeyCode::Tab + ); + if self.view_stack.is_empty() + && let Some(questions) = self.questions.as_mut().filter(|q| q.expanded) + { + questions + .composer + .copy_history_for_key(&self.composer, key_event); + questions.composer.set_task_running(self.is_task_running); + questions.handle_key_event(key_event); + if records_composer_activity { + self.record_composer_activity_at(Instant::now()); + } + self.request_redraw(); + self.schedule_active_view_frame(); + return InputResult::None; + } + // If a modal/view is active, handle it here; otherwise forward to composer. + if !self.view_stack.is_empty() { + if key_event.kind == KeyEventKind::Release { + return InputResult::None; + } + + // We need three pieces of information after routing the key: + // whether Esc completed the view, whether the view finished for any + // reason, and whether a paste-burst timer should be scheduled. + let (ctrl_c_completed, view_complete, completion, view_in_paste_burst) = { + let last_index = self.view_stack.len() - 1; + let view = &mut self.view_stack[last_index]; + let prefer_esc = + key_event.code == KeyCode::Esc && view.prefer_esc_to_handle_key_event(); + let ctrl_c_completed = key_event.code == KeyCode::Esc + && !prefer_esc + && matches!(view.on_ctrl_c(), CancellationEvent::Handled) + && view.is_complete(); + if ctrl_c_completed { + (true, true, view.completion(), false) + } else { + view.handle_key_event(key_event); + ( + false, + view.is_complete(), + view.completion(), + view.is_in_paste_burst(), + ) + } + }; + + if ctrl_c_completed { + self.pop_active_view_with_completion(completion); + if let Some(next_view) = self.view_stack.last() + && next_view.is_in_paste_burst() + { + self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); + } + } else if view_complete { + self.pop_active_view_with_completion(completion); + } else if view_in_paste_burst { + self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); + } + self.request_redraw(); + InputResult::None + } else { + if self.handle_inline_banner_key(key_event) { + return InputResult::None; + } + // Allow the configured interrupt while a task is running, even when its + // status row is hidden or the composer has focus. + // When a popup is active, prefer dismissing it over interrupting the task. + if self.should_interrupt_running_task(key_event) { + if let Some(status) = &self.status { + status.interrupt(); + } else { + self.app_event_tx.interrupt(); + } + self.request_redraw(); + return InputResult::None; + } + let (input_result, needs_redraw) = self.composer.handle_key_event(key_event); + if records_composer_activity { + self.record_composer_activity_at(Instant::now()); + } + if needs_redraw { + self.request_redraw(); + } + if self.composer.is_in_paste_burst() { + self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); + } + input_result + } + } + + /// Return the contexts whose ordinary handlers can consume the next key. + pub(crate) fn keymap_contexts(&self) -> KeymapContextSet { + if let Some(view) = self.view_stack.last() { + view.keymap_contexts() + } else if let Some(questions) = self.questions.as_ref().filter(|q| q.expanded) { + questions.keymap_contexts() + } else { + self.composer.keymap_contexts() + } + } + + /// Handles a Ctrl+C press within the bottom pane. + /// + /// An active modal view is given the first chance to consume the key (typically to dismiss + /// itself). If no view is active, Ctrl+C cancels active history search before falling back to + /// clearing draft composer input. + /// + /// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C + /// was received, but it does not decide whether the process should exit; `ChatWidget` owns the + /// quit/interrupt state machine and uses the result to decide what happens next. + pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent { + if self.view_stack.is_empty() + && let Some(questions) = self.questions.as_mut().filter(|q| q.expanded) + { + return questions.on_ctrl_c(); + } + if let Some(view) = self.view_stack.last_mut() { + let event = view.on_ctrl_c(); + let view_complete = view.is_complete(); + let completion = view.completion(); + if matches!(event, CancellationEvent::Handled) { + if view_complete { + self.pop_active_view_with_completion(completion); + } + self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c'))); + self.request_redraw(); + } + event + } else if self.composer.cancel_vim_search() || self.composer.cancel_history_search() { + self.request_redraw(); + CancellationEvent::Handled + } else if self.composer_is_empty() { + CancellationEvent::NotHandled + } else { + self.view_stack.pop(); + self.clear_composer_for_ctrl_c(); + self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c'))); + self.request_redraw(); + CancellationEvent::Handled + } + } + + pub fn handle_paste(&mut self, pasted: String) { + if self.view_stack.is_empty() + && let Some(questions) = self.questions.as_mut().filter(|q| q.expanded) + { + questions.handle_paste(pasted); + self.record_composer_activity_at(Instant::now()); + self.request_redraw(); + return; + } + let has_pasted_text = !pasted.is_empty(); + if let Some(view) = self.view_stack.last_mut() { + let needs_redraw = view.handle_paste(pasted); + let view_complete = view.is_complete(); + if view_complete { + self.view_stack.clear(); + self.on_active_view_complete(); + } + if needs_redraw || view_complete { + self.request_redraw(); + } + } else { + let needs_redraw = self.composer.handle_paste(pasted); + if has_pasted_text { + self.record_composer_activity_at(Instant::now()); + } + if needs_redraw { + self.request_redraw(); + } + } + } + + pub(crate) fn insert_str(&mut self, text: &str) { + self.composer.insert_str(text); + self.request_redraw(); + } + + pub(crate) fn pre_draw_tick(&mut self) { + self.pre_draw_tick_at(Instant::now()); + } + + fn pre_draw_tick_at(&mut self, now: Instant) { + self.composer.sync_popups(); + self.maybe_show_delayed_approval_requests_at(now); + self.tick_active_view(now); + self.schedule_active_view_frame(); + } + + fn tick_active_view(&mut self, now: Instant) { + let Some(view) = self.view_stack.last_mut() else { + return; + }; + let needs_redraw = view.pre_draw_tick(now); + let view_complete = view.is_complete(); + if view_complete { + self.view_stack.clear(); + self.on_active_view_complete(); + } + if needs_redraw || view_complete { + self.request_redraw(); + } + } + + fn schedule_active_view_frame(&self) { + if let Some(questions) = &self.questions { + if let Some(delay) = questions.next_frame_delay() { + self.request_redraw_in(delay); + } + if questions.is_in_paste_burst() { + self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); + } + } + if let Some(delay) = self.active_view().map_or_else( + || self.composer.footer_flash_delay(), + BottomPaneView::next_frame_delay, + ) { + self.request_redraw_in(delay); + } + } + + /// Replace the composer text with `text`. + /// + /// This is intended for fresh input where mention linkage does not need to + /// survive; it routes to `ChatComposer::set_text_content`, which resets + /// mention bindings. + pub(crate) fn set_composer_text( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.composer + .set_text_content(text, text_elements, local_image_paths); + self.composer.move_cursor_to_end(); + self.request_redraw(); + } + + /// Replace the composer text while preserving mention link targets. + /// + /// Use this when rehydrating a draft after a local validation/gating + /// failure (for example unsupported image submit) so previously selected + /// mention targets remain stable across retry. + pub(crate) fn set_composer_text_with_mention_bindings( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + mention_bindings: Vec, + ) { + self.composer.set_text_content_with_mention_bindings( + text, + text_elements, + local_image_paths, + mention_bindings, + ); + self.composer.move_cursor_to_end(); + self.request_redraw(); + } + + #[allow(dead_code)] + pub(crate) fn set_composer_input_enabled( + &mut self, + enabled: bool, + placeholder: Option, + ) { + self.composer.set_input_enabled(enabled, placeholder); + self.request_redraw(); + } + + pub(crate) fn show_shutdown_in_progress(&mut self) { + self.view_stack.clear(); + self.composer.show_shutdown_in_progress(); + self.request_redraw(); + } + + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { + if let Some(text) = self.composer.clear_for_ctrl_c() { + if let Some(thread_id) = self.thread_id { + self.app_event_tx + .send(AppEvent::AppendMessageHistoryEntry { thread_id, text }); + } else { + tracing::warn!( + "failed to append Ctrl+C-cleared draft to history: no active thread id" + ); + } + } + self.request_redraw(); + } + + /// Get the current composer text (for tests and programmatic checks). + pub(crate) fn composer_text(&self) -> String { + self.composer.current_text() + } + + #[cfg(test)] + pub(crate) fn composer_cursor(&self) -> usize { + self.composer.cursor() + } + + #[cfg(test)] + pub(crate) fn composer_text_elements(&self) -> Vec { + self.composer.text_elements() + } + + pub(crate) fn composer_local_images(&self) -> Vec { + self.composer.local_images() + } + + #[cfg(test)] + pub(crate) fn composer_local_image_paths(&self) -> Vec { + self.composer.local_image_paths() + } + + pub(crate) fn composer_text_with_pending(&self) -> String { + self.composer.current_text_with_pending() + } + + /// Returns whether the composer currently accepts interactive draft edits. + pub(crate) fn composer_input_enabled(&self) -> bool { + self.composer.input_enabled() + } + + pub(crate) fn composer_pending_pastes(&self) -> Vec<(String, String)> { + self.composer.pending_pastes() + } + + pub(crate) fn apply_external_edit(&mut self, text: String) { + self.composer.apply_external_edit(text); + self.request_redraw(); + } + + pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + self.composer.set_footer_hint_override(items); + self.request_redraw(); + } + + pub(crate) fn set_voice_strip(&mut self, state: Option) { + self.composer + .set_voice_strip(state, self.frame_requester.clone()); + self.request_redraw(); + } + + pub(crate) fn set_remote_image_urls(&mut self, urls: Vec) { + self.composer.set_remote_image_urls(urls); + self.request_redraw(); + } + + pub(crate) fn remote_image_urls(&self) -> Vec { + self.composer.remote_image_urls() + } + + pub(crate) fn take_remote_image_urls(&mut self) -> Vec { + let urls = self.composer.take_remote_image_urls(); + self.request_redraw(); + urls + } + + pub(crate) fn set_composer_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) { + self.composer.set_pending_pastes(pending_pastes); + self.request_redraw(); + } + + /// Update the status indicator header (defaults to "Working") and details below it. + /// + /// Passing `None` clears any existing details. Returns whether the active status indicator + /// was updated and requested a redraw. + pub(crate) fn update_status( + &mut self, + header: String, + details: Option, + details_capitalization: StatusDetailsCapitalization, + details_max_lines: usize, + ) -> bool { + if let Some(status) = self.status.as_mut() { + status.update_header(header); + status.update_details(details, details_capitalization, details_max_lines.max(1)); + self.request_redraw(); + return true; + } + false + } + + /// Show the transient "press again to quit" hint for `key`. + /// + /// `ChatWidget` owns the quit shortcut state machine (it decides when quit is + /// allowed), while the bottom pane owns rendering. We also schedule a redraw + /// after [`QUIT_SHORTCUT_TIMEOUT`] so the hint disappears even if the user + /// stops typing and no other events trigger a draw. + pub(crate) fn show_quit_shortcut_hint(&mut self, key: KeyBinding) { + if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { + return; + } + + self.composer + .show_quit_shortcut_hint(key, self.has_input_focus); + let frame_requester = self.frame_requester.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + tokio::time::sleep(QUIT_SHORTCUT_TIMEOUT).await; + frame_requester.schedule_frame(); + }); + } else { + // In tests (and other non-Tokio contexts), fall back to a thread so + // the hint can still expire without requiring an explicit draw. + std::thread::spawn(move || { + std::thread::sleep(QUIT_SHORTCUT_TIMEOUT); + frame_requester.schedule_frame(); + }); + } + self.request_redraw(); + } + + /// Clear the "press again to quit" hint immediately. + pub(crate) fn clear_quit_shortcut_hint(&mut self) { + self.composer.clear_quit_shortcut_hint(self.has_input_focus); + self.request_redraw(); + } + + #[cfg(test)] + pub(crate) fn quit_shortcut_hint_visible(&self) -> bool { + self.composer.quit_shortcut_hint_visible() + } + + #[cfg(test)] + pub(crate) fn status_indicator_visible(&self) -> bool { + self.status_widget().is_some() + } + + #[cfg(test)] + pub(crate) fn status_line_text(&self) -> Option { + self.composer.status_line_text() + } + + pub(crate) fn show_esc_backtrack_hint(&mut self) { + self.esc_backtrack_hint = true; + self.composer.set_esc_backtrack_hint(/*show*/ true); + self.request_redraw(); + } + + pub(crate) fn clear_esc_backtrack_hint(&mut self) { + if self.esc_backtrack_hint { + self.esc_backtrack_hint = false; + self.composer.set_esc_backtrack_hint(/*show*/ false); + self.request_redraw(); + } + } + + // esc_backtrack_hint_visible removed; hints are controlled internally. + + pub fn set_task_running(&mut self, running: bool) { + let was_running = self.is_task_running; + self.is_task_running = running; + self.composer.set_task_running(running); + + if running { + if !was_running { + self.status_timer.reset(Duration::ZERO); + if self.status.is_none() { + self.status = Some(StatusIndicatorWidget::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + self.animations_enabled, + )); + } + if let Some(status) = self.status.as_mut() { + status.set_interrupt_hint_visible(/*visible*/ true); + status.set_interrupt_binding( + self.keymap + .primary_hint(KeymapContext::Chat, "interrupt_turn"), + ); + } + self.sync_status_inline_message(); + self.request_redraw(); + } + } else { + // Hide the status indicator when a task completes, but keep other modal views. + self.hide_status_indicator(); + } + } + + pub(crate) fn set_queue_submissions(&mut self, queue_submissions: bool) { + self.composer.set_queue_submissions(queue_submissions); + } + + /// Hide the status indicator while leaving task-running state untouched. + pub(crate) fn hide_status_indicator(&mut self) { + if self.status.take().is_some() { + self.request_redraw(); + } + } + + pub(crate) fn ensure_status_indicator(&mut self) { + if self.status.is_none() { + self.status.get_or_insert_with(|| { + StatusIndicatorWidget::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + self.animations_enabled, + ) + }); + if let Some(status) = self.status.as_mut() { + status.set_interrupt_binding( + self.keymap + .primary_hint(KeymapContext::Chat, "interrupt_turn"), + ); + } + self.sync_status_inline_message(); + self.request_redraw(); + } + } + + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { + if let Some(status) = self.status.as_mut() { + status.set_interrupt_hint_visible(visible); + self.request_redraw(); + } + } + + pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { + if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens + { + return; + } + + self.context_window_percent = percent; + self.context_window_used_tokens = used_tokens; + self.composer + .set_context_window(percent, self.context_window_used_tokens); + self.request_redraw(); + } + + pub(crate) fn set_context_window_pending(&mut self, pending: bool) { + self.composer.set_context_window_pending(pending); + self.request_redraw(); + } + + /// Show a generic list selection view with the provided items. + pub(crate) fn show_selection_view( + &mut self, + mut params: list_selection_view::SelectionViewParams, + ) { + self.apply_standard_popup_hint(&mut params); + let view = list_selection_view::ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ); + self.push_view(Box::new(view)); + } + + fn apply_standard_popup_hint(&self, params: &mut list_selection_view::SelectionViewParams) { + // Configured list actions take precedence over optional row shortcuts. + for item in &mut params.items { + if item.secondary_action.as_ref().is_some_and(|secondary| { + let (code, modifiers) = secondary.key.parts(); + self.keymap + .list + .action_for(KeyEvent::new(code, modifiers)) + .is_some() + }) { + item.secondary_action = None; + } + } + if !params.allow_cancel { + if params.footer_hint.is_none() + || params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line()) + { + params.footer_hint = None; + } + return; + } + if params.footer_hint.is_none() + || params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line()) + { + params.footer_hint = Some(self.standard_popup_hint_line()); + } + } + + /// Replace the active selection view when it matches `view_id`. + pub(crate) fn replace_selection_view_if_active( + &mut self, + view_id: &'static str, + mut params: list_selection_view::SelectionViewParams, + ) -> bool { + let is_match = self + .view_stack + .last() + .is_some_and(|view| view.view_id() == Some(view_id)); + if !is_match { + return false; + } + + self.view_stack.pop(); + self.apply_standard_popup_hint(&mut params); + let view = list_selection_view::ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ); + self.push_view(Box::new(view)); + true + } + + /// Replace the newest matching selection view without disturbing views stacked above it. + /// Preserve pending parent cleanup when an already-open child is accepted. + pub(crate) fn replace_selection_view_if_present( + &mut self, + view_id: &'static str, + mut params: list_selection_view::SelectionViewParams, + ) -> bool { + let Some(index) = self + .view_stack + .iter() + .rposition(|view| view.view_id() == Some(view_id)) + else { + return false; + }; + + let replaces_active_view = index + 1 == self.view_stack.len(); + self.apply_standard_popup_hint(&mut params); + let mut view = list_selection_view::ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ); + view.dismiss_after_child_accept = self.view_stack[index].dismiss_after_child_accept(); + self.view_stack[index] = Box::new(view); + if replaces_active_view { + self.schedule_active_view_frame(); + } + self.request_redraw(); + true + } + + pub(crate) fn standard_popup_hint_line(&self) -> Line<'static> { + popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list) + } + + pub(crate) fn replace_view_if_present( + &mut self, + view_id: &'static str, + view: Box, + ) { + if let Some(index) = self + .view_stack + .iter() + .rposition(|existing| existing.view_id() == Some(view_id)) + { + self.view_stack[index] = view; + self.request_redraw(); + } + } + + pub(crate) fn list_keymap(&self) -> crate::keymap::ListKeymap { + self.keymap.list.clone() + } + + /// Replace one or more active views whose IDs are in `view_ids` with a + /// generic list selection view. + pub(crate) fn replace_active_views_with_selection_view( + &mut self, + view_ids: &[&'static str], + mut params: list_selection_view::SelectionViewParams, + ) -> bool { + let is_match = self + .view_stack + .last() + .and_then(|view| view.view_id()) + .is_some_and(|view_id| view_ids.contains(&view_id)); + if !is_match { + return false; + } + + while self + .view_stack + .last() + .and_then(|view| view.view_id()) + .is_some_and(|view_id| view_ids.contains(&view_id)) + { + self.view_stack.pop(); + } + self.apply_standard_popup_hint(&mut params); + let view = list_selection_view::ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ); + self.push_view(Box::new(view)); + true + } + + pub(crate) fn selected_index_for_active_view(&self, view_id: &'static str) -> Option { + self.view_stack + .last() + .filter(|view| view.view_id() == Some(view_id)) + .and_then(|view| view.selected_index()) + } + + pub(crate) fn selected_index_for_present_view(&self, view_id: &'static str) -> Option { + self.view_stack + .iter() + .rfind(|view| view.view_id() == Some(view_id)) + .and_then(|view| view.selected_index()) + } + + pub(crate) fn active_tab_id_for_active_view(&self, view_id: &'static str) -> Option<&str> { + self.view_stack + .last() + .filter(|view| view.view_id() == Some(view_id)) + .and_then(|view| view.active_tab_id()) + } + + /// Forward a suggestion to its matching prompt, even beneath another view. + pub(crate) fn apply_text_suggestion( + &mut self, + request_id: uuid::Uuid, + suggestion: Option<&str>, + ) -> bool { + let changed = self + .view_stack + .iter_mut() + .rev() + .any(|view| view.apply_text_suggestion(request_id, suggestion)); + + if changed { + self.request_redraw(); + } + + changed + } + + pub(crate) fn dismiss_active_view_if_id(&mut self, view_id: &'static str) -> bool { + let is_match = self + .view_stack + .last() + .is_some_and(|view| view.view_id() == Some(view_id)); + if !is_match { + return false; + } + + self.view_stack.pop(); + self.request_redraw(); + true + } + + /// Dismiss the newest matching view without disturbing views stacked above it. + pub(crate) fn dismiss_view_by_id(&mut self, view_id: &'static str) -> bool { + let Some(index) = self + .view_stack + .iter() + .rposition(|view| view.view_id() == Some(view_id)) + else { + return false; + }; + + let removed_active_view = index + 1 == self.view_stack.len(); + self.view_stack.remove(index); + if removed_active_view { + self.schedule_active_view_frame(); + } + self.request_redraw(); + true + } + + /// Update the pending-input preview shown above the composer. + pub(crate) fn set_pending_input_preview( + &mut self, + queued: Vec, + pending_steers: Vec, + rejected_steers: Vec, + ) { + self.pending_input_preview.pending_steers = pending_steers; + self.pending_input_preview.rejected_steers = rejected_steers; + self.pending_input_preview.queued_messages = queued; + self.request_redraw(); + } + + /// Update the inactive-thread approval list shown above the composer. + pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { + if self.pending_thread_approvals.set_threads(threads) { + self.request_redraw(); + } + } + + #[cfg(test)] + pub(crate) fn pending_thread_approvals(&self) -> &[String] { + self.pending_thread_approvals.threads() + } + + /// Update the unified-exec process set and refresh whichever summary surface is active. + /// + /// The summary may be displayed inline in the status row or as a dedicated + /// footer row depending on whether a status indicator is currently visible. + pub(crate) fn set_unified_exec_processes(&mut self, processes: Vec) { + if self.unified_exec_footer.set_processes(processes) { + self.sync_status_inline_message(); + self.request_redraw(); + } + } + + /// Update hook activity after the lifecycle reveal delay, even outside a turn. + pub(crate) fn set_hook_status_message(&mut self, message: Option) { + if self.hook_status_message == message { + return; + } + + self.hook_status_message = message; + if self.hook_status_message.is_some() && self.status.is_none() && self.is_task_running() { + self.ensure_status_indicator(); + } else { + self.sync_status_inline_message(); + self.request_redraw(); + } + } + + /// Copy background activity and hook text into the active status row, if any. + /// + /// This keeps status-line inline text synchronized without forcing the + /// standalone unified-exec footer row to be visible. + fn sync_status_inline_message(&mut self) { + if let Some(status) = self.status.as_mut() { + status.update_inline_message(self.unified_exec_footer.summary_text()); + status.update_hook_status_message(self.hook_status_message.clone()); + } + } + + pub(crate) fn composer_is_empty(&self) -> bool { + self.composer.is_empty() + } + + pub(crate) fn composer_is_vim_enabled(&self) -> bool { + self.composer.is_vim_enabled() + } + + pub(crate) fn composer_should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool { + self.composer.should_handle_vim_insert_escape(key_event) + } + + pub(crate) fn is_task_running(&self) -> bool { + self.is_task_running + } + + pub(crate) fn should_interrupt_running_task(&self, key_event: KeyEvent) -> bool { + let is_agent_command = self + .composer_text() + .lines() + .next() + .and_then(parse_slash_name) + .is_some_and(|(name, _, _)| matches!(name, "agents" | "subagents")); + + self.keymap.chat.interrupt_turn.is_pressed(key_event) + && self.is_task_running + && !(is_agent_command && key_event.code == KeyCode::Esc) + && self.no_modal_or_popup_active() + && !self.composer_should_handle_vim_insert_escape(key_event) + } + + pub(crate) fn terminal_title_requires_action(&self) -> bool { + self.questions + .as_ref() + .is_some_and(|q| q.unanswered_count() > 0) + || self + .active_view() + .is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action) + } + + pub(crate) fn has_active_view(&self) -> bool { + !self.view_stack.is_empty() + } + + pub(crate) fn active_view_will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + self.is_task_running + && self + .active_view() + .is_some_and(|view| view.will_interrupt_turn_on_key_event(key_event)) + } + + #[cfg(test)] + pub(crate) fn active_view_id(&self) -> Option<&'static str> { + self.view_stack.last().and_then(|view| view.view_id()) + } + + /// Return true when the pane is in the regular composer state without any + /// overlays or popups and not running a task. This is the safe context to + /// use Esc-Esc for backtracking from the main view. + pub(crate) fn is_normal_backtrack_mode(&self) -> bool { + !self.is_task_running + && !self.questions.as_ref().is_some_and(|q| q.expanded) + && self.view_stack.is_empty() + && !self.composer.popup_active() + && !self.inline_banner_accepts_dismissal() + } + + /// Return true when no popups or modal views are active, regardless of task state. + pub(crate) fn can_launch_external_editor(&self) -> bool { + self.view_stack.is_empty() + && !self.composer.popup_active() + && !self.questions.as_ref().is_some_and(|q| q.expanded) + } + + /// Returns true when the bottom pane has no active modal view and no active composer popup. + /// + /// This is the UI-level definition of "no modal/popup is active" for key routing decisions. + /// It intentionally does not include task state, since some actions are safe while a task is + /// running and some are not. + pub(crate) fn no_modal_or_popup_active(&self) -> bool { + self.can_launch_external_editor() + } + + pub(crate) fn show_view(&mut self, view: Box) { + self.push_view(view); + } + + /// Show a text prompt with the composer's current editing preferences. + pub(crate) fn show_text_prompt(&mut self, mut view: custom_prompt_view::CustomPromptView) { + view.set_keymap_bindings(&self.keymap); + if self.composer_is_vim_enabled() { + view.enable_vim_in_insert_mode(); + } + self.push_view(Box::new(view)); + } + + /// Called when the agent requests user approval. + pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { + let request = if let Some(view) = self.view_stack.last_mut() { + match view.try_consume_approval_request(request) { + Some(request) => request, + None => { + self.request_redraw(); + return; + } + } + } else { + request + }; + + let now = Instant::now(); + if !self.delayed_approval_requests.is_empty() + || self.approval_prompt_delay_remaining(now).is_some() + { + self.delayed_approval_requests + .push_back(DelayedApprovalRequest { + request, + features: features.clone(), + }); + self.maybe_show_delayed_approval_requests_at(now); + } else { + // No recent composer activity, so show the approval modal immediately. + let modal = ApprovalOverlay::new( + request, + self.app_event_tx.clone(), + features.clone(), + self.keymap.approval.clone(), + self.keymap.list.clone(), + ); + self.pause_status_timer_for_modal(); + self.push_view(Box::new(modal)); + } + } + + /// Called when the agent requests user input. + pub fn push_user_input_request(&mut self, request: ToolRequestUserInputParams) { + let request = if let Some(view) = self.view_stack.last_mut() { + match view.try_consume_user_input_request(request) { + Some(request) => request, + None => { + self.request_redraw(); + return; + } + } + } else { + request + }; + + let modal = RequestUserInputOverlay::new_with_keymap( + request, + self.app_event_tx.clone(), + self.has_input_focus, + self.enhanced_keys_supported, + self.disable_paste_burst, + self.keymap.clone(), + ); + self.pause_status_timer_for_modal(); + self.set_composer_input_enabled( + /*enabled*/ false, + Some("Answer the questions to continue.".to_string()), + ); + self.push_view(Box::new(modal)); + } + + pub(crate) fn push_mcp_server_elicitation_request( + &mut self, + request: McpServerElicitationFormRequest, + ) { + let request = if let Some(view) = self.view_stack.last_mut() { + match view.try_consume_mcp_server_elicitation_request(request) { + Some(request) => request, + None => { + self.request_redraw(); + return; + } + } + } else { + request + }; + + if let Some(tool_suggestion) = request.tool_suggestion() + && let Some(install_url) = tool_suggestion.install_url.clone() + { + let suggestion_type = match tool_suggestion.suggest_type { + mcp_server_elicitation::ToolSuggestionType::Install => { + AppLinkSuggestionType::Install + } + mcp_server_elicitation::ToolSuggestionType::Enable => AppLinkSuggestionType::Enable, + }; + let is_installed = matches!( + tool_suggestion.suggest_type, + mcp_server_elicitation::ToolSuggestionType::Enable + ); + let view = AppLinkView::new_with_keymap( + AppLinkViewParams { + app_id: tool_suggestion.tool_id.clone(), + title: tool_suggestion.tool_name.clone(), + description: None, + instructions: match suggestion_type { + AppLinkSuggestionType::Install => { + "Install this app in your browser, then return here.".to_string() + } + AppLinkSuggestionType::Enable => { + "Enable this app to use it for the current request.".to_string() + } + AppLinkSuggestionType::Auth => unreachable!( + "auth uses URL mode elicitation, not tool suggestion forms" + ), + AppLinkSuggestionType::ExternalAction => unreachable!( + "external actions use URL mode elicitation, not tool suggestion forms" + ), + }, + url: install_url, + is_installed, + is_enabled: false, + suggest_reason: Some(tool_suggestion.suggest_reason.clone()), + suggestion_type: Some(suggestion_type), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id: request.thread_id(), + server_name: request.server_name().to_string(), + request_id: request.request_id().clone(), + }), + }, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ); + self.pause_status_timer_for_modal(); + self.set_composer_input_enabled( + /*enabled*/ false, + Some("Respond to the tool suggestion to continue.".to_string()), + ); + self.push_view(Box::new(view)); + return; + } + + let modal = McpServerElicitationOverlay::new_with_keymap( + request, + self.app_event_tx.clone(), + self.has_input_focus, + self.enhanced_keys_supported, + self.disable_paste_burst, + self.keymap.clone(), + ); + self.pause_status_timer_for_modal(); + self.set_composer_input_enabled( + /*enabled*/ false, + Some("Respond to the MCP server request to continue.".to_string()), + ); + self.push_view(Box::new(modal)); + } + + pub(crate) fn dismiss_app_server_request( + &mut self, + request: &ResolvedAppServerRequest, + ) -> bool { + let delayed_len = self.delayed_approval_requests.len(); + self.delayed_approval_requests + .retain(|delayed| !delayed.request.matches_resolved_request(request)); + let delayed_changed = self.delayed_approval_requests.len() != delayed_len; + + if self.view_stack.is_empty() { + if delayed_changed { + self.request_redraw(); + } + return delayed_changed; + } + + let mut changed = delayed_changed; + let mut completed_indices = Vec::new(); + for index in (0..self.view_stack.len()).rev() { + let view = &mut self.view_stack[index]; + if !view.dismiss_app_server_request(request) { + continue; + } + changed = true; + if view.is_complete() { + completed_indices.push(index); + } + } + if !changed { + return false; + } + for index in completed_indices { + self.view_stack.remove(index); + } + self.on_view_stack_depth_decreased(); + self.request_redraw(); + true + } + + fn on_active_view_complete(&mut self) { + self.resume_status_timer_after_modal(); + self.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); + } + + fn pause_status_timer_for_modal(&mut self) { + self.status_timer.pause_at(Instant::now()); + } + + fn resume_status_timer_after_modal(&mut self) { + self.status_timer.resume_at(Instant::now()); + self.request_redraw(); + } + + /// Height (terminal rows) required by the current bottom pane. + pub(crate) fn request_redraw(&self) { + self.frame_requester.schedule_frame(); + } + + pub(crate) fn request_redraw_in(&self, dur: Duration) { + self.frame_requester.schedule_frame_in(dur); + } + + // --- History helpers --- + + pub(crate) fn set_history_metadata( + &mut self, + thread_id: ThreadId, + log_id: u64, + entry_count: usize, + ) { + self.thread_id = Some(thread_id); + self.composer + .set_history_metadata(thread_id, log_id, entry_count); + } + + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { + // Every editor can retain buffered input after losing focus. Flush all + // of them, matching the buffers that can defer drawing below. + let mut flushed = self.composer.flush_paste_burst_if_due(); + if let Some(view) = self.view_stack.last_mut() { + flushed |= view.flush_paste_burst_if_due(); + } + if let Some(questions) = &mut self.questions { + flushed |= questions.flush_paste_burst_if_due(); + } + flushed + } + + pub(crate) fn is_in_paste_burst(&self) -> bool { + self.view_stack + .last() + .is_some_and(|view| view.is_in_paste_burst()) + || self + .questions + .as_deref() + .is_some_and(bottom_pane_view::BottomPaneView::is_in_paste_burst) + || self.composer.is_in_paste_burst() + } + + pub(crate) fn on_history_lookup_response(&mut self, response: HistoryLookupResponse) { + let mut updated = false; + for composer in std::iter::once(&mut self.composer) + .chain(self.questions.iter_mut().map(|q| &mut q.composer)) + { + updated |= match response.clone() { + HistoryLookupResponse::Entry { + offset, + log_id, + entry, + } => composer.on_history_entry_response(log_id, offset, entry), + HistoryLookupResponse::Batch { + cursor, + log_id, + entries, + next_older_cursor, + } => composer.on_history_batch_response(log_id, cursor, entries, next_older_cursor), + HistoryLookupResponse::BatchError { cursor, log_id } => { + composer.on_history_batch_error(log_id, cursor) + } + }; + } + if updated { + self.request_redraw(); + } + } + + pub(crate) fn record_replayed_user_message_history(&mut self, entry: HistoryEntry) { + self.composer.record_replayed_user_message_history(entry); + } + + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + self.composer.on_file_search_result(query, matches); + self.request_redraw(); + } + + pub(crate) fn attach_image(&mut self, path: PathBuf) { + if self.view_stack.is_empty() { + self.composer.attach_image(path); + self.request_redraw(); + } + } + + #[cfg(test)] + pub(crate) fn take_recent_submission_images(&mut self) -> Vec { + self.composer.take_recent_submission_images() + } + + pub(crate) fn take_recent_submission_images_with_placeholders( + &mut self, + ) -> Vec { + self.composer + .take_recent_submission_images_with_placeholders() + } + + pub(crate) fn prepare_inline_args_submission( + &mut self, + record_history: bool, + ) -> Option<(String, Vec)> { + self.composer.prepare_inline_args_submission(record_history) + } + + fn as_renderable(&'_ self) -> RenderableItem<'_> { + self.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0) + } + + pub(crate) fn as_renderable_with_composer_right_reserve( + &'_ self, + composer_right_reserve: u16, + ) -> RenderableItem<'_> { + if (self.is_task_running || !self.view_stack.is_empty()) + && let Some(banner) = &self.inline_banner + { + banner.visible.set(false); + } + if let Some(view) = self.active_view() { + RenderableItem::Borrowed(view) + } else { + let mut flex = FlexRenderable::new(); + if let Some(banner) = self + .inline_banner + .as_ref() + .filter(|_| !self.is_task_running) + { + flex.push(/*flex*/ 0, RenderableItem::Borrowed(banner)); + } + if let Some(status) = self.status_widget() { + flex.push( + /*flex*/ 0, + RenderableItem::Owned(Box::new(status.with_timer(&self.status_timer))), + ); + } + if self.status.is_none() + && let Some(message) = &self.hook_status_message + { + flex.push( + /*flex*/ 0, + RenderableItem::Owned(Box::new(hook_status::HookStatus { + message, + animations_enabled: self.animations_enabled, + })), + ); + } + // Avoid double-surfacing the same summary and avoid adding an extra + // row while the status line is already visible. + if self.status_widget().is_none() && !self.unified_exec_footer.is_empty() { + flex.push( + /*flex*/ 0, + RenderableItem::Borrowed(&self.unified_exec_footer), + ); + } + let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); + let has_questions = self + .questions + .as_ref() + .is_some_and(|q| q.unanswered_count() > 0); + let has_pending_input = has_questions + || !self.pending_input_preview.queued_messages.is_empty() + || !self.pending_input_preview.pending_steers.is_empty() + || !self.pending_input_preview.rejected_steers.is_empty(); + let has_status_or_footer = self.status_widget().is_some() + || self.hook_status_message.is_some() + || !self.unified_exec_footer.is_empty(); + let has_inline_previews = has_pending_thread_approvals || has_pending_input; + if has_inline_previews && has_status_or_footer { + flex.push(/*flex*/ 0, RenderableItem::Owned("".into())); + } + flex.push( + /*flex*/ 1, + RenderableItem::Borrowed(&self.pending_thread_approvals), + ); + if has_pending_thread_approvals && has_pending_input { + flex.push(/*flex*/ 0, RenderableItem::Owned("".into())); + } + flex.push( + /*flex*/ 1, + if has_questions { + RenderableItem::Owned(Box::new( + pending_input_preview::PendingInputPreviewContent( + &self.pending_input_preview, + ), + )) + } else { + RenderableItem::Borrowed(&self.pending_input_preview) + }, + ); + let question_editor = self.questions.as_ref().filter(|q| q.expanded); + if !has_inline_previews + && has_status_or_footer + && question_editor.is_none_or(|q| q.unanswered_count() > 1) + { + flex.push(/*flex*/ 0, RenderableItem::Owned("".into())); + } + if let Some(summary) = self.question_summary(Instant::now()) { + flex.push( + /*flex*/ 0, + RenderableItem::Owned(Box::new(Paragraph::new(summary))), + ); + } + let mut flex2 = FlexRenderable::new(); + flex2.push(/*flex*/ 1, RenderableItem::Owned(flex.into())); + let composer: RenderableItem<'_> = if let Some(questions) = question_editor { + RenderableItem::Borrowed(questions.as_ref()) + } else if composer_right_reserve == 0 { + RenderableItem::Borrowed(&self.composer) + } else { + RenderableItem::Owned(Box::new(ChatComposerRightReserveRenderable { + composer: &self.composer, + right_reserve: composer_right_reserve, + })) + }; + flex2.push(/*flex*/ 0, composer); + RenderableItem::Owned(Box::new(flex2)) + } + } + + pub(crate) fn set_status_line(&mut self, status_line: Option>) { + if self.composer.set_status_line(status_line) { + self.request_redraw(); + } + } + + pub(crate) fn set_luna_reserve_active(&mut self, active: bool) { + if self.composer.set_luna_reserve_active(active) { + self.request_redraw(); + } + } + + pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { + if self.composer.set_status_line_hyperlink(url) { + self.request_redraw(); + } + } + + pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) { + if self.composer.set_status_line_enabled(enabled) { + self.request_redraw(); + } + } + + /// Updates the contextual footer label and requests a redraw only when it changed. + /// + /// This keeps the footer plumbing cheap during thread transitions where `App` may recompute + /// the label several times while the visible thread settles. + pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) { + if self.composer.set_active_agent_label(active_agent_label) { + self.request_redraw(); + } + } + + pub(crate) fn set_side_conversation_context_label(&mut self, label: Option) { + if self.composer.set_side_conversation_context_label(label) { + self.request_redraw(); + } + } +} + +struct ChatComposerRightReserveRenderable<'a> { + composer: &'a chat_composer::ChatComposer, + right_reserve: u16, +} + +impl Renderable for ChatComposerRightReserveRenderable<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.composer.render_with_mask_and_textarea_right_reserve( + area, + buf, + /*mask_char*/ None, + self.right_reserve, + ); + } + + fn desired_height(&self, width: u16) -> u16 { + self.composer + .desired_height_with_textarea_right_reserve(width, self.right_reserve) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.composer + .cursor_pos_with_textarea_right_reserve(area, self.right_reserve) + } + + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.composer.cursor_style(area) + } +} + +impl Renderable for BottomPane { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_renderable().render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable().desired_height(width) + } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_renderable().cursor_pos(area) + } + + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.as_renderable().cursor_style(area) + } +} + +#[cfg(test)] +mod tests { + #[path = "actionable_banner_tests.rs"] + mod actionable_banner_tests; + + use super::*; + use crate::app::app_server_requests::ResolvedAppServerRequest; + use crate::app_command::AppCommand as Op; + use crate::app_event::AppEvent; + use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; + use crate::status_indicator_widget::StatusDetailsCapitalization; + use crate::test_support::PathBufExt; + use crate::test_support::test_path_buf; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyEventKind; + use crossterm::event::KeyModifiers; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use std::cell::Cell; + use std::rc::Rc; + use std::time::Instant; + use tokio::sync::mpsc::unbounded_channel; + + fn snapshot_buffer(buf: &Buffer) -> String { + let mut lines = Vec::new(); + for y in 0..buf.area().height { + let mut row = String::new(); + for x in 0..buf.area().width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(row); + } + lines.join("\n") + } + + fn render_snapshot(pane: &BottomPane, area: Rect) -> String { + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + snapshot_buffer(&buf) + } + + pub(super) fn test_pane(app_event_tx: AppEventSender) -> BottomPane { + test_pane_with_disable_paste_burst(app_event_tx, /*disable_paste_burst*/ false) + } + + fn test_pane_with_disable_paste_burst( + app_event_tx: AppEventSender, + disable_paste_burst: bool, + ) -> BottomPane { + BottomPane::new(BottomPaneParams { + app_event_tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst, + animations_enabled: true, + skills: Some(Vec::new()), + }) + } + + fn exec_request() -> ApprovalRequest { + ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), + thread_id: codex_protocol::ThreadId::new(), + thread_label: None, + id: "1".to_string(), + environment_id: None, + command: vec!["echo".into(), "ok".into()], + reason: None, + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], + network_approval_context: None, + additional_permissions: None, + }) + } + + #[test] + fn flush_buffered_typing_in_background_and_covering_editors() { + let (tx, _rx) = unbounded_channel(); + let tx = AppEventSender::new(tx); + let mut pane = test_pane(tx.clone()); + for ch in "main draft".chars() { + pane.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + assert!(pane.composer.is_in_paste_burst()); + let request = ToolRequestUserInputParams { + thread_id: "thread".into(), + turn_id: "turn".into(), + item_id: "call".into(), + is_blocking: true, + auto_resolution_ms: None, + questions: vec![codex_app_server_protocol::ToolRequestUserInputQuestion { + id: "question".into(), + header: String::new(), + question: "Which way?".into(), + is_other: false, + is_secret: false, + options: None, + }], + }; + let mut covering = RequestUserInputOverlay::new_with_keymap( + request, + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + RuntimeKeymap::defaults(), + ); + for ch in "modal".chars() { + covering.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + assert!(covering.is_in_paste_burst()); + pane.push_view(Box::new(covering)); + std::thread::sleep(paste_burst::PasteBurst::recommended_active_flush_delay()); + assert!(pane.flush_paste_burst_if_due()); + assert!(!pane.is_in_paste_burst()); + assert_eq!(pane.composer_text(), "main draft"); + } + + #[test] + fn inline_banner_snapshot() { + let (tx, _rx) = unbounded_channel(); + let mut pane = test_pane(AppEventSender::new(tx)); + pane.set_inline_banner(Some(ActionableBanner { + title: "Choose how to continue working".to_string(), + description: "A long description wraps to fit the available terminal width.\n" + .repeat(/*n*/ 8), + actions: vec![ + SelectionItem { + name: "Open usage settings".to_string(), + ..Default::default() + }, + SelectionItem { + name: "Notify owner".to_string(), + ..Default::default() + }, + ], + ..Default::default() + })); + let width = 44; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + assert_snapshot!( + "inline_banner_wrapped_and_truncated", + render_snapshot(&pane, area) + ); + assert!(!pane.is_normal_backtrack_mode()); + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(pane.is_normal_backtrack_mode()); + } + + #[test] + fn backend_banner_snapshots_and_numbered_actions() { + for (kind, action, label) in [ + ("personal_limit", "view_usage", "View usage"), + ("workspace_member_credits", "notify_owner", "Notify owner"), + ] { + let banner = crate::backend_banners::BackendBanner::parse(&serde_json::json!({ + "banner_type": kind, + "presentation": "dismissible", + "title": "Usage limit\u{7} reached", + "description": "Your included usage is depleted.\nChoose an action to continue.", + "ctas": [ + {"action": "unsupported", "label": "Hidden action"}, + {"action": "view_usage", "label": "Open usage settings"}, + {"action": action, "label": label}, + {"action": "view_usage", "label": "Extra action"} + ] + })) + .expect("valid optional banner"); + let (tx, mut rx) = unbounded_channel(); + let mut pane = test_pane(AppEventSender::new(tx)); + pane.set_inline_banner(Some(banner.actionable_banner())); + let width = 44; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + assert_snapshot!( + format!("backend_banner_{kind}"), + render_snapshot(&pane, area) + ); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + let selected = match rx.try_recv().expect("numbered CTA dispatch") { + AppEvent::OpenUrlInBrowser { url } => url, + AppEvent::SendAddCreditsNudgeEmail { credit_type } => format!("{credit_type:?}"), + other => panic!("unexpected banner action: {other:?}"), + }; + assert_eq!( + selected, + if action == "view_usage" { + "https://chatgpt.com/codex/settings/usage" + } else { + "Credits" + } + ); + assert!(pane.inline_banner.is_some()); + } + } + + #[derive(Default)] + struct DismissibleView { + id: Option<&'static str>, + dismiss_exec_id: Option<&'static str>, + complete: bool, + } + + impl Renderable for DismissibleView { + fn render(&self, _area: Rect, _buf: &mut Buffer) {} + + fn desired_height(&self, _width: u16) -> u16 { + 0 + } + } + + impl BottomPaneView for DismissibleView { + fn is_complete(&self) -> bool { + self.complete + } + + fn view_id(&self) -> Option<&'static str> { + self.id + } + + fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool { + let ResolvedAppServerRequest::ExecApproval { id, .. } = request else { + return false; + }; + if self.dismiss_exec_id != Some(id.as_str()) { + return false; + } + + self.complete = true; + true + } + } + + #[derive(Default)] + struct CompletingView { + id: Option<&'static str>, + complete: bool, + } + + impl Renderable for CompletingView { + fn render(&self, _area: Rect, _buf: &mut Buffer) {} + + fn desired_height(&self, _width: u16) -> u16 { + 0 + } + } + + impl BottomPaneView for CompletingView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.code == KeyCode::Enter { + self.complete = true; + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn view_id(&self) -> Option<&'static str> { + self.id + } + } + + #[test] + fn ctrl_c_on_modal_consumes_without_showing_quit_hint() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: true, + animations_enabled: true, + skills: Some(Vec::new()), + }); + pane.push_approval_request(exec_request(), &features); + assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); + assert!(!pane.quit_shortcut_hint_visible()); + assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c()); + } + + #[test] + fn ctrl_c_cancels_history_search_without_clearing_draft_or_showing_quit_hint() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: true, + animations_enabled: true, + skills: Some(Vec::new()), + }); + pane.insert_str("draft"); + + pane.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(pane.composer.popup_active()); + + assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); + assert_eq!(pane.composer_text(), "draft"); + assert!(!pane.composer.popup_active()); + assert!(!pane.quit_shortcut_hint_visible()); + pane.composer.set_vim_enabled(/*enabled*/ true); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); + assert_eq!(pane.composer_text(), "draft"); + assert!(!pane.composer.popup_active()); + } + + // live ring removed; related tests deleted. + + #[test] + fn overlay_not_shown_above_approval_modal() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Create an approval modal (active view). + pane.push_approval_request(exec_request(), &features); + + // Render and verify the top row does not include an overlay. + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + + let mut r0 = String::new(); + for x in 0..area.width { + r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); + } + assert!( + !r0.contains("Working"), + "overlay should not render above modal" + ); + } + + #[test] + fn approval_request_shows_immediately_without_recent_typing() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = test_pane(tx); + + pane.push_approval_request(exec_request(), &features); + + assert_eq!(pane.view_stack.len(), 1); + assert!(pane.delayed_approval_requests.is_empty()); + } + + #[test] + fn approval_request_is_delayed_after_recent_typing() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = test_pane(tx); + let now = Instant::now(); + pane.last_composer_activity_at = Some(now); + + pane.push_approval_request(exec_request(), &features); + + assert!(pane.view_stack.is_empty()); + assert_eq!(pane.delayed_approval_requests.len(), 1); + + pane.pre_draw_tick_at( + now + APPROVAL_PROMPT_TYPING_IDLE_DELAY - Duration::from_millis(/*millis*/ 1), + ); + assert!(pane.view_stack.is_empty()); + assert_eq!(pane.delayed_approval_requests.len(), 1); + + pane.pre_draw_tick_at(now + APPROVAL_PROMPT_TYPING_IDLE_DELAY); + assert_eq!(pane.view_stack.len(), 1); + assert!(pane.delayed_approval_requests.is_empty()); + } + + #[test] + fn continued_typing_resets_delayed_approval_idle_deadline() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = test_pane(tx); + let first_activity = Instant::now(); + pane.last_composer_activity_at = Some(first_activity); + pane.push_approval_request(exec_request(), &features); + + let continued_activity = first_activity + Duration::from_millis(/*millis*/ 750); + pane.record_composer_activity_at(continued_activity); + + pane.pre_draw_tick_at(first_activity + APPROVAL_PROMPT_TYPING_IDLE_DELAY); + assert!(pane.view_stack.is_empty()); + assert_eq!(pane.delayed_approval_requests.len(), 1); + + pane.pre_draw_tick_at(continued_activity + APPROVAL_PROMPT_TYPING_IDLE_DELAY); + assert_eq!(pane.view_stack.len(), 1); + assert!(pane.delayed_approval_requests.is_empty()); + } + + #[test] + fn typed_approval_shortcuts_during_delay_stay_in_composer() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = test_pane_with_disable_paste_burst(tx, /*disable_paste_burst*/ true); + pane.last_composer_activity_at = Some(Instant::now()); + pane.push_approval_request(exec_request(), &features); + + pane.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + + assert_eq!(pane.composer_text(), "ya"); + assert!(pane.view_stack.is_empty()); + assert_eq!(pane.delayed_approval_requests.len(), 1); + pane.handle_disconnected_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE)); + pane.pre_draw_tick_at(Instant::now() + APPROVAL_PROMPT_TYPING_IDLE_DELAY); + pane.handle_paste(" kept".into()); + assert_eq!(pane.composer_text(), "ya kept"); + assert!(pane.view_stack.is_empty()); + while let Ok(event) = rx.try_recv() { + assert!( + !matches!(event, AppEvent::SubmitThreadOp { .. }), + "delayed approval shortcut should not submit an approval: {event:?}" + ); + } + } + + #[test] + fn delayed_approval_shortcut_works_after_idle_deadline() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = test_pane(tx); + let now = Instant::now(); + pane.last_composer_activity_at = Some(now); + pane.push_approval_request(exec_request(), &features); + + pane.pre_draw_tick_at(now + APPROVAL_PROMPT_TYPING_IDLE_DELAY); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + let mut approval_decision = None; + while let Ok(event) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ExecApproval { decision, .. }, + .. + } = event + { + approval_decision = Some(decision); + } + } + assert_eq!( + approval_decision, + Some(CommandExecutionApprovalDecision::Accept) + ); + } + + #[test] + fn dismiss_app_server_request_prunes_delayed_approval() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = test_pane(tx); + let now = Instant::now(); + pane.last_composer_activity_at = Some(now); + let request = exec_request(); + let thread_id = request.thread_id(); + pane.push_approval_request(request, &features); + + assert!( + pane.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval { + thread_id: thread_id.to_string(), + id: "1".to_string(), + }) + ); + assert!(pane.delayed_approval_requests.is_empty()); + + pane.pre_draw_tick_at(now + APPROVAL_PROMPT_TYPING_IDLE_DELAY); + assert!(pane.view_stack.is_empty()); + } + + #[test] + fn dismiss_app_server_request_removes_matching_buried_view() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = test_pane(tx); + + pane.push_view(Box::new(DismissibleView { + id: Some("buried"), + dismiss_exec_id: Some("request-1"), + complete: false, + })); + pane.push_view(Box::new(DismissibleView { + id: Some("top"), + dismiss_exec_id: None, + complete: false, + })); + + assert!( + pane.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval { + thread_id: "thread-1".to_string(), + id: "request-1".to_string(), + }) + ); + assert_eq!(pane.view_stack.len(), 1); + assert_eq!( + pane.view_stack.last().and_then(|view| view.view_id()), + Some("top") + ); + } + + #[test] + fn apply_text_suggestion_updates_matching_prompt_beneath_an_overlay() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = test_pane(tx); + let request_id = uuid::Uuid::new_v4(); + let prompt = custom_prompt_view::CustomPromptView::new( + "Rename thread".to_string(), + "Type a name".to_string(), + /*initial_text*/ String::new(), + /*context_label*/ None, + Box::new(|_| {}), + ) + .with_text_suggestion(request_id, "Loading".to_string(), "Ready".to_string()); + pane.show_text_prompt(prompt); + pane.push_view(Box::new(CompletingView { + id: Some("overlay"), + complete: false, + })); + + assert!(pane.apply_text_suggestion(request_id, Some("Fix login timeout"))); + + pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let rendered = render_snapshot( + &pane, + Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 8, + ), + ); + + assert!(rendered.contains("Fix login timeout")); + assert!(rendered.contains("Ready")); + assert!(!rendered.contains("Loading")); + } + + #[test] + fn dismiss_app_server_request_returns_false_when_no_view_matches() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = test_pane(tx); + + pane.push_view(Box::new(DismissibleView { + id: Some("first"), + dismiss_exec_id: Some("other-request"), + complete: false, + })); + pane.push_view(Box::new(DismissibleView { + id: Some("second"), + dismiss_exec_id: None, + complete: false, + })); + + assert!( + !pane.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval { + thread_id: "thread-1".to_string(), + id: "request-1".to_string(), + }) + ); + assert_eq!(pane.view_stack.len(), 2); + assert_eq!( + pane.view_stack.last().and_then(|view| view.view_id()), + Some("second") + ); + } + + #[test] + fn completing_top_view_preserves_underlying_view() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = test_pane(tx); + + pane.push_view(Box::new(DismissibleView { + id: Some("underlying"), + dismiss_exec_id: None, + complete: false, + })); + pane.push_view(Box::new(CompletingView { + id: Some("top"), + complete: false, + })); + + pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(pane.view_stack.len(), 1); + assert_eq!( + pane.view_stack.last().and_then(|view| view.view_id()), + Some("underlying") + ); + } + + #[test] + fn composer_shown_after_denied_while_task_running() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Start a running task so the status indicator is active above the composer. + pane.set_task_running(/*running*/ true); + + // Push an approval modal (e.g., command approval) which should hide the status view. + pane.push_approval_request(exec_request(), &features); + + // Simulate pressing 'n' (No) on the modal. + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + pane.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + + // After denial, since the task is still running, the status indicator should be + // visible above the composer. The modal should be gone. + assert!( + pane.view_stack.is_empty(), + "no active modal view after denial" + ); + + // Render and ensure the top row includes the Working header and a composer line below. + // Give the animation thread a moment to tick. + std::thread::sleep(Duration::from_millis(120)); + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + let mut row0 = String::new(); + for x in 0..area.width { + row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); + } + assert!( + row0.contains("Working"), + "expected Working header after denial on row 0: {row0:?}" + ); + + // Composer placeholder should be visible somewhere below. + let mut found_composer = false; + for y in 1..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains("Ask Codex") { + found_composer = true; + break; + } + } + assert!( + found_composer, + "expected composer visible under status line" + ); + } + + #[test] + fn status_indicator_visible_during_command_execution() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Begin a task: show initial status. + pane.set_task_running(/*running*/ true); + + // Use a height that allows the status line to be visible above the composer. + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + + let bufs = snapshot_buffer(&buf); + assert!(bufs.contains("• Working"), "expected Working header"); + + pane.reset_status_timer(Duration::from_secs(/*secs*/ 42)); + pane.hide_status_indicator(); + pane.pause_status_timer_for_modal(); + let paused = pane.status_timer.elapsed_at(Instant::now()); + pane.ensure_status_indicator(); + assert_snapshot!( + "status_timer_survives_hidden_row", + render_snapshot(&pane, area) + ); + assert_eq!( + pane.status_timer + .elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10)), + paused + ); + pane.resume_status_timer_after_modal(); + assert!( + pane.status_timer + .elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10)) + >= paused + Duration::from_secs(/*secs*/ 10) + ); + pane.set_task_running(/*running*/ false); + pane.set_task_running(/*running*/ true); + assert!(pane.status_timer.elapsed_at(Instant::now()) < paused); + } + + #[test] + fn turn_start_keeps_an_outstanding_approval_paused() { + let (tx, _rx) = unbounded_channel::(); + let mut pane = test_pane(AppEventSender::new(tx)); + // MCP startup can already own the running row when an agent turn starts. + pane.set_task_running(/*running*/ true); + pane.push_approval_request(exec_request(), &Features::default()); + pane.hide_status_indicator(); + pane.set_task_running(/*running*/ true); + pane.reset_status_timer(Duration::ZERO); + pane.ensure_status_indicator(); + let later = Instant::now() + Duration::from_secs(/*secs*/ 120); + assert_eq!(pane.status_timer.elapsed_at(later), Duration::ZERO); + + pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(pane.no_modal_or_popup_active()); + assert!( + pane.status_timer + .elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10)) + >= Duration::from_secs(/*secs*/ 10) + ); + } + + #[test] + fn status_and_composer_fill_height_without_bottom_padding() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Activate spinner (status view replaces composer) with no live ring. + pane.set_task_running(/*running*/ true); + + // Use height == desired_height; expect spacer + status + composer rows without trailing padding. + let height = pane.desired_height(/*width*/ 30); + assert!( + height >= 3, + "expected at least 3 rows to render spacer, status, and composer; got {height}" + ); + let area = Rect::new(0, 0, 30, height); + assert_snapshot!( + "status_and_composer_fill_height_without_bottom_padding", + render_snapshot(&pane, area) + ); + } + + #[test] + fn status_only_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(/*running*/ true); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!("status_only_snapshot", render_snapshot(&pane, area)); + } + + #[test] + fn unified_exec_summary_does_not_increase_height_when_status_visible() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(/*running*/ true); + let width = 120; + let before = pane.desired_height(width); + + pane.set_unified_exec_processes(vec!["sleep 5".to_string()]); + let after = pane.desired_height(width); + + assert_eq!(after, before); + + let area = Rect::new(0, 0, width, after); + let rendered = render_snapshot(&pane, area); + assert!(rendered.contains("background terminal running · /ps to view")); + } + + #[test] + fn status_with_details_and_queued_messages_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(/*running*/ true); + pane.update_status( + "Working".to_string(), + Some("First detail line\nSecond detail line".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "status_with_details_and_queued_messages_snapshot", + render_snapshot(&pane, area) + ); + } + + #[test] + fn queued_messages_visible_when_status_hidden_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(/*running*/ true); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); + pane.hide_status_indicator(); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "queued_messages_visible_when_status_hidden_snapshot", + render_snapshot(&pane, area) + ); + } + + #[test] + fn status_and_queued_messages_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(/*running*/ true); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "status_and_queued_messages_snapshot", + render_snapshot(&pane, area) + ); + } + + #[test] + fn remote_images_render_above_composer_text() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_remote_image_urls(vec![ + "https://example.com/one.png".to_string(), + "data:image/png;base64,aGVsbG8=".to_string(), + ]); + + assert_eq!(pane.composer_text(), ""); + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + let snapshot = render_snapshot(&pane, area); + assert!(snapshot.contains("[Image #1]")); + assert!(snapshot.contains("[Image #2]")); + } + + #[test] + fn drain_pending_submission_state_clears_remote_image_urls() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]); + assert_eq!(pane.remote_image_urls().len(), 1); + + pane.drain_pending_submission_state(); + + assert!(pane.remote_image_urls().is_empty()); + } + + #[test] + fn esc_with_skill_popup_does_not_interrupt_task() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(vec![SkillMetadata { + name: "test-skill".to_string(), + description: "test skill".to_string(), + short_description: None, + interface: None, + dependencies: None, + path: test_path_buf("/tmp/test-skill/SKILL.md").abs(), + scope: crate::test_support::skill_scope_user(), + enabled: true, + plugin_id: None, + }]), + }); + + pane.set_task_running(/*running*/ true); + + // Repro: a running task + skill popup + Esc should dismiss the popup, not interrupt. + pane.insert_str("$"); + assert!( + pane.composer.popup_active(), + "expected skill popup after typing `$`" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + while let Ok(ev) = rx.try_recv() { + assert!( + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), + "expected Esc to not send Op::Interrupt when dismissing skill popup" + ); + } + assert!( + !pane.composer.popup_active(), + "expected Esc to dismiss skill popup" + ); + } + + #[test] + fn esc_dismisses_slash_command_popup_without_interrupting_task() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(/*running*/ true); + + // Repro: a running task + slash-command popup + Esc should dismiss the popup without + // interrupting the task. + pane.insert_str("/rev"); + assert!( + pane.composer.popup_active(), + "expected command popup after typing `/rev`" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + while let Ok(ev) = rx.try_recv() { + assert!( + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), + "expected Esc to not send Op::Interrupt while command popup is active" + ); + } + assert!(!pane.composer.popup_active()); + assert_eq!(pane.composer_text(), "/rev"); + + let width = 60; + let area = Rect::new(0, 0, width, pane.desired_height(width)); + assert_snapshot!( + "slash_command_popup_dismissed", + render_snapshot(&pane, area) + ); + + pane.insert_str("i"); + assert!(pane.composer.popup_active()); + } + + #[test] + fn esc_with_agent_command_without_popup_does_not_interrupt_task() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(/*running*/ true); + + // Repro: `/subagents ` hides the popup (cursor past command name). Esc should + // keep editing command text instead of interrupting the running task. + pane.insert_str("/subagents "); + assert!( + !pane.composer.popup_active(), + "expected command popup to be hidden after entering `/subagents `" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + while let Ok(ev) = rx.try_recv() { + assert!( + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), + "expected Esc to not send Op::Interrupt while typing `/subagents`" + ); + } + assert_eq!(pane.composer_text(), "/subagents "); + } + + #[test] + fn esc_release_after_dismissing_agent_picker_does_not_interrupt_task() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(/*running*/ true); + pane.show_selection_view(SelectionViewParams { + title: Some("Agents".to_string()), + items: vec![SelectionItem { + name: "Main".to_string(), + ..Default::default() + }], + ..Default::default() + }); + + pane.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Press, + )); + pane.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Release, + )); + + while let Ok(ev) = rx.try_recv() { + assert!( + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), + "expected Esc release after dismissing agent picker to not interrupt" + ); + } + assert!( + pane.no_modal_or_popup_active(), + "expected Esc press to dismiss the agent picker" + ); + } + + #[test] + fn esc_interrupts_running_task_when_no_popup() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(/*running*/ true); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt while a task is running" + ); + + pane.hide_status_indicator(); + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::CodexOp(Op::Interrupt)) + )); + } + + #[test] + fn remapped_interrupt_turn_uses_configured_key_including_agent_drafts() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = test_pane(tx); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + pane.set_keymap_bindings(&keymap); + pane.set_task_running(/*running*/ true); + pane.insert_str("/subagents "); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + rx.try_recv().is_err(), + "expected Esc to remain local after remapping interruption" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected configured key to interrupt while `/subagents` is being edited" + ); + + pane.hide_status_indicator(); + pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::CodexOp(Op::Interrupt)) + )); + } + + #[test] + fn selection_view_esc_respects_remapped_list_cancel() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = test_pane(tx); + let mut keymap = RuntimeKeymap::defaults(); + keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::Char('q'))]; + pane.set_keymap_bindings(&keymap); + pane.show_selection_view(SelectionViewParams { + title: Some("Agents".to_string()), + items: vec![SelectionItem { + name: "Main".to_string(), + ..Default::default() + }], + on_cancel: Some(Box::new(|tx: &_| { + tx.send(AppEvent::OpenApprovalsPopup); + })), + ..Default::default() + }); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(pane.active_view().is_some()); + assert!(rx.try_recv().is_err()); + + pane.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + + assert!(pane.no_modal_or_popup_active()); + assert!(matches!(rx.try_recv(), Ok(AppEvent::OpenApprovalsPopup))); + } + + #[test] + fn esc_routes_to_handle_key_event_when_requested() { + #[derive(Default)] + struct EscRoutingView { + on_ctrl_c_calls: Rc>, + handle_calls: Rc>, + } + + impl Renderable for EscRoutingView { + fn render(&self, _area: Rect, _buf: &mut Buffer) {} + + fn desired_height(&self, _width: u16) -> u16 { + 0 + } + } + + impl BottomPaneView for EscRoutingView { + fn handle_key_event(&mut self, _key_event: KeyEvent) { + self.handle_calls + .set(self.handle_calls.get().saturating_add(1)); + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.on_ctrl_c_calls + .set(self.on_ctrl_c_calls.get().saturating_add(1)); + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + } + + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + let on_ctrl_c_calls = Rc::new(Cell::new(0)); + let handle_calls = Rc::new(Cell::new(0)); + pane.push_view(Box::new(EscRoutingView { + on_ctrl_c_calls: Rc::clone(&on_ctrl_c_calls), + handle_calls: Rc::clone(&handle_calls), + })); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(on_ctrl_c_calls.get(), 0); + assert_eq!(handle_calls.get(), 1); + } + + #[test] + fn release_events_are_ignored_for_active_view() { + #[derive(Default)] + struct CountingView { + handle_calls: Rc>, + } + + impl Renderable for CountingView { + fn render(&self, _area: Rect, _buf: &mut Buffer) {} + + fn desired_height(&self, _width: u16) -> u16 { + 0 + } + } + + impl BottomPaneView for CountingView { + fn handle_key_event(&mut self, _key_event: KeyEvent) { + self.handle_calls + .set(self.handle_calls.get().saturating_add(1)); + } + } + + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + let handle_calls = Rc::new(Cell::new(0)); + pane.push_view(Box::new(CountingView { + handle_calls: Rc::clone(&handle_calls), + })); + + pane.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Down, + KeyModifiers::NONE, + KeyEventKind::Press, + )); + pane.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Down, + KeyModifiers::NONE, + KeyEventKind::Release, + )); + + assert_eq!(handle_calls.get(), 1); + } + + #[test] + fn paste_completion_clears_stacked_views_and_restores_composer_input() { + #[derive(Default)] + struct BlockingView { + handle_calls: Rc>, + } + + impl Renderable for BlockingView { + fn render(&self, _area: Rect, _buf: &mut Buffer) {} + + fn desired_height(&self, _width: u16) -> u16 { + 0 + } + } + + impl BottomPaneView for BlockingView { + fn handle_key_event(&mut self, _key_event: KeyEvent) { + self.handle_calls + .set(self.handle_calls.get().saturating_add(1)); + } + } + + #[derive(Default)] + struct PasteCompletesView { + complete: bool, + } + + impl Renderable for PasteCompletesView { + fn render(&self, _area: Rect, _buf: &mut Buffer) {} + + fn desired_height(&self, _width: u16) -> u16 { + 0 + } + } + + impl BottomPaneView for PasteCompletesView { + fn handle_paste(&mut self, _pasted: String) -> bool { + self.complete = true; + true + } + + fn is_complete(&self) -> bool { + self.complete + } + } + + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_composer_input_enabled(/*enabled*/ false, /*placeholder*/ None); + + let lower_view_handle_calls = Rc::new(Cell::new(0)); + pane.push_view(Box::new(BlockingView { + handle_calls: Rc::clone(&lower_view_handle_calls), + })); + pane.push_view(Box::new(PasteCompletesView::default())); + + pane.handle_paste("hello".to_string()); + + assert!( + pane.view_stack.is_empty(), + "paste completion should tear down the active modal flow" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + + let area = Rect::new(0, 0, 40, pane.desired_height(/*width*/ 40).max(2)); + assert!(pane.cursor_pos(area).is_some()); + assert_eq!(lower_view_handle_calls.get(), 0); + } +} diff --git a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs new file mode 100644 index 0000000000000000000000000000000000000000..7f89e332eb26ffcdb80ebb65c782ffa9eae0d93d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs @@ -0,0 +1,1099 @@ +//! Multi-select picker widget for selecting multiple items from a list. +//! +//! This module provides a fuzzy-searchable, scrollable picker that allows users +//! to toggle multiple items on/off. It supports: +//! +//! - **Fuzzy search**: Type to filter items by name +//! - **Toggle selection**: Space to toggle items on/off +//! - **Reordering**: Optional left/right arrow support to reorder items +//! - **Live preview**: Optional callback to show a preview of current selections +//! - **Callbacks**: Hooks for change, confirm, and cancel events +//! +//! # Example +//! +//! ```ignore +//! let picker = MultiSelectPicker::new( +//! "Select Items".to_string(), +//! Some("Choose which items to enable".to_string()), +//! app_event_tx, +//! ) +//! .items(vec![ +//! MultiSelectItem { +//! id: "a".into(), +//! name: "Item A".into(), +//! description: None, +//! enabled: true, +//! orderable: true, +//! section_break_after: false, +//! }, +//! MultiSelectItem { +//! id: "b".into(), +//! name: "Item B".into(), +//! description: None, +//! enabled: false, +//! orderable: true, +//! section_break_after: false, +//! }, +//! ]) +//! .on_confirm(|selected_ids, tx| { /* handle confirmation */ }) +//! .build(); +//! ``` + +use codex_utils_fuzzy_match::fuzzy_match; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Widget; + +use super::selection_popup_common::GenericDisplayRow; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS; +use crate::bottom_pane::scroll_state::ScrollState; +use crate::bottom_pane::selection_popup_common::render_rows_single_line; +use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::key_hint::is_plain_text_key_event; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::keymap::RuntimeKeymap; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::Insets; +use crate::render::RectExt; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::style::user_message_style; +use crate::text_formatting::truncate_text; + +/// Maximum display length for item names before truncation. +const ITEM_NAME_TRUNCATE_LEN: usize = 21; + +/// Placeholder text shown in the search input when empty. +const SEARCH_PLACEHOLDER: &str = "Type to search"; + +/// Prefix displayed before the search query (mimics a command prompt). +const SEARCH_PROMPT_PREFIX: &str = "> "; + +const SECTION_BREAK_ROW: &str = " ───────────────────────"; + +/// Direction for reordering items in the list. +enum Direction { + Up, + Down, +} + +/// Callback invoked when any item's state changes (toggled or reordered). +/// Receives the full list of items and the event sender. +pub type ChangeCallBack = Box; + +/// Callback invoked when the user confirms their selection (presses Enter). +/// Receives a list of IDs for all enabled items. +pub type ConfirmCallback = Box; + +/// Callback invoked when the user cancels the picker (presses Escape). +pub type CancelCallback = Box; + +/// Callback to generate an optional preview line based on current item states. +/// Returns `None` to hide the preview area. +pub type PreviewCallback = Box Option> + Send + Sync>; + +/// A single selectable item in the multi-select picker. +/// +/// Each item has a unique identifier, display name, optional description, +/// and an enabled/disabled state that can be toggled by the user. +pub(crate) struct MultiSelectItem { + /// Unique identifier returned in the confirm callback when this item is enabled. + pub id: String, + + /// Display name shown in the picker list. Will be truncated if too long. + pub name: String, + + /// Optional description shown alongside the name (dimmed). + pub description: Option, + + /// Whether this item is currently selected/enabled. + pub enabled: bool, + + /// Whether this item can be moved when ordering is enabled. + pub orderable: bool, + + /// Whether to draw a divider after this item when another visible item follows. + pub section_break_after: bool, +} + +impl Default for MultiSelectItem { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + description: None, + enabled: false, + orderable: true, + section_break_after: false, + } + } +} + +struct BuiltRows { + rows: Vec, + state: ScrollState, +} + +/// A multi-select picker widget with fuzzy search and optional reordering. +/// +/// The picker displays a scrollable list of items with checkboxes. Users can: +/// - Type to fuzzy-search and filter the list +/// - Use Up/Down (or Ctrl+P/Ctrl+N) to navigate +/// - Press Space to toggle the selected item +/// - Press Enter to confirm and close +/// - Press Escape to cancel and close +/// - Use Left/Right arrows to reorder items (if ordering is enabled) +/// +/// Create instances using the builder pattern via [`MultiSelectPicker::new`]. +pub(crate) struct MultiSelectPicker { + /// All items in the picker (unfiltered). + items: Vec, + + /// Scroll and selection state for the visible list. + state: ScrollState, + + /// Whether the picker has been closed (confirmed or cancelled). + pub(crate) complete: bool, + + /// Channel for sending application events. + app_event_tx: AppEventSender, + + /// Header widget displaying title and subtitle. + header: Box, + + /// Footer line showing keyboard hints. + footer_hint: Line<'static>, + + /// Current search/filter query entered by the user. + search_query: String, + + /// Indices into `items` that match the current filter, in display order. + filtered_indices: Vec, + + /// Whether left/right arrow reordering is enabled. + ordering_enabled: bool, + + /// Shared list keybindings for navigation and completion. + keymap: ListKeymap, + + /// Optional callback to generate a preview line from current item states. + preview_builder: Option, + + /// Cached preview line (updated on item changes). + preview_line: Option>, + + /// Callback invoked when items change (toggle or reorder). + on_change: Option, + + /// Callback invoked when the user confirms their selection. + on_confirm: Option, + + /// Callback invoked when the user cancels the picker. + on_cancel: Option, +} + +impl MultiSelectPicker { + /// Creates a new builder for constructing a `MultiSelectPicker`. + /// + /// # Arguments + /// + /// * `title` - The main title displayed at the top of the picker + /// * `subtitle` - Optional subtitle displayed below the title (dimmed) + /// * `app_event_tx` - Event sender for dispatching application events + pub fn builder( + title: String, + subtitle: Option, + app_event_tx: AppEventSender, + ) -> MultiSelectPickerBuilder { + MultiSelectPickerBuilder::new(title, subtitle, app_event_tx) + } + + /// Applies the current search query to filter and sort items. + /// + /// Updates `filtered_indices` to contain only matching items, sorted by + /// fuzzy match score. Attempts to preserve the current selection if it + /// still matches the filter. + fn apply_filter(&mut self) { + // Filter + sort while preserving the current selection when possible. + let previously_selected = self + .state + .selected_idx + .and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied()); + + let filter = self.search_query.trim(); + if filter.is_empty() { + self.filtered_indices = (0..self.items.len()).collect(); + } else { + let mut matches: Vec<(usize, i32)> = Vec::new(); + for (idx, item) in self.items.iter().enumerate() { + let display_name = item.name.as_str(); + if let Some((_indices, score)) = match_item(filter, display_name, &item.name) { + matches.push((idx, score)); + } + } + + matches.sort_by(|a, b| { + a.1.cmp(&b.1).then_with(|| { + let an = self.items[a.0].name.as_str(); + let bn = self.items[b.0].name.as_str(); + an.cmp(bn) + }) + }); + + self.filtered_indices = matches.into_iter().map(|(idx, _score)| idx).collect(); + } + + let len = self.filtered_indices.len(); + self.state.selected_idx = previously_selected + .and_then(|actual_idx| { + self.filtered_indices + .iter() + .position(|idx| *idx == actual_idx) + }) + .or_else(|| (len > 0).then_some(0)); + + let visible = Self::max_visible_rows(len); + self.state.clamp_selection(len); + self.state.ensure_visible(len, visible); + } + + /// Returns the number of items visible after filtering. + fn visible_len(&self) -> usize { + self.filtered_indices.len() + } + + /// Returns the maximum number of rows that can be displayed at once. + fn max_visible_rows(len: usize) -> usize { + MAX_POPUP_ROWS.min(len.max(1)) + } + + /// Calculates the width available for row content (accounts for borders). + fn rows_width(total_width: u16) -> u16 { + total_width.saturating_sub(2) + } + + /// Calculates the height needed for the row list area. + fn rows_height(&self, rows: &BuiltRows) -> u16 { + rows.rows + .len() + .clamp(1, MAX_POPUP_ROWS) + .try_into() + .unwrap_or(1) + } + + /// Builds the display rows for all currently visible (filtered) items. + /// + /// Each row shows: `› [x] Item Name` where `›` indicates cursor position + /// and `[x]` or `[ ]` indicates enabled/disabled state. + fn build_rows(&self) -> BuiltRows { + let mut rows = Vec::new(); + let mut visible_to_row = Vec::with_capacity(self.filtered_indices.len()); + for (visible_idx, actual_idx) in self.filtered_indices.iter().enumerate() { + let Some(item) = self.items.get(*actual_idx) else { + continue; + }; + visible_to_row.push(rows.len()); + let is_selected = self.state.selected_idx == Some(visible_idx); + let prefix = if is_selected { '›' } else { ' ' }; + let marker = if item.enabled { 'x' } else { ' ' }; + let item_name = truncate_text(&item.name, ITEM_NAME_TRUNCATE_LEN); + let name = format!("{prefix} [{marker}] {item_name}"); + rows.push(GenericDisplayRow { + name, + description: item.description.clone(), + ..Default::default() + }); + + if item.section_break_after && visible_idx + 1 < self.filtered_indices.len() { + rows.push(GenericDisplayRow { + name: SECTION_BREAK_ROW.to_string(), + is_disabled: true, + ..Default::default() + }); + } + } + + let selected_idx = self + .state + .selected_idx + .and_then(|visible_idx| visible_to_row.get(visible_idx).copied()); + let scroll_top = visible_to_row + .get(self.state.scroll_top) + .copied() + .unwrap_or(0); + BuiltRows { + rows, + state: ScrollState { + selected_idx, + scroll_top, + }, + } + } + + /// Moves the selection cursor up, wrapping to the bottom if at the top. + fn move_up(&mut self) { + let len = self.visible_len(); + self.state.move_up_wrap(len); + let visible = Self::max_visible_rows(len); + self.state.ensure_visible(len, visible); + } + + /// Moves the selection cursor down, wrapping to the top if at the bottom. + fn move_down(&mut self) { + let len = self.visible_len(); + self.state.move_down_wrap(len); + let visible = Self::max_visible_rows(len); + self.state.ensure_visible(len, visible); + } + + fn page_up(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_up_clamped(len, visible); + } + + fn page_down(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_down_clamped(len, visible); + } + + fn jump_top(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_top(len, visible); + } + + fn jump_bottom(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_bottom(len, visible); + } + + /// Toggles the enabled state of the currently selected item. + /// + /// Updates the preview line and invokes the `on_change` callback if set. + fn toggle_selected(&mut self) { + let Some(idx) = self.state.selected_idx else { + return; + }; + let Some(actual_idx) = self.filtered_indices.get(idx).copied() else { + return; + }; + let Some(item) = self.items.get_mut(actual_idx) else { + return; + }; + + item.enabled = !item.enabled; + self.update_preview_line(); + if let Some(on_change) = &self.on_change { + on_change(&self.items, &self.app_event_tx); + } + } + + /// Confirms the current selection and closes the picker. + /// + /// Collects the IDs of all enabled items and passes them to the + /// `on_confirm` callback. Does nothing if already complete. + fn confirm_selection(&mut self) { + if self.complete { + return; + } + self.complete = true; + + if let Some(on_confirm) = &self.on_confirm { + let selected_ids: Vec = self + .items + .iter() + .filter(|item| item.enabled) + .map(|item| item.id.clone()) + .collect(); + on_confirm(&selected_ids, &self.app_event_tx); + } + } + + /// Moves the currently selected item up or down in the list. + /// + /// Only works when: + /// - The search query is empty (reordering is disabled during filtering) + /// - Ordering is enabled via [`MultiSelectPickerBuilder::enable_ordering`] + /// + /// Updates the preview line and invokes the `on_change` callback. + fn move_selected_item(&mut self, direction: Direction) { + if !self.search_query.is_empty() { + return; + } + + let Some(visible_idx) = self.state.selected_idx else { + return; + }; + let Some(actual_idx) = self.filtered_indices.get(visible_idx).copied() else { + return; + }; + + let len = self.items.len(); + if len == 0 { + return; + } + + if !self + .items + .get(actual_idx) + .is_some_and(|item| item.orderable) + { + return; + } + + let new_idx = match direction { + Direction::Up if actual_idx > 0 => actual_idx - 1, + Direction::Down if actual_idx + 1 < len => actual_idx + 1, + _ => return, + }; + + if !self.items.get(new_idx).is_some_and(|item| item.orderable) { + return; + } + + // move item in underlying list + self.items.swap(actual_idx, new_idx); + + self.update_preview_line(); + if let Some(on_change) = &self.on_change { + on_change(&self.items, &self.app_event_tx); + } + + // rebuild filtered indices to keep search/filter consistent + self.apply_filter(); + + // restore selection to moved item + let moved_idx = new_idx; + if let Some(new_visible_idx) = self + .filtered_indices + .iter() + .position(|idx| *idx == moved_idx) + { + self.state.selected_idx = Some(new_visible_idx); + } + } + + /// Regenerates the preview line using the preview callback. + /// + /// Called after any item state change (toggle or reorder). + fn update_preview_line(&mut self) { + self.preview_line = self + .preview_builder + .as_ref() + .and_then(|builder| builder(&self.items)); + } + + /// Closes the picker without confirming, invoking the `on_cancel` callback. + /// + /// Does nothing if already complete. + pub fn close(&mut self) { + if self.complete { + return; + } + self.complete = true; + if let Some(on_cancel) = &self.on_cancel { + on_cancel(&self.app_event_tx); + } + } +} + +impl BottomPaneView for MultiSelectPicker { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.close(); + CancellationEvent::Handled + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + // Printable characters always feed search. Movement aliases such as + // plain j/k only apply through non-text events or modified bindings. + let allow_plain_char_navigation = !is_plain_text_key_event(key_event); + + match key_event { + _ if allow_plain_char_navigation + && self.ordering_enabled + && self.keymap.move_left.is_pressed(key_event) => + { + self.move_selected_item(Direction::Up); + } + _ if allow_plain_char_navigation + && self.ordering_enabled + && self.keymap.move_right.is_pressed(key_event) => + { + self.move_selected_item(Direction::Down); + } + _ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => { + self.move_up() + } + _ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => { + self.move_down() + } + _ if allow_plain_char_navigation && self.keymap.page_up.is_pressed(key_event) => { + self.page_up() + } + _ if allow_plain_char_navigation && self.keymap.page_down.is_pressed(key_event) => { + self.page_down() + } + _ if allow_plain_char_navigation && self.keymap.jump_top.is_pressed(key_event) => { + self.jump_top() + } + _ if allow_plain_char_navigation && self.keymap.jump_bottom.is_pressed(key_event) => { + self.jump_bottom() + } + KeyEvent { + code: KeyCode::Backspace, + .. + } => { + self.search_query.pop(); + self.apply_filter(); + } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } => self.toggle_selected(), + _ if self.keymap.accept.is_pressed(key_event) => self.confirm_selection(), + _ if self.keymap.cancel.is_pressed(key_event) => self.close(), + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.search_query.push(c); + self.apply_filter(); + } + _ => {} + } + } +} + +impl Renderable for MultiSelectPicker { + fn desired_height(&self, width: u16) -> u16 { + let rows = self.build_rows(); + let rows_height = self.rows_height(&rows); + let preview_height = if self.preview_line.is_some() { 1 } else { 0 }; + + let mut height = self.header.desired_height(width.saturating_sub(4)); + height = height.saturating_add(rows_height + 3); + height = height.saturating_add(2); + height.saturating_add(1 + preview_height) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + // Reserve the footer line for the key-hint row. + let preview_height = if self.preview_line.is_some() { 1 } else { 0 }; + let footer_height = 1 + preview_height; + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_height)]).areas(area); + + Block::default() + .style(user_message_style()) + .render(content_area, buf); + + let header_height = self + .header + .desired_height(content_area.width.saturating_sub(4)); + let rows = self.build_rows(); + let rows_width = Self::rows_width(content_area.width); + let rows_height = self.rows_height(&rows); + let [header_area, _, search_area, list_area] = Layout::vertical([ + Constraint::Max(header_height), + Constraint::Max(1), + Constraint::Length(2), + Constraint::Length(rows_height), + ]) + .areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2))); + + self.header.render(header_area, buf); + + // Render the search prompt as two lines to mimic the composer. + if search_area.height >= 2 { + let [placeholder_area, input_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(search_area); + Line::from(SEARCH_PLACEHOLDER.dim()).render(placeholder_area, buf); + let line = if self.search_query.is_empty() { + Line::from(vec![SEARCH_PROMPT_PREFIX.dim()]) + } else { + Line::from(vec![ + SEARCH_PROMPT_PREFIX.dim(), + self.search_query.clone().into(), + ]) + }; + line.render(input_area, buf); + } else if search_area.height > 0 { + let query_span = if self.search_query.is_empty() { + SEARCH_PLACEHOLDER.dim() + } else { + self.search_query.clone().into() + }; + Line::from(query_span).render(search_area, buf); + } + + if list_area.height > 0 { + let render_area = Rect { + x: list_area.x.saturating_sub(2), + y: list_area.y, + width: rows_width.max(1), + height: list_area.height, + }; + render_rows_single_line( + render_area, + buf, + &rows.rows, + &rows.state, + render_area.height as usize, + "no matches", + ); + } + + let hint_area = if let Some(preview_line) = &self.preview_line { + let [preview_area, hint_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(footer_area); + let preview_area = Rect { + x: preview_area.x + 2, + y: preview_area.y, + width: preview_area.width.saturating_sub(2), + height: preview_area.height, + }; + let max_preview_width = preview_area.width.saturating_sub(2) as usize; + let preview_line = + truncate_line_with_ellipsis_if_overflow(preview_line.clone(), max_preview_width); + preview_line.render(preview_area, buf); + hint_area + } else { + footer_area + }; + let hint_area = Rect { + x: hint_area.x + 2, + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + self.footer_hint.clone().dim().render(hint_area, buf); + } +} + +/// Builder for constructing a [`MultiSelectPicker`] with a fluent API. +/// +/// # Example +/// +/// ```ignore +/// let picker = MultiSelectPicker::new("Title".into(), None, tx) +/// .items(items) +/// .enable_ordering() +/// .on_preview(|items| Some(Line::from("Preview"))) +/// .on_confirm(|ids, tx| { /* handle */ }) +/// .on_cancel(|tx| { /* handle */ }) +/// .build(); +/// ``` +pub(crate) struct MultiSelectPickerBuilder { + title: String, + subtitle: Option, + instructions: Vec>, + items: Vec, + ordering_enabled: bool, + app_event_tx: AppEventSender, + keymap: ListKeymap, + preview_builder: Option, + on_change: Option, + on_confirm: Option, + on_cancel: Option, +} + +impl MultiSelectPickerBuilder { + /// Creates a new builder with the given title, optional subtitle, and event sender. + pub fn new(title: String, subtitle: Option, app_event_tx: AppEventSender) -> Self { + Self { + title, + subtitle, + instructions: Vec::new(), + items: Vec::new(), + ordering_enabled: false, + app_event_tx, + keymap: RuntimeKeymap::defaults().list, + preview_builder: None, + on_change: None, + on_confirm: None, + on_cancel: None, + } + } + + /// Sets the list of selectable items. + pub fn items(mut self, items: Vec) -> Self { + self.items = items; + self + } + + /// Enables left/right arrow keys for reordering items. + /// + /// Reordering is only active when the search query is empty. + pub fn enable_ordering(mut self) -> Self { + self.ordering_enabled = true; + self + } + + /// Sets the shared list keymap used for navigation and completion. + pub fn list_keymap(mut self, keymap: ListKeymap) -> Self { + self.keymap = keymap; + self + } + + /// Sets a callback to generate a preview line from the current item states. + /// + /// The callback receives all items and should return a [`Line`] to display, + /// or `None` to hide the preview area. + pub fn on_preview(mut self, callback: F) -> Self + where + F: Fn(&[MultiSelectItem]) -> Option> + Send + Sync + 'static, + { + self.preview_builder = Some(Box::new(callback)); + self + } + + /// Sets a callback invoked whenever an item's state changes. + /// + /// This includes both toggles and reordering operations. + #[allow(dead_code)] + pub fn on_change(mut self, callback: F) -> Self + where + F: Fn(&[MultiSelectItem], &AppEventSender) + Send + Sync + 'static, + { + self.on_change = Some(Box::new(callback)); + self + } + + /// Sets a callback invoked when the user confirms their selection (Enter). + /// + /// The callback receives a list of IDs for all enabled items. + pub fn on_confirm(mut self, callback: F) -> Self + where + F: Fn(&[String], &AppEventSender) + Send + Sync + 'static, + { + self.on_confirm = Some(Box::new(callback)); + self + } + + /// Sets a callback invoked when the user cancels the picker (Escape). + pub fn on_cancel(mut self, callback: F) -> Self + where + F: Fn(&AppEventSender) + Send + Sync + 'static, + { + self.on_cancel = Some(Box::new(callback)); + self + } + + /// Builds the [`MultiSelectPicker`] with all configured options. + /// + /// Initializes the filter to show all items and generates the initial + /// preview line if a preview callback was set. + pub fn build(self) -> MultiSelectPicker { + let mut header = ColumnRenderable::new(); + header.push(Line::from(self.title.bold())); + + if let Some(subtitle) = self.subtitle { + header.push(Line::from(subtitle.dim())); + } + + let instructions = if self.instructions.is_empty() { + let mut spans = vec![ + "Press ".into(), + key_hint::plain(KeyCode::Char(' ')).into(), + " to toggle".into(), + ]; + if self.ordering_enabled + && let (Some(move_left), Some(move_right)) = ( + self.keymap.primary_hint(ListAction::MoveLeft), + self.keymap.primary_hint(ListAction::MoveRight), + ) + { + spans.push("; ".into()); + spans.push(move_left.into()); + spans.push("/".into()); + spans.push(move_right.into()); + spans.push(" to move".into()); + } + if let Some(accept) = self.keymap.primary_hint(ListAction::Accept) { + spans.push("; ".into()); + spans.push(accept.into()); + spans.push(" to confirm and close".into()); + } + if let Some(cancel) = self.keymap.primary_hint(ListAction::Cancel) { + spans.push("; ".into()); + spans.push(cancel.into()); + spans.push(" to close".into()); + } + spans + } else { + self.instructions + }; + + let mut view = MultiSelectPicker { + items: self.items, + state: ScrollState::new(), + complete: false, + app_event_tx: self.app_event_tx, + header: Box::new(header), + footer_hint: Line::from(instructions), + ordering_enabled: self.ordering_enabled, + keymap: self.keymap, + search_query: String::new(), + filtered_indices: Vec::new(), + preview_builder: self.preview_builder, + preview_line: None, + on_change: self.on_change, + on_confirm: self.on_confirm, + on_cancel: self.on_cancel, + }; + view.apply_filter(); + view.update_preview_line(); + view + } +} + +/// Performs fuzzy matching on an item against a filter string. +/// +/// Tries to match against the display name first, then falls back to name if different. Returns +/// the matching character indices (if matched on display name) and a score for sorting. +/// +/// # Arguments +/// +/// * `filter` - The search query to match against +/// * `display_name` - The primary name to match (shown to user) +/// * `name` - A secondary/canonical name to try if display name doesn't match +/// +/// # Returns +/// +/// * `Some((Some(indices), score))` - Matched on display name with highlight indices +/// * `Some((None, score))` - Matched on skill name only (no highlights for display) +/// * `None` - No match +pub(crate) fn match_item( + filter: &str, + display_name: &str, + name: &str, +) -> Option<(Option>, i32)> { + if let Some((indices, score)) = fuzzy_match(display_name, filter) { + return Some((Some(indices), score)); + } + if display_name != name + && let Some((_indices, score)) = fuzzy_match(name, filter) + { + return Some((None, score)); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn test_picker(items: Vec) -> MultiSelectPicker { + let (tx, _rx) = unbounded_channel::(); + MultiSelectPicker::builder( + "Test".to_string(), + /*subtitle*/ None, + AppEventSender::new(tx), + ) + .items(items) + .enable_ordering() + .build() + } + + fn item(id: &str, orderable: bool, section_break_after: bool) -> MultiSelectItem { + MultiSelectItem { + id: id.to_string(), + name: id.to_string(), + orderable, + section_break_after, + ..Default::default() + } + } + + #[test] + fn non_orderable_items_cannot_move_or_be_crossed() { + let mut picker = test_picker(vec![ + item( + "theme-colors", + /*orderable*/ false, + /*section_break_after*/ true, + ), + item( + "model", /*orderable*/ true, /*section_break_after*/ false, + ), + item( + "branch", /*orderable*/ true, /*section_break_after*/ false, + ), + ]); + + picker.move_selected_item(Direction::Down); + assert_eq!( + picker + .items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + vec!["theme-colors", "model", "branch"] + ); + + picker.move_down(); + picker.move_selected_item(Direction::Up); + assert_eq!( + picker + .items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + vec!["theme-colors", "model", "branch"] + ); + } + + #[test] + fn horizontal_list_keys_reorder_orderable_items() { + let mut picker = test_picker(vec![ + item( + "model", /*orderable*/ true, /*section_break_after*/ false, + ), + item( + "branch", /*orderable*/ true, /*section_break_after*/ false, + ), + ]); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert_eq!( + picker + .items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + vec!["branch", "model"] + ); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!( + picker + .items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + vec!["model", "branch"] + ); + } + + #[test] + fn section_break_after_item_renders_separator_row() { + let picker = test_picker(vec![ + item( + "theme-colors", + /*orderable*/ false, + /*section_break_after*/ true, + ), + item( + "model", /*orderable*/ true, /*section_break_after*/ false, + ), + ]); + + let rows = picker.build_rows(); + + assert_eq!( + rows.rows + .iter() + .map(|row| row.name.as_str()) + .collect::>(), + vec!["› [ ] theme-colors", SECTION_BREAK_ROW, " [ ] model"] + ); + assert_eq!(rows.state.selected_idx, Some(0)); + } + + #[test] + fn searchable_plain_j_updates_query_instead_of_navigating() { + let mut picker = test_picker(vec![ + item( + "alpha", /*orderable*/ true, /*section_break_after*/ false, + ), + item( + "jupiter", /*orderable*/ true, /*section_break_after*/ false, + ), + ]); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + + assert_eq!(picker.search_query, "j"); + assert_eq!(picker.filtered_indices, vec![1]); + assert_eq!(picker.state.selected_idx, Some(0)); + } + + #[test] + fn page_and_jump_navigation_use_list_keymap() { + let (tx, _rx) = unbounded_channel::(); + let mut keymap = RuntimeKeymap::defaults().list; + keymap.page_down = vec![key_hint::ctrl(KeyCode::Char('d'))]; + keymap.page_up = vec![key_hint::ctrl(KeyCode::Char('u'))]; + keymap.jump_bottom = vec![key_hint::ctrl(KeyCode::Char('e'))]; + keymap.jump_top = vec![key_hint::ctrl(KeyCode::Char('a'))]; + let mut picker = MultiSelectPicker::builder( + "Test".to_string(), + /*subtitle*/ None, + AppEventSender::new(tx), + ) + .items( + (0..12) + .map(|idx| { + item( + &format!("item-{idx}"), + /*orderable*/ true, + /*section_break_after*/ false, + ) + }) + .collect(), + ) + .list_keymap(keymap) + .build(); + + picker.handle_key_event(KeyEvent::from(KeyCode::PageDown)); + assert_eq!(picker.state.selected_idx, Some(0)); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); + assert_eq!(picker.state.selected_idx, Some(8)); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert_eq!(picker.state.selected_idx, Some(0)); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!(picker.state.selected_idx, Some(11)); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); + assert_eq!(picker.state.selected_idx, Some(0)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs new file mode 100644 index 0000000000000000000000000000000000000000..5fcd6615f086f5d462ab1768b9651a3c9a7c0b0a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -0,0 +1,590 @@ +//! Paste-burst detection for terminals without bracketed paste. +//! +//! On some platforms (notably Windows), pastes often arrive as a rapid stream of +//! `KeyCode::Char`, `KeyCode::Enter`, and `KeyCode::Tab` key events rather than as a single "paste" event. +//! In that mode, the composer needs to: +//! +//! - Prevent transient UI side effects (e.g. toggles bound to `?`) from triggering on pasted text. +//! - Ensure Enter is treated as a newline *inside the paste*, not as "submit the message". +//! - Avoid flicker caused by inserting a typed prefix and then immediately reclassifying it as +//! paste once enough chars have arrived. +//! +//! This module provides the `PasteBurst` state machine. `ChatComposer` feeds it only +//! text-producing character events (plain, Shift, or Windows AltGr) and uses the full buffering +//! decisions to either: +//! +//! - briefly hold a first ASCII char (flicker suppression), +//! - buffer a burst as a single pasted string, or +//! - let input flow through as normal typing. +//! +//! # Call Pattern +//! +//! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds +//! it events and then applies the chosen action: +//! +//! - For each text-producing `KeyCode::Char`, call [`PasteBurst::on_plain_char`] (ASCII) or +//! [`PasteBurst::on_plain_char_no_hold`] (non-ASCII/IME). +//! - If the decision indicates buffering, the caller appends to `PasteBurst.buffer` via +//! [`PasteBurst::append_char_to_buffer`]. +//! - On Enter or Tab, [`PasteBurst::append_control_char_if_active`] promotes a held first character +//! into the active buffer before appending the newline or tab and refreshing the idle timeout. +//! - On a UI tick, call [`PasteBurst::flush_if_due`]. If it returns [`FlushResult::Typed`], insert +//! that char as normal typing. If it returns [`FlushResult::Paste`], treat the returned string as +//! an explicit paste. +//! - Before applying non-text input (arrow keys; Ctrl/Alt shortcuts other than Windows AltGr; +//! or Super, Hyper, and Meta modifiers), use +//! [`PasteBurst::flush_before_modified_input`] to avoid leaving buffered text "stuck", and then +//! [`PasteBurst::clear_window_after_non_char`] so subsequent typing does not get grouped into a +//! previous burst. +//! - Direct-insert callers can skip buffering, use +//! [`PasteBurst::direct_insert_newline_should_insert`] in their Enter handler, and call +//! [`PasteBurst::extend_window`] when Enter or [`PasteBurst::on_plain_char_no_hold`] reports a +//! burst-like stream. +//! +//! # State Variables +//! +//! This state machine is encoded in a few fields with slightly different meanings: +//! +//! - `active`: true while we are still *actively* accepting characters into the current burst. +//! - `buffer`: accumulated burst text that will eventually flush as a single `Paste(String)`. +//! A non-empty buffer is treated as "in burst context" even if `active` has been cleared. +//! - `pending_first_char`: a single held ASCII char used for flicker suppression. The caller must +//! not render this char until it joins a burst through `BeginBufferFromPending` or +//! `append_control_char_if_active`, or flushes as a normal typed char (`FlushResult::Typed`). +//! - `last_plain_char_time`/`consecutive_plain_char_burst`: the timing/count heuristic for +//! "paste-like" streams. +//! - `burst_window_until`: the Enter suppression window ("Enter inserts newline") that outlives the +//! buffer itself. +//! +//! # Timing Model +//! +//! There are two timeouts: +//! +//! - `PASTE_BURST_CHAR_INTERVAL`: maximum delay between consecutive "plain" chars for them to be +//! considered part of a single burst. It also bounds how long `pending_first_char` is held. +//! - `PASTE_BURST_ACTIVE_IDLE_TIMEOUT`: once buffering is active, how long to wait after the last +//! char before flushing the accumulated buffer as a paste. +//! +//! `flush_if_due()` intentionally uses `>` (not `>=`) when comparing elapsed time, so tests and UI +//! ticks should cross the threshold by at least 1ms (see `recommended_flush_delay()`). +//! +//! # Retro Capture Details +//! +//! Retro-capture exists to handle the case where we initially inserted characters as "normal +//! typing", but later decide that the stream is paste-like. When that happens, we retroactively +//! remove a prefix of already-inserted text from the textarea and move it into the burst buffer so +//! the eventual `handle_paste(...)` sees a contiguous pasted string. +//! +//! Retro-capture mostly matters on paths that do *not* hold the first character (non-ASCII/IME +//! input, and retro-grab scenarios). The ASCII path usually prefers +//! `RetainFirstChar -> BeginBufferFromPending`, which avoids needing retro-capture at all. +//! +//! Retro-capture is expressed in terms of characters, not bytes: +//! +//! - `CharDecision::BeginBuffer { retro_chars }` uses `retro_chars` as a character count. +//! - `decide_begin_buffer(now, before_cursor, retro_chars)` turns that into a UTF-8 byte range by +//! calling `retro_start_index()`. +//! - `RetroGrab.start_byte` is a byte index into the `before_cursor` slice; callers must clamp the +//! cursor to a char boundary before slicing so `start_byte..cursor` is always valid UTF-8. +//! +//! # Clearing vs Flushing +//! +//! There are two ways callers end burst handling, and they are not interchangeable: +//! +//! - `flush_before_modified_input()` returns the buffered text (and/or a pending first ASCII char) +//! so the caller can apply it through the normal paste path before handling an unrelated input. +//! - `clear_window_after_non_char()` clears the *classification window* so subsequent typing does +//! not get grouped into the previous burst. It assumes the caller has already flushed any buffer +//! because it clears `last_plain_char_time`, which means `flush_if_due()` will not flush a +//! non-empty buffer until another plain char updates the timestamp. +//! +//! # States (Conceptually) +//! +//! - **Idle**: no buffered text, no pending char. +//! - **Pending first char**: `pending_first_char` holds one ASCII char for up to +//! `PASTE_BURST_CHAR_INTERVAL` while we wait to see if a burst follows. +//! - **Active buffer**: `active`/`buffer` holds paste-like content until it times out and flushes. +//! - **Enter suppress window**: `burst_window_until` keeps Enter treated as newline briefly after +//! burst activity so multiline pastes stay grouped. +//! +//! # ASCII vs Non-ASCII +//! +//! - [`PasteBurst::on_plain_char`] may return [`CharDecision::RetainFirstChar`] to hold the first +//! ASCII char and avoid flicker. +//! - [`PasteBurst::on_plain_char_no_hold`] never holds (used for IME/non-ASCII paths), since +//! holding a non-ASCII character can feel like dropped input. +//! +//! # Contract With Callers +//! +//! `PasteBurst` does not mutate the UI text buffer on its own. Callers must interpret decisions +//! and apply the corresponding UI edits. `ChatComposer` uses the full buffering contract: +//! +//! - For each plain ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char`]. +//! - [`CharDecision::RetainFirstChar`]: do **not** insert the char into the textarea yet. +//! - [`CharDecision::BeginBufferFromPending`]: call [`PasteBurst::append_char_to_buffer`] for the +//! current char (the previously-held char is already in the burst buffer). +//! - [`CharDecision::BeginBuffer { retro_chars }`]: consider retro-capturing the already-inserted +//! prefix by calling [`PasteBurst::decide_begin_buffer`]. If it returns `Some`, remove the +//! returned `start_byte..cursor` range from the textarea and then call +//! [`PasteBurst::append_char_to_buffer`] for the current char. If it returns `None`, fall back +//! to normal insertion. +//! - [`CharDecision::BufferAppend`]: call [`PasteBurst::append_char_to_buffer`]. +//! +//! - For each plain non-ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char_no_hold`] and then: +//! - If it returns `Some(CharDecision::BufferAppend)`, call +//! [`PasteBurst::append_char_to_buffer`]. +//! - If it returns `Some(CharDecision::BeginBuffer { retro_chars })`, call +//! [`PasteBurst::decide_begin_buffer`] as above (and if buffering starts, remove the grabbed +//! prefix from the textarea and then append the current char to the buffer). +//! - If it returns `None`, insert normally. +//! +//! - Before applying non-char input (or any input that should not join a burst), call +//! [`PasteBurst::flush_before_modified_input`] and pass the returned string (if any) through the +//! normal paste path. +//! +//! - Periodically (e.g. on a UI tick), call [`PasteBurst::flush_if_due`]. +//! - [`FlushResult::Typed`]: insert that single char as normal typing. +//! - [`FlushResult::Paste`]: treat the returned string as an explicit paste. +//! +//! - When non-text input is pressed (Ctrl/Alt shortcuts other than Windows AltGr; Super, Hyper, +//! and Meta modifiers; arrows; etc.), callers should use +//! [`PasteBurst::clear_window_after_non_char`] to prevent the next keystroke from being +//! incorrectly grouped into a previous burst. + +use std::time::Duration; +use std::time::Instant; + +// Heuristic thresholds for detecting paste-like input bursts. +// Detect quickly to avoid showing typed prefix before paste is recognized +const PASTE_BURST_MIN_CHARS: u16 = 3; +const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120); + +// Maximum delay between consecutive chars to be considered part of a paste burst. +const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8); + +// Idle timeout before flushing buffered paste content. +// Slower paste bursts have been observed in Windows environments. +#[cfg(not(windows))] +const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8); +#[cfg(windows)] +const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60); + +#[derive(Default)] +pub(crate) struct PasteBurst { + last_plain_char_time: Option, + consecutive_plain_char_burst: u16, + burst_window_until: Option, + buffer: String, + active: bool, + // Hold first fast char briefly to avoid rendering flicker + pending_first_char: Option<(char, Instant)>, +} + +pub(crate) enum CharDecision { + /// Start buffering and retroactively capture some already-inserted chars. + BeginBuffer { retro_chars: u16 }, + /// We are currently buffering; append the current char into the buffer. + BufferAppend, + /// Do not insert/render this char yet; temporarily save the first fast + /// char while we wait to see if a paste-like burst follows. + RetainFirstChar, + /// Begin buffering using the previously saved first char (no retro grab needed). + BeginBufferFromPending, +} + +pub(crate) struct RetroGrab { + pub start_byte: usize, + pub grabbed: String, +} + +pub(crate) enum FlushResult { + Paste(String), + Typed(char), + None, +} + +impl PasteBurst { + /// Recommended delay to wait between simulated keypresses (or before + /// scheduling a UI tick) so that a pending fast keystroke is flushed + /// out of the burst detector as normal typed input. + /// + /// Primarily used by tests and by the TUI to reliably cross the + /// paste-burst timing threshold. + pub fn recommended_flush_delay() -> Duration { + PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1) + } + + #[cfg(test)] + pub(crate) fn recommended_active_flush_delay() -> Duration { + PASTE_BURST_ACTIVE_IDLE_TIMEOUT + Duration::from_millis(1) + } + + /// Entry point: decide how to treat a plain char with current timing. + pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision { + self.note_plain_char(now); + + if self.active { + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return CharDecision::BufferAppend; + } + + // If we already held a first char and receive a second fast char, + // start buffering without retro-grabbing (we never rendered the first). + if let Some((held, held_at)) = self.pending_first_char + && now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL + { + self.active = true; + // take() to clear pending; we already captured the held char above + let _ = self.pending_first_char.take(); + self.buffer.push(held); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return CharDecision::BeginBufferFromPending; + } + + if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS { + return CharDecision::BeginBuffer { + retro_chars: self.consecutive_plain_char_burst.saturating_sub(1), + }; + } + + // Save the first fast char very briefly to see if a burst follows. + self.pending_first_char = Some((ch, now)); + CharDecision::RetainFirstChar + } + + /// Like on_plain_char(), but never holds the first char. + /// + /// Used for non-ASCII input paths (e.g., IMEs) where holding a character can + /// feel like dropped input, while still allowing burst-based paste detection. + /// + /// Note: This method will only ever return BufferAppend or BeginBuffer. + pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option { + self.note_plain_char(now); + + if self.active { + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return Some(CharDecision::BufferAppend); + } + + if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS { + return Some(CharDecision::BeginBuffer { + retro_chars: self.consecutive_plain_char_burst.saturating_sub(1), + }); + } + + None + } + + fn note_plain_char(&mut self, now: Instant) { + match self.last_plain_char_time { + Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => { + self.consecutive_plain_char_burst = + self.consecutive_plain_char_burst.saturating_add(1) + } + _ => self.consecutive_plain_char_burst = 1, + } + self.last_plain_char_time = Some(now); + } + + /// Flushes any buffered burst if the inter-key timeout has elapsed. + /// + /// Returns: + /// + /// - [`FlushResult::Paste`] when a paste burst was active and buffered text is emitted as one + /// pasted string. + /// - [`FlushResult::Typed`] when a single fast first ASCII char was being held (flicker + /// suppression) and no burst followed before the timeout elapsed. + /// - [`FlushResult::None`] when the timeout has not elapsed, or there is nothing to flush. + pub fn flush_if_due(&mut self, now: Instant) -> FlushResult { + let timeout = if self.is_active_internal() { + PASTE_BURST_ACTIVE_IDLE_TIMEOUT + } else { + PASTE_BURST_CHAR_INTERVAL + }; + let timed_out = self + .last_plain_char_time + .is_some_and(|t| now.duration_since(t) > timeout); + if timed_out && self.is_active_internal() { + self.active = false; + let out = std::mem::take(&mut self.buffer); + FlushResult::Paste(out) + } else if timed_out { + // If we were saving a single fast char and no burst followed, + // flush it as normal typed input. + if let Some((ch, _at)) = self.pending_first_char.take() { + FlushResult::Typed(ch) + } else { + FlushResult::None + } + } else { + FlushResult::None + } + } + + /// Accumulate a newline or tab into the burst instead of invoking a shortcut. + /// A held first character is promoted into the buffer before the control character. + /// + /// Returns true if the character was appended (we are in a burst context), + /// false otherwise. + pub fn append_control_char_if_active(&mut self, ch: char, now: Instant) -> bool { + if self.is_active() { + if let Some((held, _)) = self.pending_first_char.take() { + self.buffer.push(held); + } + self.active = true; + self.buffer.push(ch); + self.last_plain_char_time = Some(now); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + true + } else { + false + } + } + + /// Decide if Enter should insert a newline (burst context) vs submit. + pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool { + let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until); + self.is_active() || in_burst_window + } + + /// Decide if Enter should insert a newline for callers that insert chars immediately. + pub fn direct_insert_newline_should_insert(&self, now: Instant) -> bool { + self.newline_should_insert_instead_of_submit(now) + || self + .last_plain_char_time + .is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL) + } + + /// Keep the burst window alive. + pub fn extend_window(&mut self, now: Instant) { + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Begin buffering with retroactively grabbed text. + pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) { + if !grabbed.is_empty() { + self.buffer.push_str(&grabbed); + } + self.active = true; + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Append a char into the burst buffer. + pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) { + self.buffer.push(ch); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Try to append a char into the burst buffer only if a burst is already active. + /// + /// Returns true when the char was captured into the existing burst, false otherwise. + pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool { + if self.active || !self.buffer.is_empty() { + self.append_char_to_buffer(ch, now); + true + } else { + false + } + } + + /// Decide whether to begin buffering by retroactively capturing recent + /// chars from the slice before the cursor. + /// + /// Heuristic: if the retro-grabbed slice contains any whitespace or is + /// sufficiently long (>= 16 characters), treat it as paste-like to avoid + /// rendering the typed prefix momentarily before the paste is recognized. + /// This favors responsiveness and prevents flicker for typical pastes + /// (URLs, file paths, multiline text) while not triggering on short words. + /// + /// Returns Some(RetroGrab) with the start byte and grabbed text when we + /// decide to buffer retroactively; otherwise None. + pub fn decide_begin_buffer( + &mut self, + now: Instant, + before: &str, + retro_chars: usize, + ) -> Option { + let start_byte = retro_start_index(before, retro_chars); + let grabbed = before[start_byte..].to_string(); + let looks_pastey = + grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16; + if looks_pastey { + // Note: caller is responsible for removing this slice from UI text. + self.begin_with_retro_grabbed(grabbed.clone(), now); + Some(RetroGrab { + start_byte, + grabbed, + }) + } else { + None + } + } + + /// Before applying modified/non-char input: flush buffered burst immediately. + pub fn flush_before_modified_input(&mut self) -> Option { + if !self.is_active() { + return None; + } + self.active = false; + let mut out = std::mem::take(&mut self.buffer); + if let Some((ch, _at)) = self.pending_first_char.take() { + out.push(ch); + } + Some(out) + } + + /// Clear only the timing window and any pending first-char. + /// + /// Does not emit or clear the buffered text itself; callers should have + /// already flushed (if needed) via one of the flush methods above. + pub fn clear_window_after_non_char(&mut self) { + self.consecutive_plain_char_burst = 0; + self.last_plain_char_time = None; + self.burst_window_until = None; + self.active = false; + self.pending_first_char = None; + } + + /// Returns true if we are in any paste-burst related transient state + /// (actively buffering, have a non-empty buffer, or have saved the first + /// fast char while waiting for a potential burst). + pub fn is_active(&self) -> bool { + self.is_active_internal() || self.pending_first_char.is_some() + } + + fn is_active_internal(&self) -> bool { + self.active || !self.buffer.is_empty() + } + + pub fn clear_after_explicit_paste(&mut self) { + self.last_plain_char_time = None; + self.consecutive_plain_char_burst = 0; + self.burst_window_until = None; + self.active = false; + self.buffer.clear(); + self.pending_first_char = None; + } +} + +pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize { + if retro_chars == 0 { + return before.len(); + } + before + .char_indices() + .rev() + .nth(retro_chars.saturating_sub(1)) + .map(|(idx, _)| idx) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + /// Behavior: for ASCII input we "hold" the first fast char briefly. If no burst follows, + /// that held char should eventually flush as normal typed input (not as a paste). + #[test] + fn ascii_first_char_is_held_then_flushes_as_typed() { + let mut burst = PasteBurst::default(); + let t0 = Instant::now(); + assert!(matches!( + burst.on_plain_char('a', t0), + CharDecision::RetainFirstChar + )); + + let t1 = t0 + PasteBurst::recommended_flush_delay() + Duration::from_millis(1); + assert!(matches!(burst.flush_if_due(t1), FlushResult::Typed('a'))); + assert!(!burst.is_active()); + } + + /// Behavior: if two ASCII chars arrive quickly, we should start buffering without ever + /// rendering the first one, then flush the whole buffered payload as a paste. + #[test] + fn ascii_two_fast_chars_start_buffer_from_pending_and_flush_as_paste() { + let mut burst = PasteBurst::default(); + let t0 = Instant::now(); + assert!(matches!( + burst.on_plain_char('a', t0), + CharDecision::RetainFirstChar + )); + + let t1 = t0 + Duration::from_millis(1); + assert!(matches!( + burst.on_plain_char('b', t1), + CharDecision::BeginBufferFromPending + )); + burst.append_char_to_buffer('b', t1); + + let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1); + assert!(matches!( + burst.flush_if_due(t2), + FlushResult::Paste(ref s) if s == "ab" + )); + } + + /// Behavior: when non-char input is about to be applied, we flush any transient burst state + /// immediately (including a single pending ASCII char) so state doesn't leak across inputs. + #[test] + fn flush_before_modified_input_includes_pending_first_char() { + let mut burst = PasteBurst::default(); + let t0 = Instant::now(); + assert!(matches!( + burst.on_plain_char('a', t0), + CharDecision::RetainFirstChar + )); + + assert_eq!(burst.flush_before_modified_input(), Some("a".to_string())); + assert!(!burst.is_active()); + } + + /// Behavior: retro-grab buffering is only enabled when the already-inserted prefix looks + /// paste-like (whitespace or "long enough") so short IME bursts don't get misclassified. + #[test] + fn decide_begin_buffer_only_triggers_for_pastey_prefixes() { + let mut burst = PasteBurst::default(); + let now = Instant::now(); + + assert!( + burst + .decide_begin_buffer(now, "ab", /*retro_chars*/ 2) + .is_none() + ); + assert!(!burst.is_active()); + + let grab = burst + .decide_begin_buffer(now, "a b", /*retro_chars*/ 2) + .expect("whitespace should be considered paste-like"); + assert_eq!(grab.start_byte, 1); + assert_eq!(grab.grabbed, " b"); + assert!(burst.is_active()); + } + + /// Behavior: after a paste-like burst, we keep an "enter suppression window" alive briefly so + /// a slightly-late Enter still inserts a newline instead of submitting. + #[test] + fn newline_suppression_window_outlives_buffer_flush() { + let mut burst = PasteBurst::default(); + let t0 = Instant::now(); + assert!(matches!( + burst.on_plain_char('a', t0), + CharDecision::RetainFirstChar + )); + + let t1 = t0 + Duration::from_millis(1); + assert!(matches!( + burst.on_plain_char('b', t1), + CharDecision::BeginBufferFromPending + )); + burst.append_char_to_buffer('b', t1); + + let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1); + assert!(matches!(burst.flush_if_due(t2), FlushResult::Paste(ref s) if s == "ab")); + assert!(!burst.is_active()); + + assert!(burst.newline_should_insert_instead_of_submit(t2)); + let t3 = t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1); + assert!(!burst.newline_should_insert_instead_of_submit(t3)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs new file mode 100644 index 0000000000000000000000000000000000000000..34ea0990c2add4b1c02ed89ad4586a7f6e56efc4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -0,0 +1,425 @@ +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +use crate::key_hint; +use crate::render::renderable::Renderable; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; + +/// Widget that displays pending steers plus follow-up inputs held while a turn is in progress. +/// +/// The widget renders pending steers first, then rejected steers that will be +/// resubmitted at end of turn, then ordinary queued user messages. Pending +/// steers explain that they will be submitted after the next tool/result +/// boundary unless the user invokes the interrupt binding to send them +/// immediately. The edit hint at the bottom only appears when there are actual +/// queued user inputs to pop back into the composer. Because some terminals +/// intercept certain modifier-key combinations, the displayed binding is +/// configurable via [`set_edit_binding`](Self::set_edit_binding). +pub(crate) struct PendingInputPreview { + pub pending_steers: Vec, + pub rejected_steers: Vec, + pub queued_messages: Vec, + /// Key combination rendered in the hint line. Defaults to Alt+Up but may + /// be overridden for terminals where that chord is unavailable. + pub(super) edit_binding: Option, + /// Key combination rendered for immediately interrupting and sending steers. + interrupt_binding: Option, +} + +enum QuestionPresence { + Absent, + Present, +} + +const PREVIEW_LINE_LIMIT: usize = 3; + +impl PendingInputPreview { + pub(crate) fn new() -> Self { + Self { + pending_steers: Vec::new(), + rejected_steers: Vec::new(), + queued_messages: Vec::new(), + edit_binding: Some(key_hint::alt(KeyCode::Up).into()), + interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()), + } + } + + /// Replace the keybinding shown in the hint line at the bottom of the + /// queued-messages list. The caller is responsible for also wiring the + /// corresponding key event handler. + pub(crate) fn set_edit_binding(&mut self, binding: Option) { + self.edit_binding = binding; + } + + pub(crate) fn set_interrupt_binding(&mut self, binding: Option) { + self.interrupt_binding = binding; + } + + fn push_truncated_preview_lines( + lines: &mut Vec>, + wrapped: Vec>, + overflow_line: Line<'static>, + ) { + let wrapped_len = wrapped.len(); + lines.extend(wrapped.into_iter().take(PREVIEW_LINE_LIMIT)); + if wrapped_len > PREVIEW_LINE_LIMIT { + lines.push(overflow_line); + } + } + + fn push_section_header(lines: &mut Vec>, width: u16, header: Line<'static>) { + let mut spans = vec!["• ".dim()]; + spans.extend(header.spans); + lines.extend(adaptive_wrap_lines( + std::iter::once(Line::from(spans)), + RtOptions::new(width as usize).subsequent_indent(Line::from(" ".dim())), + )); + } + + fn as_renderable(&self, width: u16, questions: QuestionPresence) -> Box { + let has_questions = matches!(questions, QuestionPresence::Present); + if (self.pending_steers.is_empty() + && self.rejected_steers.is_empty() + && self.queued_messages.is_empty() + && !has_questions) + || width < 4 + { + return Box::new(()); + } + + let mut lines = vec![]; + + if !self.pending_steers.is_empty() { + let mut header = vec!["Messages to be submitted after next tool call".into()]; + if let Some(interrupt_binding) = self.interrupt_binding { + header.extend(vec![ + " (press ".dim(), + interrupt_binding.into(), + " to interrupt and send immediately)".dim(), + ]); + } + Self::push_section_header(&mut lines, width, Line::from(header)); + + for steer in &self.pending_steers { + let wrapped = adaptive_wrap_lines( + steer + .lines() + .take(PREVIEW_LINE_LIMIT + 1) + .map(|line| Line::from(line.dim())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + } + } + + if !self.rejected_steers.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + Self::push_section_header( + &mut lines, + width, + "Messages to be submitted at end of turn".into(), + ); + + for steer in &self.rejected_steers { + let wrapped = adaptive_wrap_lines( + steer + .lines() + .take(PREVIEW_LINE_LIMIT + 1) + .map(|line| Line::from(line.dim())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + } + } + + if !self.queued_messages.is_empty() || has_questions { + if !lines.is_empty() { + lines.push(Line::from("")); + } + Self::push_section_header(&mut lines, width, "Queued follow-up inputs".into()); + + for message in &self.queued_messages { + let wrapped = adaptive_wrap_lines( + message + .lines() + .take(PREVIEW_LINE_LIMIT + 1) + .map(|line| Line::from(line.dim().italic())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines( + &mut lines, + wrapped, + Line::from(" …".dim().italic()), + ); + } + } + + if !self.queued_messages.is_empty() + && !has_questions + && let Some(edit_binding) = self.edit_binding + { + lines.push( + Line::from(vec![ + " ".into(), + edit_binding.into(), + " edit last queued message".into(), + ]) + .dim(), + ); + } + + Paragraph::new(lines).into() + } +} + +impl Renderable for PendingInputPreview { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + self.as_renderable(area.width, QuestionPresence::Absent) + .render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable(width, QuestionPresence::Absent) + .desired_height(width) + } +} + +/// Pending questions keep the follow-up group visible and provide its navigation hint. +pub(super) struct PendingInputPreviewContent<'a>(pub(super) &'a PendingInputPreview); + +impl Renderable for PendingInputPreviewContent<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.0 + .as_renderable(area.width, QuestionPresence::Present) + .render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.0 + .as_renderable(width, QuestionPresence::Present) + .desired_height(width) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + + #[test] + fn desired_height_empty() { + let queue = PendingInputPreview::new(); + assert_eq!(queue.desired_height(/*width*/ 40), 0); + } + + #[test] + fn desired_height_one_message() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + assert_eq!(queue.desired_height(/*width*/ 40), 3); + } + + #[test] + fn render_one_message() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_one_message", format!("{buf:?}")); + } + + #[test] + fn render_one_message_with_shift_left_binding() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue.set_edit_binding(Some(key_hint::shift(KeyCode::Left).into())); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_message_with_shift_left_binding", + format!("{buf:?}") + ); + } + + #[test] + fn render_two_messages() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue + .queued_messages + .push("This is another message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_two_messages", format!("{buf:?}")); + } + + #[test] + fn render_more_than_three_messages() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue + .queued_messages + .push("This is another message".to_string()); + queue + .queued_messages + .push("This is a third message".to_string()); + queue + .queued_messages + .push("This is a fourth message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_more_than_three_messages", format!("{buf:?}")); + } + + #[test] + fn render_wrapped_message() { + let mut queue = PendingInputPreview::new(); + queue + .queued_messages + .push("This is a longer message that should be wrapped".to_string()); + queue + .queued_messages + .push("This is another message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_wrapped_message", format!("{buf:?}")); + } + + #[test] + fn render_many_line_message() { + let mut queue = PendingInputPreview::new(); + queue + .queued_messages + .push("This is\na message\nwith many\n\nlines".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_many_line_message", format!("{buf:?}")); + } + + #[test] + fn long_url_like_message_does_not_expand_into_wrapped_ellipsis_rows() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push( + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/session_id=abc123def456ghi789" + .to_string(), + ); + + let width = 36; + let height = queue.desired_height(width); + assert_eq!( + height, 3, + "expected header, one message row, and hint row for URL-like token" + ); + + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + + let rendered_rows = (0..height) + .map(|y| { + (0..width) + .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' ')) + .collect::() + }) + .collect::>(); + + assert!( + !rendered_rows.iter().any(|row| row.contains('…')), + "expected no wrapped-ellipsis row for URL-like token, got rows: {rendered_rows:?}" + ); + } + + #[test] + fn render_one_pending_steer() { + let mut queue = PendingInputPreview::new(); + queue.pending_steers.push("Please continue.".to_string()); + let width = 48; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_one_pending_steer", format!("{buf:?}")); + } + + #[test] + fn render_one_pending_steer_with_remapped_interrupt_binding() { + let mut queue = PendingInputPreview::new(); + queue.pending_steers.push("Please continue.".to_string()); + queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)).into())); + let width = 48; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_pending_steer_with_remapped_interrupt_binding", + format!("{buf:?}") + ); + } + + #[test] + fn render_pending_steers_above_queued_messages() { + let mut queue = PendingInputPreview::new(); + queue.pending_steers.push("Please continue.".to_string()); + queue + .pending_steers + .push("Check the last command output.".to_string()); + queue + .rejected_steers + .push("Rejected steer that will be retried.".to_string()); + queue + .queued_messages + .push("Queued follow-up question".to_string()); + let width = 52; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_pending_steers_above_queued_messages", + format!("{buf:?}") + ); + } + + #[test] + fn render_multiline_pending_steer_uses_single_prefix_and_truncates() { + let mut queue = PendingInputPreview::new(); + queue + .pending_steers + .push("First line\nSecond line\nThird line\n\nFourth line".to_string()); + let width = 48; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_multiline_pending_steer_uses_single_prefix_and_truncates", + format!("{buf:?}") + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/pending_thread_approvals.rs b/codex-rs/tui/src/bottom_pane/pending_thread_approvals.rs new file mode 100644 index 0000000000000000000000000000000000000000..2ff9e6cdbb5ad193e6db290f07d7e1d049c7a5b1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/pending_thread_approvals.rs @@ -0,0 +1,149 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +use crate::render::renderable::Renderable; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; + +/// Widget that lists inactive threads with outstanding approval requests. +pub(crate) struct PendingThreadApprovals { + threads: Vec, +} + +impl PendingThreadApprovals { + pub(crate) fn new() -> Self { + Self { + threads: Vec::new(), + } + } + + pub(crate) fn set_threads(&mut self, threads: Vec) -> bool { + if self.threads == threads { + return false; + } + self.threads = threads; + true + } + + pub(crate) fn is_empty(&self) -> bool { + self.threads.is_empty() + } + + #[cfg(test)] + pub(crate) fn threads(&self) -> &[String] { + &self.threads + } + + fn as_renderable(&self, width: u16) -> Box { + if self.threads.is_empty() || width < 4 { + return Box::new(()); + } + + let mut lines = Vec::new(); + for thread in self.threads.iter().take(3) { + let wrapped = adaptive_wrap_lines( + std::iter::once(Line::from(format!("Approval needed in {thread}"))), + RtOptions::new(width as usize) + .initial_indent(Line::from(vec![" ".into(), "!".red().bold(), " ".into()])) + .subsequent_indent(Line::from(" ")), + ); + lines.extend(wrapped); + } + + if self.threads.len() > 3 { + lines.push(Line::from(" ...".dim().italic())); + } + + lines.push( + Line::from(vec![ + " ".into(), + "/subagents".cyan().bold(), + " to switch threads".dim(), + ]) + .dim(), + ); + + Paragraph::new(lines).into() + } +} + +impl Renderable for PendingThreadApprovals { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + self.as_renderable(area.width).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable(width).desired_height(width) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + + fn snapshot_rows(widget: &PendingThreadApprovals, width: u16) -> String { + let height = widget.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + widget.render(Rect::new(0, 0, width, height), &mut buf); + + (0..height) + .map(|y| { + (0..width) + .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' ')) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn desired_height_empty() { + let widget = PendingThreadApprovals::new(); + assert_eq!(widget.desired_height(/*width*/ 40), 0); + } + + #[test] + fn render_single_thread_snapshot() { + let mut widget = PendingThreadApprovals::new(); + widget.set_threads(vec!["Robie [explorer]".to_string()]); + + assert_snapshot!( + snapshot_rows(&widget, /*width*/ 40).replace(' ', "."), + @r" + ..!.Approval.needed.in.Robie.[explorer]. + ..../subagents.to.switch.threads........ + " + ); + } + + #[test] + fn render_multiple_threads_snapshot() { + let mut widget = PendingThreadApprovals::new(); + widget.set_threads(vec![ + "Main [default]".to_string(), + "Robie [explorer]".to_string(), + "Inspector".to_string(), + "Extra agent".to_string(), + ]); + + assert_snapshot!( + snapshot_rows(&widget, /*width*/ 44).replace(' ', "."), + @r" + ..!.Approval.needed.in.Main.[default]....... + ..!.Approval.needed.in.Robie.[explorer]..... + ..!.Approval.needed.in.Inspector............ + ............................................ + ..../subagents.to.switch.threads............ + " + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs new file mode 100644 index 0000000000000000000000000000000000000000..e2c7c8a56ba50c07fc7b43037eb6561e69a626f8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/popup_consts.rs @@ -0,0 +1,61 @@ +//! Shared popup-related constants for bottom pane widgets. + +use ratatui::text::Line; + +use crate::key_hint; +use crate::key_hint::ShortcutHint; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crossterm::event::KeyCode; + +/// Maximum number of rows any popup should attempt to display. +/// Keep this consistent across all popups for a uniform feel. +pub(crate) const MAX_POPUP_ROWS: usize = 8; + +/// Standard footer hint text used by popups. +pub(crate) fn standard_popup_hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to confirm or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to go back".into(), + ]) +} + +pub(crate) fn standard_popup_hint_line_for_keymap(list_keymap: &ListKeymap) -> Line<'static> { + accept_cancel_hint_line( + list_keymap.primary_hint(ListAction::Accept), + "to confirm", + list_keymap.primary_hint(ListAction::Cancel), + "to go back", + ) +} + +pub(crate) fn accept_cancel_hint_line( + accept: Option, + accept_label: &'static str, + cancel: Option, + cancel_label: &'static str, +) -> Line<'static> { + match (accept, cancel) { + (Some(accept), Some(cancel)) => Line::from(vec![ + "Press ".into(), + accept.into(), + format!(" {accept_label} or ").into(), + cancel.into(), + format!(" {cancel_label}").into(), + ]), + (Some(accept), None) => Line::from(vec![ + "Press ".into(), + accept.into(), + format!(" {accept_label}").into(), + ]), + (None, Some(cancel)) => Line::from(vec![ + "Press ".into(), + cancel.into(), + format!(" {cancel_label}").into(), + ]), + (None, None) => Line::from(""), + } +} diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs new file mode 100644 index 0000000000000000000000000000000000000000..7c816d5ee7ea0cb110e7f87329d407b006426181 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -0,0 +1,25 @@ +/// Parse a first-line slash command of the form `/name `. +/// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/` +/// and contains a non-empty name; otherwise returns `None`. +/// +/// `rest_offset` is the byte index into the original line where `rest_after_name` +/// starts after trimming leading whitespace (so `line[rest_offset..] == rest_after_name`). +pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> { + let stripped = line.strip_prefix('/')?; + let mut name_end_in_stripped = stripped.len(); + for (idx, ch) in stripped.char_indices() { + if ch.is_whitespace() { + name_end_in_stripped = idx; + break; + } + } + let name = &stripped[..name_end_in_stripped]; + if name.is_empty() { + return None; + } + let rest_untrimmed = &stripped[name_end_in_stripped..]; + let rest = rest_untrimmed.trim_start(); + let rest_start_in_stripped = name_end_in_stripped + (rest_untrimmed.len() - rest.len()); + let rest_offset = rest_start_in_stripped + 1; + Some((name, rest, rest_offset)) +} diff --git a/codex-rs/tui/src/bottom_pane/questions.rs b/codex-rs/tui/src/bottom_pane/questions.rs new file mode 100644 index 0000000000000000000000000000000000000000..7645406e6a2aa8c24c51c50e2ae18cffe5031a81 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/questions.rs @@ -0,0 +1,69 @@ +//! Own question-editor creation, restoration, and the collapsed entry point. + +use super::*; + +impl BottomPane { + pub(crate) fn push_async_questions( + &mut self, + message_id: &str, + questions: &[codex_protocol::items::AsyncUserInputQuestion], + ) { + self.question_editor().append(message_id, questions); + self.schedule_active_view_frame(); + self.request_redraw(); + } + + pub(crate) fn clear_pending_questions(&mut self) { + if let Some(questions) = &mut self.questions { + questions.clear_pending(); + self.request_redraw(); + } + } + + fn question_editor(&mut self) -> &mut AsyncQuestions { + self.questions.get_or_insert_with(|| { + let mut questions = AsyncQuestions::new( + self.app_event_tx.clone(), + self.has_input_focus, + self.enhanced_keys_supported, + self.disable_paste_burst, + self.keymap.clone(), + ); + questions.set_vim_enabled(self.composer.is_vim_enabled()); + questions.next_hint = self.pending_input_preview.edit_binding; + Box::new(questions) + }) + } + + pub(crate) fn restore_questions(&mut self, state: Option) { + if let Some(state) = state { + self.question_editor().restore(state); + } + self.request_redraw(); + } + + pub(super) fn question_summary(&self, now: Instant) -> Option>> { + let questions = self + .questions + .as_ref() + .filter(|q| !q.expanded && q.unanswered_count() > 0)?; + let count = questions.unanswered_count(); + let countdown = questions + .countdown(now) + .map(|text| format!(" · {text}")) + .unwrap_or_default(); + let mut lines = vec![Line::from(vec![ + " ? ".dim(), + Span::styled( + format!("{count} question{}", if count == 1 { "" } else { "s" }), + crate::style::accent_style(), + ) + .bold(), + countdown.dim(), + ])]; + if let Some(binding) = self.pending_input_preview.edit_binding { + lines.push(Line::from(vec![" ".into(), binding.into(), " to answer".into()]).dim()); + } + Some(lines) + } +} diff --git a/codex-rs/tui/src/bottom_pane/questions_tests.rs b/codex-rs/tui/src/bottom_pane/questions_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..fb3406cc51c0c459778deb77b946e2830d52c94a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/questions_tests.rs @@ -0,0 +1,52 @@ +//! Question orchestration retains input buffers and renders pending-question summaries. +use super::*; +use pretty_assertions::assert_eq; +use tokio::sync::mpsc::unbounded_channel; + +#[test] +fn questions_flush_buffered_typing_and_record_activity() { + let (tx, _rx) = unbounded_channel(); + let mut pane = super::tests::test_pane(AppEventSender::new(tx)); + let questions = [codex_protocol::items::AsyncUserInputQuestion { + title: "Which way?".into(), + options: None, + }]; + pane.push_async_questions("message", &questions); + pane.questions + .as_mut() + .unwrap() + .set_expanded(/*expanded*/ true); + pane.handle_paste("pasted ".into()); + assert!(pane.last_composer_activity_at.is_some()); + pane.last_composer_activity_at = None; + for ch in "answer".chars() { + pane.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + assert!(pane.last_composer_activity_at.is_some()); + std::thread::sleep(paste_burst::PasteBurst::recommended_active_flush_delay()); + assert!(pane.flush_paste_burst_if_due()); + assert_eq!( + pane.questions.as_ref().unwrap().composer.current_text(), + "pasted answer" + ); + pane.questions + .as_mut() + .unwrap() + .set_expanded(/*expanded*/ false); + pane.push_async_questions("next", &questions); + let now = Instant::now() + Duration::from_secs(15); + insta::assert_snapshot!( + "question_collapsed_countdown", + pane.question_summary(now) + .unwrap() + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + ); + let editor = pane.questions.as_mut().unwrap(); + assert!(editor.countdown(now + Duration::from_secs(30)).is_none()); + editor.set_expanded(/*expanded*/ true); + editor.append("while-open", &questions); + assert!(editor.countdown(now).is_none()); +} diff --git a/codex-rs/tui/src/bottom_pane/scroll_state.rs b/codex-rs/tui/src/bottom_pane/scroll_state.rs new file mode 100644 index 0000000000000000000000000000000000000000..ce208c79526125d18d031c605b9c7df6b0a22839 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/scroll_state.rs @@ -0,0 +1,204 @@ +/// Generic scroll/selection state for a vertical list menu. +/// +/// Encapsulates the common behavior of a selectable list that supports: +/// - Optional selection (None when list is empty) +/// - Wrap-around navigation on Up/Down +/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible +/// +/// Callers own the filtered row count and the visible window size. Every +/// mutation method takes those values instead of caching them here, so list +/// views can apply filters, pagination, or density changes without this helper +/// knowing about their data model. Passing a stale length after filtering would +/// leave selection pointing at the wrong row, so callers should clamp or move +/// through this type immediately after changing their visible row set. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub(crate) struct ScrollState { + pub selected_idx: Option, + pub scroll_top: usize, +} + +impl ScrollState { + pub fn new() -> Self { + Self { + selected_idx: None, + scroll_top: 0, + } + } + + /// Reset selection and scroll. + pub fn reset(&mut self) { + self.selected_idx = None; + self.scroll_top = 0; + } + + /// Clamp selection to be within the [0, len-1] range, or None when empty. + pub fn clamp_selection(&mut self, len: usize) { + if self.clear_if_empty(len) { + return; + } + self.selected_idx = Some(self.selected_idx.unwrap_or(0).min(len - 1)); + } + + /// Move selection up by one, wrapping to the bottom when necessary. + pub fn move_up_wrap(&mut self, len: usize) { + if self.clear_if_empty(len) { + return; + } + self.selected_idx = Some(match self.selected_idx { + Some(idx) if idx > 0 => idx - 1, + Some(_) => len - 1, + None => 0, + }); + } + + /// Move selection down by one, wrapping to the top when necessary. + pub fn move_down_wrap(&mut self, len: usize) { + if self.clear_if_empty(len) { + return; + } + self.selected_idx = Some(match self.selected_idx { + Some(idx) if idx + 1 < len => idx + 1, + _ => 0, + }); + } + + /// Move selection up by one visible page, clamping at the first row. + /// + /// Page movement intentionally does not wrap. It mirrors terminal list + /// behavior where repeated page-up/page-down converges at the nearest edge + /// while still keeping the selected row visible. + pub fn page_up_clamped(&mut self, len: usize, visible_rows: usize) { + if self.clear_if_empty(len) { + return; + } + let step = visible_rows.max(1); + let current = self.selected_idx.unwrap_or(0).min(len - 1); + self.selected_idx = Some(current.saturating_sub(step)); + self.ensure_visible(len, visible_rows); + } + + /// Move selection down by one visible page, clamping at the last row. + /// + /// Page movement intentionally does not wrap. It mirrors terminal list + /// behavior where repeated page-up/page-down converges at the nearest edge + /// while still keeping the selected row visible. + pub fn page_down_clamped(&mut self, len: usize, visible_rows: usize) { + if self.clear_if_empty(len) { + return; + } + let step = visible_rows.max(1); + let current = self.selected_idx.unwrap_or(0).min(len - 1); + self.selected_idx = Some(current.saturating_add(step).min(len - 1)); + self.ensure_visible(len, visible_rows); + } + + /// Jump selection to the first row. + pub fn jump_top(&mut self, len: usize, visible_rows: usize) { + if self.clear_if_empty(len) { + return; + } + self.selected_idx = Some(0); + self.ensure_visible(len, visible_rows); + } + + /// Jump selection to the last row. + pub fn jump_bottom(&mut self, len: usize, visible_rows: usize) { + if self.clear_if_empty(len) { + return; + } + self.selected_idx = Some(len - 1); + self.ensure_visible(len, visible_rows); + } + + fn clear_if_empty(&mut self, len: usize) -> bool { + if len != 0 { + return false; + } + self.selected_idx = None; + self.scroll_top = 0; + true + } + + /// Adjust `scroll_top` so that the current `selected_idx` is visible within + /// the window of `visible_rows`. + pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) { + if len == 0 || visible_rows == 0 { + self.scroll_top = 0; + return; + } + if let Some(sel) = self.selected_idx { + if sel < self.scroll_top { + self.scroll_top = sel; + } else { + let bottom = self.scroll_top + visible_rows - 1; + if sel > bottom { + self.scroll_top = sel + 1 - visible_rows; + } + } + } else { + self.scroll_top = 0; + } + } +} + +#[cfg(test)] +mod tests { + use super::ScrollState; + + #[test] + fn wrap_navigation_and_visibility() { + let mut s = ScrollState::new(); + let len = 10; + let vis = 5; + + s.clamp_selection(len); + assert_eq!(s.selected_idx, Some(0)); + s.ensure_visible(len, vis); + assert_eq!(s.scroll_top, 0); + + s.move_up_wrap(len); + s.ensure_visible(len, vis); + assert_eq!(s.selected_idx, Some(len - 1)); + match s.selected_idx { + Some(sel) => assert!(s.scroll_top <= sel), + None => panic!("expected Some(selected_idx) after wrap"), + } + + s.move_down_wrap(len); + s.ensure_visible(len, vis); + assert_eq!(s.selected_idx, Some(0)); + assert_eq!(s.scroll_top, 0); + } + + #[test] + fn page_and_jump_navigation_clamps() { + let mut s = ScrollState::new(); + let len = 10; + let vis = 4; + + s.clamp_selection(len); + s.page_down_clamped(len, vis); + assert_eq!(s.selected_idx, Some(4)); + assert_eq!(s.scroll_top, 1); + + s.page_down_clamped(len, vis); + assert_eq!(s.selected_idx, Some(8)); + assert_eq!(s.scroll_top, 5); + + s.page_down_clamped(len, vis); + assert_eq!(s.selected_idx, Some(9)); + assert_eq!(s.scroll_top, 6); + + s.page_up_clamped(len, vis); + assert_eq!(s.selected_idx, Some(5)); + assert_eq!(s.scroll_top, 5); + + s.jump_top(len, vis); + assert_eq!(s.selected_idx, Some(0)); + assert_eq!(s.scroll_top, 0); + + s.jump_bottom(len, vis); + assert_eq!(s.selected_idx, Some(9)); + assert_eq!(s.scroll_top, 6); + } +} diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs new file mode 100644 index 0000000000000000000000000000000000000000..aceecb92edbcd80e78fbd3903813a7a2f2f9521f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -0,0 +1,969 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +// Note: Table-based layout previously used Constraint; the manual renderer +// below no longer requires it. +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Widget; + +use crate::key_hint::ShortcutHint; +use crate::line_truncation::line_width; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::style::accent_style; +use crate::style::user_message_style; +use crate::width::display_width; + +use super::scroll_state::ScrollState; +use super::selection_row_layout::SelectionDescriptionLayout; +use super::selection_row_layout::build_full_line; +use super::selection_row_layout::line_to_owned; +use super::selection_row_layout::wrap_stacked_row; + +/// Render-ready representation of one row in a selection popup. +/// +/// This type contains presentation-focused fields that are intentionally more +/// concrete than source domain models. `match_indices` are character offsets +/// into `name`, and `wrap_indent` is interpreted in terminal cell columns. +#[derive(Default)] +pub(crate) struct GenericDisplayRow { + pub name: String, + pub name_prefix_spans: Vec>, + pub display_shortcut: Option, + pub match_indices: Option>, // indices to bold (char positions) + pub description: Option, // optional grey text after the name + pub category_tag: Option, // optional right-side category label + pub disabled_reason: Option, // optional disabled message + pub is_disabled: bool, + pub wrap_indent: Option, // optional indent for wrapped lines +} + +/// Controls how selection rows choose the split between left/right name/description columns. +/// +/// Callers should use the same mode for both measurement and rendering, or the +/// popup can reserve the wrong number of lines and clip content. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) enum ColumnWidthMode { + /// Derive column placement from only the visible viewport rows. + #[default] + AutoVisible, + /// Derive column placement from all rows so scrolling does not shift columns. + AutoAllRows, + /// Use a fixed two-column split: 30% left (name), 70% right (description). + Fixed, +} + +/// Column-width behavior plus an optional shared left-column width override. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ColumnWidthConfig { + pub mode: ColumnWidthMode, + pub name_column_width: Option, + pub description_layout: SelectionDescriptionLayout, +} + +impl ColumnWidthConfig { + pub(crate) const fn new(mode: ColumnWidthMode, name_column_width: Option) -> Self { + Self { + mode, + name_column_width, + description_layout: SelectionDescriptionLayout::Columns, + } + } + + pub(crate) const fn with_description_layout( + mut self, + description_layout: SelectionDescriptionLayout, + ) -> Self { + self.description_layout = description_layout; + self + } +} + +// Fixed split used by explicitly fixed column mode: 30% label, 70% +// description. +const FIXED_LEFT_COLUMN_NUMERATOR: usize = 3; +const FIXED_LEFT_COLUMN_DENOMINATOR: usize = 10; + +const MENU_SURFACE_INSET_V: u16 = 1; +const MENU_SURFACE_INSET_H: u16 = 2; + +/// Apply the shared "menu surface" padding used by bottom-pane overlays. +/// +/// Rendering code should generally call [`render_menu_surface`] and then lay +/// out content inside the returned inset rect. +pub(crate) fn menu_surface_inset(area: Rect) -> Rect { + area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H)) +} + +/// Total vertical padding introduced by the menu surface treatment. +pub(crate) const fn menu_surface_padding_height() -> u16 { + MENU_SURFACE_INSET_V * 2 +} + +/// Paint the shared menu background and return the inset content area. +/// +/// This keeps the surface treatment consistent across selection-style overlays +/// (for example `/model`, approvals, and request-user-input). Callers should +/// render all inner content in the returned rect, not the original area. +pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { + if area.is_empty() { + return area; + } + Block::default() + .style(user_message_style()) + .render(area, buf); + menu_surface_inset(area) +} + +/// Wrap a styled line while preserving span styles. +/// +/// The function clamps `width` to at least one terminal cell so callers can use +/// it safely with narrow layouts. +pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { + use crate::wrapping::RtOptions; + use crate::wrapping::word_wrap_line; + + let width = width.max(1) as usize; + let opts = RtOptions::new(width) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from("")); + word_wrap_line(line, opts) +} + +fn compute_desc_col( + rows_all: &[GenericDisplayRow], + start_idx: usize, + visible_items: usize, + content_width: u16, + column_width: ColumnWidthConfig, +) -> usize { + if content_width <= 1 { + return 0; + } + + let max_desc_col = content_width.saturating_sub(1) as usize; + // Reuse the existing fixed split constants to derive the auto cap: + // if fixed mode is 30/70 (label/description), auto mode caps label width + // at 70% to keep at least 30% available for descriptions. + let max_auto_desc_col = max_desc_col.min( + ((content_width as usize * (FIXED_LEFT_COLUMN_DENOMINATOR - FIXED_LEFT_COLUMN_NUMERATOR)) + / FIXED_LEFT_COLUMN_DENOMINATOR) + .max(1), + ); + match column_width.mode { + ColumnWidthMode::Fixed => ((content_width as usize * FIXED_LEFT_COLUMN_NUMERATOR) + / FIXED_LEFT_COLUMN_DENOMINATOR) + .clamp(1, max_desc_col), + ColumnWidthMode::AutoVisible | ColumnWidthMode::AutoAllRows => { + let max_name_width = match column_width.mode { + ColumnWidthMode::AutoVisible => rows_all + .iter() + .enumerate() + .skip(start_idx) + .take(visible_items) + .map(|(_, row)| { + let mut spans = row.name_prefix_spans.clone(); + spans.push(row.name.clone().into()); + if row.disabled_reason.is_some() { + spans.push(" (disabled)".dim()); + } + line_width(&Line::from(spans)) + }) + .max() + .unwrap_or(0), + ColumnWidthMode::AutoAllRows => rows_all + .iter() + .map(|row| { + let mut spans = row.name_prefix_spans.clone(); + spans.push(row.name.clone().into()); + if row.disabled_reason.is_some() { + spans.push(" (disabled)".dim()); + } + line_width(&Line::from(spans)) + }) + .max() + .unwrap_or(0), + ColumnWidthMode::Fixed => 0, + }; + + column_width + .name_column_width + .map(|width| width.max(max_name_width)) + .unwrap_or(max_name_width) + .saturating_add(2) + .min(max_auto_desc_col) + } + } +} + +/// Determine how many spaces to indent wrapped lines for a row. +fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize { + let max_indent = max_width.saturating_sub(1) as usize; + let indent = row.wrap_indent.unwrap_or_else(|| { + if row.description.is_some() || row.disabled_reason.is_some() { + desc_col + } else { + 0 + } + }); + indent.min(max_indent) +} + +fn should_wrap_name_in_column(row: &GenericDisplayRow) -> bool { + // This path intentionally targets plain option rows that opt into wrapped + // labels. Styled/fuzzy-matched rows keep the legacy combined-line path. + row.wrap_indent.is_some() + && row.description.is_some() + && row.disabled_reason.is_none() + && row.match_indices.is_none() + && row.display_shortcut.is_none() + && row.category_tag.is_none() + && row.name_prefix_spans.is_empty() +} + +fn wrap_two_column_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec> { + use crate::wrapping::RtOptions; + use crate::wrapping::word_wrap_lines; + + let Some(description) = row.description.as_deref() else { + return Vec::new(); + }; + + let width = width.max(1); + let max_desc_col = width.saturating_sub(1) as usize; + if max_desc_col == 0 { + // No valid description column exists at this width; let callers fall + // back to single-line wrapping path. + return Vec::new(); + } + + let desc_col = desc_col.clamp(1, max_desc_col); + let left_width = desc_col.saturating_sub(2).max(1); + let right_width = width.saturating_sub(desc_col as u16).max(1) as usize; + let name_wrap_indent = row + .wrap_indent + .unwrap_or(0) + .min(left_width.saturating_sub(1)); + + let name_options = RtOptions::new(left_width) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(" ".repeat(name_wrap_indent))); + let name_lines = word_wrap_lines(row.name.lines(), name_options); + + let desc_options = RtOptions::new(right_width).initial_indent(Line::from("")); + let desc_lines = word_wrap_lines(description.lines(), desc_options); + + let rows = name_lines.len().max(desc_lines.len()).max(1); + let mut out = Vec::with_capacity(rows); + for idx in 0..rows { + let mut spans: Vec> = Vec::new(); + if let Some(name) = name_lines.get(idx) { + spans.push(name.to_string().into()); + } + + if let Some(desc) = desc_lines.get(idx) { + let left_used = spans + .iter() + .map(|span| display_width(span.content.as_ref())) + .sum::(); + let gap = if left_used == 0 { + desc_col + } else { + desc_col.saturating_sub(left_used).max(2) + }; + if gap > 0 { + spans.push(" ".repeat(gap).into()); + } + spans.push(desc.to_string().dim()); + } + + out.push(Line::from(spans)); + } + + out +} + +fn wrap_standard_row( + row: &GenericDisplayRow, + desc_col: usize, + width: u16, + description_layout: SelectionDescriptionLayout, +) -> Vec> { + use crate::wrapping::RtOptions; + use crate::wrapping::word_wrap_line; + + let full_line = build_full_line(row, desc_col, description_layout); + let continuation_indent = wrap_indent(row, desc_col, width); + let options = RtOptions::new(width.max(1) as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(" ".repeat(continuation_indent))); + word_wrap_line(&full_line, options) + .into_iter() + .map(line_to_owned) + .collect() +} + +fn wrap_row_lines( + row: &GenericDisplayRow, + desc_col: usize, + width: u16, + description_layout: SelectionDescriptionLayout, +) -> Vec> { + if description_layout.should_stack(width, desc_col) { + return wrap_stacked_row(row, width); + } + if should_wrap_name_in_column(row) { + let wrapped = wrap_two_column_row(row, desc_col, width); + if !wrapped.is_empty() { + return wrapped; + } + } + + wrap_standard_row(row, desc_col, width, description_layout) +} + +fn apply_row_state_style(lines: &mut [Line<'static>], selected: bool, is_disabled: bool) { + if selected { + for line in lines.iter_mut() { + line.spans.iter_mut().for_each(|span| { + span.style = accent_style(); + }); + } + } + if is_disabled { + for line in lines.iter_mut() { + line.spans.iter_mut().for_each(|span| { + span.style = span.style.dim(); + }); + } + } +} + +fn compute_item_window_start( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_items: usize, +) -> usize { + if rows_all.is_empty() || max_items == 0 { + return 0; + } + + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else { + let bottom = start_idx.saturating_add(max_items.saturating_sub(1)); + if sel > bottom { + start_idx = sel + 1 - max_items; + } + } + } + start_idx +} + +#[derive(Clone, Copy)] +struct WrappedViewport { + width: u16, + height: u16, + description_layout: SelectionDescriptionLayout, +} + +fn is_selected_visible_in_wrapped_viewport( + rows_all: &[GenericDisplayRow], + start_idx: usize, + max_items: usize, + selected_idx: usize, + desc_col: usize, + viewport: WrappedViewport, +) -> bool { + if viewport.height == 0 { + return false; + } + + let mut used_lines = 0usize; + let viewport_height = viewport.height as usize; + for (idx, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) { + let row_lines = wrap_row_lines(row, desc_col, viewport.width, viewport.description_layout) + .len() + .max(1); + // Keep rendering semantics in sync: always show the first row, even if + // it overflows the viewport. + if used_lines > 0 && used_lines.saturating_add(row_lines) > viewport_height { + break; + } + if idx == selected_idx { + return true; + } + used_lines = used_lines.saturating_add(row_lines); + if used_lines >= viewport_height { + break; + } + } + false +} + +fn adjust_start_for_wrapped_selection_visibility( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_items: usize, + desc_measure_items: usize, + width: u16, + viewport_height: u16, + column_width: ColumnWidthConfig, +) -> usize { + let mut start_idx = compute_item_window_start(rows_all, state, max_items); + let Some(sel) = state.selected_idx else { + return start_idx; + }; + if viewport_height == 0 { + return start_idx; + } + + // If wrapped row heights push the selected item out of view, advance the + // item window until the selected row is visible. + while start_idx < sel { + let desc_col = + compute_desc_col(rows_all, start_idx, desc_measure_items, width, column_width); + if is_selected_visible_in_wrapped_viewport( + rows_all, + start_idx, + max_items, + sel, + desc_col, + WrappedViewport { + width, + height: viewport_height, + description_layout: column_width.description_layout, + }, + ) { + break; + } + start_idx = start_idx.saturating_add(1); + } + start_idx +} + +/// Render a list of rows using the provided ScrollState, with shared styling +/// and behavior for selection popups. +/// Returns the number of terminal lines actually rendered (including the +/// single-line empty placeholder when shown). +#[derive(Clone, Copy, Default)] +pub(crate) struct RenderedRows { + pub(crate) lines: u16, + pub(crate) items: usize, +} + +fn render_rows_inner( + area: Rect, + buf: &mut Buffer, + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + empty_message: &str, + column_width: ColumnWidthConfig, +) -> RenderedRows { + if rows_all.is_empty() { + if area.height > 0 { + Line::from(empty_message.dim().italic()).render(area, buf); + } + // Count the placeholder line only when there is vertical space to draw it. + return RenderedRows { + lines: u16::from(area.height > 0), + items: 0, + }; + } + + let max_items = max_results.min(rows_all.len()); + if max_items == 0 { + return RenderedRows::default(); + } + let desc_measure_items = max_items.min(area.height.max(1) as usize); + + // Keep item-window semantics, then correct for wrapped row heights so the + // selected row remains visible in a line-based viewport. + let start_idx = adjust_start_for_wrapped_selection_visibility( + rows_all, + state, + max_items, + desc_measure_items, + area.width, + area.height, + column_width, + ); + + let desc_col = compute_desc_col( + rows_all, + start_idx, + desc_measure_items, + area.width, + column_width, + ); + + // Render items, wrapping descriptions and aligning wrapped lines under the + // shared description column. Stop when we run out of vertical space. + let mut cur_y = area.y; + let mut rendered_lines: u16 = 0; + let mut rendered_items = 0; + for (i, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) { + if cur_y >= area.y + area.height { + break; + } + + let mut wrapped = + wrap_row_lines(row, desc_col, area.width, column_width.description_layout); + apply_row_state_style( + &mut wrapped, + Some(i) == state.selected_idx && !row.is_disabled, + row.is_disabled, + ); + + // Render the wrapped lines. + let mut rendered_item = false; + for line in wrapped { + if cur_y >= area.y + area.height { + break; + } + line.render( + Rect { + x: area.x, + y: cur_y, + width: area.width, + height: 1, + }, + buf, + ); + cur_y = cur_y.saturating_add(1); + rendered_lines = rendered_lines.saturating_add(1); + rendered_item = true; + } + if rendered_item { + rendered_items += 1; + } + } + + RenderedRows { + lines: rendered_lines, + items: rendered_items, + } +} + +/// Render a list of rows using the provided ScrollState, with shared styling +/// and behavior for selection popups. +/// Description alignment is computed from visible rows only, which allows the +/// layout to adapt tightly to the current viewport. +/// +/// This function should be paired with [`measure_rows_height`] when reserving +/// space; pairing it with a different measurement mode can cause clipping. +/// Returns the number of terminal lines actually rendered. +pub(crate) fn render_rows( + area: Rect, + buf: &mut Buffer, + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + empty_message: &str, +) -> u16 { + render_rows_inner( + area, + buf, + rows_all, + state, + max_results, + empty_message, + ColumnWidthConfig::default(), + ) + .lines +} + +/// Render a list of rows using the provided ScrollState and explicit +/// [`ColumnWidthMode`] behavior. +/// +/// This is the low-level entry point for callers that need to thread a mode +/// through higher-level configuration. +/// Returns the terminal lines and selectable items actually rendered. +pub(crate) fn render_rows_with_col_width_mode( + area: Rect, + buf: &mut Buffer, + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + empty_message: &str, + column_width: ColumnWidthConfig, +) -> RenderedRows { + render_rows_inner( + area, + buf, + rows_all, + state, + max_results, + empty_message, + column_width, + ) +} + +/// Render rows as a single line each (no wrapping), truncating overflow with an ellipsis. +/// +/// This path always uses viewport-local width alignment and is best for dense +/// list UIs where multi-line descriptions would add too much vertical churn. +/// Returns the number of terminal lines actually rendered. +pub(crate) fn render_rows_single_line( + area: Rect, + buf: &mut Buffer, + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + empty_message: &str, +) -> u16 { + render_rows_single_line_with_col_width_mode( + area, + buf, + rows_all, + state, + max_results, + empty_message, + ColumnWidthConfig::default(), + ) + .lines +} + +/// Render a list of rows as a single line each (no wrapping), truncating overflow with an +/// ellipsis while honoring the configured column width behavior. +/// Returns the terminal lines and selectable items actually rendered. +pub(crate) fn render_rows_single_line_with_col_width_mode( + area: Rect, + buf: &mut Buffer, + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + empty_message: &str, + column_width: ColumnWidthConfig, +) -> RenderedRows { + if rows_all.is_empty() { + if area.height > 0 { + Line::from(empty_message.dim().italic()).render(area, buf); + } + // Count the placeholder line only when there is vertical space to draw it. + return RenderedRows { + lines: u16::from(area.height > 0), + items: 0, + }; + } + + let visible_items = max_results + .min(rows_all.len()) + .min(area.height.max(1) as usize); + + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else if visible_items > 0 { + let bottom = start_idx + visible_items - 1; + if sel > bottom { + start_idx = sel + 1 - visible_items; + } + } + } + + let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width, column_width); + + let mut cur_y = area.y; + let mut rendered_lines: u16 = 0; + for (i, row) in rows_all + .iter() + .enumerate() + .skip(start_idx) + .take(visible_items) + { + if cur_y >= area.y + area.height { + break; + } + + let mut full_line = build_full_line(row, desc_col, column_width.description_layout); + if Some(i) == state.selected_idx && !row.is_disabled { + full_line.spans.iter_mut().for_each(|span| { + span.style = accent_style(); + }); + } + if row.is_disabled { + full_line.spans.iter_mut().for_each(|span| { + span.style = span.style.dim(); + }); + } + + let full_line = truncate_line_with_ellipsis_if_overflow(full_line, area.width as usize); + full_line.render( + Rect { + x: area.x, + y: cur_y, + width: area.width, + height: 1, + }, + buf, + ); + cur_y = cur_y.saturating_add(1); + rendered_lines = rendered_lines.saturating_add(1); + } + + RenderedRows { + lines: rendered_lines, + items: rendered_lines as usize, + } +} + +/// Compute the number of terminal rows required to render up to `max_results` +/// items from `rows_all` given the current scroll/selection state and the +/// available `width`. Accounts for description wrapping and alignment so the +/// caller can allocate sufficient vertical space. +/// +/// This function matches [`render_rows`] semantics (`AutoVisible` column +/// sizing). Mixing it with stable or fixed render modes can under- or +/// over-estimate required height. +pub(crate) fn measure_rows_height( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + width: u16, +) -> u16 { + measure_rows_height_inner( + rows_all, + state, + max_results, + width, + ColumnWidthConfig::default(), + ) +} + +/// Measure selection-row height using explicit [`ColumnWidthMode`] behavior. +/// +/// This is the low-level companion to [`render_rows_with_col_width_mode`]. +pub(crate) fn measure_rows_height_with_col_width_mode( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + width: u16, + column_width: ColumnWidthConfig, +) -> u16 { + measure_rows_height_inner(rows_all, state, max_results, width, column_width) +} + +fn measure_rows_height_inner( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + width: u16, + column_width: ColumnWidthConfig, +) -> u16 { + if rows_all.is_empty() { + return 1; // placeholder "no matches" line + } + + let content_width = width.saturating_sub(1).max(1); + + let visible_items = max_results.min(rows_all.len()); + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else if visible_items > 0 { + let bottom = start_idx + visible_items - 1; + if sel > bottom { + start_idx = sel + 1 - visible_items; + } + } + } + + let desc_col = compute_desc_col( + rows_all, + start_idx, + visible_items, + content_width, + column_width, + ); + + let mut total: u16 = 0; + for row in rows_all + .iter() + .enumerate() + .skip(start_idx) + .take(visible_items) + .map(|(_, r)| r) + { + let wrapped_lines = wrap_row_lines( + row, + desc_col, + content_width, + column_width.description_layout, + ) + .len(); + total = total.saturating_add(wrapped_lines as u16); + } + total.max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Modifier; + + #[test] + fn one_cell_width_falls_back_without_panic_for_wrapped_two_column_rows() { + let row = GenericDisplayRow { + name: "1. Very long option label".to_string(), + description: Some("Very long description".to_string()), + wrap_indent: Some(4), + ..Default::default() + }; + + let two_col = wrap_two_column_row(&row, /*desc_col*/ 0, /*width*/ 1); + assert_eq!(two_col.len(), 0); + } + + #[test] + fn popup_name_truncation_counts_halfwidth_sound_marks() { + for (name, desc_col, match_index, expected) in + [("abガc", 6, None, "abガ…"), ("aガc", 4, Some(1), "a…")] + { + let row = GenericDisplayRow { + name: name.to_string(), + description: Some("description".to_string()), + match_indices: match_index.map(|index| vec![index]), + ..Default::default() + }; + let text = + build_full_line(&row, desc_col, SelectionDescriptionLayout::Columns).to_string(); + + assert!(text.starts_with(expected), "unexpected row: {text:?}"); + } + } + + #[test] + fn fuzzy_matched_emoji_graphemes_keep_description_alignment() { + let rows = [ + GenericDisplayRow { + name: "👍🏻".to_string(), + match_indices: Some(vec![1]), + description: Some("description".to_string()), + ..Default::default() + }, + GenericDisplayRow { + name: "👨‍👩‍👧‍👦".to_string(), + match_indices: Some(vec![2]), + description: Some("description".to_string()), + ..Default::default() + }, + ]; + let area = Rect::new(0, 0, /*width*/ 20, /*height*/ 2); + let mut buf = Buffer::empty(area); + + for (row_index, row) in rows.iter().enumerate() { + let line = build_full_line( + row, + /*desc_col*/ 4, + SelectionDescriptionLayout::Columns, + ); + let name = line.spans.first().expect("fuzzy-matched name span"); + assert_eq!(name.content.as_ref(), row.name); + assert!(name.style.add_modifier.contains(Modifier::BOLD)); + + let row_area = Rect::new(area.x, area.y + row_index as u16, area.width, 1); + ratatui::widgets::Widget::render( + ratatui::widgets::Paragraph::new(line), + row_area, + &mut buf, + ); + assert_eq!(buf[(4, row_index as u16)].symbol(), "d"); + } + + insta::assert_snapshot!("popup_fuzzy_matched_emoji_graphemes", format!("{buf:?}")); + } + + #[test] + fn wrapped_two_column_rows_count_halfwidth_sound_marks() { + let rows = vec![GenericDisplayRow { + name: "abガc".to_string(), + description: Some("abパc".to_string()), + wrap_indent: Some(0), + ..Default::default() + }]; + let area = Rect::new(0, 0, 9, 2); + let mut buf = Buffer::empty(area); + + let rendered_lines = render_rows( + area, + &mut buf, + &rows, + &ScrollState::default(), + /*max_results*/ 1, + "no rows", + ); + let rendered = (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| buf[(x, y)].symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n"); + + assert_eq!(rendered_lines, 2); + insta::assert_snapshot!(rendered, @r" + abガ ab + c パ c + "); + } + + #[test] + fn wrapped_two_column_rows_preserve_hard_line_breaks() { + let row = GenericDisplayRow { + name: "first\nsecond".to_string(), + description: Some("alpha\nbeta".to_string()), + wrap_indent: Some(0), + ..Default::default() + }; + + let rendered = wrap_two_column_row(&row, /*desc_col*/ 8, /*width*/ 24) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(rendered, @r" + first alpha + second beta + "); + } + + #[test] + fn selected_rows_use_the_shared_accent_style() { + let rows = vec![GenericDisplayRow { + name: "selected".to_string(), + ..Default::default() + }]; + let state = ScrollState { + selected_idx: Some(0), + ..Default::default() + }; + let area = Rect::new(0, 0, 16, 1); + let mut buf = Buffer::empty(area); + + render_rows( + area, &mut buf, &rows, &state, /*max_results*/ 1, "no rows", + ); + + let style = buf[(0, 0)].style(); + let expected = accent_style(); + assert_eq!(style.fg, expected.fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/selection_row_layout.rs b/codex-rs/tui/src/bottom_pane/selection_row_layout.rs new file mode 100644 index 0000000000000000000000000000000000000000..eace3095bf9ec5f1c8605ac29dae5db98a05c460 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/selection_row_layout.rs @@ -0,0 +1,197 @@ +use std::borrow::Cow; + +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use unicode_segmentation::UnicodeSegmentation; + +use super::selection_popup_common::GenericDisplayRow; +use crate::line_truncation::line_width; +use crate::width::display_width; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; + +/// Controls whether selection-row descriptions stay in a column or move below +/// their labels when the description column becomes too narrow to read. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum SelectionDescriptionLayout { + #[default] + Columns, + StackBelowWhenNarrow { + min_description_width: u16, + }, +} + +impl SelectionDescriptionLayout { + pub(super) fn should_stack(self, width: u16, desc_col: usize) -> bool { + let Self::StackBelowWhenNarrow { + min_description_width, + } = self + else { + return false; + }; + let desc_col = desc_col.min(width as usize) as u16; + width.saturating_sub(desc_col) < min_description_width + } +} + +pub(super) fn line_to_owned(line: Line<'_>) -> Line<'static> { + Line { + style: line.style, + alignment: line.alignment, + spans: line + .spans + .into_iter() + .map(|span| Span { + style: span.style, + content: Cow::Owned(span.content.into_owned()), + }) + .collect(), + } +} + +fn combined_description( + row: &GenericDisplayRow, + description_layout: SelectionDescriptionLayout, +) -> Option { + match (&row.description, &row.disabled_reason) { + (Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")), + (Some(desc), None) => Some(desc.clone()), + (None, Some(reason)) + if matches!( + description_layout, + SelectionDescriptionLayout::StackBelowWhenNarrow { .. } + ) => + { + Some(reason.clone()) + } + (None, Some(reason)) => Some(format!("disabled: {reason}")), + (None, None) => None, + } +} + +fn stacked_description(row: &GenericDisplayRow) -> Option { + match (&row.description, &row.disabled_reason) { + (Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")), + (Some(desc), None) => Some(desc.clone()), + (None, Some(reason)) => Some(reason.clone()), + (None, None) => None, + } +} + +fn build_name_spans(row: &GenericDisplayRow, name_limit: usize) -> Vec> { + let mut name_spans = Vec::with_capacity(row.name.len()); + let mut used_width = 0usize; + let mut truncated = false; + + let mut match_indices = row.match_indices.iter().flatten().peekable(); + let mut char_idx = 0usize; + for grapheme in row.name.graphemes(/*is_extended*/ true) { + let next_width = used_width.saturating_add(display_width(grapheme)); + if next_width > name_limit { + truncated = true; + break; + } + used_width = next_width; + + let mut matched = false; + for _ in grapheme.chars() { + matched |= match_indices.next_if(|next| **next == char_idx).is_some(); + char_idx += 1; + } + + let grapheme = grapheme.to_string(); + name_spans.push(if matched { + grapheme.bold() + } else { + grapheme.into() + }); + } + + if truncated { + name_spans.push("…".into()); + } + if row.disabled_reason.is_some() { + name_spans.push(" (disabled)".dim()); + } + name_spans +} + +fn append_shortcut(row: &GenericDisplayRow, spans: &mut Vec>) { + if let Some(display_shortcut) = row.display_shortcut { + spans.push(" (".into()); + spans.push(display_shortcut.into()); + spans.push(")".into()); + } +} + +fn append_category_tag(row: &GenericDisplayRow, spans: &mut Vec>) { + if let Some(tag) = row.category_tag.as_deref().filter(|tag| !tag.is_empty()) { + spans.push(" ".into()); + spans.push(tag.to_string().dim()); + } +} + +/// Build the full display line for a row with the description padded to start +/// at `desc_col`. +pub(super) fn build_full_line( + row: &GenericDisplayRow, + desc_col: usize, + description_layout: SelectionDescriptionLayout, +) -> Line<'static> { + let description = combined_description(row, description_layout); + let name_prefix_width = line_width(&Line::from(row.name_prefix_spans.clone())); + let name_limit = description + .as_ref() + .map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width)) + .unwrap_or(usize::MAX); + let name_spans = build_name_spans(row, name_limit); + let name_width = name_prefix_width + line_width(&Line::from(name_spans.clone())); + + let mut spans = row.name_prefix_spans.clone(); + spans.extend(name_spans); + append_shortcut(row, &mut spans); + if let Some(description) = description { + let gap = desc_col.saturating_sub(name_width); + if gap > 0 { + spans.push(" ".repeat(gap).into()); + } + spans.push(description.dim()); + } + append_category_tag(row, &mut spans); + Line::from(spans) +} + +/// Render a row as a full-width label followed by an indented description. +pub(super) fn wrap_stacked_row(row: &GenericDisplayRow, width: u16) -> Vec> { + let width = width.max(1); + let prefix_width = line_width(&Line::from(row.name_prefix_spans.clone())) + .min(width.saturating_sub(1) as usize); + let indent = " ".repeat(prefix_width); + + let mut label_spans = row.name_prefix_spans.clone(); + label_spans.extend(build_name_spans(row, usize::MAX)); + append_shortcut(row, &mut label_spans); + append_category_tag(row, &mut label_spans); + let label = Line::from(label_spans); + let label_options = RtOptions::new(width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(indent.clone())); + let mut lines = word_wrap_line(&label, label_options) + .into_iter() + .map(line_to_owned) + .collect::>(); + + if let Some(description) = stacked_description(row) { + let description = Line::from(description.dim()); + let description_options = RtOptions::new(width as usize) + .initial_indent(Line::from(indent.clone())) + .subsequent_indent(Line::from(indent)); + lines.extend( + word_wrap_line(&description, description_options) + .into_iter() + .map(line_to_owned), + ); + } + lines +} diff --git a/codex-rs/tui/src/bottom_pane/selection_tabs.rs b/codex-rs/tui/src/bottom_pane/selection_tabs.rs new file mode 100644 index 0000000000000000000000000000000000000000..a4c4c42a9dd45934630dc713083d92a17c662a30 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/selection_tabs.rs @@ -0,0 +1,106 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Widget; + +use crate::render::renderable::Renderable; +use crate::style::accent_style; + +use super::SelectionItem; + +const TAB_GAP_WIDTH: usize = 2; + +pub(crate) struct SelectionTab { + pub(crate) id: String, + pub(crate) label: String, + pub(crate) header: Box, + pub(crate) items: Vec, +} + +pub(crate) fn tab_bar_height(tabs: &[SelectionTab], active_idx: usize, width: u16) -> u16 { + if tabs.is_empty() { + return 0; + } + tab_bar_lines(tabs, active_idx, width) + .len() + .try_into() + .unwrap_or(u16::MAX) +} + +pub(crate) fn render_tab_bar( + tabs: &[SelectionTab], + active_idx: usize, + area: Rect, + buf: &mut Buffer, +) { + for (offset, line) in tab_bar_lines(tabs, active_idx, area.width) + .into_iter() + .take(area.height as usize) + .enumerate() + { + line.render( + Rect { + x: area.x, + y: area.y.saturating_add(offset as u16), + width: area.width, + height: 1, + }, + buf, + ); + } +} + +fn tab_bar_lines(tabs: &[SelectionTab], active_idx: usize, width: u16) -> Vec> { + if tabs.is_empty() { + return Vec::new(); + } + + let max_width = width.max(1) as usize; + let mut lines = Vec::new(); + let mut current_spans: Vec> = Vec::new(); + let mut current_width = 0usize; + + for (idx, tab) in tabs.iter().enumerate() { + let unit = tab_unit(tab.label.as_str(), idx == active_idx); + let unit_width = Line::from(unit.clone()).width(); + let gap_width = if current_spans.is_empty() { + 0 + } else { + TAB_GAP_WIDTH + }; + + if !current_spans.is_empty() && current_width + gap_width + unit_width > max_width { + lines.push(Line::from(current_spans)); + current_spans = Vec::new(); + current_width = 0; + } + + if !current_spans.is_empty() { + current_spans.push(" ".into()); + current_width += TAB_GAP_WIDTH; + } + current_width += unit_width; + current_spans.extend(unit); + } + + if !current_spans.is_empty() { + lines.push(Line::from(current_spans)); + } + lines +} + +fn tab_unit(label: &str, active: bool) -> Vec> { + if active { + let style = accent_style(); + vec![ + "[".set_style(style), + label.to_string().set_style(style), + "]".set_style(style), + ] + } else { + vec![label.to_string().dim()] + } +} diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs new file mode 100644 index 0000000000000000000000000000000000000000..d70c95423dad2ed5c74968cd6dae7f493104ddc6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -0,0 +1,434 @@ +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::render_rows_single_line; +use crate::key_hint; +use crate::render::Insets; +use crate::render::RectExt; +use crate::text_formatting::truncate_text; +use codex_utils_fuzzy_match::fuzzy_match; + +#[derive(Clone, Debug)] +pub(crate) struct MentionItem { + pub(crate) display_name: String, + pub(crate) description: Option, + pub(crate) insert_text: String, + pub(crate) search_terms: Vec, + pub(crate) path: Option, + pub(crate) category_tag: Option, + pub(crate) sort_rank: u8, +} + +/// The best fuzzy match for a mention and the display-name positions that can be highlighted. +/// +/// `display_name_indices` is absent when only an alternate search term matched. +pub(crate) struct MentionMatch { + display_name_indices: Option>, + score: i32, +} + +impl MentionItem { + /// Matches the display name first, then falls back to the best distinct search term. + pub(crate) fn fuzzy_match_query(&self, query: &str) -> Option { + if let Some((indices, score)) = fuzzy_match(&self.display_name, query) { + Some(MentionMatch { + display_name_indices: Some(indices), + score, + }) + } else { + self.search_terms + .iter() + .filter(|term| *term != &self.display_name) + .filter_map(|term| fuzzy_match(term, query).map(|(_indices, score)| score)) + .min() + .map(|score| MentionMatch { + display_name_indices: None, + score, + }) + } + } +} + +const MENTION_NAME_TRUNCATE_LEN: usize = 28; + +pub(crate) struct SkillPopup { + query: String, + mentions: Vec, + state: ScrollState, +} + +impl SkillPopup { + pub(crate) fn new(mentions: Vec) -> Self { + Self { + query: String::new(), + mentions, + state: ScrollState::new(), + } + } + + pub(crate) fn set_mentions(&mut self, mentions: Vec) { + self.mentions = mentions; + self.clamp_selection(); + } + + pub(crate) fn set_query(&mut self, query: &str) { + self.query = query.to_string(); + self.clamp_selection(); + } + + pub(crate) fn calculate_required_height(&self, _width: u16) -> u16 { + let rows = self.rows_from_matches(self.filtered()); + let visible = rows.len().clamp(1, MAX_POPUP_ROWS); + (visible as u16).saturating_add(2) + } + + pub(crate) fn move_up(&mut self) { + let len = self.filtered_items().len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn move_down(&mut self) { + let len = self.filtered_items().len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn selected_mention(&self) -> Option<&MentionItem> { + let matches = self.filtered_items(); + let idx = self.state.selected_idx?; + let mention_idx = matches.get(idx)?; + self.mentions.get(*mention_idx) + } + + fn clamp_selection(&mut self) { + let len = self.filtered_items().len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn filtered_items(&self) -> Vec { + self.filtered().into_iter().map(|(idx, _, _)| idx).collect() + } + + fn rows_from_matches( + &self, + matches: Vec<(usize, Option>, i32)>, + ) -> Vec { + matches + .into_iter() + .map(|(idx, indices, _score)| { + let mention = &self.mentions[idx]; + let name = truncate_text(&mention.display_name, MENTION_NAME_TRUNCATE_LEN); + let description = match ( + mention.category_tag.as_deref(), + mention.description.as_deref(), + ) { + (Some(tag), Some(description)) if !description.is_empty() => { + Some(format!("{tag} {description}")) + } + (Some(tag), _) => Some(tag.to_string()), + (None, Some(description)) if !description.is_empty() => { + Some(description.to_string()) + } + _ => None, + }; + GenericDisplayRow { + name, + name_prefix_spans: Vec::new(), + match_indices: indices, + display_shortcut: None, + description, + category_tag: None, + is_disabled: false, + disabled_reason: None, + wrap_indent: None, + } + }) + .collect() + } + + fn filtered(&self) -> Vec<(usize, Option>, i32)> { + let filter = self.query.trim(); + let mut out: Vec<(usize, Option>, i32)> = Vec::new(); + + for (idx, mention) in self.mentions.iter().enumerate() { + if filter.is_empty() { + out.push((idx, None, 0)); + continue; + } + + if let Some(mention_match) = mention.fuzzy_match_query(filter) { + out.push((idx, mention_match.display_name_indices, mention_match.score)); + } + } + + out.sort_by(|a, b| { + if filter.is_empty() { + self.mentions[a.0] + .sort_rank + .cmp(&self.mentions[b.0].sort_rank) + } else { + a.1.is_none() + .cmp(&b.1.is_none()) + .then_with(|| a.2.cmp(&b.2)) + .then_with(|| { + self.mentions[a.0] + .sort_rank + .cmp(&self.mentions[b.0].sort_rank) + }) + } + .then_with(|| { + let an = self.mentions[a.0].display_name.as_str(); + let bn = self.mentions[b.0].display_name.as_str(); + an.cmp(bn) + }) + }); + + out + } +} + +impl WidgetRef for SkillPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let (list_area, hint_area) = if area.height > 2 { + let [list_area, _spacer_area, hint_area] = Layout::vertical([ + Constraint::Length(area.height - 2), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(area); + (list_area, Some(hint_area)) + } else { + (area, None) + }; + let rows = self.rows_from_matches(self.filtered()); + render_rows_single_line( + list_area.inset(Insets::tlbr( + /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, + )), + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + "no matches", + ); + if let Some(hint_area) = hint_area { + let hint_area = Rect { + x: hint_area.x + 2, + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + skill_popup_hint_line().render(hint_area, buf); + } + } +} + +fn skill_popup_hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to insert or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to close".into(), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + fn mention_item(index: usize) -> MentionItem { + MentionItem { + display_name: format!("Mention {index:02}"), + description: Some(format!("Description {index:02}")), + insert_text: format!("$mention-{index:02}"), + search_terms: vec![format!("mention-{index:02}")], + path: Some(format!("skill://mention-{index:02}")), + category_tag: Some("[Skill]".to_string()), + sort_rank: 1, + } + } + + fn ranked_mention_item( + display_name: &str, + search_terms: &[&str], + category_tag: &str, + sort_rank: u8, + ) -> MentionItem { + MentionItem { + display_name: display_name.to_string(), + description: None, + insert_text: format!("${display_name}"), + search_terms: search_terms + .iter() + .map(|term| (*term).to_string()) + .collect(), + path: None, + category_tag: Some(category_tag.to_string()), + sort_rank, + } + } + + fn named_mention_item(display_name: &str, search_terms: &[&str]) -> MentionItem { + ranked_mention_item(display_name, search_terms, "[Skill]", /*sort_rank*/ 1) + } + + fn plugin_mention_item(display_name: &str, search_terms: &[&str]) -> MentionItem { + ranked_mention_item(display_name, search_terms, "[Plugin]", /*sort_rank*/ 0) + } + + #[test] + fn filtered_mentions_preserve_results_beyond_popup_height() { + let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect()); + + let filtered_names: Vec = popup + .filtered_items() + .into_iter() + .map(|idx| popup.mentions[idx].display_name.clone()) + .collect(); + + assert_eq!( + filtered_names, + (0..(MAX_POPUP_ROWS + 2)) + .map(|idx| format!("Mention {idx:02}")) + .collect::>() + ); + assert_eq!( + popup.calculate_required_height(72), + (MAX_POPUP_ROWS as u16) + 2 + ); + } + + fn render_popup(popup: &SkillPopup, width: u16) -> String { + let area = Rect::new(0, 0, width, popup.calculate_required_height(width)); + let mut buf = Buffer::empty(area); + popup.render_ref(area, &mut buf); + format!("{buf:?}") + } + + #[test] + fn scrolling_mentions_shifts_rendered_window_snapshot() { + let mut popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect()); + + for _ in 0..=MAX_POPUP_ROWS { + popup.move_down(); + } + + insta::assert_snapshot!("skill_popup_scrolled", render_popup(&popup, /*width*/ 72)); + } + + #[test] + fn lowercase_expansion_preserves_match_ranking_and_highlighting() { + let mut popup = SkillPopup::new(vec![ + named_mention_item("İx", &[]), + named_mention_item("i\u{0307}x", &[]), + named_mention_item("aİx", &[]), + named_mention_item("ai\u{0307}x", &[]), + ]); + popup.set_query("\u{0307}x"); + + // Each pair lowercases identically. The dot starts a contiguous match, + // but not a prefix, even when it came from the expansion of 'İ'. + assert_eq!( + popup.filtered(), + vec![ + (3, Some(vec![2, 3]), 0), + (2, Some(vec![1, 2]), 0), + (1, Some(vec![1, 2]), 0), + (0, Some(vec![0, 1]), 0), + ] + ); + insta::assert_snapshot!( + "skill_popup_lowercase_expansion", + render_popup(&popup, /*width*/ 48) + ); + } + + #[test] + fn display_name_match_sorting_beats_worse_secondary_search_term_matches() { + let mut popup = SkillPopup::new(vec![ + named_mention_item("pr-review-triage", &["pr-review-triage"]), + named_mention_item("prd", &["prd"]), + named_mention_item("PR Babysitter", &["babysit-pr", "PR Babysitter"]), + named_mention_item("Plugin Creator", &["plugin-creator", "Plugin Creator"]), + named_mention_item( + "Logging Best Practices", + &["logging-best-practices", "Logging Best Practices"], + ), + ]); + popup.set_query("pr"); + + let filtered_names: Vec = popup + .filtered_items() + .into_iter() + .map(|idx| popup.mentions[idx].display_name.clone()) + .collect(); + + assert_eq!( + filtered_names, + vec![ + "PR Babysitter".to_string(), + "pr-review-triage".to_string(), + "prd".to_string(), + "Plugin Creator".to_string(), + "Logging Best Practices".to_string(), + ] + ); + } + + #[test] + fn query_match_score_sorts_before_plugin_rank_bias() { + let mut popup = SkillPopup::new(vec![ + plugin_mention_item("GitHub", &["github", "pull requests", "pr"]), + named_mention_item("pr-review-triage", &["pr-review-triage"]), + named_mention_item("prd", &["prd"]), + named_mention_item("Plugin Creator", &["plugin-creator", "Plugin Creator"]), + named_mention_item( + "Logging Best Practices", + &["logging-best-practices", "Logging Best Practices"], + ), + named_mention_item("PR Babysitter", &["babysit-pr", "PR Babysitter"]), + ]); + popup.set_query("pr"); + + let filtered_items: Vec<(String, Option)> = popup + .filtered_items() + .into_iter() + .map(|idx| { + ( + popup.mentions[idx].display_name.clone(), + popup.mentions[idx].category_tag.clone(), + ) + }) + .collect(); + + assert_eq!( + filtered_items, + vec![ + ("PR Babysitter".to_string(), Some("[Skill]".to_string())), + ("pr-review-triage".to_string(), Some("[Skill]".to_string())), + ("prd".to_string(), Some("[Skill]".to_string())), + ("Plugin Creator".to_string(), Some("[Skill]".to_string())), + ( + "Logging Best Practices".to_string(), + Some("[Skill]".to_string()) + ), + ("GitHub".to_string(), Some("[Plugin]".to_string())), + ] + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..e3146db82093dfe07705ec3095c466c6e24c6380 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs @@ -0,0 +1,602 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::Widget; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::key_hint::ShortcutHint; +use crate::key_hint::is_plain_text_key_event; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::skills_helpers::match_skill; +use crate::style::user_message_style; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::render_rows_single_line; + +const SEARCH_PLACEHOLDER: &str = "Type to search skills"; +const SEARCH_PROMPT_PREFIX: &str = "> "; + +pub(crate) struct SkillsToggleItem { + pub name: String, + pub skill_name: String, + pub description: String, + pub enabled: bool, + pub path: AbsolutePathBuf, +} + +pub(crate) struct SkillsToggleView { + items: Vec, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, + header: Box, + footer_hint: Line<'static>, + search_query: String, + filtered_indices: Vec, + keymap: ListKeymap, +} + +impl SkillsToggleView { + pub(crate) fn new( + items: Vec, + app_event_tx: AppEventSender, + keymap: ListKeymap, + ) -> Self { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Enable/Disable Skills".bold())); + header.push(Line::from( + "Turn skills on or off. Your changes are saved automatically.".dim(), + )); + + let mut view = Self { + items, + state: ScrollState::new(), + complete: false, + app_event_tx, + header: Box::new(header), + footer_hint: skills_toggle_hint_line(&keymap), + search_query: String::new(), + filtered_indices: Vec::new(), + keymap, + }; + view.apply_filter(); + view + } + + fn visible_len(&self) -> usize { + self.filtered_indices.len() + } + + fn max_visible_rows(len: usize) -> usize { + MAX_POPUP_ROWS.min(len.max(1)) + } + + fn apply_filter(&mut self) { + // Filter + sort while preserving the current selection when possible. + let previously_selected = self + .state + .selected_idx + .and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied()); + + let filter = self.search_query.trim(); + if filter.is_empty() { + self.filtered_indices = (0..self.items.len()).collect(); + } else { + let mut matches: Vec<(usize, i32)> = Vec::new(); + for (idx, item) in self.items.iter().enumerate() { + let display_name = item.name.as_str(); + if let Some((_indices, score)) = match_skill(filter, display_name, &item.skill_name) + { + matches.push((idx, score)); + } + } + + matches.sort_by(|a, b| { + a.1.cmp(&b.1).then_with(|| { + let an = self.items[a.0].name.as_str(); + let bn = self.items[b.0].name.as_str(); + an.cmp(bn) + }) + }); + + self.filtered_indices = matches.into_iter().map(|(idx, _score)| idx).collect(); + } + + let len = self.filtered_indices.len(); + self.state.selected_idx = previously_selected + .and_then(|actual_idx| { + self.filtered_indices + .iter() + .position(|idx| *idx == actual_idx) + }) + .or_else(|| (len > 0).then_some(0)); + + let visible = Self::max_visible_rows(len); + self.state.clamp_selection(len); + self.state.ensure_visible(len, visible); + } + + fn build_rows(&self) -> Vec { + self.filtered_indices + .iter() + .enumerate() + .filter_map(|(visible_idx, actual_idx)| { + self.items.get(*actual_idx).map(|item| { + let is_selected = self.state.selected_idx == Some(visible_idx); + let prefix = if is_selected { '›' } else { ' ' }; + let marker = if item.enabled { 'x' } else { ' ' }; + let item_name = &item.name; + let name = format!("{prefix} [{marker}] {item_name}"); + GenericDisplayRow { + name, + description: Some(item.description.clone()), + ..Default::default() + } + }) + }) + .collect() + } + + fn move_up(&mut self) { + let len = self.visible_len(); + self.state.move_up_wrap(len); + let visible = Self::max_visible_rows(len); + self.state.ensure_visible(len, visible); + } + + fn move_down(&mut self) { + let len = self.visible_len(); + self.state.move_down_wrap(len); + let visible = Self::max_visible_rows(len); + self.state.ensure_visible(len, visible); + } + + fn page_up(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_up_clamped(len, visible); + } + + fn page_down(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_down_clamped(len, visible); + } + + fn jump_top(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_top(len, visible); + } + + fn jump_bottom(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_bottom(len, visible); + } + + fn toggle_selected(&mut self) { + let Some(idx) = self.state.selected_idx else { + return; + }; + let Some(actual_idx) = self.filtered_indices.get(idx).copied() else { + return; + }; + let Some(item) = self.items.get_mut(actual_idx) else { + return; + }; + + item.enabled = !item.enabled; + self.app_event_tx.send(AppEvent::SetSkillEnabled { + path: item.path.clone(), + enabled: item.enabled, + }); + } + + fn close(&mut self) { + if self.complete { + return; + } + self.complete = true; + self.app_event_tx.send(AppEvent::ManageSkillsClosed); + self.app_event_tx + .list_skills(Vec::new(), /*force_reload*/ true); + } + + fn rows_width(total_width: u16) -> u16 { + total_width.saturating_sub(2) + } + + fn rows_height(&self, rows: &[GenericDisplayRow]) -> u16 { + rows.len().clamp(1, MAX_POPUP_ROWS).try_into().unwrap_or(1) + } +} + +impl BottomPaneView for SkillsToggleView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + // Printable characters always feed search. Movement aliases such as + // plain j/k only apply through non-text events or modified bindings. + let allow_plain_char_navigation = !is_plain_text_key_event(key_event); + + match key_event { + _ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => { + self.move_up() + } + _ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => { + self.move_down() + } + _ if allow_plain_char_navigation && self.keymap.page_up.is_pressed(key_event) => { + self.page_up() + } + _ if allow_plain_char_navigation && self.keymap.page_down.is_pressed(key_event) => { + self.page_down() + } + _ if allow_plain_char_navigation && self.keymap.jump_top.is_pressed(key_event) => { + self.jump_top() + } + _ if allow_plain_char_navigation && self.keymap.jump_bottom.is_pressed(key_event) => { + self.jump_bottom() + } + KeyEvent { + code: KeyCode::Backspace, + .. + } => { + self.search_query.pop(); + self.apply_filter(); + } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } => self.toggle_selected(), + _ if self.keymap.accept.is_pressed(key_event) => self.toggle_selected(), + _ if self.keymap.cancel.is_pressed(key_event) => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.search_query.push(c); + self.apply_filter(); + } + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.close(); + CancellationEvent::Handled + } +} + +impl Renderable for SkillsToggleView { + fn desired_height(&self, width: u16) -> u16 { + let rows = self.build_rows(); + let rows_height = self.rows_height(&rows); + + let mut height = self.header.desired_height(width.saturating_sub(4)); + height = height.saturating_add(rows_height + 3); + height = height.saturating_add(2); + height.saturating_add(1) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + // Reserve the footer line for the key-hint row. + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + + Block::default() + .style(user_message_style()) + .render(content_area, buf); + + let header_height = self + .header + .desired_height(content_area.width.saturating_sub(4)); + let rows = self.build_rows(); + let rows_width = Self::rows_width(content_area.width); + let rows_height = self.rows_height(&rows); + let [header_area, _, search_area, list_area] = Layout::vertical([ + Constraint::Max(header_height), + Constraint::Max(1), + Constraint::Length(2), + Constraint::Length(rows_height), + ]) + .areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2))); + + self.header.render(header_area, buf); + + // Render the search prompt as two lines to mimic the composer. + if search_area.height >= 2 { + let [placeholder_area, input_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(search_area); + Line::from(SEARCH_PLACEHOLDER.dim()).render(placeholder_area, buf); + let line = if self.search_query.is_empty() { + Line::from(vec![SEARCH_PROMPT_PREFIX.dim()]) + } else { + Line::from(vec![ + SEARCH_PROMPT_PREFIX.dim(), + self.search_query.clone().into(), + ]) + }; + line.render(input_area, buf); + } else if search_area.height > 0 { + let query_span = if self.search_query.is_empty() { + SEARCH_PLACEHOLDER.dim() + } else { + self.search_query.clone().into() + }; + Line::from(query_span).render(search_area, buf); + } + + if list_area.height > 0 { + let render_area = Rect { + x: list_area.x.saturating_sub(2), + y: list_area.y, + width: rows_width.max(1), + height: list_area.height, + }; + render_rows_single_line( + render_area, + buf, + &rows, + &self.state, + render_area.height as usize, + "no matches", + ); + } + + let hint_area = Rect { + x: footer_area.x + 2, + y: footer_area.y, + width: footer_area.width.saturating_sub(2), + height: footer_area.height, + }; + self.footer_hint.clone().dim().render(hint_area, buf); + } +} + +fn skills_toggle_hint_line(keymap: &ListKeymap) -> Line<'static> { + let space = key_hint::plain(KeyCode::Char(' ')); + let accept = keymap + .primary_hint(ListAction::Accept) + .filter(|binding| *binding != ShortcutHint::Single(space)); + let cancel = keymap.primary_hint(ListAction::Cancel); + + match (accept, cancel) { + (Some(accept), Some(cancel)) => Line::from(vec![ + "Press ".into(), + space.into(), + " or ".into(), + accept.into(), + " to toggle; ".into(), + cancel.into(), + " to close".into(), + ]), + (Some(accept), None) => Line::from(vec![ + "Press ".into(), + space.into(), + " or ".into(), + accept.into(), + " to toggle".into(), + ]), + (None, Some(cancel)) => Line::from(vec![ + "Press ".into(), + space.into(), + " to toggle; ".into(), + cancel.into(), + " to close".into(), + ]), + (None, None) => Line::from(vec!["Press ".into(), space.into(), " to toggle".into()]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::test_support::PathBufExt; + use crate::test_support::test_path_buf; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + fn render_lines(view: &SkillsToggleView, 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") + } + + fn long_name_items() -> Vec { + vec![ + SkillsToggleItem { + name: "superpowers-systematic-debugging (polish)".to_string(), + skill_name: "polish:superpowers-systematic-debugging".to_string(), + description: "Find root causes before fixing bugs".to_string(), + enabled: true, + path: test_path_buf("/tmp/skills/systematic-debugging/SKILL.md").abs(), + }, + SkillsToggleItem { + name: "superpowers-verification-before-completion (polish)".to_string(), + skill_name: "polish:superpowers-verification-before-completion".to_string(), + description: "Verify completion before claiming success".to_string(), + enabled: false, + path: test_path_buf("/tmp/skills/verification-before-completion/SKILL.md").abs(), + }, + ] + } + + #[test] + fn renders_basic_popup() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SkillsToggleItem { + name: "Repo Scout".to_string(), + skill_name: "repo_scout".to_string(), + description: "Summarize the repo layout".to_string(), + enabled: true, + path: test_path_buf("/tmp/skills/repo_scout.toml").abs(), + }, + SkillsToggleItem { + name: "Changelog Writer".to_string(), + skill_name: "changelog_writer".to_string(), + description: "Draft release notes".to_string(), + enabled: false, + path: test_path_buf("/tmp/skills/changelog_writer.toml").abs(), + }, + ]; + let view = SkillsToggleView::new(items, tx, crate::keymap::RuntimeKeymap::defaults().list); + assert_snapshot!("skills_toggle_basic", render_lines(&view, /*width*/ 72)); + } + + #[test] + fn build_rows_preserves_full_skill_display_names() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = SkillsToggleView::new( + long_name_items(), + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + let row_names = view + .build_rows() + .into_iter() + .map(|row| row.name) + .collect::>(); + + assert_eq!( + row_names, + vec![ + "› [x] superpowers-systematic-debugging (polish)", + " [ ] superpowers-verification-before-completion (polish)", + ] + ); + } + + #[test] + fn filtering_long_skill_names_preserves_the_matching_row() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = SkillsToggleView::new( + long_name_items(), + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + view.search_query = "completion".to_string(); + view.apply_filter(); + + let row_names = view + .build_rows() + .into_iter() + .map(|row| row.name) + .collect::>(); + + assert_eq!( + row_names, + vec!["› [ ] superpowers-verification-before-completion (polish)"] + ); + } + + #[test] + fn renders_long_names_using_available_width() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = SkillsToggleView::new( + long_name_items(), + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + assert_snapshot!( + "skills_toggle_long_names_use_available_width", + render_lines(&view, /*width*/ 96) + ); + } + + #[test] + fn renders_long_names_at_narrow_width() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = SkillsToggleView::new( + long_name_items(), + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + assert_snapshot!( + "skills_toggle_long_names_at_narrow_width", + render_lines(&view, /*width*/ 48) + ); + } + + #[test] + fn footer_hint_uses_list_keymap_accept_and_cancel() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; + keymap.accept = vec![key_hint::ctrl(KeyCode::Char('t'))]; + keymap.cancel = vec![key_hint::ctrl(KeyCode::Char('x'))]; + let view = SkillsToggleView::new(Vec::new(), tx, keymap); + let rendered = render_lines(&view, /*width*/ 72); + + assert!(rendered.contains("ctrl + t")); + assert!(rendered.contains("ctrl + x")); + assert!(!rendered.contains("enter")); + assert!(!rendered.contains("esc")); + } +} diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs new file mode 100644 index 0000000000000000000000000000000000000000..d3723a42dc4e2a6ec4a93e7c5a544e75dafc6dcf --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -0,0 +1,373 @@ +//! Shared helpers for filtering and matching built-in and model service-tier slash commands. +//! +//! The same sandbox- and feature-gating rules are used by both the composer +//! and the command popup. Centralizing them here keeps those call sites small +//! and ensures they stay in sync. +use std::str::FromStr; + +use codex_utils_fuzzy_match::fuzzy_match; + +use crate::slash_command::SlashCommand; +use crate::slash_command::built_in_slash_commands; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ServiceTierCommand { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) description: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SlashCommandItem { + Builtin(SlashCommand), + ServiceTier(ServiceTierCommand), +} + +impl SlashCommandItem { + pub(crate) fn command(&self) -> &str { + match self { + Self::Builtin(cmd) => cmd.command(), + Self::ServiceTier(command) => &command.name, + } + } + + pub(crate) fn supports_inline_args(&self) -> bool { + match self { + Self::Builtin(cmd) => cmd.supports_inline_args(), + Self::ServiceTier(_) => false, + } + } + + pub(crate) fn available_in_side_conversation(&self) -> bool { + match self { + Self::Builtin(cmd) => cmd.available_in_side_conversation(), + Self::ServiceTier(_) => false, + } + } + + pub(crate) fn available_during_task(&self) -> bool { + match self { + Self::Builtin(cmd) => cmd.available_during_task(), + Self::ServiceTier(_) => true, + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct BuiltinCommandFlags { + pub(crate) collaboration_modes_enabled: bool, + pub(crate) connectors_enabled: bool, + pub(crate) plugins_command_enabled: bool, + pub(crate) token_activity_command_enabled: bool, + pub(crate) service_tier_commands_enabled: bool, + pub(crate) goal_command_enabled: bool, + pub(crate) voice_command_enabled: bool, + pub(crate) worktrees_enabled: bool, + pub(crate) allow_elevate_sandbox: bool, + pub(crate) side_conversation_active: bool, +} + +/// Return the built-ins that should be visible/usable for the current input. +pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static str, SlashCommand)> { + built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| flags.allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Plan) + .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) + .filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins) + .filter(|(_, cmd)| flags.token_activity_command_enabled || *cmd != SlashCommand::Usage) + .filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal) + .filter(|(_, cmd)| flags.worktrees_enabled || *cmd != SlashCommand::Worktree) + .filter(|(_, cmd)| flags.voice_command_enabled || *cmd != SlashCommand::Voice) + .filter(|(_, cmd)| !flags.side_conversation_active || cmd.available_in_side_conversation()) + .collect() +} + +pub(crate) fn commands_for_input( + flags: BuiltinCommandFlags, + service_tier_commands: &[ServiceTierCommand], +) -> Vec { + let mut commands = Vec::new(); + let tiers_enabled = flags.service_tier_commands_enabled; + for (_, cmd) in builtins_for_input(flags) { + commands.push(SlashCommandItem::Builtin(cmd)); + if cmd == SlashCommand::Model && tiers_enabled { + commands.extend( + service_tier_commands + .iter() + .cloned() + .map(SlashCommandItem::ServiceTier), + ); + } + } + commands + .into_iter() + .filter(|cmd| !flags.side_conversation_active || cmd.available_in_side_conversation()) + .collect() +} + +/// Find a single built-in command by a recognized name or alias, after applying feature gating. +/// +/// Side-conversation and token-activity gating are intentionally enforced by dispatch rather than +/// command lookup so a typed command can produce a specific unavailable message while the popup +/// still hides it. +pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option { + let cmd = SlashCommand::from_str(name).ok().or_else(|| { + let repeated_os = name.strip_prefix('g')?.strip_suffix("al")?; + (!repeated_os.is_empty() && repeated_os.bytes().all(|byte| byte == b'o')) + .then_some(SlashCommand::Goal) + })?; + builtins_for_input(BuiltinCommandFlags { + token_activity_command_enabled: true, + side_conversation_active: false, + ..flags + }) + .into_iter() + .any(|(_, visible_cmd)| visible_cmd == cmd) + .then_some(cmd) +} + +pub(crate) fn find_slash_command( + name: &str, + flags: BuiltinCommandFlags, + service_tier_commands: &[ServiceTierCommand], +) -> Option { + if let Some(cmd) = find_builtin_command(name, flags) { + return Some(SlashCommandItem::Builtin(cmd)); + } + + let tiers_enabled = flags.service_tier_commands_enabled; + tiers_enabled + .then(|| { + service_tier_commands + .iter() + .find(|command| command.name == name) + .cloned() + .map(SlashCommandItem::ServiceTier) + }) + .flatten() +} + +pub(crate) fn has_slash_command_prefix( + name: &str, + flags: BuiltinCommandFlags, + service_tier_commands: &[ServiceTierCommand], +) -> bool { + commands_for_input(flags, service_tier_commands) + .into_iter() + .any(|command| fuzzy_match(command.command(), name).is_some()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::slice::from_ref; + + fn all_enabled_flags() -> BuiltinCommandFlags { + BuiltinCommandFlags { + collaboration_modes_enabled: true, + connectors_enabled: true, + plugins_command_enabled: true, + token_activity_command_enabled: true, + service_tier_commands_enabled: true, + goal_command_enabled: true, + voice_command_enabled: true, + worktrees_enabled: true, + allow_elevate_sandbox: true, + side_conversation_active: false, + } + } + + #[test] + fn worktree_command_lookup_requires_feature() { + assert_eq!( + find_builtin_command("worktree", BuiltinCommandFlags::default()), + None + ); + assert_eq!( + find_builtin_command("worktree", all_enabled_flags()), + Some(SlashCommand::Worktree) + ); + } + + #[test] + fn debug_command_still_resolves_for_dispatch() { + let cmd = find_builtin_command("debug-config", all_enabled_flags()); + assert_eq!(cmd, Some(SlashCommand::DebugConfig)); + } + + #[test] + fn clear_command_resolves_for_dispatch() { + assert_eq!( + find_builtin_command("clear", all_enabled_flags()), + Some(SlashCommand::Clear) + ); + } + + #[test] + fn goal_command_allows_extra_os_for_dispatch() { + assert_eq!( + find_builtin_command("goooooooooooal", all_enabled_flags()), + Some(SlashCommand::Goal) + ); + } + + #[test] + fn stop_command_resolves_for_dispatch() { + assert_eq!( + find_builtin_command("stop", all_enabled_flags()), + Some(SlashCommand::Stop) + ); + } + + #[test] + fn clean_command_alias_resolves_for_dispatch() { + assert_eq!( + find_builtin_command("clean", all_enabled_flags()), + Some(SlashCommand::Stop) + ); + } + + #[test] + fn service_tier_commands_are_hidden_when_disabled() { + let mut flags = all_enabled_flags(); + flags.service_tier_commands_enabled = false; + let commands = vec![ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "fastest inference".to_string(), + }]; + + assert_eq!(find_slash_command("fast", flags, &commands), None); + } + + #[test] + fn all_service_tiers_are_exposed_as_commands_after_model() { + let commands = vec![ + ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "fastest inference".to_string(), + }, + ServiceTierCommand { + id: "batch".to_string(), + name: "slow".to_string(), + description: "slower inference with lower priority".to_string(), + }, + ]; + + let items = commands_for_input(all_enabled_flags(), &commands); + let model_idx = items + .iter() + .position(|item| matches!(item, SlashCommandItem::Builtin(SlashCommand::Model))) + .expect("model command should be visible"); + let inserted = items + .into_iter() + .skip(model_idx + 1) + .take(commands.len()) + .collect::>(); + let expected = commands + .into_iter() + .map(SlashCommandItem::ServiceTier) + .collect::>(); + + assert_eq!(inserted, expected); + } + + #[test] + fn goal_command_is_hidden_when_disabled() { + let mut flags = all_enabled_flags(); + flags.goal_command_enabled = false; + assert_eq!(find_builtin_command("goal", flags), None); + } + + #[test] + fn voice_command_is_hidden_when_disabled() { + let mut flags = all_enabled_flags(); + flags.voice_command_enabled = false; + assert_eq!(find_builtin_command("voice", flags), None); + } + + #[test] + fn usage_command_is_hidden_from_input_when_account_token_activity_is_disabled() { + let mut flags = all_enabled_flags(); + flags.token_activity_command_enabled = false; + assert_eq!( + builtins_for_input(flags) + .into_iter() + .find(|(_, command)| *command == SlashCommand::Usage), + None + ); + } + + #[test] + fn usage_command_exact_lookup_still_resolves_when_account_token_activity_is_disabled() { + let mut flags = all_enabled_flags(); + flags.token_activity_command_enabled = false; + assert_eq!( + find_builtin_command("usage", flags), + Some(SlashCommand::Usage) + ); + } + + #[test] + fn side_conversation_hides_commands_without_side_flag() { + let commands = builtins_for_input(BuiltinCommandFlags { + side_conversation_active: true, + ..all_enabled_flags() + }) + .into_iter() + .map(|(_, command)| command) + .collect::>(); + + assert_eq!( + commands, + vec![ + SlashCommand::Ide, + SlashCommand::Agents, + SlashCommand::Copy, + SlashCommand::Export, + SlashCommand::Raw, + SlashCommand::Diff, + SlashCommand::Mention, + SlashCommand::Status, + SlashCommand::Daemon, + SlashCommand::Pwd, + SlashCommand::Usage, + ] + ); + } + + #[test] + fn side_conversation_exact_lookup_still_resolves_hidden_commands_for_dispatch_error() { + assert_eq!( + find_builtin_command( + "review", + BuiltinCommandFlags { + side_conversation_active: true, + ..all_enabled_flags() + }, + ), + Some(SlashCommand::Review) + ); + } + + #[test] + fn side_conversation_exact_lookup_still_resolves_service_tier_commands_for_dispatch_error() { + let command = ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "fastest inference".to_string(), + }; + let flags = BuiltinCommandFlags { + side_conversation_active: true, + ..all_enabled_flags() + }; + + assert_eq!( + find_slash_command("fast", flags, from_ref(&command)), + Some(SlashCommandItem::ServiceTier(command)) + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/startup.rs b/codex-rs/tui/src/bottom_pane/startup.rs new file mode 100644 index 0000000000000000000000000000000000000000..86b73cb729df06fd636faa4af79cb972b310b9d3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/startup.rs @@ -0,0 +1,113 @@ +//! Bottom-pane adapters for the provisional startup composer and its protected handoff. +//! +//! Startup accepts safe editor input while keeping submission, search, and other actions +//! unavailable. Once initialization finishes, these adapters preserve buffered paste text, +//! composer history and cursor state, and the approval-idle timing inherited by the real composer. + +use std::time::Instant; + +use crossterm::event::KeyEvent; + +use super::BottomPane; +use super::ComposerDraftSnapshot; +use super::HistoryEntry; +use crate::key_hint::KeyBindingListExt; + +impl BottomPane { + /// Return whether a key would submit, queue, or search outside the startup draft. + pub(crate) fn is_startup_composer_action(&self, key_event: KeyEvent) -> bool { + let composer = &self.keymap.composer; + [ + &composer.submit, + &composer.queue, + &composer.history_search_previous, + &composer.history_search_next, + ] + .into_iter() + .any(|bindings| bindings.is_pressed(key_event)) + || self.composer_is_empty() && composer.toggle_shortcuts.is_pressed(key_event) + } + + /// Return whether a key is bound only to local startup-safe text editing. + pub(crate) fn is_safe_startup_editor_key(&self, key_event: KeyEvent) -> bool { + let editor = &self.keymap.editor; + [ + &editor.insert_newline, + &editor.move_left, + &editor.move_right, + &editor.move_up, + &editor.move_down, + &editor.move_word_left, + &editor.move_word_right, + &editor.move_line_start, + &editor.move_line_end, + &editor.delete_backward, + &editor.delete_forward, + &editor.delete_backward_word, + &editor.delete_forward_word, + &editor.kill_line_start, + &editor.kill_whole_line, + &editor.kill_line_end, + &editor.yank, + ] + .into_iter() + .any(|bindings| bindings.is_pressed(key_event)) + } + + /// Enable Vim without interrupting an in-progress startup draft. + pub(crate) fn enable_vim_in_insert_mode(&mut self) { + self.composer.enable_vim_in_insert_mode(); + self.request_redraw(); + } + + /// Apply paste-burst preferences without dropping input buffered before config loading. + pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { + self.disable_paste_burst = disabled; + self.composer.set_disable_paste_burst(disabled); + } + + /// Capture draft content together with the activity timestamp used by delayed approvals. + pub(crate) fn composer_draft_snapshot(&self) -> ComposerDraftSnapshot { + let mut draft = self.composer.draft_snapshot(); + draft.last_composer_activity_at = self.last_composer_activity_at; + draft + } + + /// Restore the visible cursor before synchronizing composer-owned popups. + pub(crate) fn set_composer_cursor(&mut self, cursor: usize) { + self.composer.set_current_cursor(cursor); + self.composer.sync_popups(); + } + + /// Preserve startup-local history and the newest protected-prompt activity timestamp. + pub(crate) fn restore_startup_composer_state( + &mut self, + last_composer_activity_at: Option, + startup_local_history: Vec, + ) { + self.composer + .restore_startup_local_history(startup_local_history); + if let Some(last_composer_activity_at) = last_composer_activity_at { + let last_composer_activity_at = self + .last_composer_activity_at + .map_or(last_composer_activity_at, |current| { + current.max(last_composer_activity_at) + }); + self.record_composer_activity_at(last_composer_activity_at); + } + } + + /// Return whether an approval is waiting to become visible after typing stops. + pub(crate) fn has_pending_approval(&self) -> bool { + !self.delayed_approval_requests.is_empty() + } + + /// Preserve buffered composer input before its owner or input mode changes. + pub(crate) fn flush_composer_paste_burst(&mut self) { + if self.composer.is_in_paste_burst() { + self.composer.set_disable_paste_burst(/*disabled*/ true); + self.composer + .set_disable_paste_burst(self.disable_paste_burst); + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs new file mode 100644 index 0000000000000000000000000000000000000000..64562d146c047979adc7feb2d95e337d8e76ddec --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -0,0 +1,803 @@ +//! Status line configuration view for customizing the TUI status bar. +//! +//! This module provides an interactive picker for selecting which items appear +//! in the status line at the bottom of the terminal. Users can: +//! +//! - **Select items**: Toggle which information is displayed +//! - **Reorder items**: Use left/right arrows to change display order +//! - **Preview changes**: See a live preview of the configured status line +//! +//! # Available Status Line Items +//! +//! - Model information (name, reasoning level) +//! - Directory paths (current dir, project root) +//! - Machine hostname +//! - Git information (branch name) +//! - Permissions profile +//! - Approval mode +//! - Context usage (remaining %, used %, window size) +//! - Usage limits (primary, secondary) +//! - Session info (thread title, thread ID, tokens used) +//! - Application version + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use std::collections::HashSet; +use strum::IntoEnumIterator; +use strum_macros::Display; +use strum_macros::EnumIter; +use strum_macros::EnumString; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +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::keymap::ListKeymap; +use crate::render::renderable::Renderable; + +const STATUS_LINE_USE_THEME_COLORS_ITEM_ID: &str = "status-line-use-theme-colors"; + +/// Available items that can be displayed in the status line. +/// +/// Each variant represents a piece of information that can be shown at the +/// bottom of the TUI. Items are serialized to kebab-case for configuration +/// storage (e.g., `ModelWithReasoning` becomes `model-with-reasoning`). +/// +/// Some items are conditionally displayed based on availability: +/// - Git-related items only show when in a git repository +/// - Context/limit items only show when data is available from the API +/// - Thread ID only shows after a session has started +#[derive(EnumIter, EnumString, Display, Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] +#[strum(serialize_all = "kebab_case")] +pub(crate) enum StatusLineItem { + /// The current model name. + #[strum(to_string = "model", serialize = "model-name")] + ModelName, + + /// Model name with reasoning level suffix. + ModelWithReasoning, + + /// Current reasoning level. + Reasoning, + + /// Current working directory path. + CurrentDir, + + /// Project root directory (if detected). + #[strum( + to_string = "project-name", + serialize = "project", + serialize = "project-root" + )] + ProjectRoot, + + /// Hostname of the machine running Codex. + Hostname, + + /// Current git branch name (if in a repository). + GitBranch, + + /// Open pull request number for the current branch. + PullRequestNumber, + + /// Committed branch diff stats relative to the default branch. + BranchChanges, + + /// Compact runtime run-state text. + #[strum(to_string = "run-state", serialize = "status")] + Status, + + /// Active permission profile or sandbox summary. + Permissions, + + /// Active command approval mode. + #[strum(to_string = "approval-mode", serialize = "approval")] + ApprovalMode, + + /// Percentage of context window remaining. + ContextRemaining, + + /// Percentage of context window used. + /// + /// Also accepts the legacy `context-usage` config value. + #[strum(to_string = "context-used", serialize = "context-usage")] + ContextUsed, + + /// Remaining usage on the primary rate limit. + FiveHourLimit, + + /// Remaining usage on the secondary rate limit. + WeeklyLimit, + + /// Codex application version. + CodexVersion, + + /// Total context window size in tokens. + ContextWindowSize, + + /// Total tokens used in the current session. + UsedTokens, + + /// Total input tokens consumed. + TotalInputTokens, + + /// Total output tokens generated. + TotalOutputTokens, + + /// Estimated credits attributed directly to the current enterprise thread. + ThreadCredits, + + /// Estimated dollar cost attributed directly to the current enterprise thread. + EstimatedThreadCost, + + /// Full thread UUID. + #[strum(to_string = "thread-id", serialize = "session-id")] + SessionId, + + /// Whether Fast mode is currently active. + FastMode, + + /// Whether raw scrollback mode is currently active. + RawOutput, + + /// Current thread name, omitted when unnamed. + ThreadName, + + /// Current thread title, falling back to its identifier when unnamed. + ThreadTitle, + + /// Current workspace notification headline. + WorkspaceHeadline, + + /// Latest checklist task progress from `update_plan` (if available). + TaskProgress, +} + +impl StatusLineItem { + /// User-visible description shown in the popup. + pub(crate) fn description(self) -> &'static str { + match self { + StatusLineItem::ModelName => "Current model name", + StatusLineItem::ModelWithReasoning => "Current model name with reasoning level", + StatusLineItem::Reasoning => "Current reasoning level", + StatusLineItem::CurrentDir => "Current working directory", + StatusLineItem::ProjectRoot => "Project name (omitted when unavailable)", + StatusLineItem::Hostname => "Current machine hostname (omitted when unavailable)", + StatusLineItem::GitBranch => "Current Git branch (omitted when unavailable)", + StatusLineItem::PullRequestNumber => { + "Open pull request number for the current branch (omitted when unavailable)" + } + StatusLineItem::BranchChanges => { + "Committed branch changes against the default branch (omitted when unavailable)" + } + StatusLineItem::Status => "Compact session run-state text (Ready, Working, Thinking)", + StatusLineItem::Permissions => "Active permission profile or sandbox mode", + StatusLineItem::ApprovalMode => "Active command approval mode", + StatusLineItem::ContextRemaining => { + "Percentage of context window remaining (omitted when unknown)" + } + StatusLineItem::ContextUsed => { + "Percentage of context window used (omitted when unknown)" + } + StatusLineItem::FiveHourLimit => { + "Remaining usage on the primary usage limit (omitted when unavailable)" + } + StatusLineItem::WeeklyLimit => { + "Remaining usage on the secondary usage limit (omitted when unavailable)" + } + StatusLineItem::CodexVersion => "Codex application version", + StatusLineItem::ContextWindowSize => { + "Total context window size in tokens (omitted when unknown)" + } + StatusLineItem::UsedTokens => "Total tokens used in session (omitted when zero)", + StatusLineItem::TotalInputTokens => "Total input tokens used in session", + StatusLineItem::TotalOutputTokens => "Total output tokens used in session", + StatusLineItem::ThreadCredits => { + "Estimated current-thread credits (Enterprise workspaces only; omitted when unavailable)" + } + StatusLineItem::EstimatedThreadCost => { + "Estimated current-thread cost in USD (Enterprise workspaces only; omitted when unavailable)" + } + StatusLineItem::SessionId => "Current thread identifier (omitted until thread starts)", + StatusLineItem::FastMode => "Whether Fast mode is currently active", + StatusLineItem::RawOutput => "Whether raw scrollback mode is active", + StatusLineItem::ThreadName => "Current thread name (omitted when unnamed)", + StatusLineItem::ThreadTitle => { + "Current thread title, or thread identifier when unnamed" + } + StatusLineItem::WorkspaceHeadline => { + "Workspace notification headline (Enterprise workspaces only; omitted when unavailable)" + } + StatusLineItem::TaskProgress => { + "Latest task progress from update_plan (omitted until available)" + } + } + } + + pub(crate) fn preview_item(self) -> StatusSurfacePreviewItem { + match self { + StatusLineItem::ModelName => StatusSurfacePreviewItem::Model, + StatusLineItem::ModelWithReasoning => StatusSurfacePreviewItem::ModelWithReasoning, + StatusLineItem::Reasoning => StatusSurfacePreviewItem::Reasoning, + StatusLineItem::CurrentDir => StatusSurfacePreviewItem::CurrentDir, + StatusLineItem::ProjectRoot => StatusSurfacePreviewItem::ProjectRoot, + StatusLineItem::Hostname => StatusSurfacePreviewItem::Hostname, + StatusLineItem::GitBranch => StatusSurfacePreviewItem::GitBranch, + StatusLineItem::PullRequestNumber => StatusSurfacePreviewItem::PullRequestNumber, + StatusLineItem::BranchChanges => StatusSurfacePreviewItem::BranchChanges, + StatusLineItem::Status => StatusSurfacePreviewItem::Status, + StatusLineItem::Permissions => StatusSurfacePreviewItem::Permissions, + StatusLineItem::ApprovalMode => StatusSurfacePreviewItem::ApprovalMode, + 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::ThreadCredits => StatusSurfacePreviewItem::ThreadCredits, + StatusLineItem::EstimatedThreadCost => StatusSurfacePreviewItem::EstimatedThreadCost, + StatusLineItem::SessionId => StatusSurfacePreviewItem::SessionId, + StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, + StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput, + StatusLineItem::ThreadName => StatusSurfacePreviewItem::ThreadName, + StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, + StatusLineItem::WorkspaceHeadline => StatusSurfacePreviewItem::WorkspaceHeadline, + StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress, + } + } +} + +/// Interactive view for configuring which items appear in the status line. +/// +/// Wraps a [`MultiSelectPicker`] with status-line-specific behavior: +/// - Pre-populates items from current configuration +/// - Shows a live preview of the configured status line +/// - Emits [`AppEvent::StatusLineSetup`] on confirmation +/// - Emits [`AppEvent::StatusLineSetupCancelled`] on cancellation +pub(crate) struct StatusLineSetupView { + /// The underlying multi-select picker widget. + picker: MultiSelectPicker, +} + +impl StatusLineSetupView { + /// Creates a new status line setup view. + /// + /// # Arguments + /// + /// * `status_line_items` - Currently configured item IDs (in display order), + /// or `None` to start with all items disabled + /// * `use_theme_colors` - Whether the preview and saved status line use colors from + /// the active theme + /// * `app_event_tx` - Event sender for dispatching configuration changes + /// + /// Items from `status_line_items` are shown first (in order) and marked as + /// enabled. Remaining items are appended and marked as disabled. + pub(crate) fn new( + status_line_items: Option<&[String]>, + use_theme_colors: bool, + preview_data: StatusSurfacePreviewData, + app_event_tx: AppEventSender, + list_keymap: ListKeymap, + ) -> Self { + let mut used_ids = HashSet::new(); + let mut items = vec![MultiSelectItem { + id: STATUS_LINE_USE_THEME_COLORS_ITEM_ID.to_string(), + name: "Use theme colors".to_string(), + description: Some("Apply colors from the active /theme".to_string()), + enabled: use_theme_colors, + orderable: false, + section_break_after: true, + }]; + + if let Some(selected_items) = status_line_items.as_ref() { + for id in *selected_items { + let Ok(item) = id.parse::() else { + continue; + }; + let item_id = item.to_string(); + if !used_ids.insert(item_id.clone()) { + continue; + } + items.push(Self::status_line_select_item( + item, + /*enabled*/ true, + &preview_data, + )); + } + } + + for item in StatusLineItem::iter() { + let item_id = item.to_string(); + if used_ids.contains(&item_id) { + continue; + } + items.push(Self::status_line_select_item( + item, + /*enabled*/ false, + &preview_data, + )); + } + + Self { + picker: MultiSelectPicker::builder( + "Configure Status Line".to_string(), + Some("Select which items to display in the status line.".to_string()), + app_event_tx, + ) + .list_keymap(list_keymap) + .items(items) + .enable_ordering() + .on_preview(move |items| { + let use_theme_colors = items + .iter() + .find(|item| item.id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID) + .map(|item| item.enabled) + .unwrap_or(true); + preview_data.status_line_for_items( + items + .iter() + .filter(|item| item.enabled) + .filter_map(|item| item.id.parse::().ok()), + use_theme_colors, + ) + }) + .on_confirm(|ids, app_event| { + let use_theme_colors = ids + .iter() + .any(|id| id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID); + let items = ids + .iter() + .filter_map(|id| id.parse::().ok()) + .collect::>(); + app_event.send(AppEvent::StatusLineSetup { + items, + use_theme_colors, + }); + }) + .on_cancel(|app_event| { + app_event.send(AppEvent::StatusLineSetupCancelled); + }) + .build(), + } + } + + /// Converts a [`StatusLineItem`] into a [`MultiSelectItem`] for the picker. + fn status_line_select_item( + item: StatusLineItem, + enabled: bool, + preview_data: &StatusSurfacePreviewData, + ) -> MultiSelectItem { + let default_name = item.to_string(); + let default_description = item.description(); + let (name, description) = match item { + StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => ( + preview_data.rate_limit_item_name(item.preview_item(), &default_name), + preview_data.rate_limit_item_description(item.preview_item(), default_description), + ), + _ => (default_name, default_description.to_string()), + }; + + MultiSelectItem { + id: item.to_string(), + name, + description: Some(description), + enabled, + orderable: true, + section_break_after: false, + } + } +} + +impl BottomPaneView for StatusLineSetupView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + } + + fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) { + self.picker.handle_key_event(key_event); + } + + fn is_complete(&self) -> bool { + self.picker.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.picker.close(); + CancellationEvent::Handled + } +} + +impl Renderable for StatusLineSetupView { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.picker.render(area, buf) + } + + fn desired_height(&self, width: u16) -> u16 { + self.picker.desired_height(width) + } +} + +#[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 ratatui::text::Line; + use tokio::sync::mpsc::unbounded_channel; + + use crate::app_event::AppEvent; + + #[test] + fn context_used_accepts_context_usage_legacy_id() { + assert_eq!(StatusLineItem::ContextUsed.to_string(), "context-used"); + assert_eq!( + "context-used".parse::(), + Ok(StatusLineItem::ContextUsed) + ); + assert_eq!( + "context-usage".parse::(), + Ok(StatusLineItem::ContextUsed) + ); + } + + #[test] + fn context_remaining_is_selectable_id() { + assert_eq!( + "context-remaining".parse::(), + Ok(StatusLineItem::ContextRemaining) + ); + assert_eq!( + StatusLineItem::ContextRemaining.to_string(), + "context-remaining" + ); + } + + #[test] + fn thread_usage_items_are_independently_selectable() { + assert_eq!( + "thread-credits".parse::(), + Ok(StatusLineItem::ThreadCredits) + ); + assert_eq!( + "estimated-thread-cost".parse::(), + Ok(StatusLineItem::EstimatedThreadCost) + ); + } + + #[test] + fn project_name_is_canonical_and_accepts_legacy_ids() { + assert_eq!(StatusLineItem::ProjectRoot.to_string(), "project-name"); + assert_eq!( + "project-name".parse::(), + Ok(StatusLineItem::ProjectRoot) + ); + assert_eq!( + "project".parse::(), + Ok(StatusLineItem::ProjectRoot) + ); + assert_eq!( + "project-root".parse::(), + Ok(StatusLineItem::ProjectRoot) + ); + } + + #[test] + fn model_is_canonical_and_accepts_model_name_legacy_id() { + assert_eq!(StatusLineItem::ModelName.to_string(), "model"); + assert_eq!( + "model".parse::(), + Ok(StatusLineItem::ModelName) + ); + assert_eq!( + "model-name".parse::(), + Ok(StatusLineItem::ModelName) + ); + } + + #[test] + fn reasoning_is_selectable_id() { + assert_eq!(StatusLineItem::Reasoning.to_string(), "reasoning"); + assert_eq!( + "reasoning".parse::(), + Ok(StatusLineItem::Reasoning) + ); + } + + #[test] + fn run_state_is_canonical_and_accepts_status_legacy_id() { + assert_eq!(StatusLineItem::Status.to_string(), "run-state"); + assert_eq!( + "run-state".parse::(), + Ok(StatusLineItem::Status) + ); + assert_eq!( + "status".parse::(), + Ok(StatusLineItem::Status) + ); + } + + #[test] + fn git_summary_items_are_selectable_ids() { + assert_eq!( + "pull-request-number".parse::(), + Ok(StatusLineItem::PullRequestNumber) + ); + assert_eq!( + "branch-changes".parse::(), + Ok(StatusLineItem::BranchChanges) + ); + } + + #[test] + fn parse_status_line_items_accepts_title_only_variants() { + let items = ["run-state", "task-progress"] + .into_iter() + .map(str::parse::) + .collect::, _>>(); + assert_eq!( + items, + Ok(vec![StatusLineItem::Status, StatusLineItem::TaskProgress,]) + ); + } + + #[test] + fn preview_uses_runtime_values() { + let preview_data = StatusSurfacePreviewData::from_iter([ + ( + StatusLineItem::ModelName.preview_item(), + "gpt-5".to_string(), + ), + ( + StatusLineItem::CurrentDir.preview_item(), + "/repo".to_string(), + ), + ]); + let items = [ + MultiSelectItem { + id: StatusLineItem::ModelName.to_string(), + name: String::new(), + description: None, + enabled: true, + orderable: true, + section_break_after: false, + }, + MultiSelectItem { + id: StatusLineItem::CurrentDir.to_string(), + name: String::new(), + description: None, + enabled: true, + orderable: true, + section_break_after: false, + }, + ]; + + assert_eq!( + line_text( + preview_data.status_line_for_items( + items + .iter() + .filter_map(|item| item.id.parse::().ok()), + /*use_theme_colors*/ true, + ) + ), + Some("gpt-5 · /repo".to_string()) + ); + } + + #[test] + fn preview_uses_placeholders_when_runtime_values_are_missing() { + let preview_data = StatusSurfacePreviewData::from_iter([( + StatusSurfacePreviewItem::Model, + "gpt-5".to_string(), + )]); + let items = [ + MultiSelectItem { + id: StatusLineItem::ModelName.to_string(), + name: String::new(), + description: None, + enabled: true, + orderable: true, + section_break_after: false, + }, + MultiSelectItem { + id: StatusLineItem::GitBranch.to_string(), + name: String::new(), + description: None, + enabled: true, + orderable: true, + section_break_after: false, + }, + ]; + + assert_eq!( + line_text( + preview_data.status_line_for_items( + items + .iter() + .filter_map(|item| item.id.parse::().ok()), + /*use_theme_colors*/ true, + ) + ), + Some("gpt-5 · feat/awesome-feature".to_string()) + ); + } + + #[test] + fn preview_includes_thread_title() { + let preview_data = StatusSurfacePreviewData::from_iter([ + ( + StatusLineItem::ModelName.preview_item(), + "gpt-5".to_string(), + ), + ( + StatusLineItem::ThreadTitle.preview_item(), + "Roadmap cleanup".to_string(), + ), + ]); + let items = [ + MultiSelectItem { + id: StatusLineItem::ModelName.to_string(), + name: String::new(), + description: None, + enabled: true, + orderable: true, + section_break_after: false, + }, + MultiSelectItem { + id: StatusLineItem::ThreadTitle.to_string(), + name: String::new(), + description: None, + enabled: true, + orderable: true, + section_break_after: false, + }, + ]; + + assert_eq!( + line_text( + preview_data.status_line_for_items( + items + .iter() + .filter_map(|item| item.id.parse::().ok()), + /*use_theme_colors*/ true, + ) + ), + Some("gpt-5 · Roadmap cleanup".to_string()) + ); + } + + #[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(), + ]), + /*use_theme_colors*/ true, + StatusSurfacePreviewData::from_iter([ + ( + StatusLineItem::ModelName.preview_item(), + "gpt-5-codex".to_string(), + ), + ( + StatusLineItem::CurrentDir.preview_item(), + "~/codex-rs".to_string(), + ), + ( + StatusLineItem::GitBranch.preview_item(), + "jif/statusline-preview".to_string(), + ), + ( + StatusLineItem::WeeklyLimit.preview_item(), + "weekly 82% left".to_string(), + ), + ]), + AppEventSender::new(tx_raw), + crate::keymap::RuntimeKeymap::defaults().list, + ); + + assert_snapshot!(render_lines(&view, /*width*/ 72)); + } + + #[test] + fn setup_view_snapshot_includes_thread_usage_items() { + let (tx_raw, _rx) = unbounded_channel::(); + let view = StatusLineSetupView::new( + Some(&[ + StatusLineItem::ThreadCredits.to_string(), + StatusLineItem::EstimatedThreadCost.to_string(), + ]), + /*use_theme_colors*/ true, + StatusSurfacePreviewData::from_iter([ + ( + StatusLineItem::ThreadCredits.preview_item(), + "5.2 credits".to_string(), + ), + ( + StatusLineItem::EstimatedThreadCost.preview_item(), + "~$1.82".to_string(), + ), + ]), + AppEventSender::new(tx_raw), + crate::keymap::RuntimeKeymap::defaults().list, + ); + + assert_snapshot!(render_lines(&view, /*width*/ 100)); + } + + #[test] + fn setup_view_snapshot_includes_hostname() { + let (tx_raw, _rx) = unbounded_channel::(); + let view = StatusLineSetupView::new( + Some(&[ + StatusLineItem::Hostname.to_string(), + StatusLineItem::CurrentDir.to_string(), + ]), + /*use_theme_colors*/ true, + StatusSurfacePreviewData::from_iter([ + ( + StatusLineItem::Hostname.preview_item(), + "ssh-build-01.example.com".to_string(), + ), + ( + StatusLineItem::CurrentDir.preview_item(), + "~/codex-rs".to_string(), + ), + ]), + AppEventSender::new(tx_raw), + crate::keymap::RuntimeKeymap::defaults().list, + ); + + assert_snapshot!( + render_lines(&view, /*width*/ 100) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n") + ); + } + + 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") + } + + fn line_text(line: Option>) -> Option { + line.map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + } +} diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs new file mode 100644 index 0000000000000000000000000000000000000000..3e5fcacb1a438a2fca40bf71ee3ad5cbdab88e6a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -0,0 +1,342 @@ +//! Theme-derived styling for the configurable footer statusline. + +use ratatui::prelude::Stylize; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; + +use super::status_line_setup::StatusLineItem; +use crate::render::highlight::foreground_style_for_scopes; +use crate::thread_color::thread_color; +use codex_protocol::ThreadId; + +const STATUS_LINE_SEPARATOR: &str = " · "; +const STATUS_LINE_COLOR_SATURATION_PERCENT: u16 = 85; +const STATUS_LINE_COLOR_BRIGHTNESS_PERCENT: u16 = 100; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StatusLineAccent { + Model, + Path, + Branch, + State, + Usage, + Limit, + Metadata, + Mode, + Thread, + Progress, +} + +impl StatusLineAccent { + fn for_item(item: StatusLineItem) -> Self { + match item { + StatusLineItem::ModelName + | StatusLineItem::ModelWithReasoning + | StatusLineItem::Reasoning => Self::Model, + StatusLineItem::CurrentDir | StatusLineItem::ProjectRoot => Self::Path, + StatusLineItem::GitBranch + | StatusLineItem::PullRequestNumber + | StatusLineItem::BranchChanges => Self::Branch, + StatusLineItem::Status => Self::State, + StatusLineItem::ContextRemaining + | StatusLineItem::ContextUsed + | StatusLineItem::ContextWindowSize + | StatusLineItem::UsedTokens + | StatusLineItem::TotalInputTokens + | StatusLineItem::TotalOutputTokens + | StatusLineItem::ThreadCredits + | StatusLineItem::EstimatedThreadCost => Self::Usage, + StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => Self::Limit, + StatusLineItem::CodexVersion | StatusLineItem::Hostname | StatusLineItem::SessionId => { + Self::Metadata + } + StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode, + StatusLineItem::Permissions => Self::Mode, + StatusLineItem::ApprovalMode => Self::Mode, + StatusLineItem::ThreadName + | StatusLineItem::ThreadTitle + | StatusLineItem::WorkspaceHeadline => Self::Thread, + StatusLineItem::TaskProgress => Self::Progress, + } + } + + fn scopes(self) -> &'static [&'static str] { + match self { + Self::Model => &["entity.name.type", "support.type", "variable"], + Self::Path => &["string", "markup.underline.link"], + Self::Branch => &["entity.name.function", "entity.name.tag"], + Self::State => &["keyword.control", "keyword"], + Self::Usage => &["constant.numeric", "constant"], + Self::Limit => &["constant.language", "storage.type"], + Self::Metadata => &["comment", "constant.other"], + Self::Mode => &["storage.modifier", "keyword.operator"], + Self::Thread => &["markup.heading", "entity.name.section"], + Self::Progress => &["markup.inserted", "constant.numeric"], + } + } + + fn fallback_style(self) -> Style { + match self { + Self::Model | Self::State | Self::Metadata | Self::Mode => Style::default().cyan(), + Self::Path | Self::Usage | Self::Progress => Style::default().green(), + Self::Branch | Self::Limit | Self::Thread => Style::default().magenta(), + } + } +} + +pub(crate) fn status_line_from_segments( + segments: I, + use_theme_colors: bool, + thread_id: Option, +) -> Option> +where + I: IntoIterator, +{ + status_line_from_segments_with_resolver(segments, use_theme_colors, thread_id, |accent| { + foreground_style_for_scopes(accent.scopes()) + }) +} + +fn status_line_from_segments_with_resolver( + segments: I, + use_theme_colors: bool, + thread_id: Option, + theme_style_for_accent: F, +) -> Option> +where + I: IntoIterator, + F: Fn(StatusLineAccent) -> Option + +embedded frame: +- canonical control styles +- Floating UI tooltip runtime +- Lucide icon runtime +- visualization fragment diff --git a/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_above_viewport.snap b/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_above_viewport.snap new file mode 100644 index 0000000000000000000000000000000000000000..812030a9d106024c37157c87bc2bf84e5435f88e --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_above_viewport.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/insert_history.rs +expression: "rows.join(\"\\n\")" +--- + + + +raw-start-aaaaaaaaaa +aaaaaaaaaaaaaa-tail- +must-remain diff --git a/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_overflow_above_viewport.snap b/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_overflow_above_viewport.snap new file mode 100644 index 0000000000000000000000000000000000000000..091250848abd60251fbf0b523acfd34c48dddccc --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_overflow_above_viewport.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/insert_history.rs +expression: "rows.join(\"\\n\")" +--- +aaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaa +tail-must-remain diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap new file mode 100644 index 0000000000000000000000000000000000000000..4574e129733c694535a4d0c7ed3399950f52e51a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap @@ -0,0 +1,72 @@ +--- +source: tui/src/keymap_setup.rs +expression: snapshot +--- +48 columns: + + Edit Shortcut + Open Transcript · Global + Current ctrl-t · Default keymap + Config `tui.keymap.global.open_transcript` + Open the transcript overlay. + +› 1. Replace binding + Capture a replacement key for `ctrl-t`. + 2. Add alternate binding + Keep `ctrl-t` and add another key. + 3. Replace with key chord + Replace `ctrl-t` with a two-stroke key + chord. + 4. Add alternate key chord + Keep `ctrl-t` and add a two-stroke key + chord. + – Remove custom binding (disabled) + No custom root override to remove. + 5. Back to shortcuts + Return to the shortcut list. + + Changes write the root `tui.keymap.*` + override. + enter select · esc back + +64 columns: + + Edit Shortcut + Open Transcript · Global + Current ctrl-t · Default keymap + Config `tui.keymap.global.open_transcript` + Open the transcript overlay. + +› 1. Replace binding + Capture a replacement key for `ctrl-t`. + 2. Add alternate binding + Keep `ctrl-t` and add another key. + 3. Replace with key chord + Replace `ctrl-t` with a two-stroke key chord. + 4. Add alternate key chord + Keep `ctrl-t` and add a two-stroke key chord. + – Remove custom binding (disabled) + No custom root override to remove. + 5. Back to shortcuts + Return to the shortcut list. + + Changes write the root `tui.keymap.*` override. + enter select · esc back + +96 columns: + + Edit Shortcut + Open Transcript · Global + Current ctrl-t · Default keymap + Config `tui.keymap.global.open_transcript` + Open the transcript overlay. + +› 1. Replace binding Capture a replacement key for `ctrl-t`. + 2. Add alternate binding Keep `ctrl-t` and add another key. + 3. Replace with key chord Replace `ctrl-t` with a two-stroke key chord. + 4. Add alternate key chord Keep `ctrl-t` and add a two-stroke key chord. + – Remove custom binding (disabled) No custom root override to remove. + 5. Back to shortcuts Return to the shortcut list. + + Changes write the root `tui.keymap.*` override. + enter select · esc back diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_delayed_hint.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_delayed_hint.snap new file mode 100644 index 0000000000000000000000000000000000000000..912c0520cbdba0f828a33acf965077dd22ab45aa --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_delayed_hint.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/keymap_setup.rs +expression: rendered +--- +Keypress Inspector +Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes. +Still waiting? If nothing changes when you press a key, your terminal is not sending that key to +Codex. Only received keys can be assigned as shortcuts. + +Waiting for a keypress... diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_initial.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_initial.snap new file mode 100644 index 0000000000000000000000000000000000000000..7126a40f943446d6713266ba279cbf7d0a931cbd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_initial.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_debug(&view, 80)" +--- +Keypress Inspector +Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes. +Tip: Codex can only inspect keys your terminal sends. + +Waiting for a keypress... diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap new file mode 100644 index 0000000000000000000000000000000000000000..63d3e1cb5b193ccdf0767524e83b21cf974dbb2a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/keymap_setup.rs +expression: agents +--- +Resume | o | Agents resume Resume Open the session resume picker. o Default +Search | f | Agents search Search Search the available agent tasks. f Default +New Task | n | Agents new_task New Task Open a new session in the selected checkout. n Default +New Worktree | w | Agents new_worktree New Worktree Open a new session in a worktree from the project default branch. w Default +Rename | r | Agents rename Rename Rename the selected task. r Default +Stop | x | Agents stop Stop Stop the selected running task. x Default +Archive | a | Agents archive Archive Archive the selected task and its child agents. a Default +Delete | delete | Agents delete Delete Permanently delete the selected task and its child agents. delete Default +Hide | h | Agents hide Hide Hide the selected task until explicitly resumed. h Default +Toggle Grouping | g | Agents toggle_grouping Toggle Grouping Cycle task grouping by project, status, or model. g Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap new file mode 100644 index 0000000000000000000000000000000000000000..0a6af7a34e3955d8314adfc74458275b5ededfa4 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/keymap_setup.rs +expression: snapshot +--- +Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default +Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default +Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default +Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default +Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default +Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default +Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default +Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default +Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default +Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default +Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default +Previous Permission Mode | unbound | Chat previous_permission_mode Previous Permission Mode Switch to the previous available permission mode. unbound Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap new file mode 100644 index 0000000000000000000000000000000000000000..ccad420a1931dd6f513e488bb3bc8ba4ec5a394c --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_picker(params, 120)" +--- + + Keymap + All configurable shortcuts. + 147 actions, 0 customized, 6 unbound. + + [All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug + + Type to search shortcuts +› Global - Open Agents unbound + Global Open Transcript ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global Clear Terminal ctrl-l + Global - Toggle Vim Mode unbound + Global - Toggle Fast Mode unbound + Global Toggle Raw Output alt-r + + left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap new file mode 100644 index 0000000000000000000000000000000000000000..85bbb42f4cede8df760d6173219fcec154bb66c8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_picker(params, 120)" +--- + + Keymap + All configurable shortcuts. + 146 actions, 1 customized, 6 unbound. + + [All] Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug + + Type to search shortcuts +› Global - Open Agents unbound + Global * Open Transcript ctrl-t, ctrl-x ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global Clear Terminal ctrl-l + Global - Toggle Vim Mode unbound + Global Toggle Raw Output alt-r + Global Toggle Side Conversation ctrl-/ + + left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap new file mode 100644 index 0000000000000000000000000000000000000000..20cad9e8110cdb3cb292d0930e3d0f2510b47b18 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_picker(params, 78)" +--- + + Keymap + All configurable shortcuts. + 146 actions, 0 customized, 5 unbound. + + [All] Common Customized (0) Unbound (5) App Composer Editor Vim + Navigation Agents Approval Debug + + Type to search shortcuts +› Global - Open Agents unbound + Global Open Transcript ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global Clear Terminal ctrl-l + Global - Toggle Vim Mode unbound + Global Toggle Raw Output alt-r + Global Toggle Side Conversation ctrl-/ + + left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_repeat_last_change.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_repeat_last_change.snap new file mode 100644 index 0000000000000000000000000000000000000000..21b7419bcbd28056ced8c6eb8d8277a16c39d439 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_repeat_last_change.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/keymap_setup.rs +expression: repeat_row +--- +› Vim normal Repeat Last Change . diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap new file mode 100644 index 0000000000000000000000000000000000000000..fbfec35d5e17ed89559cfe50bd7c8a663a87db4a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_picker(params, 120)" +--- + + Keymap + All configurable shortcuts. + 146 actions, 0 customized, 5 unbound. + + [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug + + Type to search shortcuts +› Global - Open Agents unbound + Global Open Transcript ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global Clear Terminal ctrl-l + Global - Toggle Vim Mode unbound + Global Toggle Raw Output alt-r + Global Toggle Side Conversation ctrl-/ + + left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_action_descriptions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_action_descriptions.snap new file mode 100644 index 0000000000000000000000000000000000000000..22c1280c450c91a2da392957b8a1063271c9ca33 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_action_descriptions.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/keymap_setup.rs +expression: descriptions +--- + + Edit Shortcut + Edit Queued Message · Chat + Current alt-up, shift-left · Default keymap + Config `tui.keymap.chat.edit_queued_message` + Move up through questions, then edit the last queued message. + + + Edit Shortcut + Prompt Stack Back · Chat + Current alt-down, shift-right · Default keymap + Config `tui.keymap.chat.prompt_stack_back` + Move back through questions toward the composer. + + + Edit Shortcut + Skip Question · Chat + Current ctrl-] · Default keymap + Config `tui.keymap.chat.skip_question` + Skip the focused question. diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__bare_url_with_tilde_keeps_complete_hyperlink.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__bare_url_with_tilde_keeps_complete_hyperlink.snap new file mode 100644 index 0000000000000000000000000000000000000000..c10dccd9ca8c763bcc0ec1e07a05fc9dc06347bb --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__bare_url_with_tilde_keeps_complete_hyperlink.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: rendered +--- +[ + ( + "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf", + [ + ( + 0..72, + "https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf", + ), + ], + ), +] diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_keeps_descriptive_label_and_target.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_keeps_descriptive_label_and_target.snap new file mode 100644 index 0000000000000000000000000000000000000000..b85cbe0197bc7a019db598c722424537027668a6 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_keeps_descriptive_label_and_target.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: rendered +--- +Your codex launcher automatically adds those overrides (project/dotslash-gen/bin/codex:1105), even though you did not specify any. diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_keeps_unrelated_relative_label_with_matching_suffix.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_keeps_unrelated_relative_label_with_matching_suffix.snap new file mode 100644 index 0000000000000000000000000000000000000000..51d10da11bfb37a904f3003f8373efe991a157d8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_keeps_unrelated_relative_label_with_matching_suffix.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "plain_lines(&text).join(\"\\n\")" +--- +other/src/lib.rs (src/lib.rs) diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_preserves_labels_with_invalid_percent_encoding.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_preserves_labels_with_invalid_percent_encoding.snap new file mode 100644 index 0000000000000000000000000000000000000000..55c635e1ab3421efc13e1ca22fc6d1fbc8f54416 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_preserves_labels_with_invalid_percent_encoding.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "plain_lines(&text).join(\"\\n\")" +--- +bad%FF label (/tmp/) + +bad%FF label (/) diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_preserves_tilde_and_absolute_destinations.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_preserves_tilde_and_absolute_destinations.snap new file mode 100644 index 0000000000000000000000000000000000000000..364b334df32cc87e58daeb92bf3c950e1fb77abd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__file_link_preserves_tilde_and_absolute_destinations.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "plain_lines(&text).join(\"\\n\")" +--- +~/notes + +/home/alice/notes + +/home/alice/notes (~/notes) + +~/notes (/home/alice/notes) + +my notes (~/notes:12:3) + +~/project/src/lib.rs + +src/lib.rs + +~ (/home/alice:12) diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__list_item_after_code_block_keeps_blank_separator.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__list_item_after_code_block_keeps_blank_separator.snap new file mode 100644 index 0000000000000000000000000000000000000000..d5cb31a6b350bf91d0c2d984d5f4b60ed9430cc9 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__list_item_after_code_block_keeps_blank_separator.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "lines.join(\"\\n\")" +--- +1. First: + + fn first() {} + +2. Second: diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..30c768ab6b4c7bf102ea2bb3a42eb643ed5c7c38 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap @@ -0,0 +1,64 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: rendered +--- +# H1: Markdown Streaming Test + +Intro paragraph with bold text, italic text, and inline code x=1. +Combined bold-italic both and escaped asterisks *literal*. +Auto-link: https://example.com (https://example.com) and reference link [ref][r1]. +Link with title: hover me (https://example.com) and mailto mailto:test@example.com (mailto:test@example.com). +Image: alt text + +> Blockquote level 1 +> +> > Blockquote level 2 with inline code + +- Unordered list item 1 + - Nested bullet with italics inner + +- Unordered list item 2 with strikethrough + +1. Ordered item one +2. Ordered item two with sublist: + 1. Alt-numbered subitem + +- [ ] Task: unchecked +- [x] Task: checked with link home (https://example.org) + +——— + +Table below (alignment test): + + Left Center Right +━━━━━━ ━━━━━━━━ ━━━━━━━ + a b c +Inline HTML: sup and sub. +HTML block: +
inline block
+Escapes: \_underscores\_, backslash \\, ticks ``code with `backtick` inside``. +Emoji shortcodes: :sparkles: :tada: (if supported). +Hard break test (line ends with two spaces) +Next line should be close to previous. +Footnote reference here[^1] and another[^longnote]. +Horizontal rule with asterisks: +*** +Fenced code block (JSON): +```json +{ "a": 1, "b": [true, false] } +``` +Fenced code with tildes and triple backticks inside: +~~~markdown +To close ``` you need tildes. +~~~ +Indented code block: + for i in range(3): print(i) +Definition-like list: +Term +: Definition with `code`. +Character entities: & < > " ' +[^1]: This is the first footnote. +[^longnote]: A longer footnote with a link to [Rust](https://www.rust-lang.org/). +Escaped pipe in text: a \| b \| c. +URL with parentheses: [link](https://example.com/path_(with)_parens). +[r1]: https://example.com/ref "Reference link title" diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_file_link_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_file_link_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..315a722dd48c99e42fa3d4cec2b61512d4ec4879 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_file_link_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: rendered +--- +See codex-rs/tui/src/markdown_render.rs:74. diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__mixed_url_markdown_wraps_prose_without_splitting_words_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__mixed_url_markdown_wraps_prose_without_splitting_words_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..930a1722625a70ba0cc454d38434b10b5aa42c08 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__mixed_url_markdown_wraps_prose_without_splitting_words_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "plain_lines(&text).join(\"\\n\")" +--- +This paragraph keeps strikethrough intact near a +link (https://example.com/path) while enough +surrounding prose forces wrapping. diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__multiline_finding_items_are_separated_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__multiline_finding_items_are_separated_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..a74cf9f0d4bd2aadd65f7775f26c8e1484a6cfd8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__multiline_finding_items_are_separated_snapshot.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "plain_lines(&text).join(\"\\n\")" +--- +Findings + +1. Correctness issue: server tool-search completions are always rejected. + + In next_prompt_suggestion.rs, the output is ignored, suppressing suggestions after completed searches. + + Minimal correction: count matching outputs and suppress only missing ones. + +2. High-confidence simplification: remove the unused error channel. + + The implementation resolves failures to None, so its contract can be narrower. + +3. High-confidence churn reduction: consolidate table-driven filter tests. diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_halfwidth_sound_marks_at_constrained_width_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_halfwidth_sound_marks_at_constrained_width_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..f57506498e6782bdd97e45ac3546bed16ca0accd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_halfwidth_sound_marks_at_constrained_width_snapshot.snap @@ -0,0 +1,41 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "format!(\"grid (23 cells):\\n{}\\n\\nrecords (17 cells):\\n{}\", plain_lines(&grid).join(\"\\n\"), plain_lines(&records).join(\"\\n\"))" +--- +grid (23 cells): + Key Notes +━━━━━━━━━━ ━━━━━━━━━━━ + ガパtail First ガ + row with + an + escaped | + pipe. +────────── ─────────── + パガtail Second パ + row with + an + escaped | + pipe. +────────── ─────────── + short Final ガ + パ row. + +records (17 cells): + Key + ガパtail + Notes + First ガ row + with an escaped + | pipe. +───────────────── + Key + パガtail + Notes + Second パ row + with an escaped + | pipe. +───────────────── + Key + short + Notes + Final ガパ row. diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_records_when_multiple_prose_columns_are_starved_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_records_when_multiple_prose_columns_are_starved_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..84938048d01a12b47e166ff8b5ac1a6699bb9882 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_records_when_multiple_prose_columns_are_starved_snapshot.snap @@ -0,0 +1,36 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "plain_lines(&text).join(\"\\n\")" +--- + Issue #24485: newline shortcut fails in PyCharm terminal on Windows + (https://github.com/openai/codex/issues/24485) + Activity +1 0, substantive comments 0 + Complexity Low + Why start New, deterministic regression range; localized composer/keymap + path. +──────────────────────────────────────────────────────────────────────────── + Issue #23926: Vim composer e stalls at word end (https://github.com/ + openai/codex/issues/23926) + Activity +1 0, comments 0 + Complexity Low + Why start Standing best quick win; deterministic motion bug. +──────────────────────────────────────────────────────────────────────────── + Issue #23651: Zellij scrollback misses Codex transcript over SSH + (https://github.com/openai/codex/issues/23651) + Activity +1 3, human comments 2 + Complexity Medium + Why start Clear regression and strong scrollback evidence. +──────────────────────────────────────────────────────────────────────────── + Issue #23740: raw ANSI/control sequences in Windows Terminal + (https://github.com/openai/codex/issues/23740) + Activity +1 7, human comments 7 + Complexity Medium + Why start Highest activity; established Windows rendering regression + family. +──────────────────────────────────────────────────────────────────────────── + Issue #24527: typing lag increases with session length (https:// + github.com/openai/codex/issues/24527) + Activity +1 0, substantive comments 0 + Complexity Medium + Why start New TUI-visible performance report; needs profiling before + implementation. diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_stacked_key_value_records_when_path_column_becomes_too_narrow_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_stacked_key_value_records_when_path_column_becomes_too_narrow_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..229efcbb21ed530b0bbb37e188b93bba2dbb8c2f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_stacked_key_value_records_when_path_column_becomes_too_narrow_snapshot.snap @@ -0,0 +1,40 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "plain_lines(&text).join(\"\\n\")" +--- + Session + 2026-05-25 current gallery (/Users/ + felipe.coury/.codex/sessions/2026/05/25/ + rollout-2026-05-25T18-13-09-019e60fc- + 0518-7c21-9596-980fe97225ba.jsonl) + Why useful + The large gallery from this thread: + emojis, links, emphasis, code, + alignment, paragraphs, and a 30+ row + table + Detected table blocks + 7 +────────────────────────────────────────── + Session + 2026-05-14 renderer testing (/Users/ + felipe.coury/.codex/sessions/2026/05/14/ + rollout-2026-05-14T12-57-18-019e2734- + e500-7011-8278-975c94d06000.jsonl) + Why useful + Explicit "markdown tables for testing" + session with several successive + assistant samples + Detected table blocks + 16 +────────────────────────────────────────── + Session + 2026-05-14 five-table test (/Users/ + felipe.coury/.codex/sessions/2026/05/14/ + rollout-2026-05-14T12-27-57-019e271a- + 064c-78c3-a5cd-a6f20a0c1ad5.jsonl) + Why useful + Explicit request for five tables + containing emojis, code, italics, and + varied cell content + Detected table blocks + 10 diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_wraps_file_paths_before_collapsing_narrative_columns_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_wraps_file_paths_before_collapsing_narrative_columns_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..95ad94f6474dd0019479b0ebff3687f6b4206958 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_wraps_file_paths_before_collapsing_narrative_columns_snapshot.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "plain_lines(&text).join(\"\\n\")" +--- + Unit Files Adds Removes What It Adds +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━ ━━━━━━ ━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Suggestion engine and unit coverage codex-rs/core/ 704 0 Sampling workflow, stable-history + src/ checks, tool-flow suppression, fast + next_prompt_sugg reasoning profile, filtering rules, + estion.rs:1, cancellation and timeout. + codex-rs/core/ + src/ + next_prompt_sugg + estion_tests.rs: + 1 +─────────────────────────────────────── ────────────────── ────── ───────── ──────────────────────────────────────── + Model instruction fragment and codex-rs/core/ 54 0 Synthetic suggestion prompt and an + contextual isolation src/context/ isolation test for ordinary user text. + next_prompt_sugg + estion.rs:1, + codex-rs/core/ + src/context/ + contextual_user_ + message_tests.rs + :1 diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__web_link_labels_have_a_visible_underline_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__web_link_labels_have_a_visible_underline_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..655ab46f7803f21973fce13dcd56286027b765b2 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__web_link_labels_have_a_visible_underline_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: text +--- +Text::from(Line::from_iter([Span::from("plain "), Span::from("plain").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")"), Span::from(" "), Span::from("code").cyan(), Span::from(" "), Span::from("code").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")")])) diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..313e80cbb7d06df3eb396290bde8df87c910276f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/model_migration.rs +expression: terminal.backend() +--- + +> Codex just got an upgrade. Introducing gpt-5.1-codex-max. + + Upgrade to gpt-5.2-codex for the latest and greatest + agentic coding model. + + You can continue using gpt-5.1-codex-mini if you prefer. + + Choose how you'd like Codex to proceed. + +› 1. Try new model + 2. Use existing model + + Use ↑/↓ to move, press enter to confirm diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap new file mode 100644 index 0000000000000000000000000000000000000000..391eda72e8075aba2edcc3a40825fc69e51b187d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/model_migration.rs +expression: terminal.backend() +--- + +> Codex just got an upgrade. Introducing gpt-5.1-codex-max. + + We recommend switching from gpt-5-codex to + gpt-5.1-codex-max. + + Codex-optimized flagship for deep and fast reasoning. + Learn more about gpt-5.1-codex-max at + https://www.codex.com/models/gpt-5.1-codex-max + + Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap new file mode 100644 index 0000000000000000000000000000000000000000..2bc6083fcd85a1ba8d4825b57d1b8a48e59d0e51 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/multi_agents.rs +expression: snapshot +--- +• Spawned Robie [explorer] (gpt-5 high) + └ Compute 11! and reply with just the integer result. + +• Sent input to Robie [explorer] + └ Please continue and return the answer only. + +• Waiting for Robie [explorer] + +• Finished waiting + └ Robie [explorer]: Completed - 39916800 + Bob [worker]: Error - tool timeout + +• Closed Robie [explorer] diff --git a/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_resume_interrupted.snap b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_resume_interrupted.snap new file mode 100644 index 0000000000000000000000000000000000000000..4766beea08d075505947fef13f3d8e2dd230d3f1 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_resume_interrupted.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/multi_agents.rs +assertion_line: 784 +expression: cell_to_text(&cell) +--- +• Resumed Robie [explorer] + └ Interrupted diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap new file mode 100644 index 0000000000000000000000000000000000000000..65ebd42c514d51252f4dcbec5cf4079f10024772 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/pager_overlay.rs +expression: term.backend() +--- +"/ S T A T I C / / / / / / / / / / / / / " +"one " +"two " +"three " +"~ " +"~ " +"───────────────────────────────── 100% ─" +" ↑/↓ to scroll pgup/pgdn to page hom" +" q close " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap new file mode 100644 index 0000000000000000000000000000000000000000..b71c911ea5972b38f0809b7e9529706c607f15ce --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/pager_overlay.rs +expression: term.backend() +--- +"/ S T A T I C / / / / / " +"a very long line that " +"should wrap when " +"rendered within a narrow" +"─────────────────── 0% ─" +" ↑/↓ to scroll pgup/pg" +" q close " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap new file mode 100644 index 0000000000000000000000000000000000000000..24ce94e912c387fcad0a6375a4080ec2a02352c8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/pager_overlay.rs +expression: snapshot +--- +/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / +• Added foo.txt (+2 -0) + 1 +hello + 2 +world + +• Added foo.txt (+2 -0) + 1 +hello + 2 +world +─────────────────────────────────────────────────────────────────────────── 0% ─ + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q close esc to edit prev diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap new file mode 100644 index 0000000000000000000000000000000000000000..b8106e6caef92d7de8e63cb0b4241f11675e3d2a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap @@ -0,0 +1,47 @@ +--- +source: tui/src/pager_overlay.rs +expression: snapshots +--- +--- loading --- +/ T R A N S C R I P T / / / / / / / / / / / / loading older history... +recent transcript +~ +~ +~ +~ +──────────────────────────────────────────────────────────────────────── + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q close esc to edit prev + +--- partial --- +/ T R A N S C R I P T / / / / / / / partial history | PgUp for earlier +recent transcript +~ +~ +~ +~ +──────────────────────────────────────────────────────────────────────── + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q close esc to edit prev + +--- failed --- +/ T R A N S C R I P T / / / / / / / history unavailable | PgUp to retry +recent transcript +~ +~ +~ +~ +──────────────────────────────────────────────────────────────────────── + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q close esc to edit prev + +--- complete --- +/ T R A N S C R I P T / / / / / / / / / / / / / / / / start of history +recent transcript +~ +~ +~ +~ +───────────────────────────────────────────────────────────────── 100% ─ + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q close esc to edit prev diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap new file mode 100644 index 0000000000000000000000000000000000000000..b823027c570dbea84685b917d57a9758b757d757 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/pager_overlay.rs +expression: term.backend() +--- +"/ T R A N S C R I P T / / / / / / / / / " +"alpha " +" " +"tail " +"~ " +"~ " +"───────────────────────────────── 100% ─" +" ↑/↓ to scroll pgup/pgdn to page hom" +" q close esc to edit prev " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap new file mode 100644 index 0000000000000000000000000000000000000000..024964cea06ed1bf48ceb8e082914e66778c3285 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/pager_overlay.rs +expression: term.backend() +--- +"/ T R A N S C R I P T / / / / / / / / / " +"alpha " +" " +"beta " +" " +"gamma " +"───────────────────────────────── 100% ─" +" ↑/↓ to scroll pgup/pgdn to page hom" +" q close esc to edit prev " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__thread_colors_survive_selection_in_both_picker_layouts.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__thread_colors_survive_selection_in_both_picker_layouts.snap new file mode 100644 index 0000000000000000000000000000000000000000..45c71c8c15af6ccfbf4db3df7eddf646d6834a47 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__thread_colors_survive_selection_in_both_picker_layouts.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/resume_picker_color_tests.rs +expression: "snapshot.join(\"\\n\")" +--- +Comfortable colors=true selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("Named task").fg(Color::Rgb(242, 205, 205)), Span::from(" ").yellow()]).yellow() +Comfortable colors=true selected=false: Line::from_iter([Span::from(" "), Span::from("Named task").fg(Color::Rgb(242, 205, 205))]) +Comfortable colors=false selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("Named task").yellow(), Span::from(" ").yellow()]).yellow() +Comfortable colors=false selected=false: Line::from_iter([Span::from(" "), Span::from("Named task")]) +Dense colors=true selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("- ").dim(), Span::from("Named task ").fg(Color::Rgb(242, 205, 205))]).yellow() +Dense colors=true selected=false: Line::from_iter([Span::from(" "), Span::from("- ").dim(), Span::from("Named task ").fg(Color::Rgb(242, 205, 205))]) +Dense colors=false selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("- ").dim(), Span::from("Named task ").yellow()]).yellow() +Dense colors=false selected=false: Line::from_iter([Span::from(" "), Span::from("- ").dim(), Span::from("Named task ")]) diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_db_fallback_transition.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_db_fallback_transition.snap new file mode 100644 index 0000000000000000000000000000000000000000..61158efc1a3737265594a6dd76bce730bd5c833d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_db_fallback_transition.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/resume_picker.rs +expression: "format!(\"---- loading fallback ----\\n{loading_snapshot}\\n---- repaired results ----\\n{repaired_snapshot}\")" +--- +---- loading fallback ---- +Loading sessions… +---- repaired results ---- +❯ first store result + 1d ago ⌁ no cwd  no branch + + repaired store result + 1d ago ⌁ no cwd  no branch + + third store result + 1d ago ⌁ no cwd  no branch diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap new file mode 100644 index 0000000000000000000000000000000000000000..62cb8d199b7a5c5811359fc194e0205e55e9681c --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 120,)" +--- +❯ 15m ago Propose session picker redesign with enough title text to exercise truncation diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap new file mode 100644 index 0000000000000000000000000000000000000000..94f74b55f3fb37e6eb903b7e59ae8e6869fffd00 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 100,)" +--- +❯ 15m ago Propose session picker redesign with enough title text to exercise truncation diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap new file mode 100644 index 0000000000000000000000000000000000000000..9b8c39ef50059daa9dbb8b585507f76c5da42c3f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 48,)" +--- +❯ 15m ago Propose session picker redesig... diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap new file mode 100644 index 0000000000000000000000000000000000000000..9b8c39ef50059daa9dbb8b585507f76c5da42c3f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 48,)" +--- +❯ 15m ago Propose session picker redesig... diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap new file mode 100644 index 0000000000000000000000000000000000000000..50e37a46b9c1d177c9637498b84b084c1c213993 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- + 15m ago First dense row +❯ 15m ago Second dense row diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap new file mode 100644 index 0000000000000000000000000000000000000000..65fb6f78576bcbe692db2933521d13190920d2ab --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/resume_picker.rs +expression: "footer_snapshot(&state, 96, 20)" +--- +───────────────────────────────────────────────────────────────────────────────── 0 / 0 · 100% ─ + enter resume esc clear ctrl+c quit tab focus ←/→ option + ctrl+o comfy ctrl+t preview ctrl+e exp ↑/↓ browse diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap new file mode 100644 index 0000000000000000000000000000000000000000..76a645aa0704ca3f747dace5342e9ca9f5de70d3 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- +↑ more +❯ item-2 + 10m ago ⌁ no cwd  no branch + + item-3 +↓ more diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap new file mode 100644 index 0000000000000000000000000000000000000000..885ba8884237e6f05a8cb0c99f21460c9e9f77e5 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/resume_picker.rs +expression: snapshot +--- +Resume a previous session Sort: Created at +Type to search + Created at Updated at Branch CWD Conversation +No sessions yet + + + + +enter to resume esc to start new ctrl + c to quit tab to toggle sort diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_error.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_error.snap new file mode 100644 index 0000000000000000000000000000000000000000..4f48ef96af6f9a16275bc8aa7102b1f05bd6750f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_error.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: snapshot +--- +Failed to read session metadata from /tmp/missing.jsonl diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap new file mode 100644 index 0000000000000000000000000000000000000000..7d9be2926dbdcb075bfd545922385f0526d01180 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- +Type to search Filter: [Cwd] All Status: [Active] Archived Sort: [Updated] Created diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap new file mode 100644 index 0000000000000000000000000000000000000000..b882050d58a08c759380bec34d93d39a83bc20ff --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/resume_picker.rs +expression: snapshot +--- + Fix resume picker timestamps + 42s ago ⌁ no cwd  no branch + +❯ Investigate lazy pagination cap + 35m ago ⌁ no cwd  no branch + + Explain the codebase + 2h ago ⌁ no cwd  no branch diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_thread_names.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_thread_names.snap new file mode 100644 index 0000000000000000000000000000000000000000..d7b3849a0c6f17854d2daca7bc33fefef449e783 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_thread_names.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/resume_picker.rs +expression: snapshot +--- + Created Updated Branch CWD Conversation +> - 2 days ago - - Keep this for now + - 3 days ago - - Named thread diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap new file mode 100644 index 0000000000000000000000000000000000000000..fa6b47cbbd8d2a99f9d0fc068c4e9b99294872a7 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/resume_picker.rs +expression: snapshot +--- +❯ Find pending threads and emails + - ⌁ no cwd  no branch + + Plan raw scrollback mod Loading transcript… + - ⌁ no cwd branch diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_fresh_session_transitions.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_fresh_session_transitions.snap new file mode 100644 index 0000000000000000000000000000000000000000..32f6de9153ba7728c4ded596147e15777785e186 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_fresh_session_transitions.snap @@ -0,0 +1,87 @@ +--- +source: tui/src/startup_draft_tests.rs +expression: "snapshots.join(\"\\n---\\n\")" +--- +resume lookup fallback: +before: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + Resuming session… + +› draft while loading + + +after: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› draft while loading + + +--- +fork lookup fallback: +before: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + Forking session… + +› draft while loading + + +after: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› draft while loading + + +--- +resume picker cancellation: +before: +hidden while picker owns input +after: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› Ask Codex to do anything + + ? for shortcuts +--- +fork picker cancellation: +before: +hidden while picker owns input +after: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› Ask Codex to do anything + + ? for shortcuts diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_full_frames.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_full_frames.snap new file mode 100644 index 0000000000000000000000000000000000000000..9195af7637709087bd4522368dd748c7ec8e26e4 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_full_frames.snap @@ -0,0 +1,70 @@ +--- +source: tui/src/startup_draft_tests.rs +expression: "snapshots.join(\"\\n---\\n\")" +--- +empty (48 columns): +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› Ask Codex to do anything + + ? for shortcuts +--- +resuming (48 columns): +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + Resuming session… + +› Ask Codex to do anything + + ? for shortcuts +--- +forking (48 columns): +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + Forking session… + +› draft while loading + + +--- +multiline (48 columns): +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› first startup line + second startup line + + +--- +narrow (18 columns): +╭────────────────╮ +│ >_ OpenAI Cod… │ +│ │ +│ model: lo… │ +│ directory: …ng │ +╰────────────────╯ + + +› first startup + line + second startup + line diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_onboarding_transition.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_onboarding_transition.snap new file mode 100644 index 0000000000000000000000000000000000000000..6284b0715efd2e4d358a280685ac07ea44854fc5 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_onboarding_transition.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/startup_draft_tests.rs +expression: frames +--- +before onboarding: hidden (0x0 viewport) +--- +after onboarding: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› Ask Codex to do anything + + ? for shortcuts diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..49106a3f5b00ec0a8aa6ea86bf3dbe7b5cfe2c65 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/startup_hooks_review.rs +expression: "render_lines(&view, 80)" +--- + + Hooks need review + 2 hooks are new or changed. + Hooks can run outside the sandbox after you trust them. + +› 1. Review hooks + 2. Trust all and continue + 3. Continue without trusting (hooks won't run) + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap new file mode 100644 index 0000000000000000000000000000000000000000..574c31b497d8bcf93bdeb03896aede5c4ba08c7b --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/startup_hooks_review.rs +expression: "render_lines(&view, 62)" +--- + + Hooks need review + 2 hooks are new or changed. + Hooks can run outside the sandbox after you trust them. + Failed to trust hooks: config/batchWrite failed in TUI: + Invalid configuration: features.fast_mode=true is not + supported; allowed set [fast_mode=false] + +› 1. Review hooks + 2. Trust all and continue + 3. Continue without trusting (hooks won't run) + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__summary_shimmer__tests__short_and_long_labels_sweep_smoothly_in_both_themes.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__summary_shimmer__tests__short_and_long_labels_sweep_smoothly_in_both_themes.snap new file mode 100644 index 0000000000000000000000000000000000000000..482ec825dc21681319c70d4e616b8b8d5bc3361a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__summary_shimmer__tests__short_and_long_labels_sweep_smoothly_in_both_themes.snap @@ -0,0 +1,35 @@ +--- +source: tui/src/summary_shimmer_tests.rs +assertion_line: 27 +expression: "frames.join(\"\\n\")" +--- +dark Working 0ms: [128, 128, 128, 128, 128, 128, 128] +dark Working 500ms: [238, 198, 144, 128, 128, 128, 128] +dark Working 1000ms: [128, 156, 212, 240, 212, 156, 128] +dark Working 1500ms: [128, 128, 128, 128, 144, 198, 238] +dark Working 2000ms: [128, 128, 128, 128, 128, 128, 128] +dark Preparing bootstrap diagnostics 0ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128] +dark Preparing bootstrap diagnostics 500ms: [128, 128, 128, 132, 175, 226, 237, 198, 145, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128] +dark Preparing bootstrap diagnostics 1000ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 159, 213, 240, 213, 159, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128] +dark Preparing bootstrap diagnostics 1500ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 145, 198, 237, 226, 175, 132, 128, 128, 128] +dark Preparing bootstrap diagnostics 2000ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128] +dark 界é界 0ms: [128, 128, 128] +dark 界é界 500ms: [198, 129, 128] +dark 界é界 1000ms: [184, 240, 184] +dark 界é界 1500ms: [128, 129, 198] +dark 界é界 2000ms: [128, 128, 128] +light Working 0ms: [128, 128, 128, 128, 128, 128, 128] +light Working 500ms: [17, 57, 111, 128, 128, 128, 128] +light Working 1000ms: [128, 100, 44, 16, 44, 100, 128] +light Working 1500ms: [128, 128, 128, 128, 111, 57, 17] +light Working 2000ms: [128, 128, 128, 128, 128, 128, 128] +light Preparing bootstrap diagnostics 0ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128] +light Preparing bootstrap diagnostics 500ms: [128, 128, 128, 123, 80, 29, 18, 57, 110, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128] +light Preparing bootstrap diagnostics 1000ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 127, 96, 42, 16, 42, 96, 127, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128] +light Preparing bootstrap diagnostics 1500ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 110, 57, 18, 29, 80, 123, 128, 128, 128] +light Preparing bootstrap diagnostics 2000ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128] +light 界é界 0ms: [128, 128, 128] +light 界é界 500ms: [57, 126, 128] +light 界é界 1000ms: [72, 16, 72] +light 界é界 1500ms: [128, 126, 57] +light 界é界 2000ms: [128, 128, 128] diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__hook_status_reflows_with_background_activity.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__hook_status_reflows_with_background_activity.snap new file mode 100644 index 0000000000000000000000000000000000000000..0528ac1820b837031abcd0b39bf0af6bae059680 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__hook_status_reflows_with_background_activity.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status_indicator_widget.rs +expression: "frames.join(\"\\n\")" +--- +124 columns: +"Working (0s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close · checking 日本語 ガパ policy" Hidden by multi-width symbols: [(107, " "), (109, " "), (111, " "), (114, " "), (116, " ")] +" └ existing details " + +123 columns: +"Working (0s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close " +" └ checking 日本語 ガパ policy " Hidden by multi-width symbols: [(14, " "), (16, " "), (18, " "), (21, " "), (23, " ")] +" └ existing details " + +24 columns: +"Working (0s • esc to in…" +" └ checking 日本語 ガ… " Hidden by multi-width symbols: [(14, " "), (16, " "), (18, " "), (21, " ")] +" └ existing details " + +124 columns: +"Working (0s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close · checking 日本語 ガパ policy" Hidden by multi-width symbols: [(107, " "), (109, " "), (111, " "), (114, " "), (116, " ")] +" └ existing details " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__hook_status_reflows_without_background_activity.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__hook_status_reflows_without_background_activity.snap new file mode 100644 index 0000000000000000000000000000000000000000..d15a97e33617a72b2ec88607be15cb2697145e64 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__hook_status_reflows_without_background_activity.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status_indicator_widget.rs +expression: "frames.join(\"\\n\")" +--- +61 columns: +"Working (0s • esc to interrupt) · checking 日本語 ガパ policy" Hidden by multi-width symbols: [(44, " "), (46, " "), (48, " "), (51, " "), (53, " ")] +" └ existing details " + +60 columns: +"Working (0s • esc to interrupt) " +" └ checking 日本語 ガパ policy " Hidden by multi-width symbols: [(14, " "), (16, " "), (18, " "), (21, " "), (23, " ")] +" └ existing details " + +24 columns: +"Working (0s • esc to in…" +" └ checking 日本語 ガ… " Hidden by multi-width symbols: [(14, " "), (16, " "), (18, " "), (21, " ")] +" └ existing details " + +61 columns: +"Working (0s • esc to interrupt) · checking 日本語 ガパ policy" Hidden by multi-width symbols: [(44, " "), (46, " "), (48, " "), (51, " "), (53, " ")] +" └ existing details " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_remapped_interrupt_hint.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_remapped_interrupt_hint.snap new file mode 100644 index 0000000000000000000000000000000000000000..ac32ebe0462598b171a4b9f3790a539605ee9975 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_remapped_interrupt_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/status_indicator_widget.rs +expression: terminal.backend() +--- +"Working (0s • f12 to interrupt) " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap new file mode 100644 index 0000000000000000000000000000000000000000..a46fbcc0439bd8a1cce59699657ed2cb6c2b8be0 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/status_indicator_widget.rs +expression: terminal.backend() +--- +"• Working (0s • esc to interrupt) " +" " +" ↳ first " +" ↳ second " +" alt + ↑ edit " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap new file mode 100644 index 0000000000000000000000000000000000000000..2b80830f81ada33b8b74c3760ef678b5e68386b1 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/status_indicator_widget.rs +expression: terminal.backend() +--- +"• Working (0s • esc to interrupt) " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap new file mode 100644 index 0000000000000000000000000000000000000000..c1f6112fe678b4db0a73686174cad2c9c155ad94 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/status_indicator_widget.rs +expression: terminal.backend() +--- +"Working (0s) " +" └ A man a plan a canal " +" panama " diff --git a/codex-rs/tui/src/snapshots/codex_tui__terminal_hyperlinks__tests__buffer_hyperlinks_follow_scrolled_wrapped_rows.snap b/codex-rs/tui/src/snapshots/codex_tui__terminal_hyperlinks__tests__buffer_hyperlinks_follow_scrolled_wrapped_rows.snap new file mode 100644 index 0000000000000000000000000000000000000000..093045056c37f801dd84cd15e9f1277d64cdc6bd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__terminal_hyperlinks__tests__buffer_hyperlinks_follow_scrolled_wrapped_rows.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/terminal_hyperlinks.rs +expression: terminal.backend() +--- +visible- +link diff --git a/codex-rs/tui/src/snapshots/codex_tui__terminal_hyperlinks__tests__forced_width_hyperlinks_render_wide_and_halfwidth_cells.snap b/codex-rs/tui/src/snapshots/codex_tui__terminal_hyperlinks__tests__forced_width_hyperlinks_render_wide_and_halfwidth_cells.snap new file mode 100644 index 0000000000000000000000000000000000000000..26ea43cdce75abe60bc221d43d3813d6b7330602 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__terminal_hyperlinks__tests__forced_width_hyperlinks_render_wide_and_halfwidth_cells.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/terminal_hyperlinks.rs +expression: terminal.backend() +--- +prefix 漢字 ガ +tail diff --git a/codex-rs/tui/src/snapshots/codex_tui__tui__panic_tests__caught_panic_cursor_commands.snap b/codex-rs/tui/src/snapshots/codex_tui__tui__panic_tests__caught_panic_cursor_commands.snap new file mode 100644 index 0000000000000000000000000000000000000000..94e77246ddd390ebfdf431e52d2144de2bbc8cd2 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__tui__panic_tests__caught_panic_cursor_commands.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/tui_panic_tests.rs +expression: "phases.join(\"\\n\")" +--- +panic hook: \u{1b}[0 q \u{1b}[?25h +hidden repaint: \u{1b}[?25l \u{1b}[1;12H \u{1b}[2;2H \u{1b}[1;1H \u{1b}[2;1H +visible frame: \u{1b}[1;1H \u{1b}[6 q \u{1b}[1;1H \u{1b}[2;5H \u{1b}[?25h diff --git a/codex-rs/tui/src/snapshots/codex_tui__unarchive_prompt__tests__narrow_inline_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__unarchive_prompt__tests__narrow_inline_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..52c5669921e6e9facd9721dbbd0849708dece305 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__unarchive_prompt__tests__narrow_inline_prompt.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/unarchive_prompt_tests.rs +assertion_line: 113 +expression: render_inline(28) +--- +$ codex fork saved-session + + +This conversation is +archived +019e72f4-e09a-70f2-b2c2-a153 +a57b8cc0 + +› 1. Unarchive and fork + 2. Cancel + +Press enter to continue or +esc to cancel diff --git a/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap new file mode 100644 index 0000000000000000000000000000000000000000..24d8831c9561bd7b2229f8d2d572e49a38f01971 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/update_prompt.rs +expression: terminal.backend() +--- + ✨ Update available! 0.0.0 -> 9.9.9 + + Release notes: https://github.com/openai/codex/releases/latest + +› 1. Update now (runs `npm install -g @openai/codex@latest`) + 2. Skip + 3. Skip until next version + + Press enter to continue diff --git a/codex-rs/tui/src/status/account.rs b/codex-rs/tui/src/status/account.rs new file mode 100644 index 0000000000000000000000000000000000000000..7d814a27ac4dc657e100629c1910b321b515a212 --- /dev/null +++ b/codex-rs/tui/src/status/account.rs @@ -0,0 +1,8 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum StatusAccountDisplay { + ChatGpt { + email: Option, + plan: Option, + }, + ApiKey, +} diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs new file mode 100644 index 0000000000000000000000000000000000000000..ad79dcf6f29c665c82e90e33eb8c6859483f560a --- /dev/null +++ b/codex-rs/tui/src/status/card.rs @@ -0,0 +1,952 @@ +use crate::history_cell::CompositeHistoryCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::plain_lines; +use crate::history_cell::with_border_with_inner_width; +use crate::legacy_core::config::Config; +use crate::line_truncation::line_width; +use crate::token_usage::TokenUsage; +use crate::token_usage::TokenUsageInfo; +use crate::version::CODEX_CLI_VERSION; +use crate::width::display_width; +use chrono::DateTime; +use chrono::Local; +use codex_app_server_protocol::AskForApproval; +use codex_model_provider_info::WireApi; +use codex_protocol::ThreadId; +use codex_protocol::account::PlanType; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; +use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_path_uri::PathUri; +use codex_utils_sandbox_summary::summarize_permission_profile; +use ratatui::prelude::*; +use ratatui::style::Stylize; +use std::collections::BTreeSet; +use std::path::PathBuf; + +use super::account::StatusAccountDisplay; +use super::format::FieldFormatter; +use super::format::push_label; +use super::format::truncate_line_to_width; +use super::helpers::compose_account_display; +use super::helpers::compose_model_display; +use super::helpers::format_directory_display; +use super::helpers::format_tokens_compact; +use super::rate_limits::RateLimitSnapshotDisplay; +use super::rate_limits::StatusRateLimitData; +use super::rate_limits::StatusRateLimitRow; +use super::rate_limits::StatusRateLimitValue; +use super::rate_limits::compose_rate_limit_data; +use super::rate_limits::compose_rate_limit_data_many; +use super::rate_limits::format_status_limit_summary; +use super::rate_limits::render_status_limit_progress_bar; +use super::remote_connection::RemoteConnectionStatus; +use super::thread_usage::StatusThreadUsage; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; +use crate::wrapping::word_wrap_lines; +use std::sync::Arc; +use std::sync::RwLock; + +const CHATGPT_USAGE_URL: &str = "https://chatgpt.com/codex/settings/usage"; + +#[derive(Debug, Clone)] +struct StatusContextWindowData { + percent_remaining: i64, + tokens_in_context: i64, + window: i64, +} + +#[derive(Debug, Clone)] +pub(crate) struct StatusTokenUsageData { + total: i64, + input: i64, + output: i64, + context_window: Option, +} + +#[derive(Debug)] +struct StatusRateLimitState { + rate_limits: StatusRateLimitData, + refreshing_rate_limits: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct StatusHistoryHandle { + card: Arc, +} + +impl StatusHistoryHandle { + pub(crate) fn copy_text(&self) -> String { + self.card + .content_lines(u16::MAX) + .iter() + .map(|line| line.to_string().trim_end().to_string()) + .collect::>() + .join("\n") + .trim() + .to_string() + } + + pub(crate) fn reserve_thread_usage_label_width(&self) { + self.card.thread_usage.reserve_label_width(); + } + + pub(crate) fn finish_rate_limit_refresh( + &self, + rate_limits: &[RateLimitSnapshotDisplay], + now: DateTime, + ) { + let rate_limits = if rate_limits.len() <= 1 { + compose_rate_limit_data(rate_limits.first(), now) + } else { + compose_rate_limit_data_many(rate_limits, now) + }; + #[expect(clippy::expect_used)] + let mut state = self + .card + .rate_limit_state + .write() + .expect("status history rate-limit state poisoned"); + state.rate_limits = rate_limits; + state.refreshing_rate_limits = false; + } + + pub(crate) fn set_thread_usage( + &self, + estimate: Option, + ) { + self.card.thread_usage.set_estimate(estimate); + } +} + +#[derive(Debug)] +struct StatusHistoryCell { + model_name: String, + model_details: Vec, + directory: PathBuf, + permissions: String, + agents_summary: Arc>, + collaboration_mode: Option, + model_provider: Option, + remote_connection: Option, + show_chatgpt_usage_link: bool, + account: Option, + thread_name: Option, + session_id: Option, + forked_from: Option, + token_usage: StatusTokenUsageData, + rate_limit_state: Arc>, + thread_usage: StatusThreadUsage, +} + +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +pub(crate) fn new_status_output( + config: &Config, + account_display: Option<&StatusAccountDisplay>, + token_info: Option<&TokenUsageInfo>, + total_usage: &TokenUsage, + session_id: &Option, + thread_name: Option, + forked_from: Option, + rate_limits: Option<&RateLimitSnapshotDisplay>, + _plan_type: Option, + now: DateTime, + model_name: &str, + collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, +) -> CompositeHistoryCell { + let snapshots = rate_limits.map(std::slice::from_ref).unwrap_or_default(); + new_status_output_with_rate_limits( + config, + account_display, + token_info, + total_usage, + session_id, + thread_name, + forked_from, + snapshots, + _plan_type, + now, + model_name, + collaboration_mode, + reasoning_effort_override, + /*refreshing_rate_limits*/ false, + ) +} + +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +pub(crate) fn new_status_output_with_rate_limits( + config: &Config, + account_display: Option<&StatusAccountDisplay>, + token_info: Option<&TokenUsageInfo>, + total_usage: &TokenUsage, + session_id: &Option, + thread_name: Option, + forked_from: Option, + rate_limits: &[RateLimitSnapshotDisplay], + _plan_type: Option, + now: DateTime, + model_name: &str, + collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, + refreshing_rate_limits: bool, +) -> CompositeHistoryCell { + new_status_output_with_rate_limits_handle( + config, + config.model_provider.requires_openai_auth, + /*model_provider_id*/ None, + /*remote_connection*/ None, + account_display, + token_info, + total_usage, + session_id, + thread_name, + forked_from, + rate_limits, + _plan_type, + now, + model_name, + collaboration_mode, + reasoning_effort_override, + "".to_string(), + refreshing_rate_limits, + ) + .0 +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn new_status_output_with_rate_limits_handle( + config: &Config, + requires_openai_auth: bool, + model_provider_id: Option<&str>, + remote_connection: Option<&RemoteConnectionStatus>, + account_display: Option<&StatusAccountDisplay>, + token_info: Option<&TokenUsageInfo>, + total_usage: &TokenUsage, + session_id: &Option, + thread_name: Option, + forked_from: Option, + rate_limits: &[RateLimitSnapshotDisplay], + _plan_type: Option, + now: DateTime, + model_name: &str, + collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, + agents_summary: String, + refreshing_rate_limits: bool, +) -> (CompositeHistoryCell, StatusHistoryHandle) { + let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); + let card = Arc::new(StatusHistoryCell::new( + config, + requires_openai_auth, + model_provider_id, + remote_connection, + account_display, + token_info, + total_usage, + session_id, + thread_name, + forked_from, + rate_limits, + _plan_type, + now, + model_name, + collaboration_mode, + reasoning_effort_override, + agents_summary, + refreshing_rate_limits, + )); + let handle = StatusHistoryHandle { + card: Arc::clone(&card), + }; + + ( + CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]), + handle, + ) +} + +impl StatusHistoryCell { + #[allow(clippy::too_many_arguments)] + fn new( + config: &Config, + requires_openai_auth: bool, + model_provider_id: Option<&str>, + remote_connection: Option<&RemoteConnectionStatus>, + account_display: Option<&StatusAccountDisplay>, + token_info: Option<&TokenUsageInfo>, + total_usage: &TokenUsage, + session_id: &Option, + thread_name: Option, + forked_from: Option, + rate_limits: &[RateLimitSnapshotDisplay], + _plan_type: Option, + now: DateTime, + model_name: &str, + collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, + agents_summary: String, + refreshing_rate_limits: bool, + ) -> Self { + let approval_policy = AskForApproval::from(config.permissions.approval_policy.value()); + let permission_profile = config.permissions.effective_permission_profile(); + let workspace_roots = config.effective_workspace_roots(); + let cwd = PathUri::from_abs_path(&config.cwd); + let model_provider = model_provider_id + .filter(|id| !id.trim().is_empty()) + .map(str::to_string); + let mut config_entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", model_name.to_string()), + ( + "approval", + config.permissions.approval_policy.value().to_string(), + ), + ( + "sandbox", + summarize_permission_profile(&permission_profile, &cwd, &workspace_roots), + ), + ]; + if let Some(provider_id) = &model_provider { + config_entries.insert(2, ("provider", provider_id.clone())); + } + if config.model_provider.wire_api == WireApi::Responses { + let effort_value = reasoning_effort_override + .unwrap_or_else(|| config.model_reasoning_effort.clone()) + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()); + config_entries.push(("reasoning effort", effort_value)); + config_entries.push(( + "reasoning summaries", + config + .model_reasoning_summary + .map(|summary| summary.to_string()) + .unwrap_or_else(|| "auto".to_string()), + )); + } + let (model_name, model_details) = compose_model_display(model_name, &config_entries); + let approval = config_entries + .iter() + .find(|(k, _)| *k == "approval") + .map(|(_, v)| v.clone()) + .unwrap_or_else(|| "".to_string()); + let active_permission_profile = config.permissions.active_permission_profile(); + let sandbox = status_permission_summary(&permission_profile, &cwd, &workspace_roots); + let workspace_root_suffix = workspace_root_suffix(&workspace_roots, &cwd); + let approval = status_approval_label(approval_policy, config.approvals_reviewer, &approval); + let permissions = status_permissions_label( + active_permission_profile.as_ref(), + &permission_profile, + approval_policy, + &sandbox, + &approval, + workspace_root_suffix.as_deref(), + ); + let show_chatgpt_usage_link = requires_openai_auth; + let account = compose_account_display(account_display); + let session_id = session_id.as_ref().map(std::string::ToString::to_string); + let forked_from = forked_from.map(|id| id.to_string()); + let default_usage = TokenUsage::default(); + let (context_usage, context_window) = match token_info { + Some(info) => (&info.last_token_usage, info.model_context_window), + None => (&default_usage, config.model_context_window), + }; + let context_window = context_window.map(|window| StatusContextWindowData { + percent_remaining: context_usage.percent_of_context_window_remaining(window), + tokens_in_context: context_usage.tokens_in_context_window(), + window, + }); + + let token_usage = StatusTokenUsageData { + total: total_usage.blended_total(), + input: total_usage.non_cached_input(), + output: total_usage.output_tokens, + context_window, + }; + let rate_limits = if rate_limits.len() <= 1 { + compose_rate_limit_data(rate_limits.first(), now) + } else { + compose_rate_limit_data_many(rate_limits, now) + }; + let rate_limit_state = Arc::new(RwLock::new(StatusRateLimitState { + rate_limits, + refreshing_rate_limits, + })); + let agents_summary = Arc::new(RwLock::new(agents_summary)); + let thread_usage = StatusThreadUsage::default(); + + Self { + model_name, + model_details, + directory: config.cwd.to_path_buf(), + permissions, + collaboration_mode: collaboration_mode.map(ToString::to_string), + model_provider, + remote_connection: remote_connection.cloned(), + show_chatgpt_usage_link, + account, + thread_name, + session_id, + forked_from, + token_usage, + agents_summary, + rate_limit_state, + thread_usage, + } + } + + fn token_usage_spans(&self) -> Vec> { + let total_fmt = format_tokens_compact(self.token_usage.total); + let input_fmt = format_tokens_compact(self.token_usage.input); + let output_fmt = format_tokens_compact(self.token_usage.output); + + vec![ + Span::from(total_fmt), + Span::from(" total "), + Span::from(" (").dim(), + Span::from(input_fmt).dim(), + Span::from(" input").dim(), + Span::from(" + ").dim(), + Span::from(output_fmt).dim(), + Span::from(" output").dim(), + Span::from(")").dim(), + ] + } + + fn context_window_spans(&self) -> Option>> { + let context = self.token_usage.context_window.as_ref()?; + let percent = context.percent_remaining; + let used_fmt = format_tokens_compact(context.tokens_in_context); + let window_fmt = format_tokens_compact(context.window); + + Some(vec![ + Span::from(format!("{percent}% left")), + Span::from(" (").dim(), + Span::from(used_fmt).dim(), + Span::from(" used / ").dim(), + Span::from(window_fmt).dim(), + Span::from(")").dim(), + ]) + } + + fn rate_limit_lines( + &self, + state: &StatusRateLimitState, + available_inner_width: usize, + formatter: &FieldFormatter, + ) -> Vec> { + match &state.rate_limits { + StatusRateLimitData::Available(rows_data) => { + if rows_data.is_empty() { + return vec![formatter.line( + "Limits", + vec![Span::from("not available for this account").dim()], + )]; + } + + self.rate_limit_row_lines(rows_data, available_inner_width, formatter) + } + StatusRateLimitData::Stale(rows_data) => { + let mut lines = + self.rate_limit_row_lines(rows_data, available_inner_width, formatter); + lines.push(formatter.line( + "Warning", + vec![Span::from(if state.refreshing_rate_limits { + "limits may be stale - run /status again shortly." + } else { + "limits may be stale - start new turn to refresh." + }) + .dim()], + )); + lines + } + StatusRateLimitData::Unavailable => { + vec![formatter.line( + "Limits", + vec![Span::from("not available for this account").dim()], + )] + } + StatusRateLimitData::Missing => { + vec![formatter.line( + "Limits", + vec![Span::from(if state.refreshing_rate_limits { + "refresh requested; run /status again shortly." + } else { + "data not available yet" + }) + .dim()], + )] + } + } + } + + fn rate_limit_row_lines( + &self, + rows: &[StatusRateLimitRow], + available_inner_width: usize, + formatter: &FieldFormatter, + ) -> Vec> { + let mut lines = Vec::with_capacity(rows.len().saturating_mul(2)); + + for row in rows { + match &row.value { + StatusRateLimitValue::Window { + percent_used, + resets_at, + details, + } => { + let percent_remaining = (100.0 - percent_used).clamp(0.0, 100.0); + let summary = format_status_limit_summary(percent_remaining); + let full_value_spans = vec![ + Span::from(render_status_limit_progress_bar(percent_remaining)), + Span::from(" "), + Span::from(summary.clone()), + ]; + // On narrow terminals, keep the percentage visible rather than + // letting the fixed-width progress bar crowd out the reset time. + let value_spans = if line_width(&Line::from(full_value_spans.clone())) + <= formatter.value_width(available_inner_width) + { + full_value_spans + } else { + vec![Span::from(summary)] + }; + let base_spans = formatter.full_spans(row.label.as_str(), value_spans); + let base_line = Line::from(base_spans.clone()); + + if let Some(resets_at) = resets_at.as_ref() { + let resets_span = Span::from(format!("(resets {resets_at})")).dim(); + let mut inline_spans = base_spans.clone(); + inline_spans.push(Span::from(" ").dim()); + inline_spans.push(resets_span.clone()); + + if line_width(&Line::from(inline_spans.clone())) <= available_inner_width { + lines.push(Line::from(inline_spans)); + } else { + lines.push(base_line); + let reset_text = format!("(resets {resets_at})"); + let reset_width = formatter.value_width(available_inner_width).max(1); + let wrap_options = + textwrap::Options::new(reset_width).break_words(false); + // Reset timestamps are the actionable part of this row, so wrap them + // onto continuation lines instead of truncating partial times/dates. + lines.extend( + textwrap::wrap(reset_text.as_str(), wrap_options) + .into_iter() + .map(|wrapped| { + formatter.continuation(vec![ + Span::from(wrapped.into_owned()).dim(), + ]) + }), + ); + } + } else { + lines.push(base_line); + } + if let Some(details) = details { + let detail_width = formatter.value_width(available_inner_width).max(1); + let wrap_options = textwrap::Options::new(detail_width).break_words(false); + lines.extend( + textwrap::wrap(details.as_str(), wrap_options) + .into_iter() + .map(|wrapped| { + formatter + .continuation(vec![Span::from(wrapped.into_owned()).dim()]) + }), + ); + } + } + StatusRateLimitValue::Text(text) => { + let label = row.label.clone(); + let spans = + formatter.full_spans(label.as_str(), vec![Span::from(text.clone())]); + lines.push(Line::from(spans)); + } + } + } + + lines + } + + fn collect_rate_limit_labels( + &self, + state: &StatusRateLimitState, + seen: &mut BTreeSet, + labels: &mut Vec, + ) { + match &state.rate_limits { + StatusRateLimitData::Available(rows) => { + if rows.is_empty() { + push_label(labels, seen, "Limits"); + } else { + for row in rows { + push_label(labels, seen, row.label.as_str()); + } + } + } + StatusRateLimitData::Stale(rows) => { + for row in rows { + push_label(labels, seen, row.label.as_str()); + } + push_label(labels, seen, "Warning"); + } + StatusRateLimitData::Unavailable => push_label(labels, seen, "Limits"), + StatusRateLimitData::Missing => push_label(labels, seen, "Limits"), + } + } +} + +fn status_permission_summary( + permission_profile: &PermissionProfile, + cwd: &PathUri, + workspace_roots: &[PathUri], +) -> String { + let summary = summarize_permission_profile(permission_profile, cwd, workspace_roots); + if let Some(details) = summary.strip_prefix("read-only") { + if details.contains("(network access enabled)") { + return "read-only with network access".to_string(); + } + return "read-only".to_string(); + } + if let Some(details) = summary.strip_prefix("workspace-write") { + if details.contains("(network access enabled)") { + return "workspace with network access".to_string(); + } + return "workspace".to_string(); + } + if summary == "custom permissions (network access enabled)" { + return "custom permissions with network access".to_string(); + } + summary +} + +fn workspace_root_suffix(workspace_roots: &[PathUri], cwd: &PathUri) -> Option { + let extra_roots = workspace_roots + .iter() + .filter(|root| root.to_string() != cwd.to_string()) + .map(PathUri::inferred_native_path_string) + .collect::>(); + if extra_roots.is_empty() { + None + } else { + Some(format!(" [{}]", extra_roots.join(", "))) + } +} + +fn status_permissions_label( + active_permission_profile: Option<&ActivePermissionProfile>, + permission_profile: &PermissionProfile, + approval_policy: AskForApproval, + sandbox: &str, + approval: &str, + workspace_root_suffix: Option<&str>, +) -> String { + let active_id = active_permission_profile.map(|active| active.id.as_str()); + match active_id { + Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY) => { + let label = if sandbox == "read-only with network access" { + "Read Only with network access" + } else { + "Read Only" + }; + return format!("{label} ({approval})"); + } + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) => match sandbox { + "workspace" => { + return format!( + "Workspace{} ({approval})", + workspace_root_suffix.unwrap_or("") + ); + } + "workspace with network access" => { + return format!( + "Workspace with network access{} ({approval})", + workspace_root_suffix.unwrap_or("") + ); + } + _ => {} + }, + Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS) + if permission_profile == &PermissionProfile::Disabled => + { + return if approval_policy == AskForApproval::Never { + "Full Access".to_string() + } else { + format!("No Sandbox ({approval})") + }; + } + Some(id) => { + let sandbox = decorate_workspace_sandbox_label(sandbox, workspace_root_suffix); + return format!("Profile {id} ({sandbox}, {approval})"); + } + None => {} + } + + if sandbox == "read-only" { + return format!("Read Only ({approval})"); + } + if approval_policy == AskForApproval::OnRequest && sandbox == "workspace" { + return format!( + "Workspace{} ({approval})", + workspace_root_suffix.unwrap_or("") + ); + } + if approval_policy == AskForApproval::Never + && permission_profile == &PermissionProfile::Disabled + { + return "Full Access".to_string(); + } + let sandbox = decorate_workspace_sandbox_label(sandbox, workspace_root_suffix); + format!("Custom ({sandbox}, {approval})") +} + +fn decorate_workspace_sandbox_label(sandbox: &str, workspace_root_suffix: Option<&str>) -> String { + match workspace_root_suffix { + Some(suffix) if sandbox.starts_with("workspace") => format!("{sandbox}{suffix}"), + _ => sandbox.to_string(), + } +} + +fn status_approval_label( + approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, + approval: &str, +) -> String { + if approval_policy == AskForApproval::OnRequest { + return match approvals_reviewer { + ApprovalsReviewer::AutoReview => "Approve for me".to_string(), + ApprovalsReviewer::User => "Ask for approval".to_string(), + }; + } + + approval.to_string() +} + +impl StatusHistoryCell { + fn content_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from(vec![ + Span::from(format!("{}>_ ", FieldFormatter::INDENT)).dim(), + Span::from("OpenAI Codex").bold(), + Span::from(" ").dim(), + Span::from(format!("(v{CODEX_CLI_VERSION})")).dim(), + ])); + + let available_inner_width = usize::from(width.saturating_sub(4)); + if available_inner_width == 0 { + return Vec::new(); + } + + let account_value = self.account.as_ref().map(|account| match account { + StatusAccountDisplay::ChatGpt { email, plan } => match (email, plan) { + (Some(email), Some(plan)) => format!("{email} ({plan})"), + (Some(email), None) => email.clone(), + (None, Some(plan)) => plan.clone(), + (None, None) => "ChatGPT".to_string(), + }, + StatusAccountDisplay::ApiKey => { + "API key configured (run codex login to use ChatGPT)".to_string() + } + }); + + let mut labels: Vec = vec!["Model", "Directory", "Permissions", "Agents.md"] + .into_iter() + .map(str::to_string) + .collect(); + let mut seen: BTreeSet = labels.iter().cloned().collect(); + let thread_name = self.thread_name.as_deref().filter(|name| !name.is_empty()); + #[expect(clippy::expect_used)] + let rate_limit_state = self + .rate_limit_state + .read() + .expect("status history rate-limit state poisoned"); + #[expect(clippy::expect_used)] + let agents_summary = self + .agents_summary + .read() + .expect("status history agents summary state poisoned") + .clone(); + + if self.model_provider.is_some() { + push_label(&mut labels, &mut seen, "Model provider"); + } + if account_value.is_some() { + push_label(&mut labels, &mut seen, "Account"); + } + if thread_name.is_some() { + push_label(&mut labels, &mut seen, "Thread name"); + } + if self.session_id.is_some() { + push_label(&mut labels, &mut seen, "Session"); + } + if self.session_id.is_some() && self.forked_from.is_some() { + push_label(&mut labels, &mut seen, "Forked from"); + } + if self.collaboration_mode.is_some() { + push_label(&mut labels, &mut seen, "Collaboration mode"); + } + push_label(&mut labels, &mut seen, "Token usage"); + if self.token_usage.context_window.is_some() { + push_label(&mut labels, &mut seen, "Context window"); + } + self.collect_rate_limit_labels(&rate_limit_state, &mut seen, &mut labels); + self.thread_usage.push_labels(&mut labels, &mut seen); + + let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str)); + let value_width = formatter.value_width(available_inner_width); + + let note_first_line = Line::from(vec![ + Span::from("Visit ").cyan(), + CHATGPT_USAGE_URL.cyan().underlined(), + Span::from(" for up-to-date").cyan(), + ]); + let note_second_line = Line::from(vec![ + Span::from("information on rate limits and credits").cyan(), + ]); + let note_lines = adaptive_wrap_lines( + [note_first_line, note_second_line], + RtOptions::new(available_inner_width), + ); + lines.push(Line::from(Vec::>::new())); + // The ChatGPT usage page only applies to providers backed by OpenAI auth; + // providers like Bedrock manage limits and billing elsewhere. + if self.show_chatgpt_usage_link { + lines.extend(note_lines); + lines.push(Line::from(Vec::>::new())); + } + if let Some(remote_connection) = self.remote_connection.as_ref() { + let wrapped_remote = word_wrap_lines( + [Line::from(vec![ + Span::from(remote_connection.address.clone()), + Span::from(" (").dim(), + Span::from(remote_connection.version.clone()).dim(), + Span::from(")").dim(), + ])], + RtOptions::new(value_width.max(1)), + ); + let mut wrapped_remote = wrapped_remote.into_iter(); + if let Some(first) = wrapped_remote.next() { + lines.push(formatter.line("Remote", first.spans)); + lines.extend(wrapped_remote.map(|line| formatter.continuation(line.spans))); + } + lines.push(Line::from(Vec::>::new())); + } + + let mut model_spans = vec![Span::from(self.model_name.clone())]; + if !self.model_details.is_empty() { + model_spans.push(Span::from(" (").dim()); + model_spans.push(Span::from(self.model_details.join(", ")).dim()); + model_spans.push(Span::from(")").dim()); + } + + let directory_value = format_directory_display(&self.directory, Some(value_width)); + + lines.push(formatter.line("Model", model_spans)); + if let Some(model_provider) = self.model_provider.as_ref() { + lines.push(formatter.line("Model provider", vec![Span::from(model_provider.clone())])); + } + lines.push(formatter.line("Directory", vec![Span::from(directory_value)])); + lines.push(formatter.line("Permissions", vec![Span::from(self.permissions.clone())])); + lines.push(formatter.line("Agents.md", vec![Span::from(agents_summary)])); + + if let Some(account_value) = account_value { + lines.push(formatter.line("Account", vec![Span::from(account_value)])); + } + + if let Some(thread_name) = thread_name { + lines.push(formatter.line("Thread name", vec![Span::from(thread_name.to_string())])); + } + if let Some(collab_mode) = self.collaboration_mode.as_ref() { + lines.push(formatter.line("Collaboration mode", vec![Span::from(collab_mode.clone())])); + } + if let Some(session) = self.session_id.as_ref() { + lines.push(formatter.line("Session", vec![Span::from(session.clone())])); + } + if self.session_id.is_some() + && let Some(forked_from) = self.forked_from.as_ref() + { + lines.push(formatter.line("Forked from", vec![Span::from(forked_from.clone())])); + } + + lines.push(Line::from(Vec::>::new())); + // Hide token usage only for ChatGPT subscribers + if !matches!(self.account, Some(StatusAccountDisplay::ChatGpt { .. })) { + lines.push(formatter.line("Token usage", self.token_usage_spans())); + } + + if let Some(spans) = self.context_window_spans() { + lines.push(formatter.line("Context window", spans)); + } + + lines.extend(self.rate_limit_lines(&rate_limit_state, available_inner_width, &formatter)); + let thread_usage_lines = self.thread_usage.lines(&formatter, value_width); + if !thread_usage_lines.is_empty() { + lines.push(Line::from(Vec::>::new())); + lines.extend(thread_usage_lines); + } + + lines + } +} + +impl HistoryCell for Arc { + fn display_lines(&self, width: u16) -> Vec> { + let available_inner_width = usize::from(width.saturating_sub(4)); + if available_inner_width == 0 { + return Vec::new(); + } + let lines = self.content_lines(width); + let content_width = lines.iter().map(line_width).max().unwrap_or(0); + let inner_width = content_width.min(available_inner_width); + let truncated_lines: Vec> = lines + .into_iter() + .map(|line| truncate_line_to_width(line, inner_width)) + .collect(); + + with_border_with_inner_width(truncated_lines, inner_width) + } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } + + fn display_hyperlink_lines( + &self, + width: u16, + ) -> Vec { + let mut lines = + crate::terminal_hyperlinks::plain_hyperlink_lines(self.display_lines(width)); + for line in &mut lines { + let visible = line + .line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + if let Some(start_byte) = visible.find(CHATGPT_USAGE_URL) { + let start = display_width(&visible[..start_byte]); + line.hyperlinks + .push(crate::terminal_hyperlinks::TerminalHyperlink::web( + start..start + display_width(CHATGPT_USAGE_URL), + CHATGPT_USAGE_URL.to_string(), + )); + } + } + lines + } + + fn transcript_hyperlink_lines( + &self, + width: u16, + ) -> Vec { + self.display_hyperlink_lines(width) + } +} diff --git a/codex-rs/tui/src/status/format.rs b/codex-rs/tui/src/status/format.rs new file mode 100644 index 0000000000000000000000000000000000000000..e7ff9fd1386d434b2372797a72696a0a8c4ff899 --- /dev/null +++ b/codex-rs/tui/src/status/format.rs @@ -0,0 +1,142 @@ +use ratatui::prelude::*; +use ratatui::style::Stylize; +use std::collections::BTreeSet; +use unicode_segmentation::UnicodeSegmentation; + +use crate::width::display_width; + +#[derive(Debug, Clone)] +pub(crate) struct FieldFormatter { + indent: &'static str, + label_width: usize, + value_offset: usize, + value_indent: String, +} + +impl FieldFormatter { + pub(crate) const INDENT: &'static str = " "; + + pub(crate) fn from_labels(labels: impl IntoIterator) -> Self + where + S: AsRef, + { + let label_width = labels + .into_iter() + .map(|label| display_width(label.as_ref())) + .max() + .unwrap_or(0); + let indent_width = display_width(Self::INDENT); + let value_offset = indent_width + label_width + 1 + 3; + + Self { + indent: Self::INDENT, + label_width, + value_offset, + value_indent: " ".repeat(value_offset), + } + } + + pub(crate) fn line( + &self, + label: &'static str, + value_spans: Vec>, + ) -> Line<'static> { + Line::from(self.full_spans(label, value_spans)) + } + + pub(crate) fn continuation(&self, mut spans: Vec>) -> Line<'static> { + let mut all_spans = Vec::with_capacity(spans.len() + 1); + all_spans.push(Span::from(self.value_indent.clone()).dim()); + all_spans.append(&mut spans); + Line::from(all_spans) + } + + pub(crate) fn value_width(&self, available_inner_width: usize) -> usize { + available_inner_width.saturating_sub(self.value_offset) + } + + pub(crate) fn full_spans( + &self, + label: &str, + mut value_spans: Vec>, + ) -> Vec> { + let mut spans = Vec::with_capacity(value_spans.len() + 1); + spans.push(self.label_span(label)); + spans.append(&mut value_spans); + spans + } + + fn label_span(&self, label: &str) -> Span<'static> { + let mut buf = String::with_capacity(self.value_offset); + buf.push_str(self.indent); + + buf.push_str(label); + buf.push(':'); + + let label_width = display_width(label); + let padding = 3 + self.label_width.saturating_sub(label_width); + for _ in 0..padding { + buf.push(' '); + } + + Span::from(buf).dim() + } +} + +pub(crate) fn push_label(labels: &mut Vec, seen: &mut BTreeSet, label: &str) { + if seen.contains(label) { + return; + } + + let owned = label.to_string(); + seen.insert(owned.clone()); + labels.push(owned); +} + +pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> { + if max_width == 0 { + return Line::from(Vec::>::new()); + } + + let mut used = 0usize; + let mut spans_out: Vec> = Vec::new(); + + for span in line.spans { + let text = span.content.into_owned(); + let style = span.style; + let span_width = display_width(text.as_str()); + + if span_width == 0 { + spans_out.push(Span::styled(text, style)); + continue; + } + + if used >= max_width { + break; + } + + if used + span_width <= max_width { + used += span_width; + spans_out.push(Span::styled(text, style)); + continue; + } + + let mut truncated = String::new(); + for grapheme in text.graphemes(/*is_extended*/ true) { + let grapheme_width = display_width(grapheme); + if used + grapheme_width > max_width { + break; + } + truncated.push_str(grapheme); + used += grapheme_width; + } + + if !truncated.is_empty() { + spans_out.push(Span::styled(truncated, style)); + } + + break; + } + + Line::from(spans_out) +} diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs new file mode 100644 index 0000000000000000000000000000000000000000..b12a0692f02dc9fea1833f16c0441c20ec0ba586 --- /dev/null +++ b/codex-rs/tui/src/status/helpers.rs @@ -0,0 +1,364 @@ +use crate::exec_command::relativize_to_home; +use crate::legacy_core::config::Config; +use crate::status::StatusAccountDisplay; +use crate::text_formatting; +use crate::width::display_width; +use chrono::DateTime; +use chrono::Local; +use codex_protocol::account::PlanType; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; +use std::path::Path; + +fn normalize_agents_display_path(path: &Path) -> String { + format_directory_display(dunce::simplified(path), /*max_width*/ None) +} + +pub(crate) fn compose_model_display( + model_name: &str, + entries: &[(&str, String)], +) -> (String, Vec) { + let mut details: Vec = Vec::new(); + if let Some((_, effort)) = entries.iter().find(|(k, _)| *k == "reasoning effort") { + details.push(format!("reasoning {}", effort.to_ascii_lowercase())); + } + if let Some((_, summary)) = entries.iter().find(|(k, _)| *k == "reasoning summaries") { + let summary = summary.trim(); + if summary.eq_ignore_ascii_case("none") || summary.eq_ignore_ascii_case("off") { + details.push("summaries off".to_string()); + } else if !summary.is_empty() { + details.push(format!("summaries {}", summary.to_ascii_lowercase())); + } + } + + (model_name.to_string(), details) +} + +pub(crate) fn compose_agents_summary(config: &Config, paths: &[PathUri]) -> String { + let mut rels: Vec = Vec::new(); + + for path in paths { + // TODO(anp): Rationalize instruction-source summaries with the TUI's broader foreign-path + // display strategy once other status surfaces can retain environment-native paths. + if path.infer_path_convention() != Some(PathConvention::native()) { + rels.push(path.inferred_native_path_string()); + continue; + } + let Ok(p) = path.to_abs_path() else { + rels.push(path.inferred_native_path_string()); + continue; + }; + let p = p.as_path(); + let file_name = p + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "".to_string()); + let display = if let Some(parent) = p.parent() { + if parent == config.cwd.as_path() { + file_name.clone() + } else { + let mut cur = config.cwd.as_path(); + let mut ups = 0usize; + let mut reached = false; + while let Some(c) = cur.parent() { + if cur == parent { + reached = true; + break; + } + cur = c; + ups += 1; + } + if reached { + let up = format!("..{}", std::path::MAIN_SEPARATOR); + format!("{}{}", up.repeat(ups), file_name) + } else if let Ok(stripped) = p.strip_prefix(&config.cwd) { + normalize_agents_display_path(stripped) + } else { + normalize_agents_display_path(p) + } + } + } else { + normalize_agents_display_path(p) + }; + rels.push(display); + } + + if rels.is_empty() { + "".to_string() + } else { + rels.join(", ") + } +} + +pub(crate) fn compose_account_display( + account_display: Option<&StatusAccountDisplay>, +) -> Option { + account_display.cloned() +} + +pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String { + if plan_type == PlanType::EnterpriseCbpAutomation { + "Enterprise (Automation)".to_string() + } else if plan_type == PlanType::SelfServeBusinessProLite { + "Business Premium".to_string() + } else if plan_type.is_team_like() { + "Business".to_string() + } else if plan_type.is_business_like() { + "Enterprise".to_string() + } else if plan_type == PlanType::ProLite { + "Pro Lite".to_string() + } else if plan_type == PlanType::EduPlus { + "Edu Plus".to_string() + } else if plan_type == PlanType::EduPro { + "Edu Pro".to_string() + } else { + title_case(format!("{plan_type:?}").as_str()) + } +} + +pub(crate) fn format_tokens_compact(value: i64) -> String { + let value = value.max(0); + if value == 0 { + return "0".to_string(); + } + if value < 1_000 { + return value.to_string(); + } + + let value_f64 = value as f64; + let (scaled, suffix) = if value >= 1_000_000_000_000 { + (value_f64 / 1_000_000_000_000.0, "T") + } else if value >= 1_000_000_000 { + (value_f64 / 1_000_000_000.0, "B") + } else if value >= 1_000_000 { + (value_f64 / 1_000_000.0, "M") + } else { + (value_f64 / 1_000.0, "K") + }; + + let decimals = if scaled < 10.0 { + 2 + } else if scaled < 100.0 { + 1 + } else { + 0 + }; + + let mut formatted = format!("{scaled:.decimals$}"); + if formatted.contains('.') { + while formatted.ends_with('0') { + formatted.pop(); + } + if formatted.ends_with('.') { + formatted.pop(); + } + } + + format!("{formatted}{suffix}") +} + +pub(crate) fn format_directory_display(directory: &Path, max_width: Option) -> String { + let formatted = if let Some(rel) = relativize_to_home(directory) { + if rel.as_os_str().is_empty() { + "~".to_string() + } else { + format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) + } + } else { + directory.display().to_string() + }; + + if let Some(max_width) = max_width { + if max_width == 0 { + return String::new(); + } + if display_width(&formatted) > max_width { + return text_formatting::center_truncate_path(&formatted, max_width); + } + } + + formatted +} + +pub(crate) fn format_reset_timestamp(dt: DateTime, captured_at: DateTime) -> String { + let time = dt.format("%H:%M").to_string(); + if dt.date_naive() == captured_at.date_naive() { + time + } else { + format!("{time} on {}", dt.format("%-d %b")) + } +} + +fn title_case(s: &str) -> String { + if s.is_empty() { + return String::new(); + } + let mut chars = s.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + let rest = chars.as_str().to_ascii_lowercase(); + first.to_uppercase().collect::() + &rest +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_core::config::ConfigBuilder; + use codex_utils_absolute_path::test_support::PathBufExt; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + async fn test_config(codex_home: &TempDir, cwd: &TempDir) -> Config { + ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("load config") + } + + #[test] + fn plan_type_display_name_remaps_display_labels() { + let cases = [ + (PlanType::Free, "Free"), + (PlanType::Go, "Go"), + (PlanType::Plus, "Plus"), + (PlanType::Pro, "Pro"), + (PlanType::ProLite, "Pro Lite"), + (PlanType::Team, "Business"), + (PlanType::SelfServeBusinessUsageBased, "Business"), + (PlanType::Business, "Enterprise"), + (PlanType::EnterpriseCbpAutomation, "Enterprise (Automation)"), + (PlanType::EnterpriseCbpUsageBased, "Enterprise"), + (PlanType::Enterprise, "Enterprise"), + (PlanType::Edu, "Edu"), + (PlanType::Unknown, "Unknown"), + ]; + + for (plan_type, expected) in cases { + assert_eq!(plan_type_display_name(plan_type), expected); + } + insta::assert_snapshot!( + plan_type_display_name(PlanType::SelfServeBusinessProLite), + @"Business Premium" + ); + insta::assert_snapshot!( + "education_plan_display_names", + [PlanType::Edu, PlanType::EduPlus, PlanType::EduPro] + .map(plan_type_display_name) + .join("\n") + ); + } + + #[test] + fn format_directory_display_truncates_halfwidth_sound_marks() { + let directory = Path::new("workspace").join("ガ").join("project"); + let max_width = display_width(directory.to_string_lossy().as_ref()) - 1; + let formatted = format_directory_display(&directory, Some(max_width)); + + insta::assert_snapshot!(formatted.replace('\\', "/"), @"workspace/…/project"); + } + + #[tokio::test] + async fn compose_agents_summary_includes_global_agents_path() { + let codex_home = TempDir::new().expect("temp codex home"); + let cwd = TempDir::new().expect("temp cwd"); + let global_agents_path = codex_home.path().join("global.md"); + let config = test_config(&codex_home, &cwd).await; + + assert_eq!( + compose_agents_summary( + &config, + &[PathUri::from_abs_path(&global_agents_path.abs())] + ), + format_directory_display(&global_agents_path, /*max_width*/ None) + ); + } + + #[tokio::test] + async fn compose_agents_summary_collapses_home_and_preserves_project_relative_paths() { + let Some(home) = dirs::home_dir() else { + return; + }; + let codex_home = TempDir::new().expect("temp codex home"); + let cwd = TempDir::new().expect("temp cwd"); + let mut config = test_config(&codex_home, &cwd).await; + config.cwd = home.join("workspace").join("project").abs(); + + let paths = [ + home.join(".codex").join("AGENTS.md"), + home.join("workspace").join("AGENTS.md"), + config.cwd.join("AGENTS.md").to_path_buf(), + config.cwd.join("nested").join("AGENTS.md").to_path_buf(), + ] + .map(|path| PathUri::from_abs_path(&path.abs())); + + let summary = compose_agents_summary(&config, &paths); + + insta::assert_snapshot!( + summary.replace('\\', "/"), + @"~/.codex/AGENTS.md, ../AGENTS.md, AGENTS.md, nested/AGENTS.md" + ); + } + + #[tokio::test] + async fn compose_agents_summary_names_global_agents_override() { + let codex_home = TempDir::new().expect("temp codex home"); + let cwd = TempDir::new().expect("temp cwd"); + let override_path = codex_home.path().join("override.md"); + let config = test_config(&codex_home, &cwd).await; + + assert_eq!( + compose_agents_summary(&config, &[PathUri::from_abs_path(&override_path.abs())]), + format_directory_display(&override_path, /*max_width*/ None) + ); + } + + #[tokio::test] + async fn compose_agents_summary_shows_relative_native_and_full_foreign_paths() { + let codex_home = TempDir::new().expect("temp codex home"); + let cwd = TempDir::new().expect("temp cwd"); + let config = test_config(&codex_home, &cwd).await; + let native_source = PathUri::from_abs_path(&config.cwd.join("AGENTS.md")); + let foreign_source = if cfg!(windows) { + PathUri::parse("file:///remote%20workspace/AGENTS.md") + .expect("POSIX instruction source") + } else { + PathUri::parse("file:///C:/remote%20workspace/AGENTS.md") + .expect("Windows instruction source") + }; + + let summary = compose_agents_summary(&config, &[native_source, foreign_source]); + if cfg!(windows) { + insta::assert_snapshot!(summary, @r"AGENTS.md, /remote workspace/AGENTS.md"); + } else { + insta::assert_snapshot!(summary, @r"AGENTS.md, C:\remote workspace\AGENTS.md"); + } + } + + #[tokio::test] + async fn compose_agents_summary_orders_global_before_project_agents() { + let codex_home = TempDir::new().expect("temp codex home"); + let cwd = TempDir::new().expect("temp cwd"); + let global_agents_path = codex_home.path().join("global.md"); + let project_agents_path = cwd.path().join("project.md"); + let config = test_config(&codex_home, &cwd).await; + + let summary = compose_agents_summary( + &config, + &[ + PathUri::from_abs_path(&global_agents_path.clone().abs()), + PathUri::from_abs_path(&project_agents_path.clone().abs()), + ], + ); + let mut paths = summary.split(", "); + assert_eq!( + paths.next(), + Some(format_directory_display(&global_agents_path, /*max_width*/ None).as_str()) + ); + let project_path = paths.next().expect("project agents path"); + assert!(project_path.ends_with("project.md")); + assert_eq!(paths.next(), None); + } +} diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..43959526fa7206be754e4da052cc1fcb858f743d --- /dev/null +++ b/codex-rs/tui/src/status/mod.rs @@ -0,0 +1,38 @@ +//! Status output formatting and display adapters for the TUI. +//! +//! This module turns protocol-level snapshots into stable display structures used by `/status` +//! output and footer/status-line helpers, while keeping rendering concerns out of transport-facing +//! code. +//! +//! `rate_limits` is the main integration point for status-line usage-limit items: it converts raw +//! window snapshots into local-time labels and classifies data as available, stale, or missing. +mod account; +mod card; +mod format; +mod helpers; +mod rate_limits; +pub(crate) mod remote_connection; +mod thread_usage; + +pub(crate) use account::StatusAccountDisplay; +pub(crate) use card::StatusHistoryHandle; +#[cfg(test)] +pub(crate) use card::new_status_output; +#[cfg(test)] +pub(crate) use card::new_status_output_with_rate_limits; +pub(crate) use card::new_status_output_with_rate_limits_handle; +pub(crate) use helpers::compose_agents_summary; +pub(crate) use helpers::format_directory_display; +pub(crate) use helpers::format_reset_timestamp; +pub(crate) use helpers::format_tokens_compact; +pub(crate) use helpers::plan_type_display_name; +pub(crate) use rate_limits::RateLimitSnapshotDisplay; +pub(crate) use rate_limits::RateLimitWindowDisplay; +#[cfg(test)] +pub(crate) use rate_limits::rate_limit_snapshot_display; +pub(crate) use rate_limits::rate_limit_snapshot_display_for_limit; +pub(crate) use thread_usage::format_credit_micros; +pub(crate) use thread_usage::format_estimated_usd_micros; + +#[cfg(test)] +mod tests; diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs new file mode 100644 index 0000000000000000000000000000000000000000..2099647d7b07e993c2950f2e5204486eb26c3538 --- /dev/null +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -0,0 +1,537 @@ +//! Rate-limit and credits display shaping for status surfaces. +//! +//! This module maps `RateLimitSnapshot` protocol payloads into display-oriented rows that the TUI +//! can render in `/status` and status-line contexts without duplicating formatting logic. +//! +//! The key contract is that time-sensitive values are interpreted relative to a caller-provided +//! capture timestamp so stale detection and reset labels remain coherent for a given draw cycle. +use crate::chatwidget::fallback_limit_label; +use crate::chatwidget::limit_label_for_window; +use crate::text_formatting::capitalize_first; + +use super::helpers::format_reset_timestamp; +use chrono::DateTime; +use chrono::Duration as ChronoDuration; +use chrono::Local; +use chrono::Utc; +use codex_app_server_protocol::CreditsSnapshot as CoreCreditsSnapshot; +use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::RateLimitWindow; +use codex_app_server_protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot; +use codex_protocol::num_format::format_with_separators; + +const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; +const STATUS_LIMIT_BAR_FILLED: &str = "█"; +const STATUS_LIMIT_BAR_EMPTY: &str = "░"; + +#[derive(Debug, Clone)] +pub(crate) struct StatusRateLimitRow { + /// Human-readable row label, such as `"5h limit"`, `"Monthly limit"`, or `"Credits"`. + pub label: String, + /// Value payload for the row. + pub value: StatusRateLimitValue, +} + +/// Display value variants for a single rate-limit row. +#[derive(Debug, Clone)] +pub(crate) enum StatusRateLimitValue { + /// Percent-based usage window with optional reset timestamp text. + Window { + /// Percent of the window that has been consumed. + percent_used: f64, + /// Localized reset string, or `None` when unknown. + resets_at: Option, + /// Optional detail line rendered beneath the progress bar. + details: Option, + }, + /// Plain text value used for non-window rows. + Text(String), +} + +/// Availability state for rate-limit data shown in status output. +#[derive(Debug, Clone)] +pub(crate) enum StatusRateLimitData { + /// Snapshot data is recent enough for normal rendering. + Available(Vec), + /// Snapshot data exists but is older than the staleness threshold. + Stale(Vec), + /// The refresh completed, but the response did not include displayable usage data. + Unavailable, + /// No snapshot data is currently available. + Missing, +} + +/// Maximum age before a snapshot is considered stale in status output. +pub(crate) const RATE_LIMIT_STALE_THRESHOLD_MINUTES: i64 = 15; + +/// Display-friendly representation of one usage window from a snapshot. +#[derive(Debug, Clone)] +pub(crate) struct RateLimitWindowDisplay { + /// Percent used for the window. + pub used_percent: f64, + /// Human-readable local reset time. + pub resets_at: Option, + /// Window length in minutes when provided by the server. + pub window_minutes: Option, +} + +impl RateLimitWindowDisplay { + fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self { + let resets_at_utc = window + .resets_at + .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)) + .map(|dt| dt.with_timezone(&Local)); + let resets_at = resets_at_utc.map(|dt| format_reset_timestamp(dt, captured_at)); + + Self { + used_percent: f64::from(window.used_percent), + resets_at, + window_minutes: window.window_duration_mins, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct RateLimitSnapshotDisplay { + /// Canonical limit identifier (for example: `codex` or `codex_other`). + pub limit_name: String, + /// Optional normal model associated with this account-read quota alias. + pub normal_model_slug: Option, + /// Local timestamp representing when this display snapshot was captured. + pub captured_at: DateTime, + /// Primary usage window. + pub primary: Option, + /// Secondary usage window. + pub secondary: Option, + /// Optional credits metadata when available. + pub credits: Option, + /// Optional effective monthly credit limit from workspace spend controls. + pub individual_limit: Option, +} + +/// Display-ready credits state extracted from protocol snapshots. +#[derive(Debug, Clone)] +pub(crate) struct CreditsSnapshotDisplay { + /// Whether credits tracking is enabled for the account. + pub has_credits: bool, + /// Whether the account has unlimited credits. + pub unlimited: bool, + /// Raw balance text as provided by the backend. + pub balance: Option, +} + +/// Display-ready effective monthly limit extracted from spend controls. +#[derive(Debug, Clone)] +pub(crate) struct SpendControlLimitSnapshotDisplay { + /// Local timestamp representing when this monthly usage value was captured. + pub captured_at: DateTime, + pub percent_remaining: f64, + pub used: String, + pub limit: String, + pub resets_at: Option, +} + +/// Converts a protocol snapshot into UI-friendly display data. +/// +/// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly +/// older or newer `captured_at` can produce misleading reset labels and stale classification. +#[cfg(test)] +pub(crate) fn rate_limit_snapshot_display( + snapshot: &RateLimitSnapshot, + captured_at: DateTime, +) -> RateLimitSnapshotDisplay { + rate_limit_snapshot_display_for_limit(snapshot, "codex".to_string(), captured_at) +} + +pub(crate) fn rate_limit_snapshot_display_for_limit( + snapshot: &RateLimitSnapshot, + limit_name: String, + captured_at: DateTime, +) -> RateLimitSnapshotDisplay { + RateLimitSnapshotDisplay { + normal_model_slug: snapshot.normal_model_slug.clone(), + limit_name, + captured_at, + primary: snapshot + .primary + .as_ref() + .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), + secondary: snapshot + .secondary + .as_ref() + .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), + credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from), + individual_limit: snapshot + .individual_limit + .as_ref() + .and_then(|limit| SpendControlLimitSnapshotDisplay::from_limit(limit, captured_at)), + } +} + +impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay { + fn from(value: &CoreCreditsSnapshot) -> Self { + Self { + has_credits: value.has_credits, + unlimited: value.unlimited, + balance: value.balance.clone(), + } + } +} + +impl SpendControlLimitSnapshotDisplay { + fn from_limit( + value: &CoreSpendControlLimitSnapshot, + captured_at: DateTime, + ) -> Option { + Some(Self { + captured_at, + percent_remaining: f64::from(value.remaining_percent.clamp(0, 100)), + used: format_credit_amount(&value.used)?, + limit: format_credit_amount(&value.limit)?, + resets_at: DateTime::::from_timestamp(value.resets_at, 0) + .map(|dt| format_reset_timestamp(dt.with_timezone(&Local), captured_at)), + }) + } +} + +/// Builds display rows from a snapshot and marks stale data by capture age. +/// +/// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make +/// fresh data appear stale or prevent stale warnings from appearing. +pub(crate) fn compose_rate_limit_data( + snapshot: Option<&RateLimitSnapshotDisplay>, + now: DateTime, +) -> StatusRateLimitData { + match snapshot { + Some(snapshot) => compose_rate_limit_data_many(std::slice::from_ref(snapshot), now), + None => StatusRateLimitData::Missing, + } +} + +pub(crate) fn compose_rate_limit_data_many( + snapshots: &[RateLimitSnapshotDisplay], + now: DateTime, +) -> StatusRateLimitData { + if snapshots.is_empty() { + return StatusRateLimitData::Missing; + } + + let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(3)); + let mut stale = false; + + // Show ordinary plan usage before model-specific quotas, regardless of backend bucket IDs. + let mut ordered_snapshots = snapshots.iter().collect::>(); + ordered_snapshots.sort_by_key(|snapshot| !snapshot.limit_name.eq_ignore_ascii_case("codex")); + for snapshot in ordered_snapshots { + let credit_row = snapshot.credits.as_ref().and_then(credit_status_row); + // Metadata-only buckets have no visible values and should not leave an empty heading. + if snapshot.primary.is_none() + && snapshot.secondary.is_none() + && credit_row.is_none() + && snapshot.individual_limit.is_none() + { + continue; + } + stale |= now.signed_duration_since(snapshot.captured_at) + > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES); + stale |= snapshot + .individual_limit + .as_ref() + .map(|limit| { + now.signed_duration_since(limit.captured_at) + > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES) + }) + .unwrap_or(false); + + let limit_bucket_label = crate::model_catalog::model_display_name(&snapshot.limit_name); + let show_limit_prefix = !limit_bucket_label.eq_ignore_ascii_case("codex"); + let primary_label = snapshot + .primary + .as_ref() + .map(|window| { + limit_label_for_window(window.window_minutes, /*is_secondary*/ false) + }) + .map(|label| capitalize_first(&label)); + let secondary_label = snapshot + .secondary + .as_ref() + .map(|window| limit_label_for_window(window.window_minutes, /*is_secondary*/ true)) + .map(|label| capitalize_first(&label)); + let window_count = + usize::from(snapshot.primary.is_some()) + usize::from(snapshot.secondary.is_some()); + let combine_non_codex_single_limit = show_limit_prefix && window_count == 1; + + if show_limit_prefix && !combine_non_codex_single_limit { + rows.push(StatusRateLimitRow { + label: format!("{limit_bucket_label} limit"), + value: StatusRateLimitValue::Text(String::new()), + }); + } + + if let Some(primary) = snapshot.primary.as_ref() { + let label = if combine_non_codex_single_limit { + format!( + "{} {} limit", + limit_bucket_label, + primary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ false) + )) + ) + } else { + format!( + "{} limit", + primary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ false) + )) + ) + }; + rows.push(StatusRateLimitRow { + label, + value: StatusRateLimitValue::Window { + percent_used: primary.used_percent, + resets_at: primary.resets_at.clone(), + details: None, + }, + }); + } + + if let Some(secondary) = snapshot.secondary.as_ref() { + let label = if combine_non_codex_single_limit { + format!( + "{} {} limit", + limit_bucket_label, + secondary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ true) + )) + ) + } else { + format!( + "{} limit", + secondary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ true) + )) + ) + }; + rows.push(StatusRateLimitRow { + label, + value: StatusRateLimitValue::Window { + percent_used: secondary.used_percent, + resets_at: secondary.resets_at.clone(), + details: None, + }, + }); + } + + if let Some(row) = credit_row { + rows.push(row); + } + if let Some(individual_limit) = snapshot.individual_limit.as_ref() { + rows.push(StatusRateLimitRow { + label: "Monthly credit limit".to_string(), + value: StatusRateLimitValue::Window { + percent_used: 100.0 - individual_limit.percent_remaining, + resets_at: individual_limit.resets_at.clone(), + details: Some(format!( + "{} of {} credits used", + individual_limit.used, individual_limit.limit + )), + }, + }); + } + } + + if rows.is_empty() { + StatusRateLimitData::Unavailable + } else if stale { + StatusRateLimitData::Stale(rows) + } else { + StatusRateLimitData::Available(rows) + } +} + +/// Renders a fixed-width progress bar from remaining percentage. +/// +/// This function expects a remaining value in the `0..=100` range and clamps out-of-range input. +/// Passing a used percentage by mistake will invert the bar and mislead users. +pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String { + render_limit_progress_bar(percent_remaining, STATUS_LIMIT_BAR_SEGMENTS) +} + +/// Shared bar geometry for the status card and compact composer usage indicators. +pub(crate) fn render_limit_progress_bar(percent_remaining: f64, segments: usize) -> String { + let ratio = (percent_remaining / 100.0).clamp(0.0, 1.0); + let filled = (ratio * segments as f64).round() as usize; + let filled = filled.min(segments); + let empty = segments.saturating_sub(filled); + format!( + "[{}{}]", + STATUS_LIMIT_BAR_FILLED.repeat(filled), + STATUS_LIMIT_BAR_EMPTY.repeat(empty) + ) +} + +/// Formats a compact textual summary from remaining percentage. +pub(crate) fn format_status_limit_summary(percent_remaining: f64) -> String { + format!("{percent_remaining:.0}% left") +} + +/// Builds a single `StatusRateLimitRow` when workspace credits are available. +/// Unlimited credits are shown explicitly; finite credits show their rounded +/// balance or `Available` when the balance is hidden. +fn credit_status_row(credits: &CreditsSnapshotDisplay) -> Option { + if credits.unlimited { + return Some(StatusRateLimitRow { + label: "Credits".to_string(), + value: StatusRateLimitValue::Text("Unlimited".to_string()), + }); + } + if !credits.has_credits { + return None; + } + let value = credits + .balance + .as_deref() + .and_then(format_credit_balance) + .map_or_else( + || "Available".to_string(), + |display_balance| format!("{display_balance} credits"), + ); + Some(StatusRateLimitRow { + label: "Credits".to_string(), + value: StatusRateLimitValue::Text(value), + }) +} + +fn format_credit_balance(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if let Ok(int_value) = trimmed.parse::() + && int_value > 0 + { + return Some(int_value.to_string()); + } + + if let Ok(value) = trimmed.parse::() + && value.is_finite() + && value > 0.0 + { + let rounded = value.round() as i64; + return Some(rounded.to_string()); + } + + None +} + +fn format_credit_amount(raw: &str) -> Option { + let value = raw.trim().parse::().ok()?; + if !value.is_finite() || value < 0.0 { + return None; + } + Some(format_with_separators(value.round() as i64)) +} + +#[cfg(test)] +mod tests { + use super::CreditsSnapshotDisplay; + use super::RateLimitSnapshotDisplay; + use super::RateLimitWindowDisplay; + use super::StatusRateLimitData; + use super::compose_rate_limit_data_many; + use chrono::Local; + use pretty_assertions::assert_eq; + + fn window(used_percent: f64) -> RateLimitWindowDisplay { + RateLimitWindowDisplay { + used_percent, + resets_at: Some("soon".to_string()), + window_minutes: Some(300), + } + } + + #[test] + fn non_codex_single_limit_renders_combined_row() { + let now = Local::now(); + let codex = RateLimitSnapshotDisplay { + limit_name: "codex".to_string(), + normal_model_slug: None, + captured_at: now, + primary: Some(window(/*used_percent*/ 10.0)), + secondary: None, + credits: Some(CreditsSnapshotDisplay { + has_credits: true, + unlimited: false, + balance: Some("25".to_string()), + }), + individual_limit: None, + }; + let other = RateLimitSnapshotDisplay { + limit_name: "codex-other".to_string(), + normal_model_slug: None, + captured_at: now, + primary: Some(window(/*used_percent*/ 20.0)), + secondary: None, + credits: Some(CreditsSnapshotDisplay { + has_credits: true, + unlimited: false, + balance: Some("99".to_string()), + }), + individual_limit: None, + }; + + let rows = match compose_rate_limit_data_many(&[codex, other], now) { + StatusRateLimitData::Available(rows) => rows, + other => panic!("unexpected status: {other:?}"), + }; + + let labels: Vec = rows.iter().map(|row| row.label.clone()).collect(); + assert_eq!( + labels, + vec![ + "5h limit".to_string(), + "Credits".to_string(), + "codex-other 5h limit".to_string(), + "Credits".to_string(), + ] + ); + assert_eq!(rows.iter().filter(|row| row.label == "Credits").count(), 2); + } + + #[test] + fn non_codex_multi_limit_keeps_group_row() { + let now = Local::now(); + let other = RateLimitSnapshotDisplay { + limit_name: "codex-other".to_string(), + normal_model_slug: None, + captured_at: now, + primary: Some(RateLimitWindowDisplay { + used_percent: 20.0, + resets_at: Some("soon".to_string()), + window_minutes: Some(60), + }), + secondary: Some(RateLimitWindowDisplay { + used_percent: 40.0, + resets_at: Some("later".to_string()), + window_minutes: Some(2 * 60), + }), + credits: None, + individual_limit: None, + }; + + let rows = match compose_rate_limit_data_many(&[other], now) { + StatusRateLimitData::Available(rows) => rows, + other => panic!("unexpected status: {other:?}"), + }; + let labels: Vec = rows.iter().map(|row| row.label.clone()).collect(); + assert_eq!( + labels, + vec![ + "codex-other limit".to_string(), + "Usage limit".to_string(), + "Secondary usage limit".to_string(), + ] + ); + } +} diff --git a/codex-rs/tui/src/status/remote_connection.rs b/codex-rs/tui/src/status/remote_connection.rs new file mode 100644 index 0000000000000000000000000000000000000000..34bd903f2b737e5188007afce501eb31014eaae6 --- /dev/null +++ b/codex-rs/tui/src/status/remote_connection.rs @@ -0,0 +1,283 @@ +use crate::AppServerTarget; +use crate::RemoteAppServerEndpoint; +use sha2::Digest; +use sha2::Sha256; +use url::Url; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RemoteConnectionStatus { + pub(crate) address: String, + pub(crate) version: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ServerVersionNotice { + pub(crate) message: String, + pub(crate) offer_update: bool, +} + +pub(crate) fn remote_connection_status_value( + app_server_target: &AppServerTarget, + server_version: Option<&str>, +) -> Option { + let endpoint = match app_server_target { + AppServerTarget::Embedded => return None, + AppServerTarget::LocalDaemon { endpoint } | AppServerTarget::Remote { endpoint } => { + endpoint + } + }; + let address = match endpoint { + RemoteAppServerEndpoint::WebSocket { websocket_url, .. } => { + sanitized_websocket_url(websocket_url) + .map(|url| url.to_string()) + .unwrap_or_else(|| "".to_string()) + } + RemoteAppServerEndpoint::UnixSocket { socket_path } => { + format!("unix://{}", socket_path.display()) + } + }; + let version = server_version + .map(|version| format!("v{version}")) + .unwrap_or_else(|| "unknown".to_string()); + Some(RemoteConnectionStatus { address, version }) +} + +pub(crate) fn server_version_notice(client: &str, server: Option<&str>) -> Option { + let server = server?; + crate::update_versions::is_official_server_older(client, server).then(|| { + format!( + "A background Codex service is running v{server}, older than your Codex CLI v{client}." + ) + }) +} + +pub(crate) fn server_version_notice_for_tui( + settings: &codex_config::types::Tui, + client: &str, + server: Option<&str>, +) -> Option { + if settings.show_server_version_notice { + server_version_notice(client, server) + } else { + None + } +} + +fn hash_identity_part(hasher: &mut Sha256, tag: &[u8], value: &[u8]) { + hasher.update(tag); + hasher.update((value.len() as u64).to_le_bytes()); + hasher.update(value); +} + +pub(crate) fn server_version_notice_key( + target: &AppServerTarget, + server_home: Option<&str>, + client: &str, + server: Option<&str>, +) -> Option { + let server = server?; + server_version_notice(client, Some(server))?; + let mut hasher = Sha256::new(); + let endpoint = match target { + AppServerTarget::Embedded => None, + AppServerTarget::LocalDaemon { endpoint } | AppServerTarget::Remote { endpoint } => { + Some(endpoint) + } + }; + if let Some(server_home) = server_home { + hash_identity_part(&mut hasher, b"server-home:", server_home.as_bytes()); + } + match endpoint { + Some(RemoteAppServerEndpoint::UnixSocket { socket_path }) => { + hash_identity_part( + &mut hasher, + b"unix:", + socket_path.as_path().as_os_str().as_encoded_bytes(), + ); + } + Some(RemoteAppServerEndpoint::WebSocket { websocket_url, .. }) => { + if let Ok(mut url) = Url::parse(websocket_url) { + let routing = url + .query_pairs() + .filter(|(name, _)| { + matches!( + name.to_ascii_lowercase().as_str(), + "workspace" + | "project" + | "tenant" + | "environment" + | "namespace" + | "organization" + | "org" + | "account" + | "route" + | "instance" + | "region" + | "deployment" + | "cluster" + ) + }) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect::>(); + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + if !routing.is_empty() { + url.query_pairs_mut().extend_pairs(routing); + } + hash_identity_part(&mut hasher, b"websocket:", url.as_str().as_bytes()); + } + } + None => hash_identity_part(&mut hasher, b"embedded:", b""), + } + Some(format!("{:x}:{client}-{server}", hasher.finalize())) +} + +pub(crate) fn pending_server_version_notice( + settings: &codex_config::types::Tui, + target: &AppServerTarget, + server_home: Option<&str>, + client: &str, + server: Option<&str>, + last_shown: Option<&str>, +) -> Option<(ServerVersionNotice, String)> { + let message = server_version_notice_for_tui(settings, client, server)?; + let notice = ServerVersionNotice { + message, + offer_update: matches!(target, AppServerTarget::LocalDaemon { .. }), + }; + let key = server_version_notice_key(target, server_home, client, server)?; + (last_shown != Some(key.as_str())).then_some((notice, key)) +} + +pub(crate) fn sanitized_websocket_url(raw: &str) -> Option { + let mut url = Url::parse(raw).ok()?; + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + Some(url) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + + #[test] + fn remote_connection_status_value_formats_display_value() -> color_eyre::Result<()> { + assert_eq!( + remote_connection_status_value(&AppServerTarget::Embedded, Some("1.2.3")), + None + ); + + let websocket_target = AppServerTarget::Remote { + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://user:secret@127.0.0.1:4500/?token=abc#frag".to_string(), + auth_token: Some("abc".to_string()), + }, + }; + assert_eq!( + remote_connection_status_value(&websocket_target, Some("1.2.3")), + Some(RemoteConnectionStatus { + address: "ws://127.0.0.1:4500/".to_string(), + version: "v1.2.3".to_string(), + }) + ); + + let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; + let daemon_target = AppServerTarget::LocalDaemon { + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: socket_path.clone(), + }, + }; + assert_eq!( + remote_connection_status_value(&daemon_target, /*server_version*/ None), + Some(RemoteConnectionStatus { + address: format!("unix://{}", socket_path.display()), + version: "unknown".to_string(), + }) + ); + Ok(()) + } + + #[test] + fn server_version_notice_only_for_older_official_server() { + assert_eq!( + server_version_notice("0.153.0", Some("0.152.1")), + Some("A background Codex service is running v0.152.1, older than your Codex CLI v0.153.0.".to_string()) + ); + assert_eq!(server_version_notice("0.153.0", Some("0.153.0")), None); + assert_eq!(server_version_notice("0.0.0", Some("0.152.1")), None); + assert_eq!(server_version_notice("0.153.0", /*server*/ None), None); + } + + #[test] + fn update_command_is_only_suggested_for_implicit_local_daemon() -> color_eyre::Result<()> { + let endpoint = RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }; + let local = AppServerTarget::LocalDaemon { + endpoint: endpoint.clone(), + }; + let remote = AppServerTarget::Remote { endpoint }; + let settings = codex_config::types::Tui { + show_server_version_notice: true, + ..Default::default() + }; + let notice = |target| { + pending_server_version_notice( + &settings, + target, + /*server_home*/ None, + "0.153.0", + Some("0.152.1"), + /*last_shown*/ None, + ) + .map(|(notice, _)| notice) + }; + let remote_notice = server_version_notice("0.153.0", Some("0.152.1")).unwrap(); + assert_eq!( + notice(&remote), + Some(ServerVersionNotice { + message: remote_notice.clone(), + offer_update: false, + }) + ); + assert_eq!( + notice(&local), + Some(ServerVersionNotice { + message: remote_notice, + offer_update: true, + }) + ); + Ok(()) + } + + #[test] + fn notice_key_identifies_socket_and_server_home_across_connection_modes() -> anyhow::Result<()> + { + let socket = |path| -> anyhow::Result { + Ok(RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::from_absolute_path(path)?, + }) + }; + let local = AppServerTarget::LocalDaemon { + endpoint: socket("/c")?, + }; + let remote = AppServerTarget::Remote { + endpoint: socket("/c")?, + }; + let key = |target: &AppServerTarget, server_home| { + server_version_notice_key(target, server_home, "0.153.0", Some("0.152.0")) + }; + assert_eq!(key(&local, Some("/a")), key(&remote, Some("/a"))); + let other = AppServerTarget::Remote { + endpoint: socket("/bunix:/c")?, + }; + assert_ne!(key(&remote, Some("/aunix:/b")), key(&other, Some("/a"))); + Ok(()) + } +} diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__helpers__tests__education_plan_display_names.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__helpers__tests__education_plan_display_names.snap new file mode 100644 index 0000000000000000000000000000000000000000..180931b48fd5737b7c4277ca3100377f4736354e --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__helpers__tests__education_plan_display_names.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/status/helpers.rs +expression: "[PlanType::Edu, PlanType::EduPlus,\nPlanType::EduPro].map(plan_type_display_name).join(\"\\n\")" +--- +Edu +Edu Plus +Edu Pro diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_not_required.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_not_required.snap new file mode 100644 index 0000000000000000000000000000000000000000..701080e695263e5c0dfcbc1681bf7578e1434e5f --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_not_required.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/status/tests.rs +expression: rendered +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Model: gpt-5.6-sol (reasoning none, summaries auto) │ +│ Model provider: server-ollama │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 0 total (0 input + 0 output) │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_required.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_required.snap new file mode 100644 index 0000000000000000000000000000000000000000..d31fdf2a03d7d7e525f68d298bf70f7a328bc55d --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_required.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: rendered +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.6-sol (reasoning none, summaries auto) │ +│ Model provider: server-openai │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 0 total (0 input + 0 output) │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap new file mode 100644 index 0000000000000000000000000000000000000000..c7d89b17aa95c7e3ce85936b60b422de0e4cf58d --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.05K total (700 input + 350 output) │ +│ Context window: 100% left (1.45K used / 272K) │ +│ 5h limit: [████████░░░░░░░░░░░░] 40% left (resets 11:32) │ +│ Weekly limit: [█████████████░░░░░░░] 65% left (resets 11:52) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap new file mode 100644 index 0000000000000000000000000000000000000000..c1636b6c15ee014cc3e4d374ef7831fa41d06f90 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 2K total (1.4K input + 600 output) │ +│ Context window: 100% left (2.2K used / 272K) │ +│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 09:25) │ +│ Weekly limit: [██████████████░░░░░░] 70% left (resets 09:55) │ +│ Credits: Available │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap new file mode 100644 index 0000000000000000000000000000000000000000..765ad8d3a7da03dc5debe01ea19018aa1826b78f --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Monthly credit limit: [██████████████░░░░░░] 68% left (resets 07:08 on 7 May) │ +│ 8,000 of 25,000 credits used │ +╰───────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap new file mode 100644 index 0000000000000000000000000000000000000000..152575179eef77bbe9f5d7e9e4b93d81c7d9e048 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ +│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap new file mode 100644 index 0000000000000000000000000000000000000000..7a4f58a8233bb51aa4769b1411e86a507034c0fb --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Monthly limit: [██████████████████░░] 88% left (resets 07:08 on 7 May) │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap new file mode 100644 index 0000000000000000000000000000000000000000..96b2ed1648d5c55e83ca475543ba327fa111655d --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning high, summaries detailed) │ +│ Directory: [[workspace]] │ +│ Permissions: Workspace (Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ Context window: 100% left (2.25K used / 272K) │ +│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ +│ Weekly limit: [███████████░░░░░░░░░] 55% left (resets 03:24) │ +╰───────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_active_user_defined_profile.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_active_user_defined_profile.snap new file mode 100644 index 0000000000000000000000000000000000000000..4604b26f7e3f01fa4aeef5c174ac91532f0e4eec --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_active_user_defined_profile.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Profile locked (read-only, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 0 total (0 input + 0 output) │ +│ Context window: 100% left (0 used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_auto_review_permissions.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_auto_review_permissions.snap new file mode 100644 index 0000000000000000000000000000000000000000..c739d550753e531847e4e2966cf6cc32834d2320 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_auto_review_permissions.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Workspace (Approve for me) │ +│ Agents.md: │ +│ │ +│ Token usage: 0 total (0 input + 0 output) │ +│ Context window: 100% left (0 used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap new file mode 100644 index 0000000000000000000000000000000000000000..0aba51f99a305a298ac8879212fffc7e4b5e3e43 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭──────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ Account: Enterprise (Automation) │ +│ │ +│ Limits: data not available yet │ +╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap new file mode 100644 index 0000000000000000000000000000000000000000..533be2797666029b414bc3e1861b28f8ef5aee24 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap new file mode 100644 index 0000000000000000000000000000000000000000..631982bcf969d7a660614e1127598ac9537a92ef --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 08:24) │ +│ Weekly limit: [██████████████░░░░░░] 70% left (resets 08:54) │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap new file mode 100644 index 0000000000000000000000000000000000000000..8facd56275e18f782acfe20a7b30fc159e06dc72 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ Context window: 100% left (2.25K used / 272K) │ +│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ +│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap new file mode 100644 index 0000000000000000000000000000000000000000..d46a483e4b5ff5ae265ce87e6725eee28ca4b200 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: not available for this account │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap new file mode 100644 index 0000000000000000000000000000000000000000..d46a483e4b5ff5ae265ce87e6725eee28ca4b200 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: not available for this account │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_halfwidth_kana_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_halfwidth_kana_in_narrow_terminal.snap new file mode 100644 index 0000000000000000000000000000000000000000..17442d7076a634c1200476eabee081e333eb117e --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_halfwidth_kana_in_narrow_terminal.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit │ +│ https://chatgpt.com/codex/settings/usa │ +│ for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: ガパガパガパガ │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspa │ +│ Agents.md: │ +│ Account: ガパガパガパ@ex │ +│ Thread name: ガパガパガパガ │ +│ Collaboration mode: ガパ collaborat │ +│ │ +│ Limits: data not availa │ +╰────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap new file mode 100644 index 0000000000000000000000000000000000000000..6b28b53b62837c57c0bd502fc855bde5ec4aadba --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning high, summaries de │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for │ +│ Agents.md: │ +│ │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ Context window: 100% left (2.25K used / 272K) │ +│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ +╰────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap new file mode 100644 index 0000000000000000000000000000000000000000..9683beed97b2578613014f483e0bfb6c54355fc7 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Remote: unix:///tmp/codex-home/app-server-control/app-server- │ +│ control.sock (v0.133.0) │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning medium, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap new file mode 100644 index 0000000000000000000000000000000000000000..6b6fad683b812297d66e313ceab1c0fae7150987 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭──────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for app │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Usage limit: [█████████████░░░░░░░] 65% left │ +│ (resets 07:08 on 7 May) │ +│ Secondary usage limit: [██████████░░░░░░░░░░] 50% left │ +│ (resets 07:08 on 8 May) │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap new file mode 100644 index 0000000000000000000000000000000000000000..cf6fa2c2ec73b4adb8225ac71dbde5fe4e7d4990 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit │ +│ https://chatgpt.com/codex/settings/usage │ +│ for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 │ +│ Context window: 100% left (1.2K u │ +│ Monthly credit limit: 68% left │ +│ (resets 07:08 on │ +│ 7 May) │ +│ 8,000 of 25,000 │ +│ credits used │ +╰────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__transcript_overlay_status_rate_limit_refresh.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__transcript_overlay_status_rate_limit_refresh.snap new file mode 100644 index 0000000000000000000000000000000000000000..9dce4bfbba8b3b71e4794897ce86059d875600f8 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__transcript_overlay_status_rate_limit_refresh.snap @@ -0,0 +1,66 @@ +--- +source: tui/src/status/tests.rs +expression: "format!(\"before:\\n{before}\\n\\nafter:\\n{after}\")" +--- +before: +/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / +/status + +╭──────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 0 total (0 input + 0 output) │ +│ Limits: refresh requested; run /status again shortly. │ +╰──────────────────────────────────────────────────────────────────────────╯ +~ +~ +~ +~ +~ +~ +~ +~ +~ +───────────────────────────────────────────────────────────────────────── 100% ─ + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q close esc to edit prev + + +after: +/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / +/status + +╭───────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 0 total (0 input + 0 output) │ +│ spark limit: │ +│ 5h limit: [███████████░░░░░░░░░] 55% left (resets soon) │ +│ Weekly limit: [██████████████░░░░░░] 70% left (resets later) │ +╰───────────────────────────────────────────────────────────────────────────╯ + +next message +~ +~ +~ +~ +~ +───────────────────────────────────────────────────────────────────────── 100% ─ + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q close esc to edit prev diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..f9384badc9a4d41d0e4786fc9183785c668e56be --- /dev/null +++ b/codex-rs/tui/src/status/tests.rs @@ -0,0 +1,2265 @@ +use super::new_status_output; +use super::new_status_output_with_rate_limits; +use super::new_status_output_with_rate_limits_handle; +use super::rate_limit_snapshot_display; +use super::rate_limits::RateLimitSnapshotDisplay; +use super::rate_limits::RateLimitWindowDisplay; +use super::rate_limits::SpendControlLimitSnapshotDisplay; +use super::rate_limits::StatusRateLimitData; +use super::rate_limits::compose_rate_limit_data_many; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::keymap::RuntimeKeymap; +use crate::legacy_core::config::Config; +use crate::legacy_core::config::ConfigBuilder; +use crate::legacy_core::config::PermissionProfileSnapshot; +use crate::pager_overlay::TranscriptOverlay; +use crate::status::StatusAccountDisplay; +use crate::status::remote_connection::RemoteConnectionStatus; +use crate::test_support::PathBufExt; +use crate::test_support::test_path_buf; +use crate::token_usage::TokenUsage; +use crate::token_usage::TokenUsageInfo; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_models_cache; +use chrono::Duration as ChronoDuration; +use chrono::Local; +use chrono::TimeZone; +use chrono::Utc; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::CreditsSnapshot; +use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::RateLimitWindow; +use codex_app_server_protocol::SpendControlLimitSnapshot; +use codex_config::LoaderOverrides; +use codex_config::types::AuthCredentialsStoreMode; +use codex_model_provider_info::ModelProviderAwsAuthInfo; +use codex_model_provider_info::ModelProviderInfo; +use codex_models_manager::test_support::construct_model_info_offline_for_tests; +use codex_models_manager::test_support::get_model_offline_for_tests; +use codex_protocol::ThreadId; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use insta::assert_snapshot; +use pretty_assertions::assert_eq; +use ratatui::prelude::*; +use std::sync::Arc; +use tempfile::TempDir; +use unicode_width::UnicodeWidthStr; + +#[test] +fn stale_monthly_limit_marks_fresh_rolling_snapshot_stale() { + let now = Local::now(); + let snapshot = RateLimitSnapshotDisplay { + limit_name: "codex".to_string(), + normal_model_slug: None, + captured_at: now, + primary: Some(RateLimitWindowDisplay { + used_percent: 20.0, + resets_at: Some("soon".to_string()), + window_minutes: Some(300), + }), + secondary: None, + credits: None, + individual_limit: Some(SpendControlLimitSnapshotDisplay { + captured_at: now - ChronoDuration::minutes(20), + percent_remaining: 68.0, + used: "8,000".to_string(), + limit: "25,000".to_string(), + resets_at: Some("later".to_string()), + }), + }; + + assert!(matches!( + compose_rate_limit_data_many(&[snapshot], now), + StatusRateLimitData::Stale(_) + )); +} + +fn app_server_workspace_write_profile(network_enabled: bool) -> PermissionProfile { + PermissionProfile::Managed { + network: if network_enabled { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted + }, + file_system: ManagedFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { subpath: None }, + }, + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::SlashTmp, + }, + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + ], + glob_scan_max_depth: None, + }, + } +} + +async fn test_config(temp_home: &TempDir) -> Config { + let mut config = ConfigBuilder::default() + .codex_home(temp_home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await + .expect("load config"); + config.approvals_reviewer = ApprovalsReviewer::User; + config + .permissions + .set_permission_profile(app_server_workspace_write_profile( + /*network_enabled*/ true, + )) + .expect("set permission profile"); + config +} + +fn set_workspace_cwd(config: &mut Config, cwd: AbsolutePathBuf) { + config.cwd = cwd.clone(); + config.workspace_roots = vec![cwd]; + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); +} + +fn test_status_account_display() -> Option { + None +} + +fn token_info_for(model_slug: &str, config: &Config, usage: &TokenUsage) -> TokenUsageInfo { + let context_window = + construct_model_info_offline_for_tests(model_slug, &config.to_models_manager_config()) + .context_window; + TokenUsageInfo { + total_token_usage: usage.clone(), + last_token_usage: usage.clone(), + model_context_window: context_window, + } +} + +fn render_lines(lines: &[Line<'static>]) -> Vec { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() +} + +fn sanitize_directory(lines: Vec) -> Vec { + let frame_width = lines + .iter() + .find(|line| line.starts_with('╭')) + .map(|line| UnicodeWidthStr::width(line.as_str())); + lines + .into_iter() + .map(|line| { + if let (Some(frame_width), Some(dir_pos), Some(pipe_idx)) = + (frame_width, line.find("Directory: "), line.rfind('│')) + { + let prefix = &line[..dir_pos + "Directory: ".len()]; + let suffix = &line[pipe_idx..]; + let replacement = "[[workspace]]"; + let content_width = frame_width.saturating_sub( + UnicodeWidthStr::width(prefix) + UnicodeWidthStr::width(suffix), + ); + let mut rebuilt = prefix.to_string(); + rebuilt.push_str(replacement); + let replacement_width = UnicodeWidthStr::width(replacement); + if content_width > replacement_width { + rebuilt.push_str(&" ".repeat(content_width - replacement_width)); + } + rebuilt.push_str(suffix); + rebuilt + } else { + line + } + }) + .collect() +} + +fn buffer_to_text(buffer: &Buffer, width: u16) -> String { + let lines = buffer + .content + .chunks(usize::from(width)) + .map(|row| { + row.iter() + .map(|cell| { + let symbol = cell.symbol(); + symbol + .strip_prefix("\x1b]8;;") + .and_then(|symbol| symbol.split_once('\x07')) + .and_then(|(_, symbol)| symbol.strip_suffix("\x1b]8;;\x07")) + .unwrap_or(symbol) + }) + .collect::() + .trim_end() + .to_string() + }) + .collect::>(); + sanitize_directory(lines).join("\n") +} + +fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> i64 { + (*captured_at + ChronoDuration::seconds(seconds)) + .with_timezone(&Utc) + .timestamp() +} + +fn permissions_text_for(config: &Config) -> Option { + permissions_text_for_width(config, /*width*/ 80) +} + +fn permissions_text_for_width(config: &Config, width: u16) -> Option { + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let composite = new_status_output( + config, + test_status_account_display().as_ref(), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + render_lines(&composite.display_lines(width)) + .iter() + .find(|line| line.contains("Permissions:")) + .and_then(|line| { + line.split("Permissions:") + .nth(1) + .map(str::trim) + .map(|text| text.trim_end_matches('│')) + .map(str::trim) + .map(ToString::to_string) + }) +} + +#[tokio::test] +async fn status_snapshot_includes_reasoning_details() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.model_reasoning_summary = Some(ReasoningSummary::Detailed); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + config + .permissions + .set_permission_profile(PermissionProfile::workspace_write()) + .expect("set permission profile"); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 150, + total_tokens: 2_250, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: Some(RateLimitWindow { + used_percent: 72, + window_duration_mins: Some(300), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 600)), + }), + secondary: Some(RateLimitWindow { + used_percent: 45, + window_duration_mins: Some(10080), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)), + }), + credits: None, + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + reasoning_effort_override, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_shows_chatgpt_plan_without_email() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + write_chatgpt_auth( + temp_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("enterprise_cbp_automation"), + AuthCredentialsStoreMode::File, + ) + .expect("write email-less ChatGPT auth"); + write_models_cache(temp_home.path()) + .await + .expect("write models cache"); + let mut app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("start embedded app server"); + let bootstrap = app_server + .bootstrap(&config) + .await + .expect("bootstrap app server session"); + app_server.shutdown().await.expect("shut down app server"); + let account_display = bootstrap + .status_account_display + .expect("bootstrap should return ChatGPT account display"); + assert_eq!( + account_display, + StatusAccountDisplay::ChatGpt { + email: None, + plan: Some("Enterprise (Automation)".to_string()), + } + ); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + + let composite = new_status_output( + &config, + Some(&account_display), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let sanitized = + sanitize_directory(render_lines(&composite.display_lines(/*width*/ 80))).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_permissions_non_default_workspace_write_uses_workspace_label() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + config + .permissions + .set_permission_profile(app_server_workspace_write_profile( + /*network_enabled*/ true, + )) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Custom (workspace with network access, Ask for approval)") + ); +} + +#[tokio::test] +async fn status_permissions_named_read_only_profile_shows_builtin_label() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::read_only(), + ActivePermissionProfile::read_only(), + )) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Read Only (Ask for approval)") + ); +} + +#[tokio::test] +async fn status_permissions_read_only_profile_shows_additional_writable_roots() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + let extra_root = test_path_buf("/workspace/extra").abs(); + let file_system_policy = PermissionProfile::read_only() + .file_system_sandbox_policy() + .with_additional_writable_roots(config.cwd.as_path(), std::slice::from_ref(&extra_root)); + config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ), + ActivePermissionProfile::read_only(), + )) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Read Only (Ask for approval)") + ); +} + +#[tokio::test] +async fn status_permissions_named_workspace_profile_shows_builtin_label() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Workspace (Ask for approval)") + ); +} + +#[tokio::test] +async fn status_permissions_workspace_auto_review_shows_reviewer_label() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Workspace (Approve for me)") + ); +} + +#[tokio::test] +async fn status_permissions_named_profile_shows_additional_writable_roots() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + let extra_root = test_path_buf("/workspace/extra").abs(); + config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write_with( + std::slice::from_ref(&extra_root), + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ), + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Workspace (Ask for approval)") + ); +} + +#[tokio::test] +async fn status_permissions_workspace_roots_show_additional_directories() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + let extra_root = test_path_buf("/workspace/extra").abs(); + config.workspace_roots = vec![config.cwd.clone(), extra_root.clone()]; + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); + config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new(":workspace"), + )) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config), + Some(format!( + "Workspace [{}] (Ask for approval)", + extra_root.display() + )) + ); +} + +#[tokio::test] +async fn status_permissions_workspace_roots_include_profile_defined_directories() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + let profile_root = test_path_buf("/workspace/shared").abs(); + config + .permissions + .set_permission_profile_from_session_snapshot( + PermissionProfileSnapshot::active_with_profile_workspace_roots( + PermissionProfile::workspace_write_with( + std::slice::from_ref(&profile_root), + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ), + ActivePermissionProfile::new(":workspace"), + vec![profile_root.clone().into()], + ), + ) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config), + Some(format!( + "Workspace [{}] (Ask for approval)", + profile_root.display() + )) + ); +} + +#[tokio::test] +async fn status_permissions_broadened_workspace_profile_shows_builtin_label() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Enabled, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ), + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Workspace with network access (Ask for approval)") + ); +} + +#[tokio::test] +async fn status_permissions_user_defined_profile_shows_name() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::read_only(), + ActivePermissionProfile::new("locked"), + )) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Profile locked (read-only, Ask for approval)") + ); +} + +#[tokio::test] +async fn status_snapshot_shows_active_user_defined_profile() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::read_only(), + ActivePermissionProfile::new("locked"), + )) + .expect("set permission profile"); + + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + + let composite = new_status_output( + &config, + test_status_account_display().as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_uses_server_provider_id_and_auth_requirement() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.6-sol".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + config.model_provider_id = "amazon-bedrock".to_string(); + config.model_provider = + ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo { + profile: None, + region: Some("eu-west-1".to_string()), + credential_export: None, + auth_refresh: None, + })); + config.model_provider.base_url = + Some("https://bedrock-mantle.us-east-1.api.aws/openai/v1".to_string()); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + + config.model_provider.requires_openai_auth = true; + let (composite, _handle) = new_status_output_with_rate_limits_handle( + &config, + /*requires_openai_auth*/ false, + Some("server-ollama"), + /*remote_connection*/ None, + test_status_account_display().as_ref(), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ &[], + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + "".to_string(), + /*refreshing_rate_limits*/ false, + ); + let rendered = + sanitize_directory(render_lines(&composite.display_lines(/*width*/ 120))).join("\n"); + assert_snapshot!("status_server_auth_not_required", rendered); + + config.model_provider_id = "openai-proxy".to_string(); + config.model_provider = ModelProviderInfo { + name: "OpenAI Proxy".to_string(), + base_url: Some("https://openai-proxy.example/v1".to_string()), + requires_openai_auth: false, + ..ModelProviderInfo::default() + }; + let (composite, _handle) = new_status_output_with_rate_limits_handle( + &config, + /*requires_openai_auth*/ true, + Some("server-openai"), + /*remote_connection*/ None, + test_status_account_display().as_ref(), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ &[], + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + "".to_string(), + /*refreshing_rate_limits*/ false, + ); + let rendered = + sanitize_directory(render_lines(&composite.display_lines(/*width*/ 120))).join("\n"); + assert_snapshot!("status_server_auth_required", rendered); + + let wide_destinations: Vec = composite + .display_hyperlink_lines(/*width*/ 120) + .into_iter() + .flat_map(|line| line.hyperlinks.into_iter()) + .map(|link| link.destination) + .collect(); + assert_eq!( + wide_destinations, + vec!["https://chatgpt.com/codex/settings/usage"] + ); + + let narrow_destinations: Vec = composite + .display_hyperlink_lines(/*width*/ 24) + .into_iter() + .flat_map(|line| line.hyperlinks.into_iter()) + .map(|link| link.destination) + .collect(); + assert_eq!(narrow_destinations, Vec::::new()); +} + +#[tokio::test] +async fn status_snapshot_shows_auto_review_permissions() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) + .expect("set permission profile"); + + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + + let composite = new_status_output( + &config, + test_status_account_display().as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_permissions_full_disk_managed_with_network_is_danger_full_access() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + config + .permissions + .set_permission_profile(PermissionProfile::Managed { + network: NetworkSandboxPolicy::Enabled, + file_system: ManagedFileSystemPermissions::Unrestricted, + }) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Custom (danger-full-access, Ask for approval)") + ); +} + +#[tokio::test] +async fn status_permissions_full_disk_managed_without_network_is_external_sandbox() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + config + .permissions + .set_permission_profile(PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Unrestricted, + }) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config).as_deref(), + Some("Custom (external-sandbox, Ask for approval)") + ); +} + +#[tokio::test] +async fn status_snapshot_includes_forked_from() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 8, 9, 10, 11, 12) + .single() + .expect("valid time"); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let session_id = + ThreadId::from_string("0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e").expect("session id"); + let forked_from = + ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id"); + + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &Some(session_id), + /*thread_name*/ None, + Some(forked_from), + /*rate_limits*/ None, + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_includes_monthly_limit() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: Some(RateLimitWindow { + used_percent: 12, + window_duration_mins: Some(43_200), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 86_400)), + }), + secondary: None, + credits: None, + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_includes_enterprise_monthly_credit_limit() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: None, + secondary: None, + credits: None, + individual_limit: Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: reset_at_from(&captured_at, /*seconds*/ 86_400), + }), + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 92)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); + + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 46)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!( + "status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal", + sanitized + ); +} + +#[tokio::test] +async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(2 * 60), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 86_400)), + }), + secondary: Some(RateLimitWindow { + used_percent: 50, + window_duration_mins: Some(3 * 60), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 172_800)), + }), + credits: None, + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_shows_unlimited_credits() { + let temp_home = TempDir::new().expect("temp home"); + let config = test_config(&temp_home).await; + let account_display = test_status_account_display(); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 2, 3, 4, 5, 6) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: true, + balance: None, + }), + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let rendered = render_lines(&composite.display_lines(/*width*/ 120)); + assert!( + rendered + .iter() + .any(|line| line.contains("Credits:") && line.contains("Unlimited")), + "expected Credits: Unlimited line, got {rendered:?}" + ); +} + +#[tokio::test] +async fn status_snapshot_shows_positive_credits() { + let temp_home = TempDir::new().expect("temp home"); + let config = test_config(&temp_home).await; + let account_display = test_status_account_display(); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 3, 4, 5, 6, 7) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("12.5".to_string()), + }), + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let rendered = render_lines(&composite.display_lines(/*width*/ 120)); + assert!( + rendered + .iter() + .any(|line| line.contains("Credits:") && line.contains("13 credits")), + "expected Credits line with rounded credits, got {rendered:?}" + ); +} + +#[tokio::test] +async fn status_snapshot_shows_available_credits_without_display_balance() { + let temp_home = TempDir::new().expect("temp home"); + let config = test_config(&temp_home).await; + let account_display = test_status_account_display(); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 4, 5, 6, 7, 8) + .single() + .expect("timestamp"); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + for balance in [ + None, + Some(String::new()), + Some("0".to_string()), + Some("not-a-number".to_string()), + Some("inf".to_string()), + ] { + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance, + }), + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let rendered = render_lines(&composite.display_lines(/*width*/ 120)); + assert!( + rendered + .iter() + .any(|line| line.contains("Credits:") && line.contains("Available")), + "expected Credits: Available line, got {rendered:?}" + ); + } +} + +#[tokio::test] +async fn status_snapshot_respects_unlimited_without_has_credits_flag() { + let temp_home = TempDir::new().expect("temp home"); + let config = test_config(&temp_home).await; + let account_display = test_status_account_display(); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: false, + unlimited: true, + balance: None, + }), + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let rendered = render_lines(&composite.display_lines(/*width*/ 120)); + assert!( + rendered + .iter() + .any(|line| line.contains("Credits:") && line.contains("Unlimited")), + "expected Credits: Unlimited line, got {rendered:?}" + ); +} + +#[tokio::test] +async fn status_card_token_usage_excludes_cached_tokens() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 0, + total_tokens: 2_100, + }; + + let now = chrono::Local + .with_ymd_and_hms(2024, 1, 1, 0, 0, 0) + .single() + .expect("timestamp"); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + now, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let rendered = render_lines(&composite.display_lines(/*width*/ 120)); + + assert!( + rendered.iter().all(|line| !line.contains("cached")), + "cached tokens should not be displayed, got: {rendered:?}" + ); +} + +#[tokio::test] +async fn status_snapshot_truncates_in_narrow_terminal() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.model_reasoning_summary = Some(ReasoningSummary::Detailed); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 150, + total_tokens: 2_250, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: Some(RateLimitWindow { + used_percent: 72, + window_duration_mins: Some(300), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 600)), + }), + secondary: None, + credits: None, + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + reasoning_effort_override, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 70)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_truncates_halfwidth_kana_in_narrow_terminal() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account = StatusAccountDisplay::ChatGpt { + email: Some("ガパガパガパ@example.com".to_string()), + plan: Some("ガパ plan".to_string()), + }; + let usage = TokenUsage::default(); + let now = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let composite = new_status_output( + &config, + Some(&account), + /*token_info*/ None, + &usage, + &None, + Some("ガパガパガパガパ thread".to_string()), + /*forked_from*/ None, + /*rate_limits*/ None, + /*plan_type*/ None, + now, + "ガパガパガパガパ-model", + Some("ガパ collaboration mode"), + /*reasoning_effort_override*/ None, + ); + let rendered_lines = render_lines(&composite.display_lines(/*width*/ 42)); + let sanitized = sanitize_directory(rendered_lines).join("\n"); + + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_shows_missing_limits_message() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 500, + cached_input_tokens: 0, + output_tokens: 250, + reasoning_output_tokens: 0, + total_tokens: 750, + }; + + let now = chrono::Local + .with_ymd_and_hms(2024, 2, 3, 4, 5, 6) + .single() + .expect("timestamp"); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + now, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_uses_default_reasoning_when_config_empty() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 500, + cached_input_tokens: 0, + output_tokens: 250, + reasoning_output_tokens: 0, + total_tokens: 750, + }; + + let now = chrono::Local + .with_ymd_and_hms(2024, 2, 3, 4, 5, 6) + .single() + .expect("timestamp"); + let remote_connection = RemoteConnectionStatus { + address: "unix:///tmp/codex-home/app-server-control/app-server-control.sock".to_string(), + version: "v0.133.0".to_string(), + }; + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let (composite, _) = new_status_output_with_rate_limits_handle( + &config, + /*requires_openai_auth*/ true, + /*model_provider_id*/ None, + Some(&remote_connection), + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + &[], + None, + now, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ Some(Some(ReasoningEffort::Medium)), + "".to_string(), + /*refreshing_rate_limits*/ false, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_shows_refreshing_limits_notice() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let usage = TokenUsage { + input_tokens: 500, + cached_input_tokens: 0, + output_tokens: 250, + reasoning_output_tokens: 0, + total_tokens: 750, + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: Some(RateLimitWindow { + used_percent: 45, + window_duration_mins: Some(300), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 900)), + }), + secondary: Some(RateLimitWindow { + used_percent: 30, + window_duration_mins: Some(10_080), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)), + }), + credits: None, + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output_with_rate_limits( + &config, + /*account_display*/ None, + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + std::slice::from_ref(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + /*refreshing_rate_limits*/ true, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn transcript_overlay_remeasures_status_after_rate_limit_refresh() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + let usage = TokenUsage::default(); + let now = Local + .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) + .single() + .expect("timestamp"); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + + let (status, handle) = new_status_output_with_rate_limits_handle( + &config, + /*requires_openai_auth*/ true, + /*model_provider_id*/ None, + /*remote_connection*/ None, + /*account_display*/ None, + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ &[], + None, + now, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + "".to_string(), + /*refreshing_rate_limits*/ true, + ); + let mut overlay = + TranscriptOverlay::new(vec![Arc::new(status)], RuntimeKeymap::defaults().pager); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 30, + ); + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + let before = buffer_to_text(&buffer, area.width); + + handle.finish_rate_limit_refresh( + &[RateLimitSnapshotDisplay { + limit_name: "spark".to_string(), + normal_model_slug: None, + captured_at: now, + primary: Some(RateLimitWindowDisplay { + used_percent: 45.0, + resets_at: Some("soon".to_string()), + window_minutes: Some(300), + }), + secondary: Some(RateLimitWindowDisplay { + used_percent: 30.0, + resets_at: Some("later".to_string()), + window_minutes: Some(10_080), + }), + credits: None, + individual_limit: None, + }], + now, + ); + overlay.insert_cell(Arc::new(PlainHistoryCell::new(vec!["next message".into()]))); + buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + let after = buffer_to_text(&buffer, area.width); + + assert!( + after.contains("spark limit"), + "status output was clipped: {after:?}" + ); + assert!( + after.contains("5h limit"), + "status output was clipped: {after:?}" + ); + assert!( + after.contains("Weekly limit"), + "status output was clipped: {after:?}" + ); + insta::assert_snapshot!( + "transcript_overlay_status_rate_limit_refresh", + format!("before:\n{before}\n\nafter:\n{after}") + ); +} + +#[tokio::test] +async fn status_snapshot_includes_credits_and_limits() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 1_500, + cached_input_tokens: 100, + output_tokens: 600, + reasoning_output_tokens: 0, + total_tokens: 2_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 7, 8, 9, 10, 11) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: Some(RateLimitWindow { + used_percent: 45, + window_duration_mins: Some(300), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 900)), + }), + secondary: Some(RateLimitWindow { + used_percent: 30, + window_duration_mins: Some(10_080), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)), + }), + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }), + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_shows_unavailable_limits_message() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 500, + cached_input_tokens: 0, + output_tokens: 250, + reasoning_output_tokens: 0, + total_tokens: 750, + }; + + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: None, + secondary: None, + credits: None, + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) + .single() + .expect("timestamp"); + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let usage = TokenUsage { + input_tokens: 500, + cached_input_tokens: 0, + output_tokens: 250, + reasoning_output_tokens: 0, + total_tokens: 750, + }; + + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: None, + secondary: None, + credits: None, + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) + .single() + .expect("timestamp"); + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output_with_rate_limits( + &config, + /*account_display*/ None, + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + std::slice::from_ref(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + /*refreshing_rate_limits*/ true, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_shows_stale_limits_message() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 150, + total_tokens: 2_250, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: Some(RateLimitWindow { + used_percent: 72, + window_duration_mins: Some(300), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 600)), + }), + secondary: Some(RateLimitWindow { + used_percent: 40, + window_duration_mins: Some(10_080), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_800)), + }), + credits: None, + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let now = captured_at + ChronoDuration::minutes(20); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + now, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_cached_limits_hide_credits_without_flag() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex".to_string()); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 900, + cached_input_tokens: 200, + output_tokens: 350, + reasoning_output_tokens: 0, + total_tokens: 1_450, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 9, 10, 11, 12, 13) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + normal_model_slug: None, + primary: Some(RateLimitWindow { + used_percent: 60, + window_duration_mins: Some(300), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)), + }), + secondary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(10_080), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_400)), + }), + credits: Some(CreditsSnapshot { + has_credits: false, + unlimited: false, + balance: Some("80".to_string()), + }), + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let now = captured_at + ChronoDuration::minutes(20); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + now, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_context_window_uses_last_usage() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model_context_window = Some(272_000); + + let account_display = test_status_account_display(); + let total_usage = TokenUsage { + input_tokens: 12_800, + cached_input_tokens: 0, + output_tokens: 879, + reasoning_output_tokens: 0, + total_tokens: 102_000, + }; + let last_usage = TokenUsage { + input_tokens: 12_800, + cached_input_tokens: 0, + output_tokens: 879, + reasoning_output_tokens: 0, + total_tokens: 13_679, + }; + + let now = chrono::Local + .with_ymd_and_hms(2024, 6, 1, 12, 0, 0) + .single() + .expect("timestamp"); + + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + let token_info = TokenUsageInfo { + total_token_usage: total_usage.clone(), + last_token_usage: last_usage, + model_context_window: config.model_context_window, + }; + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &total_usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + now, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + let context_line = rendered_lines + .into_iter() + .find(|line| line.contains("Context window")) + .expect("context line"); + + assert!( + context_line.contains("13.7K used / 272K"), + "expected context line to reflect last usage tokens, got: {context_line}" + ); + assert!( + !context_line.contains("102K"), + "context line should not use total aggregated tokens, got: {context_line}" + ); +} + +#[tokio::test] +async fn status_permissions_include_executor_profile_root() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + set_workspace_cwd(&mut config, test_path_buf("/workspace/repo").abs()); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + let root = PathUri::parse("file://server/share/foreign").expect("executor URI"); + config + .permissions + .set_permission_profile_from_session_snapshot( + PermissionProfileSnapshot::active_with_profile_workspace_roots( + PermissionProfile::workspace_write_with_path_uris( + std::slice::from_ref(&root), + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ), + ActivePermissionProfile::new("executor"), + vec![root.into()], + ), + ) + .expect("set permission snapshot"); + + assert_snapshot!( + permissions_text_for_width(&config, /*width*/ 160).expect("permissions line"), + @r"Profile executor (workspace [\\server\share\foreign], Ask for approval)" + ); +} diff --git a/codex-rs/tui/src/status/thread_usage.rs b/codex-rs/tui/src/status/thread_usage.rs new file mode 100644 index 0000000000000000000000000000000000000000..60948689fd46bdf23563fbfb91dad76c18a3bab3 --- /dev/null +++ b/codex-rs/tui/src/status/thread_usage.rs @@ -0,0 +1,337 @@ +//! Live-updating, thread-scoped billing details embedded in `/status` history cards. + +use super::format::FieldFormatter; +use super::format::push_label; +use super::helpers::format_tokens_compact; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; +use codex_app_server_protocol::ThreadUsage; +use codex_app_server_protocol::ThreadUsageBreakdownGroup; +use ratatui::prelude::Line; +use ratatui::style::Stylize; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::sync::Arc; +use std::sync::RwLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +const REASONING_ORDER: [&str; 8] = [ + "None", + "Minimal", + "Light", + "Medium", + "High", + "Extra High", + "Max", + "Ultra", +]; +const SPEED_ORDER: [&str; 3] = ["Fast mode", "Ultrafast", "Standard"]; +const MODELS_LABEL: &str = " Models"; +const REASONING_LABEL: &str = " Reasoning"; +const SPEED_LABEL: &str = " Speed"; +const BILLED_TOKENS_LABEL: &str = " Billed tokens"; + +/// Shared state updates a committed `/status` card when its asynchronous estimate arrives. +#[derive(Clone, Debug, Default)] +pub(crate) struct StatusThreadUsage { + estimate: Arc>>, + reserve_label_width: Arc, +} + +impl StatusThreadUsage { + pub(crate) fn reserve_label_width(&self) { + self.reserve_label_width + .store(/*val*/ true, Ordering::Relaxed); + } + + pub(crate) fn set_estimate(&self, estimate: Option) { + #[expect(clippy::expect_used)] + let mut stored_estimate = self + .estimate + .write() + .expect("status history thread-usage state poisoned"); + *stored_estimate = estimate; + } + + pub(crate) fn push_labels(&self, labels: &mut Vec, seen: &mut BTreeSet) { + // Keep existing card rows stable when asynchronous billing details arrive. Reserving a + // formatter label does not emit a loading placeholder or an empty billing row. + if self.reserve_label_width.load(Ordering::Relaxed) { + push_label(labels, seen, BILLED_TOKENS_LABEL); + } + #[expect(clippy::expect_used)] + let stored_estimate = self + .estimate + .read() + .expect("status history thread-usage state poisoned"); + let Some(estimate) = stored_estimate.as_ref() else { + return; + }; + push_label(labels, seen, "Thread usage"); + if !estimate.groups.is_empty() { + push_label(labels, seen, MODELS_LABEL); + push_label(labels, seen, REASONING_LABEL); + push_label(labels, seen, SPEED_LABEL); + if estimate + .groups + .iter() + .any(|group| group.input_tokens.is_some() || group.output_tokens.is_some()) + { + push_label(labels, seen, BILLED_TOKENS_LABEL); + } + } + } + + pub(crate) fn lines( + &self, + formatter: &FieldFormatter, + value_width: usize, + ) -> Vec> { + #[expect(clippy::expect_used)] + let stored_estimate = self + .estimate + .read() + .expect("status history thread-usage state poisoned"); + let Some(estimate) = stored_estimate.as_ref() else { + return Vec::new(); + }; + + let credits_micros = estimate.estimated_usage_credits_micros; + let mut usage = vec![format!("{} credits", format_credit_micros(credits_micros)).into()]; + if let Some(cost) = estimate + .estimated_usage_usd_micros + .and_then(format_estimated_usd_micros) + { + usage.push(" · ".dim()); + usage.push(cost.into()); + } + let mut lines = vec![formatter.line("Thread usage", usage)]; + + for (label, dimension) in [ + (MODELS_LABEL, BreakdownDimension::Model), + (REASONING_LABEL, BreakdownDimension::Reasoning), + (SPEED_LABEL, BreakdownDimension::Speed), + ] { + let Some(value) = grouped_usage(&estimate.groups, dimension) else { + continue; + }; + let mut wrapped = word_wrap_lines( + [Line::from(value)], + RtOptions::new(value_width.max(/*other*/ 1)), + ) + .into_iter(); + if let Some(first) = wrapped.next() { + lines.push(formatter.line(label, first.spans)); + lines.extend(wrapped.map(|line| formatter.continuation(line.spans))); + } + } + + let sum_tokens = |count: fn(&ThreadUsageBreakdownGroup) -> Option| { + estimate + .groups + .iter() + .try_fold(/*init*/ 0_i64, |total, group| { + count(group).map(|tokens| total.saturating_add(tokens)) + }) + }; + let input_tokens = sum_tokens(|group| group.input_tokens); + let cached_tokens = sum_tokens(|group| group.cached_input_tokens); + let output_tokens = sum_tokens(|group| group.output_tokens); + if !estimate.groups.is_empty() && (input_tokens.is_some() || output_tokens.is_some()) { + let mut tokens = Vec::new(); + if let Some(input_tokens) = input_tokens { + tokens.push(format!("{} input", format_tokens_compact(input_tokens))); + if let Some(cached_tokens) = cached_tokens { + tokens.push(format!("({} cached)", format_tokens_compact(cached_tokens))); + } + } + if let Some(output_tokens) = output_tokens { + if !tokens.is_empty() { + tokens.push("+".to_string()); + } + tokens.push(format!("{} output", format_tokens_compact(output_tokens))); + } + lines.push(formatter.line(BILLED_TOKENS_LABEL, vec![tokens.join(" ").into()])); + } + + lines + } +} + +#[derive(Clone, Copy)] +enum BreakdownDimension { + Model, + Reasoning, + Speed, +} + +fn grouped_usage( + groups: &[ThreadUsageBreakdownGroup], + dimension: BreakdownDimension, +) -> Option { + let mut totals = BTreeMap::::new(); + for group in groups { + if group.estimated_usage_credits_micros < 0 { + continue; + } + let value = match dimension { + BreakdownDimension::Model => group + .model + .as_deref() + .map(format_model_display_name) + .unwrap_or_else(|| "Other".to_string()), + BreakdownDimension::Reasoning => match group.reasoning_effort.as_deref() { + Some("none") => "None", + Some("minimal") => "Minimal", + Some("low") => "Light", + Some("medium") => "Medium", + Some("high") => "High", + Some("xhigh") => "Extra High", + Some("max") => "Max", + Some("ultra") => "Ultra", + Some(other) => other, + None => "Other", + } + .to_string(), + BreakdownDimension::Speed => match group.speed.as_deref() { + Some("fast") => "Fast mode", + Some("ultrafast") => "Ultrafast", + Some("standard") => "Standard", + Some(other) => other, + None => "Other", + } + .to_string(), + }; + let total = totals.entry(value).or_default(); + *total = total.saturating_add(group.estimated_usage_credits_micros); + } + if totals.is_empty() { + return None; + } + + let total_credits_micros = + totals.values().fold( + /*init*/ 0_i64, + |total, credits| total.saturating_add(*credits), + ); + let mut totals = totals.into_iter().collect::>(); + totals.sort_by( + |(left_name, left_credits), (right_name, right_credits)| match dimension { + BreakdownDimension::Model => right_credits + .cmp(left_credits) + .then_with(|| left_name.cmp(right_name)), + BreakdownDimension::Reasoning => REASONING_ORDER + .iter() + .position(|value| value == left_name) + .unwrap_or(usize::MAX) + .cmp( + &REASONING_ORDER + .iter() + .position(|value| value == right_name) + .unwrap_or(usize::MAX), + ) + .then_with(|| left_name.cmp(right_name)), + BreakdownDimension::Speed => SPEED_ORDER + .iter() + .position(|value| value == left_name) + .unwrap_or(usize::MAX) + .cmp( + &SPEED_ORDER + .iter() + .position(|value| value == right_name) + .unwrap_or(usize::MAX), + ) + .then_with(|| left_name.cmp(right_name)), + }, + ); + Some( + totals + .into_iter() + .map(|(name, credits)| { + let percentage = if total_credits_micros <= 0 { + 0 + } else { + (i128::from(credits) * 100 + i128::from(total_credits_micros) / 2) + / i128::from(total_credits_micros) + }; + format!("{name} {percentage}%") + }) + .collect::>() + .join(", "), + ) +} + +fn format_model_display_name(model: &str) -> String { + let model = crate::model_catalog::model_display_name(model); + let Some((prefix, remainder)) = model.split_once('-') else { + return model.to_string(); + }; + if !prefix.eq_ignore_ascii_case("gpt") || remainder.is_empty() { + return model.to_string(); + } + + let mut segments = remainder.split('-'); + let first = segments.next().unwrap_or_default(); + let mut display_name = format!("GPT-{first}"); + let numeric_model = first + .chars() + .next() + .is_some_and(|character| character.is_ascii_digit()); + for segment in segments { + display_name.push(if numeric_model { ' ' } else { '-' }); + if segment.eq_ignore_ascii_case("oai") { + display_name.push_str("OAI"); + continue; + } + + let mut characters = segment.chars(); + if let Some(first_character) = characters.next() { + display_name.extend(first_character.to_uppercase()); + display_name.push_str(characters.as_str()); + } + } + + display_name +} + +pub(crate) fn format_credit_micros(micros: i64) -> String { + let micros = i128::from(micros.max(/*other*/ 0)); + let (unit_micros, suffix) = [ + (1_000_000_000_000_000_000_i128, "T"), + (1_000_000_000_000_000_i128, "B"), + (1_000_000_000_000_i128, "M"), + (1_000_000_000_i128, "K"), + ] + .into_iter() + .find(|(unit, _)| micros >= *unit) + .unwrap_or((1_000_000, "")); + let tenths = (micros * 10 + unit_micros / 2) / unit_micros; + let whole = tenths / 10; + let fraction = tenths % 10; + if fraction == 0 { + format!("{whole}{suffix}") + } else { + format!("{whole}.{fraction}{suffix}") + } +} + +pub(crate) fn format_estimated_usd_micros(micros: i64) -> Option { + if micros < 0 { + return None; + } + if micros > 0 && micros < 100 { + return Some(format!("~$0.{micros:06}")); + } + if micros > 0 && micros < 10_000 { + let ten_thousandths = micros.saturating_add(/*rhs*/ 50) / 100; + return Some(format!("~$0.{ten_thousandths:04}")); + } + + let cents = micros.saturating_add(/*rhs*/ 5_000) / 10_000; + Some(format!("~${}.{:02}", cents / 100, cents % 100)) +} + +#[cfg(test)] +#[path = "thread_usage_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/status/thread_usage_tests.rs b/codex-rs/tui/src/status/thread_usage_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..e05a879b7c4be01bcdf2a5edbcccf5f572f4391e --- /dev/null +++ b/codex-rs/tui/src/status/thread_usage_tests.rs @@ -0,0 +1,70 @@ +use super::BreakdownDimension; +use super::format_credit_micros; +use super::format_model_display_name; +use super::grouped_usage; +use codex_app_server_protocol::ThreadUsageBreakdownGroup; +use pretty_assertions::assert_eq; + +#[test] +fn thread_usage_breakdown_groups_aggregate_model_credit_percentages() { + let group = ThreadUsageBreakdownGroup { + model: Some("gpt-5.4".to_string()), + reasoning_effort: Some("high".to_string()), + speed: Some("fast".to_string()), + estimated_usage_credits_micros: 30_000_000, + net_new_input_tokens: Some(60), + cached_input_tokens: Some(10), + input_tokens: Some(70), + output_tokens: Some(20), + total_tokens: Some(90), + }; + let groups = vec![ + group.clone(), + ThreadUsageBreakdownGroup { + reasoning_effort: Some("medium".to_string()), + speed: Some("standard".to_string()), + estimated_usage_credits_micros: 10_000_000, + ..group.clone() + }, + ThreadUsageBreakdownGroup { + model: Some("gpt-5-mini".to_string()), + reasoning_effort: Some("low".to_string()), + speed: Some("standard".to_string()), + estimated_usage_credits_micros: 10_000_000, + ..group + }, + ]; + + assert_eq!( + grouped_usage(&groups, BreakdownDimension::Model), + Some("GPT-5.4 80%, GPT-5 Mini 20%".to_string()) + ); + assert_eq!( + grouped_usage(&groups, BreakdownDimension::Reasoning), + Some("Light 20%, Medium 20%, High 60%".to_string()) + ); + assert_eq!( + grouped_usage(&groups, BreakdownDimension::Speed), + Some("Fast mode 60%, Standard 40%".to_string()) + ); +} + +#[test] +fn thread_usage_credit_formatting_rounds_to_one_decimal_place() { + assert_eq!(format_credit_micros(/*micros*/ 0), "0"); + assert_eq!(format_credit_micros(/*micros*/ 400_000), "0.4"); + assert_eq!(format_credit_micros(/*micros*/ 5_200_000), "5.2"); + assert_eq!(format_credit_micros(/*micros*/ 5_240_000), "5.2"); + assert_eq!(format_credit_micros(/*micros*/ 5_250_000), "5.3"); + assert_eq!(format_credit_micros(/*micros*/ 46_000_000), "46"); + assert_eq!(format_credit_micros(/*micros*/ 1_240_000_000), "1.2K"); + assert_eq!(format_credit_micros(/*micros*/ 12_400_000_000), "12.4K"); +} + +#[test] +fn thread_usage_model_names_match_desktop_display_names() { + assert_eq!(format_model_display_name("gpt-5.4"), "GPT-5.4"); + assert_eq!(format_model_display_name("gpt-5-mini"), "GPT-5 Mini"); + assert_eq!(format_model_display_name("gpt-5.6-sol"), "GPT-5.6 Sol"); + assert_eq!(format_model_display_name("examplemodel"), "examplemodel"); +} diff --git a/codex-rs/tui/src/status_indicator_widget/timer.rs b/codex-rs/tui/src/status_indicator_widget/timer.rs new file mode 100644 index 0000000000000000000000000000000000000000..8820e129be0eef4b3582f2cf3ffc4bf0d5f643ab --- /dev/null +++ b/codex-rs/tui/src/status_indicator_widget/timer.rs @@ -0,0 +1,61 @@ +//! Pause-aware status time owned independently of the optional status row. +//! A phase can override the displayed clock without changing turn accounting. + +use std::time::Duration; +use std::time::Instant; + +#[derive(Debug)] +pub(crate) struct StatusTimer { + /// Optional wall-clock origin for the displayed phase, without resetting turn accounting. + pub(crate) display_started_at: Option, + pub(super) elapsed_running: Duration, + pub(super) last_resume_at: Instant, + pub(super) is_paused: bool, +} + +impl Default for StatusTimer { + fn default() -> Self { + Self { + display_started_at: None, + elapsed_running: Duration::ZERO, + last_resume_at: Instant::now(), + is_paused: false, + } + } +} + +impl StatusTimer { + pub(crate) fn reset(&mut self, elapsed: Duration) { + self.elapsed_running = elapsed; + self.last_resume_at = Instant::now(); + // A turn can start while an MCP elicitation or approval is still open. + // Reset elapsed time without changing that modal's pause state. + } + + pub(crate) fn pause_at(&mut self, now: Instant) { + if !self.is_paused { + self.elapsed_running += now.saturating_duration_since(self.last_resume_at); + self.is_paused = true; + } + } + + pub(crate) fn resume_at(&mut self, now: Instant) { + if self.is_paused { + self.last_resume_at = now; + self.is_paused = false; + } + } + + pub(crate) fn elapsed_at(&self, now: Instant) -> Duration { + self.elapsed_running + + if self.is_paused { + Duration::ZERO + } else { + now.saturating_duration_since(self.last_resume_at) + } + } +} + +#[cfg(test)] +#[path = "timer_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/status_indicator_widget/timer_tests.rs b/codex-rs/tui/src/status_indicator_widget/timer_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..25144c910221a7bd0cb64963c6653996cc1c9782 --- /dev/null +++ b/codex-rs/tui/src/status_indicator_widget/timer_tests.rs @@ -0,0 +1,34 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn pauses_and_resumes_without_counting_wait_time() { + let baseline = Instant::now(); + let mut timer = StatusTimer { + last_resume_at: baseline, + ..StatusTimer::default() + }; + timer.pause_at(baseline + Duration::from_secs(5)); + timer.pause_at(baseline + Duration::from_secs(8)); + assert_eq!( + timer.elapsed_at(baseline + Duration::from_secs(10)), + Duration::from_secs(5) + ); + timer.resume_at(baseline + Duration::from_secs(10)); + timer.resume_at(baseline + Duration::from_secs(12)); + assert_eq!( + timer.elapsed_at(baseline + Duration::from_secs(13)), + Duration::from_secs(8) + ); + timer.reset(Duration::from_secs(24)); + assert_eq!( + timer.elapsed_at(timer.last_resume_at), + Duration::from_secs(24) + ); + timer.pause_at(timer.last_resume_at); + timer.reset(Duration::ZERO); + assert_eq!( + timer.elapsed_at(timer.last_resume_at + Duration::from_secs(/*secs*/ 120)), + Duration::ZERO + ); +} diff --git a/codex-rs/tui/src/streaming/chunking.rs b/codex-rs/tui/src/streaming/chunking.rs new file mode 100644 index 0000000000000000000000000000000000000000..9bf43cba52798cc223b75e1cdfb5de17c365068e --- /dev/null +++ b/codex-rs/tui/src/streaming/chunking.rs @@ -0,0 +1,457 @@ +//! Adaptive stream chunking policy for commit animation ticks. +//! +//! This policy preserves the baseline user experience while adapting to bursty +//! stream input. In [`ChunkingMode::Smooth`], one queued line is drained per +//! baseline commit tick. When queue pressure rises, it switches to +//! [`ChunkingMode::CatchUp`] and drains queued backlog immediately so display +//! lag converges as quickly as possible. +//! +//! The policy is source-agnostic: it depends only on queue depth and queue +//! age from [`QueueSnapshot`]. It does not branch on source identity or explicit +//! throughput targets. +//! +//! # Mental model +//! +//! Think of this as a two-gear system: +//! +//! - [`ChunkingMode::Smooth`]: steady baseline display pacing. +//! - [`ChunkingMode::CatchUp`]: full queue draining while backlog exists. +//! +//! The transition logic intentionally uses hysteresis: +//! +//! - enter catch-up on higher-pressure thresholds +//! - exit catch-up on lower-pressure thresholds, held for [`EXIT_HOLD`] +//! - after exit, suppress immediate re-entry for [`REENTER_CATCH_UP_HOLD`] +//! unless backlog is severe +//! +//! This avoids rapid gear-flapping near threshold boundaries. +//! +//! # Policy flow +//! +//! On each decision tick, [`AdaptiveChunkingPolicy::decide`] does: +//! +//! 1. If queue is empty, reset to [`ChunkingMode::Smooth`]. +//! 2. If currently smooth, call [`AdaptiveChunkingPolicy::maybe_enter_catch_up`]. +//! 3. If currently catch-up, call [`AdaptiveChunkingPolicy::maybe_exit_catch_up`]. +//! 4. Build [`DrainPlan`] (`Single` for smooth, `Batch(queued_lines)` for catch-up). +//! +//! # Concrete examples +//! +//! With current defaults: +//! +//! - `Smooth` drains one line per commit tick. +//! - `CatchUp` drains all currently queued lines in a tick. +//! +//! # Tuning guide (in code terms) +//! +//! Prefer tuning in this order so causes remain clear: +//! +//! 1. enter/exit thresholds: [`ENTER_QUEUE_DEPTH_LINES`], [`ENTER_OLDEST_AGE`], +//! [`EXIT_QUEUE_DEPTH_LINES`], [`EXIT_OLDEST_AGE`] +//! 2. hysteresis windows: [`EXIT_HOLD`], [`REENTER_CATCH_UP_HOLD`] +//! 3. severe gates: [`SEVERE_QUEUE_DEPTH_LINES`], [`SEVERE_OLDEST_AGE`] +//! +//! Symptom-oriented adjustments: +//! +//! - lag starts too late: lower enter thresholds +//! - frequent smooth/catch-up chatter: increase hold windows, or tighten exit +//! thresholds +//! - catch-up re-enters too eagerly after exit: increase re-entry hold +//! +//! # Responsibilities +//! +//! - track mode and hysteresis state +//! - produce deterministic [`ChunkingDecision`] values from queue snapshots +//! - preserve queue order by draining from queue head only +//! +//! # Non-responsibilities +//! +//! - scheduling commit ticks +//! - reordering stream lines +//! - transport/source-specific semantics +//! +//! Markdown docs remain supplemental: +//! +//! - `docs/tui-stream-chunking-review.md` +//! - `docs/tui-stream-chunking-tuning.md` +//! - `docs/tui-stream-chunking-validation.md` + +use std::time::Duration; +use std::time::Instant; + +/// Queue-depth threshold that allows entering catch-up mode. +/// +/// Crossing this threshold alone is sufficient to leave smooth mode. +const ENTER_QUEUE_DEPTH_LINES: usize = 8; + +/// Oldest-line age threshold that allows entering catch-up mode. +/// +/// Crossing this threshold alone is sufficient to leave smooth mode. +const ENTER_OLDEST_AGE: Duration = Duration::from_millis(120); + +/// Queue-depth threshold used when evaluating catch-up exit hysteresis. +/// +/// Depth must be at or below this value before exit hold timing can begin. +const EXIT_QUEUE_DEPTH_LINES: usize = 2; + +/// Oldest-line age threshold used when evaluating catch-up exit hysteresis. +/// +/// Age must be at or below this value before exit hold timing can begin. +const EXIT_OLDEST_AGE: Duration = Duration::from_millis(40); + +/// Minimum duration queue pressure must stay below exit thresholds to leave catch-up mode. +const EXIT_HOLD: Duration = Duration::from_millis(250); + +/// Cooldown window after a catch-up exit that suppresses immediate re-entry. +/// +/// Severe backlog still bypasses this hold to avoid unbounded queue-age growth. +const REENTER_CATCH_UP_HOLD: Duration = Duration::from_millis(250); + +/// Queue-depth cutoff that marks backlog as severe for faster convergence. +/// +/// This threshold is used to bypass re-entry hold after a recent catch-up exit. +const SEVERE_QUEUE_DEPTH_LINES: usize = 64; + +/// Oldest-line age cutoff that marks backlog as severe for faster convergence. +const SEVERE_OLDEST_AGE: Duration = Duration::from_millis(300); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum ChunkingMode { + /// Drain one line per baseline commit tick. + #[default] + Smooth, + /// Drain multiple lines per tick according to queue pressure. + CatchUp, +} + +/// Captures queue pressure inputs used by adaptive chunking decisions. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct QueueSnapshot { + /// Number of queued stream lines waiting to be displayed. + pub(crate) queued_lines: usize, + /// Age of the oldest queued line at decision time. + pub(crate) oldest_age: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DrainPlan { + /// Emit exactly one queued line. + Single, + /// Emit up to `usize` queued lines. + Batch(usize), +} + +/// Represents one policy decision for a specific queue snapshot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ChunkingDecision { + /// Mode after applying hysteresis transitions for this decision. + pub(crate) mode: ChunkingMode, + /// Whether this decision transitioned from `Smooth` into `CatchUp`. + pub(crate) entered_catch_up: bool, + /// Drain plan to execute for the current commit tick. + pub(crate) drain_plan: DrainPlan, +} + +/// Maintains adaptive chunking mode and hysteresis state across ticks. +#[derive(Debug, Default)] +pub(crate) struct AdaptiveChunkingPolicy { + mode: ChunkingMode, + below_exit_threshold_since: Option, + last_catch_up_exit_at: Option, +} + +impl AdaptiveChunkingPolicy { + /// Returns the policy mode used by the most recent decision. + pub(crate) fn mode(&self) -> ChunkingMode { + self.mode + } + + /// Resets state to baseline smooth mode. + pub(crate) fn reset(&mut self) { + self.mode = ChunkingMode::Smooth; + self.below_exit_threshold_since = None; + self.last_catch_up_exit_at = None; + } + + /// Computes a drain decision from the current queue snapshot. + /// + /// The decision is deterministic for a given `(mode, snapshot, now)` triple. Callers should + /// avoid inventing synthetic snapshots; stale queue age data can cause premature catch-up exits. + pub(crate) fn decide(&mut self, snapshot: QueueSnapshot, now: Instant) -> ChunkingDecision { + if snapshot.queued_lines == 0 { + self.note_catch_up_exit(now); + self.mode = ChunkingMode::Smooth; + self.below_exit_threshold_since = None; + return ChunkingDecision { + mode: self.mode, + entered_catch_up: false, + drain_plan: DrainPlan::Single, + }; + } + + let entered_catch_up = match self.mode { + ChunkingMode::Smooth => self.maybe_enter_catch_up(snapshot, now), + ChunkingMode::CatchUp => { + self.maybe_exit_catch_up(snapshot, now); + false + } + }; + + let drain_plan = match self.mode { + ChunkingMode::Smooth => DrainPlan::Single, + ChunkingMode::CatchUp => DrainPlan::Batch(snapshot.queued_lines.max(1)), + }; + + ChunkingDecision { + mode: self.mode, + entered_catch_up, + drain_plan, + } + } + + /// Switches from `Smooth` to `CatchUp` when enter thresholds are crossed. + /// + /// Returns `true` only on the transition tick so callers can emit one-shot + /// transition observability. + fn maybe_enter_catch_up(&mut self, snapshot: QueueSnapshot, now: Instant) -> bool { + if !should_enter_catch_up(snapshot) { + return false; + } + if self.reentry_hold_active(now) && !is_severe_backlog(snapshot) { + return false; + } + self.mode = ChunkingMode::CatchUp; + self.below_exit_threshold_since = None; + self.last_catch_up_exit_at = None; + true + } + + /// Applies exit hysteresis while in `CatchUp` mode. + /// + /// The policy requires queue pressure to stay below exit thresholds for the + /// full `EXIT_HOLD` window before returning to `Smooth`. + fn maybe_exit_catch_up(&mut self, snapshot: QueueSnapshot, now: Instant) { + if !should_exit_catch_up(snapshot) { + self.below_exit_threshold_since = None; + return; + } + + match self.below_exit_threshold_since { + Some(since) if now.saturating_duration_since(since) >= EXIT_HOLD => { + self.mode = ChunkingMode::Smooth; + self.below_exit_threshold_since = None; + self.last_catch_up_exit_at = Some(now); + } + Some(_) => {} + None => { + self.below_exit_threshold_since = Some(now); + } + } + } + + fn note_catch_up_exit(&mut self, now: Instant) { + if self.mode == ChunkingMode::CatchUp { + self.last_catch_up_exit_at = Some(now); + } + } + + fn reentry_hold_active(&self, now: Instant) -> bool { + self.last_catch_up_exit_at + .is_some_and(|exit| now.saturating_duration_since(exit) < REENTER_CATCH_UP_HOLD) + } +} + +/// Returns whether current queue pressure warrants entering catch-up mode. +/// +/// Either depth or age pressure is sufficient to trigger catch-up. +fn should_enter_catch_up(snapshot: QueueSnapshot) -> bool { + snapshot.queued_lines >= ENTER_QUEUE_DEPTH_LINES + || snapshot + .oldest_age + .is_some_and(|oldest| oldest >= ENTER_OLDEST_AGE) +} + +/// Returns whether queue pressure is low enough to begin exit hysteresis. +/// +/// Both depth and age must be below thresholds; this prevents oscillation when +/// one signal is still under load. +fn should_exit_catch_up(snapshot: QueueSnapshot) -> bool { + snapshot.queued_lines <= EXIT_QUEUE_DEPTH_LINES + && snapshot + .oldest_age + .is_some_and(|oldest| oldest <= EXIT_OLDEST_AGE) +} + +/// Returns whether backlog is severe enough to use a faster catch-up target. +/// +/// Severe pressure bypasses re-entry hold to avoid queue-age growth after a +/// recent catch-up exit. +fn is_severe_backlog(snapshot: QueueSnapshot) -> bool { + snapshot.queued_lines >= SEVERE_QUEUE_DEPTH_LINES + || snapshot + .oldest_age + .is_some_and(|oldest| oldest >= SEVERE_OLDEST_AGE) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn snapshot(queued_lines: usize, oldest_age_ms: u64) -> QueueSnapshot { + QueueSnapshot { + queued_lines, + oldest_age: Some(Duration::from_millis(oldest_age_ms)), + } + } + + #[test] + fn smooth_mode_is_default() { + let mut policy = AdaptiveChunkingPolicy::default(); + let now = Instant::now(); + + let decision = policy.decide(snapshot(/*queued_lines*/ 1, /*oldest_age_ms*/ 10), now); + assert_eq!(decision.mode, ChunkingMode::Smooth); + assert_eq!(decision.entered_catch_up, false); + assert_eq!(decision.drain_plan, DrainPlan::Single); + } + + #[test] + fn enters_catch_up_on_depth_threshold() { + let mut policy = AdaptiveChunkingPolicy::default(); + let now = Instant::now(); + + let decision = policy.decide(snapshot(/*queued_lines*/ 8, /*oldest_age_ms*/ 10), now); + assert_eq!(decision.mode, ChunkingMode::CatchUp); + assert_eq!(decision.entered_catch_up, true); + assert_eq!(decision.drain_plan, DrainPlan::Batch(8)); + } + + #[test] + fn enters_catch_up_on_age_threshold() { + let mut policy = AdaptiveChunkingPolicy::default(); + let now = Instant::now(); + + let decision = policy.decide(snapshot(/*queued_lines*/ 2, /*oldest_age_ms*/ 120), now); + assert_eq!(decision.mode, ChunkingMode::CatchUp); + assert_eq!(decision.entered_catch_up, true); + assert_eq!(decision.drain_plan, DrainPlan::Batch(2)); + } + + #[test] + fn severe_backlog_uses_faster_paced_batches() { + let mut policy = AdaptiveChunkingPolicy::default(); + let now = Instant::now(); + let _ = policy.decide(snapshot(/*queued_lines*/ 9, /*oldest_age_ms*/ 10), now); + + let decision = policy.decide( + snapshot(/*queued_lines*/ 64, /*oldest_age_ms*/ 10), + now + Duration::from_millis(5), + ); + assert_eq!(decision.mode, ChunkingMode::CatchUp); + assert_eq!(decision.drain_plan, DrainPlan::Batch(64)); + } + + #[test] + fn catch_up_batch_drains_current_backlog() { + let mut policy = AdaptiveChunkingPolicy::default(); + let now = Instant::now(); + let decision = policy.decide(snapshot(/*queued_lines*/ 512, /*oldest_age_ms*/ 400), now); + assert_eq!(decision.mode, ChunkingMode::CatchUp); + assert_eq!(decision.drain_plan, DrainPlan::Batch(512)); + } + + #[test] + fn exits_catch_up_after_hysteresis_hold() { + let mut policy = AdaptiveChunkingPolicy::default(); + let t0 = Instant::now(); + + let _ = policy.decide(snapshot(/*queued_lines*/ 9, /*oldest_age_ms*/ 10), t0); + assert_eq!(policy.mode(), ChunkingMode::CatchUp); + + let pre_hold = policy.decide( + snapshot(/*queued_lines*/ 2, /*oldest_age_ms*/ 40), + t0 + Duration::from_millis(200), + ); + assert_eq!(pre_hold.mode, ChunkingMode::CatchUp); + + let post_hold = policy.decide( + snapshot(/*queued_lines*/ 2, /*oldest_age_ms*/ 40), + t0 + Duration::from_millis(460), + ); + assert_eq!(post_hold.mode, ChunkingMode::Smooth); + assert_eq!(post_hold.drain_plan, DrainPlan::Single); + } + + #[test] + fn drops_back_to_smooth_when_idle() { + let mut policy = AdaptiveChunkingPolicy::default(); + let now = Instant::now(); + let _ = policy.decide(snapshot(/*queued_lines*/ 9, /*oldest_age_ms*/ 10), now); + assert_eq!(policy.mode(), ChunkingMode::CatchUp); + + let decision = policy.decide( + QueueSnapshot { + queued_lines: 0, + oldest_age: None, + }, + now + Duration::from_millis(20), + ); + assert_eq!(decision.mode, ChunkingMode::Smooth); + assert_eq!(decision.drain_plan, DrainPlan::Single); + } + + #[test] + fn holds_reentry_after_catch_up_exit() { + let mut policy = AdaptiveChunkingPolicy::default(); + let t0 = Instant::now(); + + let entered = policy.decide(snapshot(/*queued_lines*/ 8, /*oldest_age_ms*/ 20), t0); + assert_eq!(entered.mode, ChunkingMode::CatchUp); + + let drained = policy.decide( + QueueSnapshot { + queued_lines: 0, + oldest_age: None, + }, + t0 + Duration::from_millis(20), + ); + assert_eq!(drained.mode, ChunkingMode::Smooth); + + let held = policy.decide( + snapshot(/*queued_lines*/ 8, /*oldest_age_ms*/ 20), + t0 + Duration::from_millis(120), + ); + assert_eq!(held.mode, ChunkingMode::Smooth); + assert_eq!(held.drain_plan, DrainPlan::Single); + + let reentered = policy.decide( + snapshot(/*queued_lines*/ 8, /*oldest_age_ms*/ 20), + t0 + Duration::from_millis(320), + ); + assert_eq!(reentered.mode, ChunkingMode::CatchUp); + assert_eq!(reentered.drain_plan, DrainPlan::Batch(8)); + } + + #[test] + fn severe_backlog_can_reenter_during_hold() { + let mut policy = AdaptiveChunkingPolicy::default(); + let t0 = Instant::now(); + + let _ = policy.decide(snapshot(/*queued_lines*/ 8, /*oldest_age_ms*/ 20), t0); + let _ = policy.decide( + QueueSnapshot { + queued_lines: 0, + oldest_age: None, + }, + t0 + Duration::from_millis(20), + ); + + let severe = policy.decide( + snapshot(/*queued_lines*/ 64, /*oldest_age_ms*/ 20), + t0 + Duration::from_millis(120), + ); + assert_eq!(severe.mode, ChunkingMode::CatchUp); + assert_eq!(severe.drain_plan, DrainPlan::Batch(64)); + } +} diff --git a/codex-rs/tui/src/streaming/code_fence.rs b/codex-rs/tui/src/streaming/code_fence.rs new file mode 100644 index 0000000000000000000000000000000000000000..24cd36a0054f33b8bc44212499f8e135c435bc61 --- /dev/null +++ b/codex-rs/tui/src/streaming/code_fence.rs @@ -0,0 +1,124 @@ +//! A conservative fast path for one open, top-level, language-tagged code fence. + +use crate::render::highlight::MAX_HIGHLIGHT_LINE_BYTES; +use crate::render::highlight::StreamingCodeHighlighter; +use crate::render::highlight::syntax_theme_revision; +use crate::terminal_hyperlinks::HyperlinkLine; +use ratatui::text::Span; + +/// An append-only fence whose original source is identical to pulldown-cmark's code text. +pub(super) struct OpenCodeFence { + marker: u8, + marker_len: usize, + language: String, + content_start: usize, + source_len: usize, + theme_revision: u64, + /// Initialized only if another chunk arrives, avoiding duplicate work on one-shot fences. + highlighter: Option, +} + +impl OpenCodeFence { + /// Recognize the final mutable top-level block after its canonical render. + /// + /// `source` is the final block's suffix, while `source_len` includes the entire committed + /// Markdown source and anchors the retained fence within it. + /// + /// Indentation, CR/NUL normalization, and escaped info strings stay on the canonical path. + /// Any line that could end the fence also stays there, including deliberately conservative + /// false positives, so this fast path need not duplicate CommonMark's closing-fence grammar. + pub(super) fn detect(source: &str, source_len: usize, theme_revision: u64) -> Option { + let marker = *source.as_bytes().first()?; + if marker != b'`' && marker != b'~' { + return None; + } + let (opening, code) = source.split_once('\n')?; + let marker_len = opening.bytes().take_while(|byte| *byte == marker).count(); + if marker_len < 3 + || !source.ends_with('\n') + || theme_revision != syntax_theme_revision() + || source.contains(['\r', '\0']) + { + return None; + } + let info = opening[marker_len..].trim_matches([' ', '\t', '\u{b}', '\u{c}']); + if info.contains(['&', '\\']) || (marker == b'`' && info.contains('`')) { + return None; + } + let language = info + .split([',', ' ', '\t']) + .next() + .filter(|language| !language.is_empty())?; + // Retain at most one bounded language token, never the streamed code itself. + if language.len() > MAX_HIGHLIGHT_LINE_BYTES + || has_possible_closing_line(code, marker, marker_len) + { + return None; + } + Some(Self { + marker, + marker_len, + language: language.to_string(), + content_start: source_len.checked_sub(code.len())?, + source_len, + theme_revision, + highlighter: None, + }) + } + + /// Append code only while the caller's newline-committed source extends this same fence. + /// + /// Returning `None` consumes the retained state and requires a canonical whole-fence render. + /// Successful lines include the empty indent span expected by the canonical Markdown writer. + pub(super) fn append( + mut self, + raw_source: &str, + committed_source: &str, + ) -> Option<(Self, Vec)> { + if self.source_len.checked_add(committed_source.len()) != Some(raw_source.len()) + || !raw_source.ends_with(committed_source) + || committed_source.contains(['\r', '\0']) + || has_possible_closing_line(committed_source, self.marker, self.marker_len) + || self.theme_revision != syntax_theme_revision() + { + return None; + } + let highlighter = match self.highlighter.take() { + Some(highlighter) => highlighter, + None => StreamingCodeHighlighter::new( + &raw_source[self.content_start..self.source_len], + &self.language, + self.theme_revision, + )?, + }; + let (highlighter, lines) = highlighter.append(committed_source)?; + self.highlighter = Some(highlighter); + self.source_len = raw_source.len(); + let lines = lines + .into_iter() + .map(|mut line| { + // The canonical writer installs an empty indent span for top-level fences. + line.spans.insert(/*index*/ 0, Span::default()); + HyperlinkLine::new(line) + }) + .collect(); + Some((self, lines)) + } +} + +/// Conservatively detect any line that might close the fence. +/// +/// False positives intentionally fall back to CommonMark instead of duplicating its closer rules. +fn has_possible_closing_line(source: &str, marker: u8, marker_len: usize) -> bool { + source.lines().any(|line| { + line.trim_start_matches([' ', '\t']) + .bytes() + .take_while(|byte| *byte == marker) + .count() + >= marker_len + }) +} + +#[cfg(test)] +#[path = "code_fence_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/streaming/code_fence_render_tests.rs b/codex-rs/tui/src/streaming/code_fence_render_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..c3599c8832ebf4e85e5f79eb017e7482afb710c1 --- /dev/null +++ b/codex-rs/tui/src/streaming/code_fence_render_tests.rs @@ -0,0 +1,147 @@ +use super::StreamingRender; +use super::append; +use super::append_rich_and_assert_matches_full; +use super::assert_rich_stream_matches_full_render; +use super::lines_to_plain_strings; +use super::test_cwd; +use crate::history_cell::HistoryRenderMode; +use crate::render::highlight::MAX_HIGHLIGHT_LINE_BYTES; +use insta::assert_debug_snapshot; +use pretty_assertions::assert_eq; + +#[test] +fn growing_code_fences_preserve_styles_unicode_links_and_blank_lines() { + let (_, render) = assert_rich_stream_matches_full_render( + &[ + "Before [link](https://example.com).\n\n```rust,no_run\n", + "fn main() {\n", + " /* a multiline\n", + " comment */\n\n", + " println!(\"界 e\u{301} https://example.com\");\n", + "}\n", + "```\n\n~~~~python\n", + "text = \"\"\"a multiline\n", + "string\"\"\"\n", + "~~~~\n", + ], + Some(18), + ); + assert!(render.open_code_fence.is_none()); + assert_debug_snapshot!(lines_to_plain_strings(&render.lines), @r#" + [ + "Before link", + "(https://example.com).", + "", + "fn main() {", + " /* a multiline", + " comment */", + "", + " println!(\"界 e\u{301} https://example.com\");", + "}", + "", + "text = \"\"\"a multiline", + "string\"\"\"", + ] + "#); +} + +#[test] +fn long_open_fence_retains_its_rendered_prefix() { + let cwd = test_cwd(); + let mut source = String::new(); + let mut render = StreamingRender::new(); + append_rich_and_assert_matches_full( + &mut render, + &mut source, + "```rust\nfn main() {\n", + Some(80), + &cwd, + ); + let first_line = render.lines[0].line.spans[1].content.as_ptr(); + for index in 0..512 { + append( + &mut render, + &mut source, + &format!(" let value_{index} = {index};\n"), + Some(80), + &cwd, + HistoryRenderMode::Rich, + ); + assert!(render.open_code_fence.is_some()); + } + assert_eq!(render.lines[0].line.spans[1].content.as_ptr(), first_line); + append_rich_and_assert_matches_full(&mut render, &mut source, "}\n```\n", Some(80), &cwd); +} + +#[test] +fn ambiguous_and_normalized_fences_use_canonical_semantics() { + let streams: &[&[&str]] = &[ + &["```rust\n", "x\n", "``", "`\n"], + &["```rust\n", "x\n", "```\t\n", "after\n"], + &["```rust\n", "x\n", " ```` \n", "after\n"], + &["~~~rust\n", "x\n", "~~~\u{a0}\n", "after\n"], + &["```rust\r\n", "let value = 'é';\r\n", "```\r\n"], + &["```rust\n", "let value = '\0';\n", "```\n"], + &["```rust,no_run\n", "fn main() {}\n", "```\n"], + &[" ```rust\n", " fn main() {}\n", " ```\n"], + &["```unknown-language\n", "first\n", "second\n", "```\n"], + &[ + "```markdown\n| A | B |\n", + "| --- | --- |\n", + "| 1 | 2 |\n```\n\n```rust\nfn first() {}\n", + "fn second() {}\n", + "```\n", + ], + ]; + for chunks in streams { + assert_rich_stream_matches_full_render(chunks, Some(40)); + } + for indent in 0..=3 { + let closing = format!("{}~~~~~\t\n", " ".repeat(indent)); + assert_rich_stream_matches_full_render( + &["~~~~rust\n", "x\n", &closing, "after\n"], + Some(40), + ); + } +} + +#[test] +fn crossing_a_highlight_limit_removes_previously_highlighted_colors() { + let cwd = test_cwd(); + let mut source = String::new(); + let mut render = StreamingRender::new(); + for chunk in [ + "```rust\nlet first = 1;\n".to_string(), + format!("{}\n", "x".repeat(MAX_HIGHLIGHT_LINE_BYTES + 1)), + "let second = 2;\n".to_string(), + "```\n".to_string(), + ] { + append_rich_and_assert_matches_full(&mut render, &mut source, &chunk, Some(80), &cwd); + } +} + +#[test] +fn recomputing_after_resize_or_mode_change_drops_fence_state() { + let (mut source, mut render) = + assert_rich_stream_matches_full_render(&["```rust\nfn first() {}\n"], Some(80)); + assert!(render.open_code_fence.is_some()); + let cwd = test_cwd(); + render.recompute( + &source, + Some(20), + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None, + ); + assert!(render.open_code_fence.is_none()); + append_rich_and_assert_matches_full( + &mut render, + &mut source, + "fn second() {}\n", + Some(20), + &cwd, + ); + assert!(render.open_code_fence.is_some()); + render.clear(); + assert!(render.open_code_fence.is_none()); +} diff --git a/codex-rs/tui/src/streaming/code_fence_tests.rs b/codex-rs/tui/src/streaming/code_fence_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..4c9ef491cd707ff5dffe26babe074ea0c539940c --- /dev/null +++ b/codex-rs/tui/src/streaming/code_fence_tests.rs @@ -0,0 +1,69 @@ +use super::OpenCodeFence; +use super::has_possible_closing_line; +use crate::render::highlight::MAX_HIGHLIGHT_LINE_BYTES; +use crate::render::highlight::syntax_theme_revision; + +#[test] +fn detects_only_source_preserving_open_fences() { + for source in [ + "```rust,no_run\nfn main() {}\n", + "~~~~python title=demo\nvalue = 1\n", + "```\u{b}rust\u{c}\nfn main() {}\n", + "```unknown-language\ntext\n", + ] { + let fence = + OpenCodeFence::detect(source, source.len(), syntax_theme_revision()).expect(source); + assert!(fence.highlighter.is_none()); + } + for source in [ + " ```rust\ncode\n", + "```\ncode\n", + "```rust\r\ncode\r\n", + "```rust\n'\0'\n", + "```rust\ncode\n", + "```rust\\,no_run\ncode\n", + "```rust\npartial", + ] { + assert!( + OpenCodeFence::detect(source, source.len(), syntax_theme_revision()).is_none(), + "{source:?}" + ); + } + let source = format!("```{}\n", "x".repeat(MAX_HIGHLIGHT_LINE_BYTES + 1)); + assert!(OpenCodeFence::detect(&source, source.len(), syntax_theme_revision()).is_none()); +} + +#[test] +fn every_possible_closer_returns_to_the_canonical_parser() { + let opening = "~~~~rust\n"; + assert!(!has_possible_closing_line( + "~~~\n```\n", + /*marker*/ b'~', + /*marker_len*/ 4 + )); + for line in [ + "~~~~\n", + " ~~~~~~ \n", + "~~~~\t\n", + "~~~~\u{a0}\n", + "\t~~~~\n", + " ~~~~\n", + "~~~~ trailing text\n", + ] { + let source = format!("{opening}{line}"); + assert!(OpenCodeFence::detect(&source, source.len(), syntax_theme_revision()).is_none()); + } +} + +#[test] +fn lazy_initialization_rejects_a_changed_theme() { + let source = "```rust\nfn first() {}\n"; + let mut fence = OpenCodeFence::detect(source, source.len(), syntax_theme_revision()).unwrap(); + fence.theme_revision = fence.theme_revision.wrapping_sub(1); + let appended = "fn second() {}\n"; + assert!( + fence + .append(&format!("{source}{appended}"), appended) + .is_none() + ); +} diff --git a/codex-rs/tui/src/streaming/commit_tick.rs b/codex-rs/tui/src/streaming/commit_tick.rs new file mode 100644 index 0000000000000000000000000000000000000000..6c287ff9c37ab2d3f5ab5258993da79e8d8d99ed --- /dev/null +++ b/codex-rs/tui/src/streaming/commit_tick.rs @@ -0,0 +1,214 @@ +//! Orchestrates commit-tick drains across streaming controllers. +//! +//! This module bridges queue-based chunking policy (`chunking`) with the concrete stream +//! controllers (`controller`). Callers provide the current controllers and tick scope; the module +//! computes queue pressure, selects a drain plan, applies it, and returns emitted history cells. +//! +//! The module preserves ordering by draining only from controller queue heads. It does not schedule +//! ticks and it does not mutate UI state directly; callers remain responsible for animation events +//! and history insertion side effects. +//! +//! The main flow is: +//! [`run_commit_tick`] -> [`stream_queue_snapshot`] -> [`QueueSnapshot`] -> +//! [`resolve_chunking_plan`] -> [`ChunkingDecision`]/[`DrainPlan`] -> +//! [`apply_commit_tick_plan`] -> [`CommitTickOutput`]. + +use std::time::Duration; +use std::time::Instant; + +use crate::history_cell::HistoryCell; + +use super::chunking::AdaptiveChunkingPolicy; +use super::chunking::ChunkingDecision; +use super::chunking::ChunkingMode; +use super::chunking::DrainPlan; +use super::chunking::QueueSnapshot; +use super::controller::PlanStreamController; +use super::controller::StreamController; + +/// Describes whether a commit tick may run in all modes or only in catch-up mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CommitTickScope { + /// Always run the tick, regardless of current chunking mode. + AnyMode, + /// Run model transitions and policy updates, but commit lines only in `CatchUp`. + CatchUpOnly, +} + +/// Describes what a single commit tick produced. +pub(crate) struct CommitTickOutput { + /// Cells produced by drained stream lines during this tick. + pub(crate) cells: Vec>, + /// Whether at least one stream controller was present for this tick. + pub(crate) has_controller: bool, + /// Whether all present controllers were idle after this tick. + pub(crate) all_idle: bool, +} + +impl Default for CommitTickOutput { + /// Creates an output that represents "no commit performed". + /// + /// This is used when a tick is intentionally suppressed, for example when + /// the scope is [`CommitTickScope::CatchUpOnly`] and policy is not in catch-up mode. + fn default() -> Self { + Self { + cells: Vec::new(), + has_controller: false, + all_idle: true, + } + } +} + +/// Runs one commit tick against the provided stream controllers. +/// +/// This function collects a [`QueueSnapshot`], asks [`AdaptiveChunkingPolicy`] for a +/// [`ChunkingDecision`], and then applies the resulting [`DrainPlan`] to both controllers. +/// If callers pass stale controller references (for example, references not tied to the +/// current turn), queue age can be misread and the policy may stay in catch-up longer +/// than expected. +pub(crate) fn run_commit_tick( + policy: &mut AdaptiveChunkingPolicy, + stream_controller: Option<&mut StreamController>, + plan_stream_controller: Option<&mut PlanStreamController>, + scope: CommitTickScope, + now: Instant, +) -> CommitTickOutput { + let snapshot = stream_queue_snapshot( + stream_controller.as_deref(), + plan_stream_controller.as_deref(), + now, + ); + let decision = resolve_chunking_plan(policy, snapshot, now); + if scope == CommitTickScope::CatchUpOnly && decision.mode != ChunkingMode::CatchUp { + return CommitTickOutput::default(); + } + + apply_commit_tick_plan( + decision.drain_plan, + stream_controller, + plan_stream_controller, + ) +} + +/// Builds the combined queue-pressure snapshot consumed by chunking policy. +/// +/// The snapshot sums queue depth across controllers and keeps the maximum oldest age +/// so policy decisions reflect the most delayed queued line currently visible. +fn stream_queue_snapshot( + stream_controller: Option<&StreamController>, + plan_stream_controller: Option<&PlanStreamController>, + now: Instant, +) -> QueueSnapshot { + let mut queued_lines = 0usize; + let mut oldest_age: Option = None; + + if let Some(controller) = stream_controller { + queued_lines += controller.queued_lines(); + oldest_age = max_duration(oldest_age, controller.oldest_queued_age(now)); + } + if let Some(controller) = plan_stream_controller { + queued_lines += controller.queued_lines(); + oldest_age = max_duration(oldest_age, controller.oldest_queued_age(now)); + } + + QueueSnapshot { + queued_lines, + oldest_age, + } +} + +/// Computes one policy decision and emits a trace log on mode transitions. +/// +/// This keeps policy transition logging in one place so callers can rely on +/// [`run_commit_tick`] to provide consistent observability. +fn resolve_chunking_plan( + policy: &mut AdaptiveChunkingPolicy, + snapshot: QueueSnapshot, + now: Instant, +) -> ChunkingDecision { + let prior_mode = policy.mode(); + let decision = policy.decide(snapshot, now); + if decision.mode != prior_mode { + tracing::trace!( + prior_mode = ?prior_mode, + new_mode = ?decision.mode, + queued_lines = snapshot.queued_lines, + oldest_queued_age_ms = snapshot.oldest_age.map(|age| age.as_millis() as u64), + entered_catch_up = decision.entered_catch_up, + "stream chunking mode transition" + ); + } + decision +} + +/// Applies a [`DrainPlan`] to all available stream controllers. +/// +/// The returned [`CommitTickOutput`] reports emitted cells and whether all +/// present controllers are idle after draining. +fn apply_commit_tick_plan( + drain_plan: DrainPlan, + stream_controller: Option<&mut StreamController>, + plan_stream_controller: Option<&mut PlanStreamController>, +) -> CommitTickOutput { + let mut output = CommitTickOutput::default(); + + if let Some(controller) = stream_controller { + output.has_controller = true; + let (cell, is_idle) = drain_stream_controller(controller, drain_plan); + if let Some(cell) = cell { + output.cells.push(cell); + } + output.all_idle &= is_idle; + } + if let Some(controller) = plan_stream_controller { + output.has_controller = true; + let (cell, is_idle) = drain_plan_stream_controller(controller, drain_plan); + if let Some(cell) = cell { + output.cells.push(cell); + } + output.all_idle &= is_idle; + } + + output +} + +/// Applies one drain step to the main stream controller. +/// +/// [`DrainPlan::Single`] maps to one-line drain; [`DrainPlan::Batch`] maps to +/// multi-line drain (including instant catch-up when policy requests the full +/// queued backlog). +fn drain_stream_controller( + controller: &mut StreamController, + drain_plan: DrainPlan, +) -> (Option>, bool) { + match drain_plan { + DrainPlan::Single => controller.on_commit_tick(), + DrainPlan::Batch(max_lines) => controller.on_commit_tick_batch(max_lines), + } +} + +/// Applies one drain step to the plan stream controller. +/// +/// This mirrors [`drain_stream_controller`] so both controller types follow the +/// same chunking policy decisions. +fn drain_plan_stream_controller( + controller: &mut PlanStreamController, + drain_plan: DrainPlan, +) -> (Option>, bool) { + match drain_plan { + DrainPlan::Single => controller.on_commit_tick(), + DrainPlan::Batch(max_lines) => controller.on_commit_tick_batch(max_lines), + } +} + +/// Returns the greater of two optional durations. +/// +/// This helper preserves whichever side is present when only one duration exists. +fn max_duration(lhs: Option, rhs: Option) -> Option { + match (lhs, rhs) { + (Some(left), Some(right)) => Some(left.max(right)), + (Some(left), None) => Some(left), + (None, Some(right)) => Some(right), + (None, None) => None, + } +} diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs new file mode 100644 index 0000000000000000000000000000000000000000..1e5ab2caccdcade94cc7faa1066fd7466fc54e8c --- /dev/null +++ b/codex-rs/tui/src/streaming/controller.rs @@ -0,0 +1,2019 @@ +//! Two-region streaming controllers for agent messages and proposed plans. +//! +//! Each stream partitions rendered markdown into a *stable region* (committed +//! to scrollback via the animation queue in `StreamState`) and a *tail region* +//! (mutable, displayed in the active-cell slot as a transient stream-tail cell). +//! +//! `StreamCore` owns the shared bookkeeping: source accumulation, re-rendering, +//! stable/tail partitioning, commit-animation queue management, and terminal +//! resize handling. `StreamController` and `PlanStreamController` are thin +//! wrappers that add only their `emit()` styling and finalize return types. +//! +//! ## Table holdback +//! +//! Table rendering is inherently non-incremental: adding a new row can change +//! every column's width and reshape all prior rows. The holdback mechanism +//! (`table_holdback_state`) detects pipe-table patterns (header + delimiter +//! pair) in the accumulated source and keeps content from the table header +//! onward as mutable tail until the stream finalizes. Holdback is enabled for +//! agent and proposed-plan streams. Lines in `Outside` and `Markdown` fence +//! contexts are scanned; lines inside non-markdown fences are skipped. +//! +//! ## Resize handling +//! +//! On terminal width change, `StreamCore::set_width` re-renders at the new +//! width and rebuilds the queued stable region from the current emitted line +//! count. This intentionally avoids byte-level remap complexity while the +//! stream is active; finalized content is canonicalized by transcript +//! consolidation into source-backed markdown cells. +//! +//! ## Invariants +//! +//! - `emitted_stable_len <= enqueued_stable_len <= render.lines.len()`. +//! - committed source is append-only until `reset()`; never modified mid-stream. +//! - The committed-source tail starts at `enqueued_stable_len`; a bounded prose preview may +//! follow it without affecting any stable line counts. +//! - During confirmed table streaming, only lines from the table header onward +//! are forced into tail; pre-table lines may remain stable. + +use crate::history_cell::HistoryCell; +use crate::history_cell::HistoryRenderMode; +use crate::history_cell::{self}; +use crate::inline_visualization::InlineVisualizationContext; +use crate::markdown::render_markdown_agent_with_links_cwd_and_visualizations; +use crate::style::proposed_plan_style; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::prefix_hyperlink_lines; +use ratatui::prelude::Stylize; +use ratatui::text::Line; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +use super::StreamState; +use super::prose_preview::PreviewMode; +use super::prose_preview::ProsePreview; +use super::render::StreamingRender; +use super::render::render_source; +use super::table_holdback::TableHoldbackScanner; +use super::table_holdback::TableHoldbackState; +#[cfg(test)] +use super::table_holdback::table_holdback_state; + +// --------------------------------------------------------------------------- +// StreamCore — shared bookkeeping for both stream controllers +// --------------------------------------------------------------------------- + +/// Shared state and logic for the two-region streaming model. +/// +/// Both [`StreamController`] (agent messages) and [`PlanStreamController`] +/// (proposed plans) delegate their core bookkeeping here: source +/// accumulation, re-rendering, stable/tail partitioning, commit-animation +/// queue management, and terminal resize handling. +/// +/// The wrapping controllers add only their own `emit()` styling and +/// finalize return types. +struct StreamCore { + state: StreamState, + /// Current rendering width (columns available for markdown content). + width: Option, + /// Incremental render of committed source at `width`. + render: StreamingRender, + /// Disposable partial prose, never counted in the stable render or queue. + preview: ProsePreview, + /// Lines enqueued into the commit-animation queue. + enqueued_stable_len: usize, + /// Lines actually emitted to scrollback. + emitted_stable_len: usize, + /// Session cwd used to keep local file-link display stable during stream re-renders. + cwd: PathBuf, + inline_visualization_context: Option, + render_mode: HistoryRenderMode, + /// Cached rendered line count for prefix-before-table keyed by source start and width. + stable_prefix_len_cache: Option, + /// Incremental holdback scanner state for append-only source updates. + holdback_scanner: TableHoldbackScanner, +} + +struct StablePrefixLenCache { + /// Byte offset of the candidate table/header start in committed source. + source_start: usize, + /// Width that produced `stable_prefix_len`. + width: Option, + /// Rendered line count for the committed prefix before `source_start` at `width`. + /// + /// The streaming controller uses this to avoid repeatedly re-rendering the + /// same stable prefix while a live table tail is still mutating. + stable_prefix_len: usize, +} + +impl StreamCore { + fn new( + width: Option, + cwd: &Path, + render_mode: HistoryRenderMode, + inline_visualization_context: Option, + ) -> Self { + Self { + state: StreamState::new(width, cwd), + width, + render: StreamingRender::new(), + preview: ProsePreview::default(), + enqueued_stable_len: 0, + emitted_stable_len: 0, + cwd: cwd.to_path_buf(), + inline_visualization_context, + render_mode, + stable_prefix_len_cache: None, + holdback_scanner: TableHoldbackScanner::new(), + } + } + + /// Push a delta and report whether the queue or prose preview changed. + /// + /// Only newline-terminated source is committed to the stable render. This is + /// important for tables because an unterminated partial row must stay out + /// of both the stable queue and the live tail until its structure is + /// unambiguous; otherwise the user can briefly see malformed columns that + /// immediately disappear on the next delta. Unterminated prose gets a separate, + /// bounded preview that never advances the stable boundary. + fn push_delta(&mut self, delta: &str) -> bool { + if !delta.is_empty() { + self.state.has_seen_delta = true; + } + self.state.collector.push_delta(delta); + + if delta.contains('\n') { + self.preview = ProsePreview::default(); + } + let mut enqueued = false; + if delta.contains('\n') + && let Some(range) = self.state.collector.commit_complete_source() + { + let source = self.state.collector.committed_source(); + let committed_source = &source[range]; + self.holdback_scanner.push_source_chunk(committed_source); + self.render.append( + source, + committed_source, + self.width, + self.cwd.as_path(), + self.render_mode, + self.inline_visualization_context.as_ref(), + ); + enqueued = self.sync_stable_queue(); + } + let preview_changed = self.refresh_preview(); + enqueued || preview_changed + } + + fn refresh_preview(&mut self) -> bool { + let pending = self.state.collector.pending_source(); + let prose_preview = self.holdback_scanner.allows_prose_preview(); + let math = self.render_mode == HistoryRenderMode::Rich + && (self.render.pending_math_start.is_some() + || prose_preview && (pending.starts_with("$$") || pending.starts_with("\\["))); + if math || prose_preview { + self.preview.update( + pending, + self.width, + &self.cwd, + if math { + PreviewMode::Math + } else { + PreviewMode::Prose(self.render_mode) + }, + self.inline_visualization_context.as_ref(), + ) + } else { + self.preview = ProsePreview::default(); + false + } + } + + /// Drain the collector, render the final source snapshot, and return lines not yet emitted. + /// + /// This intentionally re-renders from the full raw source instead of + /// trying to stitch together queued stable lines and the current tail. The + /// final render is the canonical transcript representation used for + /// consolidation, so callers that skip `reset()` can accidentally replay a + /// finished stream into the next answer. + fn finalize_remaining(&mut self) -> (Vec, String) { + let source = self.state.collector.finalize_and_take_source(); + let mut rendered = render_source( + &source, + self.width, + self.cwd.as_path(), + self.render_mode, + self.inline_visualization_context.as_ref(), + ); + let remaining = rendered.split_off(self.emitted_stable_len.min(rendered.len())); + (remaining, source) + } + + /// Step animation: dequeue one line, update the emitted count. + fn tick(&mut self) -> Vec { + let step = self.state.step(); + self.emitted_stable_len += step.len(); + step + } + + /// Batch drain: dequeue up to `max_lines`, update the emitted count. + fn tick_batch(&mut self, max_lines: usize) -> Vec { + if max_lines == 0 { + return Vec::new(); + } + let step = self.state.drain_n(max_lines); + if step.is_empty() { + return step; + } + self.emitted_stable_len += step.len(); + step + } + + // Trivial StreamCore accessors inlined — called on every animation tick + // and render frame during active streaming. + + #[inline] + fn is_idle(&self) -> bool { + self.state.is_idle() + } + + #[inline] + fn queued_lines(&self) -> usize { + self.state.queued_len() + } + + #[inline] + fn oldest_queued_age(&self, now: Instant) -> Option { + self.state.oldest_queued_age(now) + } + + /// Lines that belong to the mutable tail, not yet queued for stable commit. + /// + /// The tail starts at `enqueued_stable_len`, so this returns the portion + /// of the current render snapshot that is still allowed to change without + /// violating scrollback ordering. If callers were to derive the tail from + /// `emitted_stable_len` instead, queued-but-not-yet-emitted lines could + /// reappear in the active cell and duplicate content on screen. + #[inline] + fn current_tail_lines(&self) -> Vec { + let start = self.enqueued_stable_len.min(self.render.lines.len()); + let mut lines = self.render.lines[start..].to_vec(); + lines.extend(self.preview.lines.iter().cloned()); + lines + } + + /// Whether committed Markdown needs tail reflow. Disposable prose previews do not. + #[inline] + fn has_tail(&self) -> bool { + self.enqueued_stable_len < self.render.lines.len() + } + + /// Update rendering width and rebuild queued stable lines for the new layout. + /// + /// Re-renders once at the new width and rebuilds queue state from the + /// current emitted line count. + /// + /// Resize is the point where source-backed rendering matters most: + /// previously emitted prose must stay in scrollback order, while any live + /// table tail is free to reshape at the new width. This method preserves + /// that split without attempting byte-for-byte line remapping. + fn set_width(&mut self, width: Option) { + if self.width == width { + return; + } + let had_pending_queue = self.state.queued_len() > 0; + let had_live_tail = self.has_tail(); + self.width = width; + self.state.collector.set_width(width); + let source = self.state.collector.committed_source(); + if source.is_empty() { + self.refresh_preview(); + return; + } + + self.render.recompute( + source, + self.width, + self.cwd.as_path(), + self.render_mode, + self.inline_visualization_context.as_ref(), + ); + self.refresh_preview(); + self.emitted_stable_len = self.emitted_stable_len.min(self.render.lines.len()); + if had_pending_queue + && self.emitted_stable_len == self.render.lines.len() + && self.emitted_stable_len > 0 + { + // If wrapped remainder compresses into fewer lines at the new width, + // keep at least one line un-emitted so pre-resize pending content is + // not skipped permanently. + self.emitted_stable_len -= 1; + } + self.state.clear_queue(); + if self.emitted_stable_len > 0 && !had_pending_queue && !had_live_tail { + // Avoid replaying already-emitted content after resize when no + // stable lines were waiting in the queue and there was no mutable + // tail to preserve. + self.enqueued_stable_len = self.render.lines.len(); + return; + } + self.rebuild_stable_queue_from_render(); + } + + /// Clear all accumulated state for current stream. + fn reset(&mut self) { + self.state.clear(); + self.render.clear(); + self.preview = ProsePreview::default(); + self.enqueued_stable_len = 0; + self.emitted_stable_len = 0; + self.stable_prefix_len_cache = None; + self.holdback_scanner.reset(); + } + + fn set_render_mode(&mut self, render_mode: HistoryRenderMode) { + if self.render_mode == render_mode { + return; + } + + let had_pending_queue = self.state.queued_len() > 0; + let had_live_tail = self.has_tail(); + self.render_mode = render_mode; + let source = self.state.collector.committed_source(); + if source.is_empty() { + self.refresh_preview(); + return; + } + + self.render.recompute( + source, + self.width, + self.cwd.as_path(), + self.render_mode, + self.inline_visualization_context.as_ref(), + ); + self.refresh_preview(); + self.emitted_stable_len = self.emitted_stable_len.min(self.render.lines.len()); + if had_pending_queue + && self.emitted_stable_len == self.render.lines.len() + && self.emitted_stable_len > 0 + { + self.emitted_stable_len -= 1; + } + self.state.clear_queue(); + if self.emitted_stable_len > 0 && !had_pending_queue && !had_live_tail { + self.enqueued_stable_len = self.render.lines.len(); + return; + } + self.rebuild_stable_queue_from_render(); + } + + /// Compute how many rendered lines should be in the stable region. + fn compute_target_stable_len(&mut self) -> usize { + let tail_budget = self.active_tail_budget_lines(); + self.render + .lines + .len() + .saturating_sub(tail_budget) + .max(self.emitted_stable_len) + } + + /// Advance `enqueued_stable_len` toward the target stable boundary and enqueue any + /// newly-stable lines. Returns `true` if new lines were enqueued. + fn sync_stable_queue(&mut self) -> bool { + let target_stable_len = self.compute_target_stable_len(); + + // A structural rewrite moved the stable boundary backward into enqueue-but-unemitted + // lines. Rebuild queue from the latest snapshot. + if target_stable_len < self.enqueued_stable_len { + self.state.clear_queue(); + if self.emitted_stable_len < target_stable_len { + self.state.enqueue( + self.render.lines[self.emitted_stable_len..target_stable_len].to_vec(), + ); + } + self.enqueued_stable_len = target_stable_len; + return self.state.queued_len() > 0; + } + + if target_stable_len == self.enqueued_stable_len { + return false; + } + + self.state + .enqueue(self.render.lines[self.enqueued_stable_len..target_stable_len].to_vec()); + self.enqueued_stable_len = target_stable_len; + true + } + + /// Rebuild the stable queue from the current render snapshot. + /// + /// This is used after `set_width()`, where any queued lines were computed + /// against the old width and can no longer be trusted to line up with the + /// current render. + fn rebuild_stable_queue_from_render(&mut self) { + let target_stable_len = self.compute_target_stable_len(); + self.state.clear_queue(); + if self.emitted_stable_len < target_stable_len { + self.state + .enqueue(self.render.lines[self.emitted_stable_len..target_stable_len].to_vec()); + } + self.enqueued_stable_len = target_stable_len; + } + + /// How many rendered lines to withhold as mutable tail. + /// + /// When a table is detected (`Confirmed` or `PendingHeader`), the entire + /// table region is held as tail because adding a row can reshape table + /// column widths. For `PendingHeader`, only content from the speculative + /// header line onward is kept mutable so earlier prose can continue + /// streaming. When no table is detected, everything flows directly to + /// stable. Unclosed display math also stays mutable until its closing delimiter arrives. + fn active_tail_budget_lines(&mut self) -> usize { + if self.render_mode == HistoryRenderMode::Raw { + return 0; + } + let scan_start = Instant::now(); + let holdback_state = self.holdback_scanner.state(); + let tail_budget = match holdback_state { + TableHoldbackState::Confirmed { table_start: start } + | TableHoldbackState::PendingHeader { + header_start: start, + } => self.tail_budget_from_source_start(start), + TableHoldbackState::None => 0, + }; + tracing::trace!( + state = ?holdback_state, + tail_budget, + elapsed_us = scan_start.elapsed().as_micros(), + "table holdback decision", + ); + let math_budget = self + .render + .pending_math_start + .map_or(0, |start| self.tail_budget_from_source_start(start)); + tail_budget.max(math_budget) + } + + /// Convert a raw-source boundary into the number of rendered tail lines. + /// + /// The important contract here is that the holdback scanner reasons in + /// byte offsets while the queue operates in rendered lines. This helper is + /// the only place where those coordinate systems are bridged. + fn tail_budget_from_source_start(&mut self, source_start: usize) -> usize { + if source_start == 0 { + return self.render.lines.len(); + } + let source_start = source_start.min(self.state.collector.committed_source().len()); + let stable_prefix_len = self.stable_prefix_len_for_source_start(source_start); + self.render.lines.len().saturating_sub(stable_prefix_len) + } + + /// Render the stable prefix before `source_start` and return its line count. + /// + /// This value is cached because dense table streams can call into this path + /// for every committed line while the header/delimiter/body are still + /// arriving incrementally. + fn stable_prefix_len_for_source_start(&mut self, source_start: usize) -> usize { + if let Some(cache) = &self.stable_prefix_len_cache + && cache.source_start == source_start + && cache.width == self.width + { + tracing::trace!( + source_start, + width = ?self.width, + stable_prefix_len = cache.stable_prefix_len, + "table holdback stable-prefix cache hit", + ); + return cache.stable_prefix_len; + } + + let render_start = Instant::now(); + let source = self.state.collector.committed_source(); + let stable_prefix_render = render_markdown_agent_with_links_cwd_and_visualizations( + &source[..source_start.min(source.len())], + self.width, + Some(self.cwd.as_path()), + self.inline_visualization_context.as_ref(), + ); + let stable_prefix_len = stable_prefix_render.len(); + tracing::trace!( + source_start, + width = ?self.width, + stable_prefix_len, + elapsed_us = render_start.elapsed().as_micros(), + "table holdback stable-prefix render", + ); + self.stable_prefix_len_cache = Some(StablePrefixLenCache { + source_start, + width: self.width, + stable_prefix_len, + }); + stable_prefix_len + } +} + +/// Controller for streaming agent message content with table-aware holdback. +/// +/// Wraps [`StreamCore`] and adds `AgentMessageCell` emission styling. +pub(crate) struct StreamController { + core: StreamCore, + header_emitted: bool, +} + +impl StreamController { + /// Create a controller whose markdown renderer shortens local file links relative to `cwd`. + /// + /// `width` is the content width available to markdown rendering, not necessarily the full + /// terminal width. Passing a stale width after resize will keep queued live output wrapped for + /// the old viewport until app-level reflow repairs the finalized transcript. + #[cfg(test)] + pub(crate) fn new(width: Option, cwd: &Path, render_mode: HistoryRenderMode) -> Self { + Self::new_with_inline_visualizations( + width, + cwd, + render_mode, + /*inline_visualization_context*/ None, + ) + } + + pub(crate) fn new_with_inline_visualizations( + width: Option, + cwd: &Path, + render_mode: HistoryRenderMode, + inline_visualization_context: Option, + ) -> Self { + Self { + core: StreamCore::new(width, cwd, render_mode, inline_visualization_context), + header_emitted: false, + } + } + + pub(crate) fn push(&mut self, delta: &str) -> bool { + self.core.push_delta(delta) + } + + /// Finalize the active stream. Returns the final cell (if any remaining lines) and the raw + /// markdown source for consolidation. + pub(crate) fn finalize(&mut self) -> (Option>, Option) { + let (remaining, source) = self.core.finalize_remaining(); + if source.is_empty() { + self.core.reset(); + return (None, None); + } + + let out = self.emit(remaining); + self.core.reset(); + (out, Some(source)) + } + + pub(crate) fn on_commit_tick(&mut self) -> (Option>, bool) { + let step = self.core.tick(); + (self.emit(step), self.core.is_idle()) + } + + pub(crate) fn on_commit_tick_batch( + &mut self, + max_lines: usize, + ) -> (Option>, bool) { + let step = self.core.tick_batch(max_lines); + (self.emit(step), self.core.is_idle()) + } + + // Thin StreamController accessors inlined — one-liner delegates called + // on every render frame and animation tick. + + #[inline] + pub(crate) fn queued_lines(&self) -> usize { + self.core.queued_lines() + } + + pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option { + self.core.oldest_queued_age(now) + } + + #[inline] + pub(crate) fn current_tail_lines(&self) -> Vec { + self.core.current_tail_lines() + } + + #[inline] + pub(crate) fn tail_starts_stream(&self) -> bool { + !self.header_emitted && self.core.enqueued_stable_len == 0 + } + + #[inline] + pub(crate) fn has_live_tail(&self) -> bool { + self.core.has_tail() + } + + pub(crate) fn clear_queue(&mut self) { + self.core.state.clear_queue(); + self.core.enqueued_stable_len = self.core.emitted_stable_len; + } + + pub(crate) fn set_width(&mut self, width: Option) { + self.core.set_width(width); + } + + pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) { + self.core.set_render_mode(render_mode); + } + + fn emit(&mut self, lines: Vec) -> Option> { + if lines.is_empty() { + return None; + } + Some(Box::new( + history_cell::AgentMessageCell::new_hyperlink_lines(lines, { + let header_emitted = self.header_emitted; + self.header_emitted = true; + !header_emitted + }), + )) + } +} +// --------------------------------------------------------------------------- +// PlanStreamController — proposed plan streams +// --------------------------------------------------------------------------- + +/// Controller that streams proposed plan markdown into a styled plan block. +/// +/// Wraps [`StreamCore`] and adds plan-specific header, indentation, and +/// background styling. +pub(crate) struct PlanStreamController { + core: StreamCore, + header_emitted: bool, + top_padding_emitted: bool, +} + +impl PlanStreamController { + /// Create a plan-stream controller whose markdown renderer shortens local file links relative + /// to `cwd`. + /// + /// The width has the same meaning as in `StreamController`: it is the markdown body width, and + /// callers must update it when the terminal width changes. + pub(crate) fn new(width: Option, cwd: &Path, render_mode: HistoryRenderMode) -> Self { + Self { + core: StreamCore::new( + width, + cwd, + render_mode, + /*inline_visualization_context*/ None, + ), + header_emitted: false, + top_padding_emitted: false, + } + } + + pub(crate) fn push(&mut self, delta: &str) -> bool { + self.core.push_delta(delta) + } + + /// Finalize the active stream. Returns the final cell (if any remaining + /// lines) plus raw markdown source for consolidation. + pub(crate) fn finalize(&mut self) -> (Option>, Option) { + let (remaining, source) = self.core.finalize_remaining(); + if source.is_empty() { + self.core.reset(); + return (None, None); + } + + let out = self.emit(remaining, /*include_bottom_padding*/ true); + self.core.reset(); + (out, Some(source)) + } + + pub(crate) fn on_commit_tick(&mut self) -> (Option>, bool) { + let step = self.core.tick(); + ( + self.emit(step, /*include_bottom_padding*/ false), + self.core.is_idle(), + ) + } + + pub(crate) fn on_commit_tick_batch( + &mut self, + max_lines: usize, + ) -> (Option>, bool) { + let step = self.core.tick_batch(max_lines); + ( + self.emit(step, /*include_bottom_padding*/ false), + self.core.is_idle(), + ) + } + + #[inline] + pub(crate) fn queued_lines(&self) -> usize { + self.core.queued_lines() + } + + #[inline] + pub(crate) fn has_live_tail(&self) -> bool { + self.core.has_tail() + } + + #[inline] + pub(crate) fn current_tail_lines(&self) -> Vec { + self.core.current_tail_lines() + } + + #[inline] + pub(crate) fn tail_starts_stream(&self) -> bool { + !self.header_emitted && self.core.enqueued_stable_len == 0 + } + + pub(crate) fn current_tail_display_lines(&self) -> Vec { + let lines = self.current_tail_lines(); + if lines.is_empty() { + return Vec::new(); + } + self.render_display_lines(lines, /*include_bottom_padding*/ false) + } + + pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option { + self.core.oldest_queued_age(now) + } + + pub(crate) fn clear_queue(&mut self) { + self.core.state.clear_queue(); + self.core.enqueued_stable_len = self.core.emitted_stable_len; + } + + pub(crate) fn set_width(&mut self, width: Option) { + self.core.set_width(width); + } + + pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) { + self.core.set_render_mode(render_mode); + } + + fn emit( + &mut self, + lines: Vec, + include_bottom_padding: bool, + ) -> Option> { + if lines.is_empty() && !include_bottom_padding { + return None; + } + + let is_stream_continuation = self.header_emitted; + let out_lines = self.render_display_lines(lines, include_bottom_padding); + self.header_emitted = true; + self.top_padding_emitted = true; + + Some(Box::new(history_cell::new_proposed_plan_stream( + out_lines, + is_stream_continuation, + ))) + } + + fn render_display_lines( + &self, + lines: Vec, + include_bottom_padding: bool, + ) -> Vec { + let mut out_lines: Vec = Vec::with_capacity(/*capacity*/ 4); + if !self.header_emitted { + out_lines.push(HyperlinkLine::new( + vec!["• ".dim(), "Proposed Plan".bold()].into(), + )); + out_lines.push(HyperlinkLine::new(Line::from(" "))); + } + + let mut plan_lines: Vec = Vec::with_capacity(/*capacity*/ 4); + if !self.top_padding_emitted { + plan_lines.push(HyperlinkLine::new(Line::from(" "))); + } + plan_lines.extend(lines); + if include_bottom_padding { + plan_lines.push(HyperlinkLine::new(Line::from(" "))); + } + + let plan_style = proposed_plan_style(); + let plan_lines = prefix_hyperlink_lines(plan_lines, " ".into(), " ".into()) + .into_iter() + .map(|line| line.style(plan_style)) + .collect::>(); + out_lines.extend(plan_lines); + out_lines + } +} + +#[cfg(test)] +#[path = "math_tests.rs"] +mod math_tests; + +#[cfg(test)] +#[path = "controller_preview_tests.rs"] +mod preview_tests; + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal_hyperlinks::visible_lines; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + fn test_cwd() -> PathBuf { + // These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or + // Windows-specific root semantics into the fixtures. + std::env::temp_dir() + } + + fn stream_controller(width: Option) -> StreamController { + StreamController::new(width, &test_cwd(), HistoryRenderMode::Rich) + } + + fn plan_stream_controller(width: Option) -> PlanStreamController { + PlanStreamController::new(width, &test_cwd(), HistoryRenderMode::Rich) + } + + fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec { + lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.clone()) + .collect::>() + .join("") + }) + .collect() + } + + fn hyperlink_lines_to_plain_strings(lines: &[HyperlinkLine]) -> Vec { + lines_to_plain_strings(&visible_lines(lines.to_vec())) + } + + fn collect_streamed_lines(deltas: &[&str], width: Option) -> Vec { + let mut ctrl = stream_controller(width); + let mut lines = Vec::new(); + for d in deltas { + ctrl.push(d); + while let (Some(cell), idle) = ctrl.on_commit_tick() { + lines.extend(cell.transcript_lines(u16::MAX)); + if idle { + break; + } + } + } + if let (Some(cell), _source) = ctrl.finalize() { + lines.extend(cell.transcript_lines(u16::MAX)); + } + lines_to_plain_strings(&lines) + .into_iter() + .map(|s| s.chars().skip(2).collect::()) + .collect() + } + + #[test] + fn queued_heading_is_emitted_once_after_incremental_append() { + let mut ctrl = stream_controller(Some(80)); + assert!(ctrl.push("Paragraph.\n\n# Heading\n\n")); + ctrl.push("Next paragraph.\n"); + + let (cell, _) = ctrl.on_commit_tick_batch(usize::MAX); + let mut streamed = cell + .into_iter() + .flat_map(|cell| cell.transcript_lines(u16::MAX)) + .collect::>(); + if let (Some(cell), _source) = ctrl.finalize() { + streamed.extend(cell.transcript_lines(u16::MAX)); + } + let streamed = lines_to_plain_strings(&streamed); + assert_eq!( + streamed + .iter() + .filter(|line| line.contains("# Heading")) + .count(), + 1, + "expected the streamed heading to be emitted once: {streamed:?}", + ); + } + + fn collect_plan_streamed_lines(deltas: &[&str], width: Option) -> Vec { + let mut ctrl = plan_stream_controller(width); + let mut lines = Vec::new(); + for d in deltas { + ctrl.push(d); + while let (Some(cell), idle) = ctrl.on_commit_tick() { + lines.extend(cell.transcript_lines(u16::MAX)); + if idle { + break; + } + } + } + if let (Some(cell), _source) = ctrl.finalize() { + lines.extend(cell.transcript_lines(u16::MAX)); + } + lines_to_plain_strings(&lines) + } + + #[test] + fn controller_set_width_rebuilds_queued_lines() { + let mut ctrl = stream_controller(Some(120)); + let delta = "This is a long line that should wrap into multiple rows when resized.\n"; + assert!(ctrl.push(delta)); + assert_eq!(ctrl.queued_lines(), 1); + + ctrl.set_width(Some(24)); + let (cell, idle) = ctrl.on_commit_tick_batch(usize::MAX); + let rendered = lines_to_plain_strings( + &cell + .expect("expected resized queued lines") + .transcript_lines(u16::MAX), + ); + + assert!(idle); + assert!( + rendered.len() > 1, + "expected resized content to occupy multiple lines, got {rendered:?}", + ); + } + + #[test] + fn controller_set_width_no_duplicate_after_emit() { + let mut ctrl = stream_controller(Some(120)); + let line = + "This is a long line that definitely wraps when the terminal shrinks to 24 columns.\n"; + ctrl.push(line); + let (cell, _) = ctrl.on_commit_tick_batch(usize::MAX); + assert!(cell.is_some(), "expected emitted cell"); + assert_eq!(ctrl.queued_lines(), 0); + + ctrl.set_width(Some(24)); + + assert_eq!( + ctrl.queued_lines(), + 0, + "already-emitted content must not be re-queued after resize", + ); + } + + #[test] + fn controller_tick_batch_zero_is_noop() { + let mut ctrl = stream_controller(Some(80)); + assert!(ctrl.push("line one\n")); + assert_eq!(ctrl.queued_lines(), 1); + + let (cell, idle) = ctrl.on_commit_tick_batch(/*max_lines*/ 0); + assert!(cell.is_none(), "batch size 0 should not emit lines"); + assert!(!idle, "batch size 0 should not drain queued lines"); + assert_eq!( + ctrl.queued_lines(), + 1, + "queue depth should remain unchanged" + ); + } + + #[test] + fn controller_has_live_tail_reflects_tail_presence() { + let mut ctrl = stream_controller(Some(80)); + assert!(!ctrl.has_live_tail()); + + ctrl.core.render.lines = vec![Line::from("tail line").into()]; + ctrl.core.enqueued_stable_len = 0; + assert!(ctrl.has_live_tail()); + + ctrl.core.enqueued_stable_len = 1; + assert!(!ctrl.has_live_tail()); + } + + #[test] + fn plan_controller_has_live_tail_reflects_tail_presence() { + let mut ctrl = plan_stream_controller(Some(80)); + assert!(!ctrl.has_live_tail()); + + ctrl.core.render.lines = vec![Line::from("tail line").into()]; + ctrl.core.enqueued_stable_len = 0; + assert!(ctrl.has_live_tail()); + + ctrl.core.enqueued_stable_len = 1; + assert!(!ctrl.has_live_tail()); + } + + #[test] + fn controller_live_tail_keeps_uncommitted_table_cell_newline_gated() { + let mut ctrl = stream_controller(Some(80)); + ctrl.push("| A | B |\n"); + ctrl.push("| --- | --- |\n"); + ctrl.push("| partial"); + + let tail = hyperlink_lines_to_plain_strings(&ctrl.current_tail_lines()).join("\n"); + assert!( + !tail.contains("partial"), + "expected live tail to remain newline-gated: {tail:?}", + ); + } + + #[test] + fn controller_live_tail_rerenders_table_tail_after_resize() { + let mut ctrl = stream_controller(Some(96)); + ctrl.push("| # | Feature | Details | Link |\n"); + ctrl.push("| --- | --- | --- | --- |\n"); + ctrl.push( + "| 1 | RESIZE_REPRO_SENTINEL | long wrapped content that should be reflowed | https://example.com/resize |\n", + ); + + for width in [48, 104, 56] { + ctrl.set_width(Some(width)); + let tail = hyperlink_lines_to_plain_strings(&ctrl.current_tail_lines()); + + let mut expected = Vec::new(); + crate::markdown::append_markdown_agent( + ctrl.core.state.collector.committed_source(), + Some(width), + &mut expected, + ); + let expected = lines_to_plain_strings(&expected); + + assert_eq!( + tail, expected, + "expected live table tail to be rerendered at width {width}", + ); + } + } + + #[test] + fn controller_set_width_partial_drain_no_lost_lines() { + let mut ctrl = stream_controller(Some(40)); + ctrl.push("AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH IIII JJJJ\n"); + ctrl.push("second line\n"); + + let (cell, idle) = ctrl.on_commit_tick(); + assert!(cell.is_some(), "expected 1 emitted line"); + assert!(!idle, "queue should still have lines"); + let remaining_before = ctrl.queued_lines(); + assert!(remaining_before > 0, "should have queued lines left"); + + ctrl.set_width(Some(20)); + + let (cell, source) = ctrl.finalize(); + let final_lines = cell + .map(|c| lines_to_plain_strings(&c.transcript_lines(u16::MAX))) + .unwrap_or_default(); + + assert!( + final_lines.iter().any(|l| l.contains("second line")), + "un-emitted 'second line' was lost after resize; got: {final_lines:?}", + ); + assert!(source.is_some(), "expected source from finalize"); + } + + #[test] + fn controller_set_width_partial_drain_keeps_pending_queue() { + let mut ctrl = stream_controller(Some(40)); + ctrl.push("AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH IIII JJJJ\n"); + ctrl.push("second line\n"); + + let (cell, idle) = ctrl.on_commit_tick(); + assert!(cell.is_some(), "expected 1 emitted line"); + assert!(!idle, "queue should still have lines"); + assert!(ctrl.queued_lines() > 0, "expected pending queued lines"); + + ctrl.set_width(Some(20)); + + assert!( + ctrl.queued_lines() > 0, + "resize must preserve pending queued lines" + ); + + let mut drained = Vec::new(); + for _ in 0..64 { + let (cell, is_idle) = ctrl.on_commit_tick(); + if let Some(cell) = cell { + drained.extend(lines_to_plain_strings(&cell.transcript_lines(u16::MAX))); + } + if is_idle { + break; + } + } + + assert!( + drained.iter().any(|l| l.contains("second line")), + "pending lines should continue draining after resize; got {drained:?}", + ); + } + + #[test] + fn controller_set_width_preserves_in_flight_tail() { + let mut ctrl = stream_controller(Some(80)); + ctrl.push("tail without newline"); + ctrl.set_width(Some(24)); + + let (cell, _source) = ctrl.finalize(); + let rendered = lines_to_plain_strings( + &cell + .expect("expected finalized tail") + .transcript_lines(u16::MAX), + ); + + assert_eq!(rendered, vec!["• tail without newline".to_string()]); + } + + #[test] + fn controller_set_width_preserves_table_tail_when_queue_is_empty() { + let mut ctrl = stream_controller(Some(80)); + ctrl.push("intro line\n"); + + let (_cell, idle) = ctrl.on_commit_tick(); + assert!(idle, "intro line should fully drain"); + assert_eq!(ctrl.queued_lines(), 0, "expected empty queue before table"); + + ctrl.push("| A | B |\n"); + assert_eq!( + ctrl.queued_lines(), + 0, + "pending table header should remain mutable tail, not queued", + ); + assert!(ctrl.has_live_tail(), "expected live tail before resize"); + + ctrl.set_width(Some(24)); + + let tail_after = hyperlink_lines_to_plain_strings(&ctrl.current_tail_lines()); + assert!( + !tail_after.is_empty(), + "resize must keep mutable tail when queue is empty", + ); + let joined = tail_after.join(" "); + assert!( + joined.contains('A') && joined.contains('B'), + "expected table header content to remain in tail after resize: {tail_after:?}", + ); + } + + #[test] + fn plan_controller_set_width_preserves_in_flight_tail() { + let mut ctrl = plan_stream_controller(Some(80)); + ctrl.push("1. Item without newline"); + ctrl.set_width(Some(24)); + + let rendered = lines_to_plain_strings( + &(ctrl + .finalize() + .0 + .expect("expected finalized tail") + .transcript_lines(u16::MAX)), + ); + + assert!( + rendered + .iter() + .any(|line| line.contains("Item without newline")), + "expected finalized plan content after resize, got {rendered:?}", + ); + } + + #[test] + fn plan_controller_holds_table_header_as_live_tail() { + let mut ctrl = plan_stream_controller(Some(80)); + assert!(ctrl.push("Intro\n")); + let (_cell, idle) = ctrl.on_commit_tick_batch(usize::MAX); + assert!(idle, "intro line should fully drain"); + + assert!(!ctrl.push("| Step | Owner |\n")); + assert!( + ctrl.has_live_tail(), + "expected plan table header to be held" + ); + } + + #[test] + fn controller_loose_vs_tight_with_commit_ticks_matches_full() { + let mut ctrl = stream_controller(/*width*/ None); + let mut lines = Vec::new(); + + let deltas = vec![ + "\n\n", + "Loose", + " vs", + ".", + " tight", + " list", + " items", + ":\n", + "1", + ".", + " Tight", + " item", + "\n", + "2", + ".", + " Another", + " tight", + " item", + "\n\n", + "1", + ".", + " Loose", + " item", + " with", + " its", + " own", + " paragraph", + ".\n\n", + " ", + " This", + " paragraph", + " belongs", + " to", + " the", + " same", + " list", + " item", + ".\n\n", + "2", + ".", + " Second", + " loose", + " item", + " with", + " a", + " nested", + " list", + " after", + " a", + " blank", + " line", + ".\n\n", + " ", + " -", + " Nested", + " bullet", + " under", + " a", + " loose", + " item", + "\n", + " ", + " -", + " Another", + " nested", + " bullet", + "\n\n", + ]; + + for d in deltas.iter() { + ctrl.push(d); + while let (Some(cell), idle) = ctrl.on_commit_tick() { + lines.extend(cell.transcript_lines(u16::MAX)); + if idle { + break; + } + } + } + if let (Some(cell), _source) = ctrl.finalize() { + lines.extend(cell.transcript_lines(u16::MAX)); + } + + let streamed: Vec<_> = lines_to_plain_strings(&lines) + .into_iter() + .map(|s| s.chars().skip(2).collect::()) + .collect(); + + let source: String = deltas.iter().copied().collect(); + let mut rendered: Vec> = Vec::new(); + crate::markdown::append_markdown_agent(&source, /*width*/ None, &mut rendered); + let rendered_strs = lines_to_plain_strings(&rendered); + + assert_eq!(streamed, rendered_strs); + + let expected = vec![ + "Loose vs. tight list items:".to_string(), + "".to_string(), + "1. Tight item".to_string(), + "2. Another tight item".to_string(), + "3. Loose item with its own paragraph.".to_string(), + "".to_string(), + " This paragraph belongs to the same list item.".to_string(), + "".to_string(), + "4. Second loose item with a nested list after a blank line.".to_string(), + " - Nested bullet under a loose item".to_string(), + " - Another nested bullet".to_string(), + ]; + assert_eq!( + streamed, expected, + "expected exact rendered lines for loose/tight section" + ); + } + + #[test] + fn controller_streamed_table_matches_full_render_widths() { + let deltas = vec![ + "| Key | Description |\n", + "| --- | --- |\n", + "| -v | Enable very verbose logging output for debugging |\n", + "\n", + ]; + + let streamed = collect_streamed_lines(&deltas, Some(80)); + + let source: String = deltas.iter().copied().collect(); + let mut rendered = Vec::new(); + crate::markdown::append_markdown_agent(&source, /*width*/ Some(80), &mut rendered); + let expected = lines_to_plain_strings(&rendered); + + assert_eq!(streamed, expected); + } + + #[test] + fn controller_holds_blockquoted_table_tail_until_stable() { + let deltas = vec![ + "> | A | B |\n", + "> | --- | --- |\n", + "> | longvalue | ok |\n", + "\n", + ]; + + let streamed = collect_streamed_lines(&deltas, Some(80)); + + let source: String = deltas.iter().copied().collect(); + let mut rendered = Vec::new(); + crate::markdown::append_markdown_agent(&source, /*width*/ Some(80), &mut rendered); + let expected = lines_to_plain_strings(&rendered); + + assert_eq!(streamed, expected); + } + + #[test] + fn controller_keeps_pre_table_lines_queued_when_table_is_confirmed() { + let mut ctrl = stream_controller(Some(80)); + + ctrl.push("Intro line before table.\n"); + assert_eq!(ctrl.queued_lines(), 1); + + ctrl.push("| Key | Value |\n"); + ctrl.push("| --- | --- |\n"); + assert_eq!( + ctrl.queued_lines(), + 1, + "pre-table line should remain queued after table confirmation", + ); + + let (cell, idle) = ctrl.on_commit_tick(); + let committed = cell + .map(|cell| lines_to_plain_strings(&cell.transcript_lines(u16::MAX))) + .unwrap_or_default(); + assert!( + committed + .iter() + .any(|line| line.contains("Intro line before table.")), + "expected pre-table line to commit independently: {committed:?}", + ); + assert!(idle, "only pre-table content should have been queued"); + } + + #[test] + fn controller_set_width_during_confirmed_table_stream_matches_finalize_render() { + let mut ctrl = stream_controller(Some(120)); + let deltas = [ + "| Key | Description |\n", + "| --- | --- |\n", + "| one | value that should wrap after resize |\n", + ]; + for delta in deltas { + ctrl.push(delta); + } + assert_eq!( + ctrl.queued_lines(), + 0, + "confirmed table should remain mutable" + ); + + ctrl.set_width(Some(32)); + + let (cell, source) = ctrl.finalize(); + let source = source.expect("expected finalized source"); + let streamed = lines_to_plain_strings( + &cell + .expect("expected finalized table") + .transcript_lines(u16::MAX), + ) + .into_iter() + .map(|line| line.chars().skip(2).collect::()) + .collect::>(); + + let mut rendered = Vec::new(); + crate::markdown::append_markdown_agent(&source, /*width*/ Some(32), &mut rendered); + let expected = lines_to_plain_strings(&rendered); + assert_eq!(streamed, expected); + } + + #[test] + fn controller_does_not_hold_back_pipe_prose_without_table_delimiter() { + let mut ctrl = stream_controller(Some(80)); + + ctrl.push("status | owner | note\n"); + let (_first_commit, first_idle) = ctrl.on_commit_tick(); + assert!(first_idle); + + ctrl.push("next line\n"); + let (second_commit, _second_idle) = ctrl.on_commit_tick(); + assert!( + second_commit.is_some(), + "expected prose lines to be released once no table delimiter follows" + ); + } + + #[test] + fn controller_does_not_stall_repeated_pipe_prose_paragraphs() { + let mut ctrl = stream_controller(Some(80)); + + ctrl.push("alpha | beta\n\n"); + let (_first_commit, first_idle) = ctrl.on_commit_tick(); + assert!(first_idle); + + ctrl.push("gamma | delta\n\n"); + let (second_commit, _second_idle) = ctrl.on_commit_tick(); + let second_lines = second_commit + .map(|cell| lines_to_plain_strings(&cell.transcript_lines(u16::MAX))) + .unwrap_or_default(); + + assert!( + second_lines + .iter() + .any(|line| line.contains("alpha | beta")), + "expected the first pipe-prose paragraph to stream before finalize; got {second_lines:?}", + ); + } + + #[test] + fn controller_handles_table_immediately_after_heading() { + let deltas = vec![ + "### 1) Basic table\n", + "| Name | Role | Status |\n", + "|---|---|---|\n", + "| Alice | Admin | Active |\n", + "| Bob | Editor | Pending |\n", + "\n", + ]; + + let streamed = collect_streamed_lines(&deltas, Some(100)); + + let source: String = deltas.iter().copied().collect(); + let mut rendered = Vec::new(); + crate::markdown::append_markdown_agent(&source, /*width*/ Some(100), &mut rendered); + let expected = lines_to_plain_strings(&rendered); + + assert_eq!(streamed, expected); + } + + #[test] + fn controller_renders_separators_for_multi_table_response_shape() { + let source = "Absolutely. Here are several different Markdown table patterns you can use for rendering tests.\n\n| Name | Role | + Location |\n|-------|-----------|----------|\n| Ava | Engineer | NYC |\n| Malik | Designer | Berlin |\n| Priya | PM | Remote + |\n\n| Item | Qty | Price | In Stock |\n|:------------|----:|------:|:--------:|\n| Keyboard | 2 | 49.99 | Yes |\n| Mouse | 10 + | 19.50 | Yes |\n| Monitor | 1 | 219.0 | No |\n\n| Field | Example | Notes + |\n|---------------|----------------------------------|--------------------------|\n| Escaped pipe | `foo \\| bar` | Should stay + in one cell |\n| Inline code | `let x = value;` | Monospace inline content |\n| Link | [OpenAI](https://openai.com) | + Standard markdown link |\n"; + + let chunked = source + .split_inclusive('\n') + .map(ToString::to_string) + .collect::>(); + let deltas = chunked.iter().map(String::as_str).collect::>(); + let streamed = collect_streamed_lines(&deltas, Some(120)); + assert!( + streamed.iter().any(|line| line.contains('━')), + "expected table separator in streamed output: {streamed:?}" + ); + } + + #[test] + fn controller_renders_separators_for_no_outer_pipes_table_shape() { + let source = "### 1) Basic\n\n| Name | Role | Active |\n|---|---|---|\n| Alice | Engineer | Yes |\n| Bob | Designer | No |\n\n### 2) No outer + pipes\n\nCol A | Col B | Col C\n--- | --- | ---\nx | y | z\n10 | 20 | 30\n\n### 3) Another table\n\n| Key | Value |\n|---|---|\n| a | b |\n"; + + let chunked = source + .split_inclusive('\n') + .map(ToString::to_string) + .collect::>(); + let deltas = chunked.iter().map(String::as_str).collect::>(); + let streamed = collect_streamed_lines(&deltas, Some(100)); + + let mut rendered = Vec::new(); + crate::markdown::append_markdown_agent(source, /*width*/ Some(100), &mut rendered); + let expected = lines_to_plain_strings(&rendered); + + assert_eq!(streamed, expected); + let has_raw_no_outer_header = streamed + .iter() + .any(|line| line.trim() == "Col A | Col B | Col C"); + assert!( + !has_raw_no_outer_header, + "no-outer-pipes header should not remain raw in final streamed output: {streamed:?}" + ); + assert!( + streamed.iter().any(|line| line.contains('━')), + "expected table separator in final streamed output: {streamed:?}" + ); + } + + #[test] + fn controller_stabilizes_first_no_outer_pipes_table_in_response() { + let deltas = vec![ + "### No outer pipes first\n\n", + "Col A | Col B | Col C\n", + "--- | --- | ---\n", + "x | y | z\n", + "10 | 20 | 30\n", + "\n", + "After table paragraph.\n", + ]; + let streamed = collect_streamed_lines(&deltas, Some(100)); + + let source: String = deltas.iter().copied().collect(); + let mut rendered = Vec::new(); + crate::markdown::append_markdown_agent(&source, /*width*/ Some(100), &mut rendered); + let expected = lines_to_plain_strings(&rendered); + + assert_eq!(streamed, expected); + assert!( + streamed.iter().any(|line| line.contains('━')), + "expected table separator for no-outer-pipes streaming: {streamed:?}" + ); + assert!( + !streamed + .iter() + .any(|line| line.trim() == "Col A | Col B | Col C"), + "did not expect raw no-outer-pipes header in final streamed output: {streamed:?}" + ); + } + + #[test] + fn controller_stabilizes_two_column_no_outer_table_in_response() { + let deltas = vec![ + "A | B\n", + "--- | ---\n", + "left | right\n", + "\n", + "After table paragraph.\n", + ]; + let streamed = collect_streamed_lines(&deltas, Some(80)); + + let source: String = deltas.iter().copied().collect(); + let mut rendered = Vec::new(); + crate::markdown::append_markdown_agent(&source, /*width*/ Some(80), &mut rendered); + let expected = lines_to_plain_strings(&rendered); + + assert_eq!(streamed, expected); + assert!( + streamed.iter().any(|line| line.contains('━')), + "expected table separator for two-column no-outer table: {streamed:?}" + ); + assert!( + !streamed.iter().any(|line| line.trim() == "A | B"), + "did not expect raw two-column no-outer header in final streamed output: {streamed:?}" + ); + } + + #[test] + fn controller_converts_no_outer_table_between_preboxed_sections() { + let source = " ┌───────┬──────────┬────────┐\n │ Name │ Role │ Active │\n ├───────┼──────────┼────────┤\n │ Alice │ Engineer │ Yes │\n │ Bob │ Designer │ No │\n │ Cara │ PM │ Yes │\n └───────┴──────────┴────────┘\n\n ### 3) No outer pipes\n\n Col A | Col B | Col C\n --- | --- | ---\n x | y | z\n 10 | 20 | 30\n\n ┌─────────────────┬────────┬────────────────────────┐\n │ Example │ Output │ Notes │\n ├─────────────────┼────────┼────────────────────────┤\n │ a | b │ `a │ b` │\n │ npm run test │ ok │ Inline code formatting │\n │ SELECT * FROM t │ 3 rows │ SQL snippet │\n └─────────────────┴────────┴────────────────────────┘\n"; + + let deltas = source + .split_inclusive('\n') + .map(ToString::to_string) + .collect::>(); + let streamed = collect_streamed_lines( + &deltas.iter().map(String::as_str).collect::>(), + Some(100), + ); + + let has_raw_no_outer_header = streamed + .iter() + .any(|line| line.trim() == "Col A | Col B | Col C"); + assert!( + !has_raw_no_outer_header, + "no-outer table header remained raw in streamed output: {streamed:?}" + ); + assert!( + streamed + .iter() + .any(|line| line.contains(" Col A Col B Col C")), + "expected converted no-outer table header in streamed output: {streamed:?}" + ); + } + + #[test] + fn controller_keeps_markdown_fenced_tables_mutable_until_finalize() { + let source = "```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n"; + let deltas = vec![ + "```md\n", + "| A | B |\n", + "|---|---|\n", + "| 1 | 2 |\n", + "```\n", + ]; + let streamed = collect_streamed_lines(&deltas, Some(80)); + + let mut rendered = Vec::new(); + crate::markdown::append_markdown_agent(source, /*width*/ Some(80), &mut rendered); + let expected = lines_to_plain_strings(&rendered); + + assert_eq!(streamed, expected); + assert!( + streamed.iter().any(|line| line.contains('━')), + "expected table separator in streamed output: {streamed:?}" + ); + assert!( + !streamed.iter().any(|line| line.trim() == "| A | B |"), + "did not expect raw table header line after finalize: {streamed:?}" + ); + } + + #[test] + fn controller_keeps_markdown_fenced_no_outer_tables_mutable_until_finalize() { + let source = + "```md\nCol A | Col B | Col C\n--- | --- | ---\nx | y | z\n10 | 20 | 30\n```\n"; + let deltas = vec![ + "```md\n", + "Col A | Col B | Col C\n", + "--- | --- | ---\n", + "x | y | z\n", + "10 | 20 | 30\n", + "```\n", + ]; + let streamed = collect_streamed_lines(&deltas, Some(100)); + + let mut rendered = Vec::new(); + crate::markdown::append_markdown_agent(source, /*width*/ Some(100), &mut rendered); + let expected = lines_to_plain_strings(&rendered); + + assert_eq!(streamed, expected); + assert!( + streamed.iter().any(|line| line.contains('━')), + "expected table separator in streamed output: {streamed:?}" + ); + assert!( + !streamed + .iter() + .any(|line| line.trim() == "Col A | Col B | Col C"), + "did not expect raw no-outer-pipes header line after finalize: {streamed:?}" + ); + } + + #[test] + fn controller_live_view_matches_render_during_interleaved_table_streaming() { + let source = "Project updates are easier to scan when narrative and structured data alternate.\n\n| Focus Area | Owner | Priority | Status |\n|---|---|---|---|\n| Authentication cleanup | Maya | High | 80% |\n| CLI error messages | Jordan | Medium | 55% |\n| Docs refresh | Lee | Low | 30% |\n\nThe first checkpoint shows progress, but we still have open risks.\n\n| Task | Command / Artifact | Due | State |\n|---|---|---|---|\n| Run unit tests | `cargo test -p codex-core` | Today | ✅ |\n| Snapshot review | `cargo insta pending-snapshots -p codex-tui` | Today | ⏳ |\n| Changelog draft | Release template (https://replacechangelog.com/) | Tomorrow | 📝 |\n\nFinal sign-off criteria are summarized below.\n"; + let width = Some(72usize); + let mut ctrl = stream_controller(width); + let mut emitted_lines: Vec> = Vec::new(); + + for delta in source.split_inclusive('\n') { + ctrl.push(delta); + loop { + let (cell, idle) = ctrl.on_commit_tick(); + if let Some(cell) = cell { + emitted_lines.extend(cell.transcript_lines(u16::MAX).into_iter().map(|line| { + let plain: String = line + .spans + .iter() + .map(|s| s.content.clone()) + .collect::>() + .join(""); + Line::from(plain.chars().skip(2).collect::()) + })); + } + if idle { + break; + } + } + + let mut visible = emitted_lines.clone(); + visible.extend(visible_lines(ctrl.current_tail_lines())); + let visible_plain = lines_to_plain_strings(&visible); + + let mut expected = Vec::new(); + crate::markdown::append_markdown_agent( + ctrl.core.state.collector.committed_source(), + /*width*/ width, + &mut expected, + ); + let expected_plain = lines_to_plain_strings(&expected); + + assert_eq!( + visible_plain, expected_plain, + "live view diverged after delta: {delta:?}" + ); + } + } + + #[test] + fn finalized_stream_table_preserves_semantic_url_fragments() { + let destination = "https://example.com/a/very/long/path/to/a/table/artifact"; + let source = format!("| Item | URL |\n| --- | --- |\n| report | {destination} |\n"); + let mut ctrl = stream_controller(/*width*/ Some(32)); + ctrl.push(&source); + + let (cell, _) = ctrl.finalize(); + let lines = cell + .expect("final stream table cell") + .display_hyperlink_lines(/*width*/ 32); + let linked_rows = lines + .iter() + .filter(|line| !line.hyperlinks.is_empty()) + .collect::>(); + + assert!(linked_rows.len() > 1); + assert!(linked_rows.iter().all(|line| { + line.hyperlinks + .iter() + .all(|link| link.destination == destination) + })); + } + + #[test] + fn controller_keeps_non_markdown_fenced_tables_as_code() { + let source = "```sh\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n"; + let deltas = vec![ + "```sh\n", + "| A | B |\n", + "|---|---|\n", + "| 1 | 2 |\n", + "```\n", + ]; + let streamed = collect_streamed_lines(&deltas, Some(80)); + + let mut rendered = Vec::new(); + crate::markdown::append_markdown_agent(source, /*width*/ Some(80), &mut rendered); + let expected = lines_to_plain_strings(&rendered); + + assert_eq!(streamed, expected); + assert!( + streamed.iter().any(|line| line.trim() == "| A | B |"), + "expected code-fenced pipe line to remain raw: {streamed:?}" + ); + assert!( + !streamed + .iter() + .any(|line| line.contains('━') || line.contains('─')), + "did not expect a table separator for non-markdown fence: {streamed:?}" + ); + } + + #[test] + fn plan_controller_streamed_table_matches_final_render() { + let deltas = vec![ + "## Build plan\n\n", + "| Step | Owner |\n", + "|---|---|\n", + "| Write tests | Agent |\n", + "| Verify output | User |\n", + "\n", + ]; + let streamed = collect_plan_streamed_lines(&deltas, Some(80)); + + let source: String = deltas.iter().copied().collect(); + let baseline = collect_plan_streamed_lines(&[source.as_str()], Some(80)); + + assert_eq!(streamed, baseline); + assert!( + streamed.iter().any(|line| line.contains('━')), + "expected table separators in plan streamed output: {streamed:?}" + ); + assert!( + !streamed + .iter() + .any(|line| line.trim() == "| Step | Owner |"), + "did not expect raw table header line in plan output: {streamed:?}" + ); + } + + #[test] + fn finalized_plan_stream_preserves_semantic_url_fragments() { + let destination = "https://example.com/a/very/long/path/to/a/table/artifact"; + let source = format!("| Step | URL |\n| --- | --- |\n| Verify | {destination} |\n"); + let mut ctrl = PlanStreamController::new( + /*width*/ Some(32), + &test_cwd(), + HistoryRenderMode::Rich, + ); + ctrl.push(&source); + + let (cell, _) = ctrl.finalize(); + let lines = cell + .expect("final plan stream table cell") + .display_hyperlink_lines(/*width*/ 32); + let linked_rows = lines + .iter() + .filter(|line| !line.hyperlinks.is_empty()) + .collect::>(); + + assert!(linked_rows.len() > 1); + assert!(linked_rows.iter().all(|line| { + line.hyperlinks + .iter() + .all(|link| link.destination == destination) + })); + } + + #[test] + fn plan_controller_streamed_markdown_fenced_table_matches_final_render() { + let deltas = vec![ + "## Build plan\n\n", + "```md\n", + "| Step | Owner |\n", + "|---|---|\n", + "| Write tests | Agent |\n", + "| Verify output | User |\n", + "```\n", + "\n", + ]; + let streamed = collect_plan_streamed_lines(&deltas, Some(80)); + + let source: String = deltas.iter().copied().collect(); + let baseline = collect_plan_streamed_lines(&[source.as_str()], Some(80)); + + assert_eq!(streamed, baseline); + assert!( + streamed.iter().any(|line| line.contains('━')), + "expected table separators in fenced plan output: {streamed:?}" + ); + assert!( + !streamed + .iter() + .any(|line| line.trim() == "| Step | Owner |"), + "did not expect raw table header line in fenced plan output: {streamed:?}" + ); + } + + #[test] + fn table_holdback_state_detects_header_plus_delimiter() { + let source = "| Key | Description |\n| --- | --- |\n"; + assert!(matches!( + table_holdback_state(source), + TableHoldbackState::Confirmed { .. } + )); + } + + #[test] + fn table_holdback_state_detects_single_column_header_plus_delimiter() { + let source = "| Only |\n| --- |\n"; + assert!(matches!( + table_holdback_state(source), + TableHoldbackState::Confirmed { .. } + )); + } + + #[test] + fn table_holdback_state_ignores_table_like_lines_inside_unclosed_long_fence() { + let source = "````sh\n```cmd\n| Key | Description |\n| --- | --- |\n````\n"; + assert!( + matches!(table_holdback_state(source), TableHoldbackState::None), + "table holdback should ignore pipe lines inside an open non-markdown fence", + ); + } + + #[test] + fn table_holdback_state_treats_indented_fence_text_as_plain_content() { + let source = " ```sh\n| Key | Description |\n| --- | --- |\n"; + assert!( + matches!( + table_holdback_state(source), + TableHoldbackState::Confirmed { .. } + ), + "indented fence-like text should not open a fence and should not block table detection", + ); + } + + #[test] + fn table_holdback_state_ignores_table_like_lines_inside_blockquoted_other_fence() { + let source = "> ```sh\n> | Key | Value |\n> | --- | --- |\n> ```\n"; + assert!( + matches!(table_holdback_state(source), TableHoldbackState::None), + "table holdback should ignore pipe lines inside non-markdown blockquoted fences", + ); + } + + #[test] + fn incremental_holdback_matches_stateless_scan_per_chunk() { + let chunks = [ + "status | owner\n", + "\n", + "> ```sh\n", + "> | A | B |\n", + "> | --- | --- |\n", + "> ```\n", + "> | Key | Value |\n", + "> | --- | --- |\n", + ]; + + let mut scanner = TableHoldbackScanner::new(); + let mut source = String::new(); + for chunk in chunks { + source.push_str(chunk); + scanner.push_source_chunk(chunk); + assert_eq!( + scanner.state(), + table_holdback_state(&source), + "scanner mismatch after chunk: {chunk:?}\nsource:\n{source}", + ); + } + } + + #[test] + fn incremental_holdback_detects_header_delimiter_across_chunk_boundary() { + let mut scanner = TableHoldbackScanner::new(); + scanner.push_source_chunk("| A | B |\n"); + assert_eq!( + scanner.state(), + TableHoldbackState::PendingHeader { header_start: 0 } + ); + scanner.push_source_chunk("| --- | --- |\n"); + assert_eq!( + scanner.state(), + TableHoldbackState::Confirmed { table_start: 0 } + ); + } + + #[test] + fn controller_set_width_after_first_line_emit_does_not_requeue_first_line() { + let mut ctrl = stream_controller(Some(120)); + ctrl.push( + "FIRSTTOKEN contains enough words to wrap once the width is reduced dramatically.\n", + ); + ctrl.push("second line remains pending\n"); + + let (first_emit, _) = ctrl.on_commit_tick(); + assert!(first_emit.is_some(), "expected first line emission"); + + ctrl.set_width(Some(20)); + + let (cell, _source) = ctrl.finalize(); + let remaining = cell + .map(|cell| lines_to_plain_strings(&cell.transcript_lines(u16::MAX))) + .unwrap_or_default() + .into_iter() + .map(|line| line.chars().skip(2).collect::()) + .collect::>(); + assert!( + !remaining.iter().any(|line| line.contains("FIRSTTOKEN")), + "first line should not be re-queued after resize: {remaining:?}", + ); + assert!( + remaining.iter().any(|line| line.contains("second line")), + "expected pending second line after resize: {remaining:?}", + ); + } + + #[test] + fn controller_set_width_partial_wrapped_emit_preserves_remaining_content() { + let mut ctrl = stream_controller(Some(20)); + ctrl.push("The quick brown fox jumps over the lazy dog near the riverbank.\n"); + ctrl.push("tail line\n"); + + let (first_emit, idle) = ctrl.on_commit_tick(); + assert!(first_emit.is_some(), "expected first wrapped line emission"); + assert!(!idle, "expected remaining queued content after one tick"); + assert!( + ctrl.queued_lines() > 0, + "expected non-empty queue before resize" + ); + + ctrl.set_width(Some(120)); + + let (cell, _source) = ctrl.finalize(); + let remaining = cell + .map(|c| lines_to_plain_strings(&c.transcript_lines(u16::MAX))) + .unwrap_or_default() + .into_iter() + .map(|line| line.chars().skip(2).collect::()) + .collect::>(); + assert!( + remaining.iter().any(|line| line.contains("tail line")), + "un-emitted content should remain after resize remap: {remaining:?}", + ); + } + + #[test] + fn controller_set_width_partial_wrapped_emit_keeps_wrapped_remainder() { + let mut ctrl = stream_controller(Some(18)); + ctrl.push("alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu\n"); + + let (first_emit, idle) = ctrl.on_commit_tick(); + assert!(first_emit.is_some(), "expected first wrapped line emission"); + assert!(!idle, "expected remaining wrapped content after one tick"); + assert!( + ctrl.queued_lines() > 0, + "expected queued wrapped remainder before resize" + ); + + ctrl.set_width(Some(80)); + + let (cell, _source) = ctrl.finalize(); + let remaining = cell + .map(|c| lines_to_plain_strings(&c.transcript_lines(u16::MAX))) + .unwrap_or_default(); + let joined = remaining.join(" "); + assert!( + joined.contains("kappa") || joined.contains("lambda") || joined.contains("mu"), + "wrapped remainder from partially emitted source line was lost after resize: {remaining:?}", + ); + } +} diff --git a/codex-rs/tui/src/streaming/controller_preview_tests.rs b/codex-rs/tui/src/streaming/controller_preview_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..60baca07e99b7d04f7355381ad1abe0327bc7cbf --- /dev/null +++ b/codex-rs/tui/src/streaming/controller_preview_tests.rs @@ -0,0 +1,137 @@ +use super::*; +use crate::terminal_hyperlinks::visible_lines; +use pretty_assertions::assert_eq; + +#[test] +fn unterminated_prose_reflows_and_finishes_without_duplication() { + let cwd = std::env::temp_dir(); + for ending in ["", "\n"] { + let mut controller = StreamController::new(Some(32), &cwd, HistoryRenderMode::Rich); + let mut source = String::from("`numbers` "); + controller.push(&source); + for number in 100..200 { + let delta = format!("{number} "); + source.push_str(&delta); + controller.push(&delta); + assert_eq!(controller.queued_lines(), 0); + assert_eq!( + controller.current_tail_lines(), + render_source( + &source, + Some(32), + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None + ), + ); + } + + controller.push(" | pending structure"); + controller.set_width(Some(48)); + assert_eq!(controller.queued_lines(), 0); + assert_eq!( + controller.current_tail_lines(), + render_source( + &source, + Some(48), + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None + ), + ); + source.push_str(" | pending structure"); + controller.push(ending); + let (queued, _) = controller.on_commit_tick_batch(usize::MAX); + let (remaining, finalized_source) = controller.finalize(); + let displayed = queued + .into_iter() + .chain(remaining) + .flat_map(|cell| cell.display_lines(/*width*/ 50)) + .collect::>(); + let expected = history_cell::AgentMessageCell::new_hyperlink_lines( + render_source( + &source, + Some(48), + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None, + ), + /*is_first_line*/ true, + ); + assert_eq!(displayed, expected.display_lines(/*width*/ 50)); + assert_eq!(finalized_source, Some(format!("{source}\n"))); + assert!(!controller.has_live_tail()); + } +} + +#[test] +fn partial_markdown_structures_remain_newline_gated() { + let cwd = std::env::temp_dir(); + for (committed, partial) in [ + ("", "| partial header"), + ("", "```rust"), + ("", "> ~~~rust"), + ("```rust\n", "let partial = 1;"), + ("- ```rust\n", " let partial = 1;"), + ("> - ```rust\n", "> let partial = 1;"), + (" indented code\n", " partial code"), + ("```markdown\n", "partial markdown fence"), + ("| A | B |\n| --- | --- |\n", "partial row"), + ] { + let mut controller = StreamController::new(Some(40), &cwd, HistoryRenderMode::Rich); + controller.push(committed); + let tail = controller.current_tail_lines(); + let queued = controller.queued_lines(); + controller.push(partial); + assert_eq!(controller.current_tail_lines(), tail); + assert_eq!(controller.queued_lines(), queued); + } +} + +#[test] +fn long_unicode_preview_keeps_recent_text_and_finalizes_full_source() { + let cwd = std::env::temp_dir(); + let mut controller = StreamController::new(Some(40), &cwd, HistoryRenderMode::Rich); + controller.push(&"🦀 ".repeat(3000)); + controller.push("latest text"); + let preview = visible_lines(controller.current_tail_lines()); + assert_eq!(preview.first(), Some(&Line::from("…"))); + let text = preview + .iter() + .map(Line::to_string) + .collect::>() + .join(" "); + assert!(text.trim_end().ends_with("latest text"), "{text}"); + assert_eq!(controller.queued_lines(), 0); + let (_, source) = controller.finalize(); + assert_eq!(source, Some(format!("{}latest text\n", "🦀 ".repeat(3000)))); +} + +#[test] +fn prose_preview_resumes_after_fenced_code() { + let cwd = std::env::temp_dir(); + let mut controller = StreamController::new(Some(40), &cwd, HistoryRenderMode::Rich); + controller.push("```rust\nlet x = 1;\n```\n"); + controller.on_commit_tick_batch(usize::MAX); + controller.push("prose after code"); + assert_eq!( + visible_lines(controller.current_tail_lines()), + vec![Line::from("prose after code")], + ); +} + +#[test] +fn prose_after_a_table_is_previewed_until_another_table_starts() { + let cwd = std::env::temp_dir(); + let mut controller = StreamController::new(Some(40), &cwd, HistoryRenderMode::Rich); + controller.push("| A | B |\n| --- | --- |\n| a | b |\n\n"); + controller.push("prose after the table"); + let tail = visible_lines(controller.current_tail_lines()); + assert_eq!(tail.last(), Some(&Line::from("prose after the table"))); + assert_eq!(controller.queued_lines(), 0); + + controller.push("\n\n| C | D |\n| --- | --- |\n"); + let tail = controller.current_tail_lines(); + controller.push("partial row"); + assert_eq!(controller.current_tail_lines(), tail); +} diff --git a/codex-rs/tui/src/streaming/inline_math_tests.rs b/codex-rs/tui/src/streaming/inline_math_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..7da430c6f06632183fd1cb39a65dc70da7ef61ac --- /dev/null +++ b/codex-rs/tui/src/streaming/inline_math_tests.rs @@ -0,0 +1,54 @@ +use super::StreamingRender; +use crate::history_cell::HistoryRenderMode; +use crate::markdown::render_streaming_markdown_agent_with_links_and_cwd; +use pretty_assertions::assert_eq; + +#[test] +fn unicode_math_inline_stream_preserves_display_exclusion_boundaries() { + let cwd = std::env::temp_dir(); + for delimiters in [("$$", "$$"), ("\\[", "\\]")] { + let mut render = StreamingRender::new(); + let mut source = String::new(); + for chunk in [ + delimiters.0, + "\n\nx^2\n\n", + delimiters.1, + "\n\n", + "After $\\alpha$.\n", + ] { + source.push_str(chunk); + render.append( + &source, + chunk, + Some(40), + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None, + ); + assert_eq!( + render.lines, + render_streaming_markdown_agent_with_links_and_cwd(&source, Some(40), Some(&cwd)) + .lines + ); + for width in [16, 40] { + let mut resized = StreamingRender::new(); + resized.recompute( + &source, + Some(width), + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None, + ); + assert_eq!( + resized.lines, + render_streaming_markdown_agent_with_links_and_cwd( + &source, + Some(width), + Some(&cwd) + ) + .lines + ); + } + } + } +} diff --git a/codex-rs/tui/src/streaming/math_tests.rs b/codex-rs/tui/src/streaming/math_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..3226278f12598b55c59dece237762f427a838f4c --- /dev/null +++ b/codex-rs/tui/src/streaming/math_tests.rs @@ -0,0 +1,195 @@ +use super::StreamCore; +use super::render_source; +use crate::history_cell::HistoryRenderMode; +use crate::markdown::render_streaming_markdown_agent_with_links_and_cwd; +use pretty_assertions::assert_eq; + +#[test] +fn unicode_math_stream_holds_display_until_closed_and_preserves_source() { + let cwd = std::env::temp_dir(); + for mode in [HistoryRenderMode::Rich, HistoryRenderMode::Raw] { + for (delimiters, prefix) in [(("$$", "$$"), "Before.\n\n"), (("\\[", "\\]"), "Before.\n")] { + let mut stream = StreamCore::new( + Some(60), + &cwd, + mode, + /*inline_visualization_context*/ None, + ); + let mut emitted = Vec::new(); + let mut source = String::new(); + for chunk in [ + prefix, + delimiters.0, + "\n", + "\\frac{a+b}{c}\n", + "\n", + delimiters.1, + "\n\n", + "After $\\alpha^2$.\n", + ] { + source.push_str(chunk); + stream.push_delta(chunk); + emitted.extend(stream.tick_batch(usize::MAX)); + if mode == HistoryRenderMode::Rich && source.ends_with("\\frac{a+b}{c}\n") { + assert_eq!( + emitted, + render_source( + prefix, + Some(60), + &cwd, + mode, + /*inline_visualization_context*/ None + ) + ); + } + } + let (remaining, raw) = stream.finalize_remaining(); + emitted.extend(remaining); + assert_eq!(raw, source); + assert_eq!( + emitted, + render_source( + &source, + Some(60), + &cwd, + mode, + /*inline_visualization_context*/ None + ) + ); + } + } +} + +#[test] +fn unicode_math_raw_preview_preserves_line_on_newline() { + let cwd = std::env::temp_dir(); + for open in ["$$", "\\["] { + let mut stream = StreamCore::new( + Some(12), + &cwd, + HistoryRenderMode::Raw, + /*inline_visualization_context*/ None, + ); + let source = format!("{open}\\frac{{abcdefghijk}}{{lmnop}}"); + stream.push_delta(&source); + let preview = stream.current_tail_lines(); + assert_eq!(preview.len(), 1); + assert_eq!(preview[0].line.to_string(), source); + if open == "$$" { + insta::assert_snapshot!("unicode_math_raw_preview", preview[0].line.to_string()); + } + stream.push_delta("\n"); + let (lines, raw) = stream.finalize_remaining(); + assert_eq!(lines, preview); + assert_eq!(raw, format!("{source}\n")); + } +} + +#[test] +fn unicode_math_unfinished_display_is_visible_and_reflows() { + let cwd = std::env::temp_dir(); + for (open, close) in [("$$", "$$"), (r"\[", r"\]")] { + let mut stream = StreamCore::new( + Some(80), + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None, + ); + for (chunk, expected) in [(&open[..1], &open[..1]), (&open[1..], open)] { + stream.push_delta(chunk); + assert_eq!(stream.current_tail_lines()[0].line.to_string(), expected); + } + stream.push_delta("\n \\frac{|a+b+c+d+e+f|}{g+h}"); + for width in [80, 20, 80] { + stream.set_width(Some(width)); + let lines = stream.current_tail_lines(); + assert!( + lines + .iter() + .any(|line| line.line.to_string().contains("frac")) + ); + assert!(lines.iter().all(|line| line.line.width() <= width)); + if open == "$$" && width == 20 { + insta::assert_snapshot!( + "unicode_math_live_source_narrow", + lines + .iter() + .map(|line| line.line.to_string()) + .collect::>() + .join("\n") + ); + } + } + stream.push_delta(&format!("\n{close}\n")); + let (lines, source) = stream.finalize_remaining(); + assert_eq!( + lines, + render_source( + &source, + Some(80), + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None + ) + ); + } +} + +#[test] +fn unicode_math_rejected_display_keeps_its_closer_and_following_text() { + let cwd = std::env::temp_dir(); + for (open, close) in [("$$", "$$"), ("\\[", "\\]")] { + for (prefix, body) in [ + ("", "x".repeat(/*n*/ 5000)), + ("", "`x`".to_owned()), + ("", format!("`{close}\nx`")), + ("", "[x](https://example.com)".to_owned()), + ("Equation: ", "x^2".to_owned()), + ] { + let mut render = super::super::render::StreamingRender::new(); + let mut source = String::new(); + for chunk in [ + format!("{prefix}{open}\n\n"), + format!("{body}\n\n"), + format!("{close}\n\n"), + "After $\\alpha$.\n\n".to_owned(), + "$$\\beta$$\n".to_owned(), + ] { + source.push_str(&chunk); + render.append( + &source, + &chunk, + Some(40), + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None, + ); + let expected = render_streaming_markdown_agent_with_links_and_cwd( + &source, + Some(40), + Some(&cwd), + ); + assert_eq!( + (&render.lines, render.pending_math_start), + (&expected.lines, expected.pending_math_start) + ); + if source.len() > 4096 || !prefix.is_empty() { + assert_eq!(render.pending_math_start, None); + } + } + assert!( + render + .lines + .iter() + .any(|line| line.line.to_string().contains("After α.")) + ); + assert!( + render + .lines + .iter() + .any(|line| line.line.to_string().contains('β')) + ); + assert_eq!(render.pending_math_start, None); + } + } +} diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..23196e9a41592eb4c49a753877a437084eb34045 --- /dev/null +++ b/codex-rs/tui/src/streaming/mod.rs @@ -0,0 +1,127 @@ +//! Streaming primitives used by the TUI transcript pipeline. +//! +//! `StreamState` owns newline-gated markdown collection and a FIFO queue of committed render lines. +//! Higher-level modules build on top of this state: +//! - `controller` adapts queued lines into `HistoryCell` emission rules for message and plan streams. +//! - `chunking` computes adaptive drain plans from queue pressure. +//! - `commit_tick` binds policy decisions to concrete controller drains. +//! +//! The key invariant is queue ordering. All drains pop from the front, and enqueue records an +//! arrival timestamp so policy code can reason about oldest queued age without peeking into text. + +use std::collections::VecDeque; +use std::path::Path; +use std::time::Duration; +use std::time::Instant; + +use crate::markdown_stream::MarkdownStreamCollector; +use crate::terminal_hyperlinks::HyperlinkLine; +pub(crate) mod chunking; +mod code_fence; +pub(crate) mod commit_tick; +pub(crate) mod controller; +mod prose_preview; +mod render; +mod table_holdback; + +struct QueuedLine { + line: HyperlinkLine, + enqueued_at: Instant, +} + +/// Holds in-flight markdown stream state and queued committed lines. +pub(crate) struct StreamState { + pub(crate) collector: MarkdownStreamCollector, + queued_lines: VecDeque, + pub(crate) has_seen_delta: bool, +} + +impl StreamState { + /// Create stream state whose markdown collector renders local file links relative to `cwd`. + /// + /// Controllers are expected to pass the session cwd here once and keep it stable for the + /// lifetime of the active stream. + pub(crate) fn new(width: Option, cwd: &Path) -> Self { + Self { + collector: MarkdownStreamCollector::new(width, cwd), + queued_lines: VecDeque::new(), + has_seen_delta: false, + } + } + /// Resets collector and queue state for the next stream lifecycle. + pub(crate) fn clear(&mut self) { + self.collector.clear(); + self.queued_lines.clear(); + self.has_seen_delta = false; + } + /// Drains one queued line from the front of the queue. + pub(crate) fn step(&mut self) -> Vec { + self.queued_lines + .pop_front() + .map(|queued| queued.line) + .into_iter() + .collect() + } + /// Drains up to `max_lines` queued lines from the front of the queue. + /// + /// Callers that pass very large values still get bounded behavior because this method clamps to + /// the currently available queue length. + pub(crate) fn drain_n(&mut self, max_lines: usize) -> Vec { + let end = max_lines.min(self.queued_lines.len()); + self.queued_lines + .drain(..end) + .map(|queued| queued.line) + .collect() + } + /// Clears queued lines while keeping collector/turn lifecycle state intact. + pub(crate) fn clear_queue(&mut self) { + self.queued_lines.clear(); + } + /// Returns whether no lines are queued for commit. + pub(crate) fn is_idle(&self) -> bool { + self.queued_lines.is_empty() + } + /// Returns the current queue depth. + pub(crate) fn queued_len(&self) -> usize { + self.queued_lines.len() + } + /// Returns the age of the oldest queued line. + pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option { + self.queued_lines + .front() + .map(|queued| now.saturating_duration_since(queued.enqueued_at)) + } + /// Appends committed lines to the queue with a shared enqueue timestamp. + pub(crate) fn enqueue(&mut self, lines: Vec) { + let now = Instant::now(); + self.queued_lines + .extend(lines.into_iter().map(|line| QueuedLine { + line, + enqueued_at: now, + })); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use ratatui::text::Line; + use std::path::PathBuf; + + fn test_cwd() -> PathBuf { + // These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or + // Windows-specific root semantics into the fixtures. + std::env::temp_dir() + } + + #[test] + fn drain_n_clamps_to_available_lines() { + let mut state = StreamState::new(/*width*/ None, &test_cwd()); + state.enqueue(vec![HyperlinkLine::new(Line::from("one"))]); + + let drained = state.drain_n(/*max_lines*/ 8); + assert_eq!(drained, vec![HyperlinkLine::new(Line::from("one"))]); + assert!(state.is_idle()); + } +} diff --git a/codex-rs/tui/src/streaming/prose_preview.rs b/codex-rs/tui/src/streaming/prose_preview.rs new file mode 100644 index 0000000000000000000000000000000000000000..94a848a33026156e4ad4128b8045e625f3834610 --- /dev/null +++ b/codex-rs/tui/src/streaming/prose_preview.rs @@ -0,0 +1,76 @@ +//! Bounded, disposable previews of unterminated prose. Preview lines never enter scrollback; +//! newline commitment and finalization render the original source independently. Math previews +//! show wrapped source until closure, without interpreting TeX punctuation as Markdown. + +use super::render::render_source; +use crate::history_cell::HistoryRenderMode; +use crate::inline_visualization::InlineVisualizationContext; +use crate::terminal_hyperlinks::HyperlinkLine; +use ratatui::text::Line; +use std::path::Path; + +const MAX_PREVIEW_BYTES: usize = 8192; + +pub(super) enum PreviewMode { + Prose(HistoryRenderMode), + Math, +} + +/// Tracks one append-only incomplete line; reset when a newline is committed. +#[derive(Default)] +pub(super) struct ProsePreview { + pub(super) lines: Vec, + scanned_len: usize, + safe_len: usize, + has_pipe: bool, +} + +impl ProsePreview { + pub(super) fn update( + &mut self, + source: &str, + width: Option, + cwd: &Path, + mode: PreviewMode, + inline_visualization_context: Option<&InlineVisualizationContext>, + ) -> bool { + // Only scan newly arrived bytes, including on very long single-line responses. + self.has_pipe |= source[self.scanned_len..].contains('|'); + self.scanned_len = source.len(); + // Indented and quoted lines may belong to nested code blocks. Keep their + // existing newline holdback instead of guessing at the missing block context. + if matches!(mode, PreviewMode::Math) + || !(self.has_pipe + || source.starts_with([' ', '\t', '>']) + || source.starts_with("```") + || source.starts_with("~~~") + || matches!(source, "`" | "``" | "~" | "~~")) + { + self.safe_len = source.len(); + } + // Retain the last safe text when tokens reveal structure, but still reflow it. + let source = &source[..self.safe_len]; + let start = source.ceil_char_boundary(source.len().saturating_sub(MAX_PREVIEW_BYTES)); + let mut lines = match mode { + PreviewMode::Prose(render_mode) => render_source( + &source[start..], + width, + cwd, + render_mode, + inline_visualization_context, + ), + PreviewMode::Math => textwrap::wrap(&source[start..], width.unwrap_or(usize::MAX)) + .into_iter() + .map(|line| HyperlinkLine::new(Line::from(line.into_owned()))) + .collect(), + }; + if start > 0 { + lines.insert(0, HyperlinkLine::new(Line::from("…"))); + } + if self.lines == lines { + return false; + } + self.lines = lines; + true + } +} diff --git a/codex-rs/tui/src/streaming/render.rs b/codex-rs/tui/src/streaming/render.rs new file mode 100644 index 0000000000000000000000000000000000000000..ff082ba8564da21a12c918ad79d12add452ccbfe --- /dev/null +++ b/codex-rs/tui/src/streaming/render.rs @@ -0,0 +1,240 @@ +//! Incremental markdown rendering for in-flight transcript streams. +//! +//! Completed top-level blocks are retained while the final block stays mutable, avoiding repeated +//! rendering of the stable prefix as newline-bearing deltas arrive. + +use super::code_fence::OpenCodeFence; +use crate::history_cell::HistoryRenderMode; +use crate::history_cell::raw_lines_from_source; +use crate::inline_visualization::InlineVisualizationContext; +use crate::inline_visualization::contains_inline_visualization; +use crate::markdown::render_markdown_agent_with_links_cwd_and_visualizations; +use crate::markdown::render_streaming_markdown_agent_with_links_and_cwd; +use crate::render::highlight::syntax_theme_revision; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::plain_hyperlink_lines; +use ratatui::text::Line; +use std::path::Path; + +/// Incremental render state split at source and rendered-line boundaries. +/// +/// The prefix before both boundaries is immutable; only the final top-level Markdown block is +/// re-rendered as committed source arrives. +pub(super) struct StreamingRender { + pub(super) lines: Vec, + pub(super) pending_math_start: Option, + /// Source prefix containing only completed top-level markdown blocks. + stable_source_len: usize, + /// Rendered-line boundary corresponding to `stable_source_len`. + stable_rendered_len: usize, + /// Reference-style link definitions can affect any earlier or later markdown block. + has_reference_link_definition: bool, + /// Inline visualization directives require source-wide rewriting once one is committed. + has_inline_visualization_directive: bool, + /// Parser state for a directly appendable, open top-level code fence. + open_code_fence: Option, +} + +impl StreamingRender { + pub(super) fn new() -> Self { + Self { + lines: Vec::with_capacity(64), + pending_math_start: None, + stable_source_len: 0, + stable_rendered_len: 0, + has_reference_link_definition: false, + has_inline_visualization_directive: false, + open_code_fence: None, + } + } + + pub(super) fn clear(&mut self) { + self.lines.clear(); + self.pending_math_start = None; + self.stable_source_len = 0; + self.stable_rendered_len = 0; + self.has_reference_link_definition = false; + self.has_inline_visualization_directive = false; + self.open_code_fence = None; + } + + /// Re-render the full source and reset both stable-prefix boundaries. + /// + /// This is used when width or render mode changes, and whenever source-wide rendering state + /// makes retaining previously rendered blocks unsafe. + pub(super) fn recompute( + &mut self, + source: &str, + width: Option, + cwd: &Path, + render_mode: HistoryRenderMode, + inline_visualization_context: Option<&InlineVisualizationContext>, + ) { + self.open_code_fence = None; + self.pending_math_start = None; + self.has_inline_visualization_directive = contains_inline_visualization(source); + self.lines = match (render_mode, inline_visualization_context) { + (HistoryRenderMode::Rich, None) if !self.has_inline_visualization_directive => { + let rendered = + render_streaming_markdown_agent_with_links_and_cwd(source, width, Some(cwd)); + self.has_reference_link_definition = rendered.has_reference_link_definition; + self.pending_math_start = rendered.pending_math_start; + rendered.lines + } + _ => { + self.has_reference_link_definition = false; + if render_mode == HistoryRenderMode::Rich { + self.pending_math_start = render_streaming_markdown_agent_with_links_and_cwd( + source, + width, + Some(cwd), + ) + .pending_math_start; + } + render_source( + source, + width, + cwd, + render_mode, + inline_visualization_context, + ) + } + }; + self.stable_source_len = 0; + self.stable_rendered_len = 0; + } + + /// Append newly committed source while retaining only the final markdown block as mutable. + /// + /// The final top-level block can still change meaning when another line arrives (for example, + /// list tightness, a setext heading, a fenced block, or a table). Earlier top-level blocks are + /// rendered once and retained. Reference-style link definitions and inline visualization + /// rewriting fall back to a full render because they can affect source-wide rendering state. + pub(super) fn append( + &mut self, + raw_source: &str, + committed_source: &str, + width: Option, + cwd: &Path, + render_mode: HistoryRenderMode, + inline_visualization_context: Option<&InlineVisualizationContext>, + ) { + if render_mode == HistoryRenderMode::Raw { + self.lines + .extend(plain_hyperlink_lines(raw_lines_from_source( + committed_source, + ))); + return; + } + + self.has_inline_visualization_directive |= contains_inline_visualization(committed_source); + if self.has_inline_visualization_directive { + self.recompute( + raw_source, + width, + cwd, + render_mode, + inline_visualization_context, + ); + return; + } + + if self.has_reference_link_definition { + self.recompute( + raw_source, + width, + cwd, + render_mode, + inline_visualization_context, + ); + return; + } + + if let Some(fence) = self.open_code_fence.take() + && let Some((fence, lines)) = fence.append(raw_source, committed_source) + { + self.lines.extend(lines); + self.open_code_fence = Some(fence); + return; + } + + let pending_source = &raw_source[self.stable_source_len..]; + let theme_revision = syntax_theme_revision(); + let pending = + render_streaming_markdown_agent_with_links_and_cwd(pending_source, width, Some(cwd)); + self.pending_math_start = pending + .pending_math_start + .map(|start| self.stable_source_len + start); + if pending.has_reference_link_definition { + self.has_reference_link_definition = true; + self.recompute( + raw_source, + width, + cwd, + render_mode, + inline_visualization_context, + ); + return; + } + + let final_block_start = pending.last_top_level_block_start.unwrap_or(/*default*/ 0); + self.open_code_fence = OpenCodeFence::detect( + &pending_source[final_block_start..], + raw_source.len(), + theme_revision, + ); + + let mut newly_stable_rendered_len = None; + if let Some(boundary) = pending.last_top_level_block_start { + let newly_stable_source = &pending_source[..boundary]; + let newly_stable = render_source( + newly_stable_source, + width, + cwd, + render_mode, + inline_visualization_context, + ); + self.stable_source_len += boundary; + newly_stable_rendered_len = Some(newly_stable.len()); + } + + self.lines.truncate(self.stable_rendered_len); + if !self.lines.is_empty() + && (!pending.lines.is_empty() || !pending_source.trim().is_empty()) + && !pending.first_top_level_block_is_html + { + self.lines.push(HyperlinkLine::new(Line::default())); + } + let pending_render_start = self.lines.len(); + self.lines.extend(pending.lines); + if let Some(newly_stable_rendered_len) = newly_stable_rendered_len { + self.stable_rendered_len = pending_render_start + newly_stable_rendered_len; + } + } +} + +pub(super) fn render_source( + source: &str, + width: Option, + cwd: &Path, + render_mode: HistoryRenderMode, + inline_visualization_context: Option<&InlineVisualizationContext>, +) -> Vec { + match render_mode { + HistoryRenderMode::Rich => render_markdown_agent_with_links_cwd_and_visualizations( + source, + width, + Some(cwd), + inline_visualization_context, + ), + HistoryRenderMode::Raw => plain_hyperlink_lines(raw_lines_from_source(source)), + } +} + +#[cfg(test)] +#[path = "render_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "inline_math_tests.rs"] +mod inline_math_tests; diff --git a/codex-rs/tui/src/streaming/render_tests.rs b/codex-rs/tui/src/streaming/render_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..55aefd1c9cb6246bd006767537e1e57d0f436590 --- /dev/null +++ b/codex-rs/tui/src/streaming/render_tests.rs @@ -0,0 +1,499 @@ +use super::StreamingRender; +use super::render_source; +use crate::history_cell::HistoryRenderMode; +use crate::inline_visualization::InlineVisualizationContext; +use crate::markdown::render_streaming_markdown_agent_with_links_and_cwd; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::visible_lines; +use codex_protocol::ThreadId; +use insta::assert_debug_snapshot; +use pretty_assertions::assert_eq; +use std::path::Path; +use std::path::PathBuf; + +#[path = "code_fence_render_tests.rs"] +mod code_fence_tests; + +fn test_cwd() -> PathBuf { + std::env::temp_dir() +} + +fn append( + render: &mut StreamingRender, + source: &mut String, + chunk: &str, + width: Option, + cwd: &Path, + render_mode: HistoryRenderMode, +) { + source.push_str(chunk); + render.append( + source, + chunk, + width, + cwd, + render_mode, + /*inline_visualization_context*/ None, + ); +} + +fn append_rich_and_assert_matches_full( + render: &mut StreamingRender, + source: &mut String, + chunk: &str, + width: Option, + cwd: &Path, +) { + append(render, source, chunk, width, cwd, HistoryRenderMode::Rich); + assert_eq!( + render.lines, + render_source( + source, + width, + cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None, + ), + "incremental render diverged after chunk {chunk:?}", + ); +} + +fn lines_to_plain_strings(lines: &[HyperlinkLine]) -> Vec { + visible_lines(lines.to_vec()) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() +} + +fn assert_rich_stream_matches_full_render( + chunks: &[&str], + width: Option, +) -> (String, StreamingRender) { + let cwd = test_cwd(); + let mut source = String::new(); + let mut render = StreamingRender::new(); + + for chunk in chunks { + append_rich_and_assert_matches_full(&mut render, &mut source, chunk, width, &cwd); + } + + (source, render) +} + +#[test] +fn incremental_render_keeps_final_block_mutable_and_matches_full_render() { + let chunks = [ + "# Heading\n", + "\n", + "First paragraph with a [link](https://example.com).\n", + "continued on the next line.\n\n", + "1. First item\n", + "2. Second item\n\n", + "> Quoted paragraph\n\n", + "```rust\n", + "fn main() {}\n", + "```\n\n", + "| Key | Value |\n", + "| --- | --- |\n", + "| alpha | beta |\n", + ]; + let (source, render) = assert_rich_stream_matches_full_render(&chunks, Some(48)); + + assert!(render.stable_source_len > 0); + assert!(render.stable_source_len < source.len()); + assert_debug_snapshot!("incremental_render_representative_stream", render.lines); +} + +#[test] +fn incremental_file_citations_preserve_metadata_unicode_and_markdown() { + let cwd = test_cwd(); + let rendered_cases = [ + ( + "Quarterly Report.xlsx", + "- :codex-file-citation{artifact_kind=\"workbook\" ", + ), + ("Résumé *final* ✨.xlsx", "- :codex-file-citation{"), + ] + .map(|(filename, prefix)| { + let tail = format!("path=\"{}\"}}\n", cwd.join(filename).display()); + let chunks = ["# Output\n\n", prefix, &tail, "\n", "Continue.\n"]; + let (_, render) = assert_rich_stream_matches_full_render(&chunks, Some(80)); + + render.lines + }); + assert_debug_snapshot!("incremental_file_citations", rendered_cases); +} + +#[test] +fn growing_single_top_level_blocks_render_and_scan_in_one_pass() { + let streams: &[&[&str]] = &[ + &[ + "A paragraph that keeps growing\n", + "without a blank line between chunks.\n", + "It stays one top-level block.\n", + ], + &[ + "| Key | Value |\n", + "| --- | --- |\n", + "| alpha | beta |\n", + "| gamma | delta |\n", + ], + ]; + let cwd = test_cwd(); + let width = Some(80); + + for chunks in streams { + let mut source = String::new(); + let mut render = StreamingRender::new(); + for chunk in *chunks { + append_rich_and_assert_matches_full(&mut render, &mut source, chunk, width, &cwd); + let pending = render_streaming_markdown_agent_with_links_and_cwd( + &source, + width, + Some(cwd.as_path()), + ); + assert_eq!(pending.last_top_level_block_start, None); + assert_eq!(render.lines, pending.lines); + assert_eq!(render.stable_source_len, 0); + } + } +} + +#[test] +fn incremental_raw_render_preserves_blank_lines() { + let cwd = test_cwd(); + let width = Some(80); + let mut source = String::new(); + let mut render = StreamingRender::new(); + + for chunk in ["alpha\n", "\n", "beta\n", "\n"] { + append( + &mut render, + &mut source, + chunk, + width, + &cwd, + HistoryRenderMode::Raw, + ); + } + + assert_eq!( + lines_to_plain_strings(&render.lines), + vec!["alpha", "", "beta", ""], + ); +} + +#[test] +fn inline_visualization_context_without_directives_keeps_stable_prefix() { + let cwd = test_cwd(); + let context = InlineVisualizationContext::new(&cwd, ThreadId::new()) + .expect("UUIDv7 thread id should provide a timestamp"); + let width = Some(80); + let mut source = String::new(); + let mut render = StreamingRender::new(); + + for chunk in ["First paragraph.\n\n", "Second paragraph.\n\n"] { + source.push_str(chunk); + render.append( + &source, + chunk, + width, + &cwd, + HistoryRenderMode::Rich, + Some(&context), + ); + assert_eq!( + render.lines, + render_source( + &source, + width, + &cwd, + HistoryRenderMode::Rich, + Some(&context), + ), + ); + } + + assert!(render.stable_source_len > 0); + assert!(!render.has_inline_visualization_directive); +} + +#[test] +fn inline_visualizations_use_canonical_full_render() { + let cwd = test_cwd(); + let context = InlineVisualizationContext::new(&cwd, ThreadId::new()) + .expect("UUIDv7 thread id should provide a timestamp"); + let width = Some(80); + let mut source = String::new(); + let mut render = StreamingRender::new(); + + for chunk in ["Before.\n\n", "::codex-inline-vis{file=\"missing.html\"}\n"] { + source.push_str(chunk); + render.append( + &source, + chunk, + width, + &cwd, + HistoryRenderMode::Rich, + Some(&context), + ); + assert_eq!( + render.lines, + render_source( + &source, + width, + &cwd, + HistoryRenderMode::Rich, + Some(&context), + ), + ); + assert_eq!(render.stable_source_len, 0); + } + + assert!(render.has_inline_visualization_directive); +} + +#[test] +fn inline_visualizations_without_context_use_canonical_full_render() { + let (_, render) = assert_rich_stream_matches_full_render( + &["Before.\n\n", "::codex-inline-vis{file=\"missing.html\"}\n"], + Some(80), + ); + + assert_eq!(render.stable_source_len, 0); + assert!(render.has_inline_visualization_directive); + assert_debug_snapshot!( + "inline_visualizations_without_context_use_canonical_full_render", + render.lines + ); +} + +#[test] +fn inline_visualization_content_references_use_canonical_full_render() { + let (_, render) = assert_rich_stream_matches_full_render( + &[ + "Before.\n\n", + "\u{e200}visualize\u{e202}{\"path\":\"/tmp/missing.html\"}\u{e201}\n", + ], + Some(80), + ); + + assert_eq!(render.stable_source_len, 0); + assert!(render.has_inline_visualization_directive); +} + +#[test] +fn inline_visualization_directive_survives_raw_to_rich_render_mode_switch() { + let cwd = test_cwd(); + let width = Some(80); + let mut source = String::new(); + let mut render = StreamingRender::new(); + + append( + &mut render, + &mut source, + "::codex-inline-vis{file=\"missing.html\"}\n", + width, + &cwd, + HistoryRenderMode::Raw, + ); + assert!(!render.has_inline_visualization_directive); + + render.recompute( + &source, + width, + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None, + ); + + assert!(render.has_inline_visualization_directive); + assert_eq!( + render.lines, + render_source( + &source, + width, + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None, + ), + ); + + render.clear(); + assert!(!render.has_inline_visualization_directive); +} + +#[test] +fn reference_link_definition_recomputes_earlier_and_later_blocks() { + let streams: &[&[&str]] = &[ + &[ + "Earlier [reference][id].\n\n", + "An unrelated paragraph.\n\n", + "[id]: https://example.com/reference\n", + "\n", + "Later [reference][id].\n", + ], + &[ + "Earlier [reference][id].\n\n", + "Another paragraph.\n\n", + "```markdown\n", + "| Key | Value |\n", + "| --- | --- |\n", + "| alpha | beta |\n", + "\n", + "[id]: https://example.com/reference\n", + "```\n", + ], + ]; + for chunks in streams { + let (_, render) = assert_rich_stream_matches_full_render(chunks, Some(80)); + assert!(render.has_reference_link_definition); + } +} + +#[test] +fn reference_link_definition_survives_raw_to_rich_render_mode_switch() { + let cwd = test_cwd(); + let width = Some(80); + let mut source = String::new(); + let mut render = StreamingRender::new(); + + append( + &mut render, + &mut source, + "[id]: https://example.com/reference\n\n", + width, + &cwd, + HistoryRenderMode::Raw, + ); + render.recompute( + &source, + width, + &cwd, + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None, + ); + for chunk in ["First [reference][id].\n\n", "Later [reference][id].\n"] { + append_rich_and_assert_matches_full(&mut render, &mut source, chunk, width, &cwd); + } + + assert!(render.has_reference_link_definition); +} + +#[test] +fn incremental_render_does_not_add_blank_line_before_html_block() { + let streams: &[&[&str]] = &[ + &[ + "Paragraph.\n\n", + "
x
\n\n", + "Following paragraph.\n", + ], + &["Paragraph.\n\n
x
\n\n", "Following paragraph.\n"], + ]; + for chunks in streams { + let (_, render) = assert_rich_stream_matches_full_render(chunks, Some(80)); + assert_eq!( + lines_to_plain_strings(&render.lines), + vec!["Paragraph.", "
x
", "", "Following paragraph.",], + ); + } +} + +#[test] +fn incremental_render_preserves_heading_and_normalized_html_seams() { + let streams: &[&[&str]] = &[ + &["Paragraph.\n\n# Heading\n\n", "Next paragraph.\n"], + &[ + "Paragraph.\n\n", + "
x
\n\n", + "```markdown\n", + "| Key | Value |\n", + "| --- | --- |\n", + "| alpha | beta |\n", + "\n", + "Following paragraph.\n", + "```\n", + ], + ]; + for chunks in streams { + assert_rich_stream_matches_full_render(chunks, Some(80)); + } +} + +#[test] +fn paragraphs_after_unwrapped_table_fence_advance_stable_source() { + let cwd = test_cwd(); + let width = Some(80); + let (mut source, mut render) = assert_rich_stream_matches_full_render( + &[ + "```markdown\n", + "| Key | Value |\n", + "| --- | --- |\n", + "| alpha | beta |\n", + "```\n\n", + ], + width, + ); + + let mut previous_stable_source_len = render.stable_source_len; + for block in ["
Post-fence HTML.
\n\n", "First paragraph.\n\n"] { + append_rich_and_assert_matches_full(&mut render, &mut source, block, width, &cwd); + assert!(render.stable_source_len > previous_stable_source_len); + previous_stable_source_len = render.stable_source_len; + } +} + +#[test] +fn shell_pid_preserves_following_equations() { + for shell in [ + "Shell examples: $HOME and echo $$.", + "Shell examples: $HOME.", + ] { + let source = format!("{shell}\n\nAfter rejected equations: $\\alpha$.\n\n$$\\beta$$"); + for width in [80, 24] { + let (_, render) = assert_rich_stream_matches_full_render( + &source.split_inclusive('$').collect::>(), + Some(width), + ); + let expected = format!("{shell}\n\nAfter rejected equations: α.\n\nβ"); + assert_eq!( + render.lines, + render_source( + &expected, + Some(width), + &test_cwd(), + HistoryRenderMode::Rich, + /*inline_visualization_context*/ None, + ) + ); + } + } +} + +#[test] +fn rejected_math_openers_allow_bounded_incremental_rendering() { + let cwd = test_cwd(); + for (open, close) in [ + ("echo $$", "$$"), + ("Equation: \\[", "\\]"), + (r"\[label\]\*", "\\]"), + ] { + let (mut source, mut render) = + assert_rich_stream_matches_full_render(&[&format!("{open}\n\n")], Some(80)); + let distant_closer = format!("{close}\n\nAfter $\\alpha$.\n\n"); + for chunk in std::iter::repeat_n( + "An ordinary paragraph that must not retain the entire response.\n\n", + /*count*/ 160, + ) + .chain(std::iter::once(distant_closer.as_str())) + { + append_rich_and_assert_matches_full(&mut render, &mut source, chunk, Some(80), &cwd); + assert!(source.len() - render.stable_source_len < 4200); + } + } +} diff --git a/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__controller__math_tests__unicode_math_live_source_narrow.snap b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__controller__math_tests__unicode_math_live_source_narrow.snap new file mode 100644 index 0000000000000000000000000000000000000000..8a24860f077474f29eb9fb5bf06deb1aaf6b0095 --- /dev/null +++ b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__controller__math_tests__unicode_math_live_source_narrow.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/streaming/math_tests.rs +assertion_line: 134 +expression: "stream.current_tail_lines().iter().map(|line|\nline.line.to_string()).collect::>().join(\"\\n\")" +--- +$$ + \frac{| +a+b+c+d+e+f|}{g+h} diff --git a/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__controller__math_tests__unicode_math_raw_preview.snap b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__controller__math_tests__unicode_math_raw_preview.snap new file mode 100644 index 0000000000000000000000000000000000000000..480853d92fc94b6c9c70a93cc8ce1bb1fc6b4ba7 --- /dev/null +++ b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__controller__math_tests__unicode_math_raw_preview.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/streaming/math_tests.rs +expression: "preview[0].line.to_string()" +--- +$$\frac{abcdefghijk}{lmnop} diff --git a/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__incremental_file_citations.snap b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__incremental_file_citations.snap new file mode 100644 index 0000000000000000000000000000000000000000..46f1f00bfb531cfc27d4854f33a82587892f5b6b --- /dev/null +++ b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__incremental_file_citations.snap @@ -0,0 +1,62 @@ +--- +source: tui/src/streaming/render_tests.rs +expression: rendered_cases +--- +[ + [ + HyperlinkLine { + line: Line::from_iter([ + Span::from("# ").bold().underlined(), + Span::from("Output").bold().underlined(), + ]), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::default(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from_iter([ + Span::from("- "), + Span::from("Quarterly Report.xlsx").cyan(), + ]), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::default(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from("Continue."), + hyperlinks: [], + }, + ], + [ + HyperlinkLine { + line: Line::from_iter([ + Span::from("# ").bold().underlined(), + Span::from("Output").bold().underlined(), + ]), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::default(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from_iter([ + Span::from("- "), + Span::from("Résumé *final* ✨.xlsx").cyan(), + ]), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::default(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from("Continue."), + hyperlinks: [], + }, + ], +] diff --git a/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__incremental_render_representative_stream.snap b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__incremental_render_representative_stream.snap new file mode 100644 index 0000000000000000000000000000000000000000..59fe18f1c893dcd3e85398b7a135b66e471c1bd5 --- /dev/null +++ b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__incremental_render_representative_stream.snap @@ -0,0 +1,127 @@ +--- +source: tui/src/streaming/render_tests.rs +expression: render.lines +--- +[ + HyperlinkLine { + line: Line::from_iter([ + Span::from("# ").bold().underlined(), + Span::from("Heading").bold().underlined(), + ]), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::default(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from_iter([ + Span::from("First paragraph with a "), + Span::from("link").cyan().underlined(), + ]), + hyperlinks: [ + TerminalHyperlink { + columns: 23..27, + destination: "https://example.com", + destination_kind: Web, + }, + ], + }, + HyperlinkLine { + line: Line::from_iter([ + Span::from("("), + Span::from("https://example.com").cyan().underlined(), + Span::from(")"), + Span::from("."), + ]), + hyperlinks: [ + TerminalHyperlink { + columns: 1..20, + destination: "https://example.com", + destination_kind: Web, + }, + ], + }, + HyperlinkLine { + line: Line::from("continued on the next line."), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::default(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from_iter([ + Span::from("1. ").light_blue(), + Span::from("First item"), + ]), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from_iter([ + Span::from("2. ").light_blue(), + Span::from("Second item"), + ]), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::default(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from_iter([ + Span::from("> "), + Span::from("Quoted paragraph"), + ]).green(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::default(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from_iter([ + Span::default(), + Span::from("fn").fg(Color::Rgb(203, 166, 247)), + Span::from(" ").fg(Color::Rgb(205, 214, 244)), + Span::from("main").fg(Color::Rgb(137, 180, 250)), + Span::from("(").fg(Color::Rgb(147, 153, 178)), + Span::from(")").fg(Color::Rgb(147, 153, 178)), + Span::from(" ").fg(Color::Rgb(205, 214, 244)), + Span::from("{").fg(Color::Rgb(147, 153, 178)), + Span::from("}").fg(Color::Rgb(147, 153, 178)), + ]), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::default(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from_iter([ + Span::from(" "), + Span::from("Key"), + Span::from(" "), + Span::from(" "), + Span::from(" "), + Span::from(" "), + Span::from("Value"), + ]).fg(Color::Rgb(249, 226, 175)).bold(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from(Span::from("━━━━━━━ ━━━━━━━").dim()), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from_iter([ + Span::from(" "), + Span::from("alpha"), + Span::from(" "), + Span::from(" "), + Span::from(" "), + Span::from("beta"), + ]), + hyperlinks: [], + }, +] diff --git a/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__inline_visualizations_without_context_use_canonical_full_render.snap b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__inline_visualizations_without_context_use_canonical_full_render.snap new file mode 100644 index 0000000000000000000000000000000000000000..687e703b40fd9aab2e23dc3b22400de38b5cb44f --- /dev/null +++ b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__render__tests__inline_visualizations_without_context_use_canonical_full_render.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/streaming/render_tests.rs +expression: render.lines +--- +[ + HyperlinkLine { + line: Line::from("Before."), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::default(), + hyperlinks: [], + }, + HyperlinkLine { + line: Line::from(Span::from("Visualization unavailable on this device.").italic()), + hyperlinks: [], + }, +] diff --git a/codex-rs/tui/src/streaming/table_holdback.rs b/codex-rs/tui/src/streaming/table_holdback.rs new file mode 100644 index 0000000000000000000000000000000000000000..e53da7bcaf7289f548863746da627111ea6fa026 --- /dev/null +++ b/codex-rs/tui/src/streaming/table_holdback.rs @@ -0,0 +1,259 @@ +//! Pipe-table holdback scanner for source-backed agent streams. +//! +//! Agent streams with markdown tables keep the active table as mutable tail so +//! adding a row can reflow earlier table rows instead of committing a stale +//! render to scrollback. +//! +//! The scanner is intentionally conservative: it only looks for enough +//! structure to decide where the mutable tail should start. It does not try to +//! validate an entire table or predict final layout. Rendering remains the job +//! of the markdown renderer. + +use std::time::Instant; + +use crate::table_detect::FenceKind; +use crate::table_detect::FenceTracker; +use crate::table_detect::is_table_delimiter_line; +use crate::table_detect::is_table_header_line; +use crate::table_detect::parse_table_segments; +use crate::table_detect::strip_blockquote_prefix; + +/// Result of scanning accumulated raw source for pipe-table patterns. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum TableHoldbackState { + /// No table detected -- all rendered lines can flow into the stable queue. + None, + /// The last non-blank line looks like a table header row but no delimiter + /// row has followed yet. Hold back in case the next delta is a delimiter. + PendingHeader { header_start: usize }, + /// A header + delimiter pair was found -- the source contains a confirmed + /// table. Content from the table header onward stays mutable. + Confirmed { table_start: usize }, +} + +/// Facts remembered about the previous committed source line. +/// +/// The scanner only needs one-line lookbehind because a table is confirmed by +/// a header row immediately followed by a delimiter row. +#[derive(Clone, Copy)] +struct PreviousLineState { + source_start: usize, + fence_kind: FenceKind, + is_header: bool, +} + +/// Incremental scanner for table holdback state on append-only source streams. +/// +/// `push_source_chunk` must receive source in the same order it will be +/// appended to the stream. The scanner stores byte offsets into that logical +/// source buffer, so feeding chunks out of order would make later tail +/// boundaries point at the wrong rendered region. +pub(super) struct TableHoldbackScanner { + source_offset: usize, + fence_tracker: FenceTracker, + previous_line: Option, + pending_header_start: Option, + confirmed_table_start: Option, + /// Unlike scrollback holdback, partial-row holdback ends at a blank line. + in_table: bool, +} + +impl TableHoldbackScanner { + pub(super) fn new() -> Self { + Self { + source_offset: 0, + fence_tracker: FenceTracker::new(), + previous_line: None, + pending_header_start: None, + confirmed_table_start: None, + in_table: false, + } + } + + pub(super) fn reset(&mut self) { + *self = Self::new(); + } + + /// Return the current holdback decision for the committed source prefix. + /// + /// `PendingHeader` means the newest non-blank line looks like a table + /// header but the delimiter row has not arrived yet, so callers should + /// optimistically keep that region mutable. `Confirmed` means the header + /// and delimiter pair has been seen and all subsequent body rows remain in + /// the live tail until finalization. + pub(super) fn state(&self) -> TableHoldbackState { + if let Some(table_start) = self.confirmed_table_start { + TableHoldbackState::Confirmed { table_start } + } else if let Some(header_start) = self.pending_header_start { + TableHoldbackState::PendingHeader { header_start } + } else { + TableHoldbackState::None + } + } + + /// Partial prose can be previewed only outside tables and fenced blocks. + pub(super) fn allows_prose_preview(&self) -> bool { + !self.in_table + && !self.previous_line.is_some_and(|line| line.is_header) + && self.fence_tracker.kind() == FenceKind::Outside + } + + /// Advance the scanner with newly committed source. + /// + /// Chunks are expected to contain only source that is now safe to commit + /// into `raw_source`, typically newline-terminated lines from the + /// streaming collector. Partial rows are intentionally excluded so the + /// scanner never treats an unfinished table row as a stable structural + /// signal. + pub(super) fn push_source_chunk(&mut self, source_chunk: &str) { + if source_chunk.is_empty() { + return; + } + + let scan_start = Instant::now(); + let mut lines = 0usize; + for source_line in source_chunk.split_inclusive('\n') { + lines += 1; + self.push_line(source_line); + } + tracing::trace!( + bytes = source_chunk.len(), + lines, + state = ?self.state(), + elapsed_us = scan_start.elapsed().as_micros(), + "table holdback incremental scan", + ); + } + + /// Fold one committed source line into the scanner state machine. + fn push_line(&mut self, source_line: &str) { + let line = source_line.strip_suffix('\n').unwrap_or(source_line); + let source_start = self.source_offset; + let fence_kind = self.fence_tracker.kind(); + + let candidate_text = if fence_kind == FenceKind::Other { + None + } else { + table_candidate_text(line) + }; + let is_header = candidate_text.is_some_and(is_table_header_line); + let is_delimiter = candidate_text.is_some_and(is_table_delimiter_line); + + if strip_blockquote_prefix(line).is_empty() { + self.in_table = false; + } + if let Some(previous_line) = self.previous_line + && previous_line.fence_kind != FenceKind::Other + && fence_kind != FenceKind::Other + && previous_line.is_header + && is_delimiter + { + self.confirmed_table_start + .get_or_insert(previous_line.source_start); + self.pending_header_start = None; + self.in_table = true; + } + + if self.confirmed_table_start.is_none() && !line.trim().is_empty() { + if fence_kind != FenceKind::Other && is_header { + self.pending_header_start = Some(source_start); + } else { + self.pending_header_start = None; + } + } + + self.previous_line = Some(PreviousLineState { + source_start, + fence_kind, + is_header, + }); + + self.fence_tracker.advance(line); + if fence_kind != FenceKind::Outside && self.fence_tracker.kind() == FenceKind::Outside { + self.in_table = false; + } + self.source_offset = self.source_offset.saturating_add(source_line.len()); + } +} + +/// Strip blockquote prefixes and return the trimmed text if it contains +/// pipe-table segments, or `None` otherwise. +/// +/// Table holdback treats quoted tables as real tables, but it still requires a +/// pipe-table shape after the quote markers are removed. +fn table_candidate_text(line: &str) -> Option<&str> { + let stripped = strip_blockquote_prefix(line).trim(); + parse_table_segments(stripped).map(|_| stripped) +} + +/// A source line annotated with whether it falls inside a fenced code block. +#[cfg(test)] +struct ParsedLine<'a> { + text: &'a str, + fence_context: FenceKind, + source_start: usize, +} + +/// Parse source into lines tagged with fenced-code context for table scanning. +#[cfg(test)] +fn parse_lines_with_fence_state(source: &str) -> Vec> { + let mut tracker = FenceTracker::new(); + let mut lines = Vec::new(); + let mut source_start = 0usize; + + for raw_line in source.split('\n') { + lines.push(ParsedLine { + text: raw_line, + fence_context: tracker.kind(), + source_start, + }); + + tracker.advance(raw_line); + source_start = source_start + .saturating_add(raw_line.len()) + .saturating_add(1); + } + + lines +} + +/// Scan `source` for pipe-table patterns outside of non-markdown fenced code +/// blocks. +#[cfg(test)] +pub(super) fn table_holdback_state(source: &str) -> TableHoldbackState { + let lines = parse_lines_with_fence_state(source); + for pair in lines.windows(2) { + let [header_line, delimiter_line] = pair else { + continue; + }; + if header_line.fence_context == FenceKind::Other + || delimiter_line.fence_context == FenceKind::Other + { + continue; + } + + let Some(header_text) = table_candidate_text(header_line.text) else { + continue; + }; + let Some(delimiter_text) = table_candidate_text(delimiter_line.text) else { + continue; + }; + + if is_table_header_line(header_text) && is_table_delimiter_line(delimiter_text) { + return TableHoldbackState::Confirmed { + table_start: header_line.source_start, + }; + } + } + + let pending_header = lines.iter().rev().find(|line| !line.text.trim().is_empty()); + if let Some(line) = pending_header + && line.fence_context != FenceKind::Other + && table_candidate_text(line.text).is_some_and(is_table_header_line) + { + return TableHoldbackState::PendingHeader { + header_start: line.source_start, + }; + } + TableHoldbackState::None +} diff --git a/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs b/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs new file mode 100644 index 0000000000000000000000000000000000000000..3a5975ee3277bf8aff84d6750f36badfc43b54cf --- /dev/null +++ b/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs @@ -0,0 +1,49 @@ +//! Paragraph rendering that keeps visible text and hyperlink annotations aligned. + +use super::HyperlinkLine; +use super::mark_buffer_hyperlinks; +use super::visible_lines_ref; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::Text; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::Wrap; + +/// Word-wraps without trimming and applies the same vertical scroll to text and links. +pub(crate) struct HyperlinkParagraph<'a> { + lines: &'a [HyperlinkLine], + paragraph: Paragraph<'a>, + scroll_rows: u16, +} + +impl<'a> HyperlinkParagraph<'a> { + pub(crate) fn new(lines: &'a [HyperlinkLine], style: Style) -> Self { + Self { + lines, + paragraph: Paragraph::new(Text::from(visible_lines_ref(lines))) + .style(style) + .wrap(Wrap { trim: false }), + scroll_rows: 0, + } + } + + pub(crate) fn line_count(&self, width: u16) -> usize { + self.paragraph.line_count(width) + } + + pub(crate) fn scroll(mut self, rows: u16) -> Self { + self.scroll_rows = rows; + self + } +} + +impl Widget for HyperlinkParagraph<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + self.paragraph + .scroll((self.scroll_rows, 0)) + .render(area, buf); + mark_buffer_hyperlinks(buf, area, self.lines, usize::from(self.scroll_rows)); + } +} diff --git a/codex-rs/tui/src/terminal_probe/startup_replay.rs b/codex-rs/tui/src/terminal_probe/startup_replay.rs new file mode 100644 index 0000000000000000000000000000000000000000..51ef26813e49b0a3b8b068f1be357ae3b78abd03 --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/startup_replay.rs @@ -0,0 +1,66 @@ +//! Preserve startup terminal input while filtering completed terminal-color replies. + +const MAX_REPLAYED_OSC_BYTES: usize = 1024; + +/// Preserve input and bracketed paste while omitting completed terminal-color replies. +/// +/// Keep bounded incomplete OSC prefixes so unread continuations stay parser-framed. +pub(super) fn startup_replay_input(input: &[u8]) -> Vec { + const PASTE_START: &[u8] = b"\x1b[200~"; + const PASTE_END: &[u8] = b"\x1b[201~"; + + let mut replay = Vec::with_capacity(input.len()); + let mut cursor = 0; + let mut inside_paste = false; + while cursor < input.len() { + if inside_paste { + if input[cursor..].starts_with(PASTE_END) { + replay.extend_from_slice(PASTE_END); + cursor += PASTE_END.len(); + inside_paste = false; + } else if let Some((slot, _)) = [(10, b"\x1b]10;"), (11, b"\x1b]11;")] + .into_iter() + .find(|(_, prefix)| input[cursor..].starts_with(*prefix)) + && let Some((end, terminator_len)) = super::osc_payload_end( + &input[cursor + 2..input.len().min(cursor + MAX_REPLAYED_OSC_BYTES)], + ) + && let response = &input[cursor..cursor + 2 + end + terminator_len] + && !response + .windows(PASTE_END.len()) + .any(|window| window == PASTE_END) + && super::parse_osc_color(response, slot).is_some() + { + cursor += 2 + end + terminator_len; + } else { + replay.push(input[cursor]); + cursor += 1; + } + continue; + } + + if input[cursor..].starts_with(PASTE_START) { + replay.extend_from_slice(PASTE_START); + cursor += PASTE_START.len(); + inside_paste = true; + continue; + } + + if input[cursor..].starts_with(b"\x1b]") { + if let Some((end, terminator_len)) = super::osc_payload_end(&input[cursor + 2..]) { + cursor += 2 + end + terminator_len; + continue; + } + let end = input.len().min(cursor + MAX_REPLAYED_OSC_BYTES); + replay.extend_from_slice(&input[cursor..end]); + break; + } + + replay.push(input[cursor]); + cursor += 1; + } + replay +} + +#[cfg(test)] +#[path = "startup_replay_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/terminal_probe/startup_replay_tests.rs b/codex-rs/tui/src/terminal_probe/startup_replay_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..0c3500aeedf66676a53262b862a630d3522f8b3a --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/startup_replay_tests.rs @@ -0,0 +1,58 @@ +use super::MAX_REPLAYED_OSC_BYTES; +use super::startup_replay_input; +use pretty_assertions::assert_eq; + +#[test] +fn startup_replay_bounds_oversized_osc_without_exposing_its_payload() { + let mut input = b"typed\x1b]10;".to_vec(); + input.extend(std::iter::repeat_n(b'x', MAX_REPLAYED_OSC_BYTES)); + input.extend_from_slice(b"1\r"); + + assert_eq!( + startup_replay_input(&input), + input[..b"typed".len() + MAX_REPLAYED_OSC_BYTES] + ); + + input.push(b'\x07'); + input.extend_from_slice(b"suffix"); + assert_eq!(startup_replay_input(&input), b"typedsuffix"); + assert_eq!( + startup_replay_input(b"typed\x1b]10;partial\x1b"), + b"typed\x1b]10;partial\x1b" + ); +} + +#[test] +fn startup_replay_preserves_osc_looking_bracketed_paste() { + let mut input = b"prefix\x1b[200~\x1b]10;".to_vec(); + input.extend(std::iter::repeat_n(b'x', 2_048)); + input.extend_from_slice(b"\x1b[201~suffix"); + + assert_eq!(startup_replay_input(&input), input); +} + +#[test] +fn startup_replay_omits_terminal_color_replies_interleaved_with_bracketed_paste() { + let input = b"prefix\x1b[200~hello\x1b]10;rgb:eeee/eeee/eeee\x07 \ + \x1b]11;rgb:1111/1111/1111\x1b\\world\x1b[201~suffix"; + + assert_eq!( + startup_replay_input(input), + b"prefix\x1b[200~hello world\x1b[201~suffix" + ); + + let unfinished_paste = b"\x1b[200~hello\x1b]10;rgb:eeee/eeee/eeee\x07 world"; + assert_eq!( + startup_replay_input(unfinished_paste), + b"\x1b[200~hello world" + ); + + let unrelated_osc = b"\x1b[200~\x1b]52;preserve me\x07\x1b[201~"; + assert_eq!(startup_replay_input(unrelated_osc), unrelated_osc); + + let response_after_paste = b"\x1b[200~\x1b]10;unfinished\x1b[201~suffix\x07"; + assert_eq!( + startup_replay_input(response_after_paste), + response_after_paste + ); +} diff --git a/codex-rs/tui/src/terminal_probe/windows.rs b/codex-rs/tui/src/terminal_probe/windows.rs new file mode 100644 index 0000000000000000000000000000000000000000..bbaafcf274f56617765be9f0bfb06657d8a08e1a --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/windows.rs @@ -0,0 +1,335 @@ +//! Probe visible Windows terminal colors without consuming startup typeahead. + +use super::DefaultColors; +use super::windows_replay::terminal_color_response_ranges; +use super::windows_replay::terminal_default_colors; +use std::io; +use std::io::ErrorKind; +use std::time::Duration; +use std::time::Instant; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::Foundation::WAIT_OBJECT_0; +use windows_sys::Win32::Foundation::WAIT_TIMEOUT; +use windows_sys::Win32::Storage::FileSystem::WriteFile; +use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFOEX; +use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfoEx; +use windows_sys::Win32::System::Console::GetNumberOfConsoleInputEvents; +use windows_sys::Win32::System::Console::GetStdHandle; +use windows_sys::Win32::System::Console::INPUT_RECORD; +use windows_sys::Win32::System::Console::KEY_EVENT; +use windows_sys::Win32::System::Console::ReadConsoleInputW; +use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; +use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; +use windows_sys::Win32::System::Console::WriteConsoleInputW; +use windows_sys::Win32::System::Threading::WaitForSingleObject; + +const MAX_WINDOWS_PROBE_RECORDS: usize = 64 * 1_024; +const WINDOWS_PROBE_READ_RECORDS: usize = 64; + +/// Prefer the terminal renderer's OSC colors while retaining the native console palette fallback. +/// +/// Windows Terminal and xterm.js can render a theme unrelated to ConPTY's console color table. +/// Their OSC responses share the input queue with user input, so the probe preserves and restores +/// the original console records without changing Windows' input-record mode. +pub(crate) fn default_colors(timeout: Duration) -> io::Result> { + let Ok(output) = std_handle(STD_OUTPUT_HANDLE) else { + return Ok(None); + }; + + if let Ok(input) = std_handle(STD_INPUT_HANDLE) + && let Ok(Some(colors)) = query_osc_default_colors(input, output, timeout) + { + tracing::debug!( + foreground = ?colors.fg, + background = ?colors.bg, + "using Windows terminal OSC default colors" + ); + return Ok(Some(colors)); + } + + let colors = query_console_default_colors(output).ok().flatten(); + if let Some(colors) = colors { + tracing::debug!( + foreground = ?colors.fg, + background = ?colors.bg, + "using Windows console palette default colors" + ); + } + Ok(colors) +} + +fn query_osc_default_colors( + input: HANDLE, + output: HANDLE, + timeout: Duration, +) -> io::Result> { + let deadline = Instant::now() + timeout; + write_all(output, b"\x1b]10;?\x1b\\\x1b]11;?\x1b\\")?; + + let mut replay = ConsoleInputReplay::new(input); + let result = replay.read_until(deadline); + let replay_result = replay.replay(); + replay_result?; + result +} + +/// Preserve native console records and map response bytes back to their original records. +#[derive(Default)] +struct BufferedConsoleInput { + records: Vec, + bytes: Vec, + byte_record_indices: Vec, +} + +impl BufferedConsoleInput { + fn push(&mut self, record: INPUT_RECORD) { + let index = self.records.len(); + self.records.push(record); + + if record.EventType != KEY_EVENT as u16 { + return; + } + + // SAFETY: EventType identifies the active INPUT_RECORD union member. + let key = unsafe { record.Event.KeyEvent }; + if key.bKeyDown == 0 { + return; + } + + // SAFETY: ReadConsoleInputW populates the UnicodeChar member of KEY_EVENT_RECORD. + let character = unsafe { key.uChar.UnicodeChar }; + if let Ok(byte) = u8::try_from(character) + && byte.is_ascii() + && byte != 0 + { + self.bytes.push(byte); + self.byte_record_indices.push(index); + } + } + + fn preserved_records(&self) -> Vec { + let mut omitted = vec![false; self.records.len()]; + for response in terminal_color_response_ranges(&self.bytes) { + for byte_index in response { + let record_index = self.byte_record_indices[byte_index]; + omitted[record_index] = true; + } + } + + self.records + .iter() + .copied() + .enumerate() + .filter_map(|(index, record)| (!omitted[index]).then_some(record)) + .collect() + } +} + +/// Replay every non-response console event even when probing exits through an error path. +struct ConsoleInputReplay { + handle: HANDLE, + input: BufferedConsoleInput, + replayed_records: usize, +} + +impl ConsoleInputReplay { + fn new(handle: HANDLE) -> Self { + Self { + handle, + input: BufferedConsoleInput::default(), + replayed_records: 0, + } + } + + fn read_until(&mut self, deadline: Instant) -> io::Result> { + loop { + if let Some(colors) = terminal_default_colors(&self.input.bytes) { + return Ok(Some(colors)); + } + if self.input.records.len() >= MAX_WINDOWS_PROBE_RECORDS { + return Ok(None); + } + + let now = Instant::now(); + if now >= deadline { + return Ok(None); + } + let timeout_ms = deadline + .saturating_duration_since(now) + .as_millis() + .min(u32::MAX as u128) as u32; + + match unsafe { + WaitForSingleObject(self.handle, timeout_ms.max(/*other*/ 1)) + } { + WAIT_OBJECT_0 => { + self.read_available()?; + } + WAIT_TIMEOUT => return Ok(None), + _ => return Err(io::Error::last_os_error()), + } + } + } + + fn read_available(&mut self) -> io::Result { + let mut pending = 0; + if unsafe { GetNumberOfConsoleInputEvents(self.handle, &mut pending) } == 0 { + return Err(io::Error::last_os_error()); + } + if pending == 0 { + return Ok(0); + } + + let mut records = + [unsafe { std::mem::zeroed::() }; WINDOWS_PROBE_READ_RECORDS]; + let max_records = records + .len() + .min(pending as usize) + .min(MAX_WINDOWS_PROBE_RECORDS.saturating_sub(self.input.records.len())); + if max_records == 0 { + return Ok(0); + } + let mut read = 0; + let result = unsafe { + ReadConsoleInputW( + self.handle, + records.as_mut_ptr(), + max_records as u32, + &mut read, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + for record in records.into_iter().take(read as usize) { + self.input.push(record); + } + Ok(read as usize) + } + + fn replay(&mut self) -> io::Result<()> { + if self.input.records.is_empty() { + return Ok(()); + } + + if self.replayed_records == 0 { + // WriteConsoleInputW appends, so include already queued later records before restoring + // the earlier records. Preserve one bounded queue snapshot without waiting on input. + let mut pending = 0; + if unsafe { GetNumberOfConsoleInputEvents(self.handle, &mut pending) } == 0 { + return Err(io::Error::last_os_error()); + } + let mut remaining = (pending as usize) + .min(MAX_WINDOWS_PROBE_RECORDS.saturating_sub(self.input.records.len())); + while remaining != 0 { + let read = self.read_available()?; + if read == 0 { + break; + } + remaining = remaining.saturating_sub(read); + } + } + + let preserved = self.input.preserved_records(); + while self.replayed_records < preserved.len() { + let remaining = &preserved[self.replayed_records..]; + let mut written = 0; + let result = unsafe { + WriteConsoleInputW( + self.handle, + remaining.as_ptr(), + remaining.len().min(u32::MAX as usize) as u32, + &mut written, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + if written == 0 { + return Err(io::Error::from(ErrorKind::WriteZero)); + } + self.replayed_records += written as usize; + } + + self.input.records.clear(); + self.input.bytes.clear(); + self.input.byte_record_indices.clear(); + self.replayed_records = 0; + Ok(()) + } +} + +impl Drop for ConsoleInputReplay { + fn drop(&mut self) { + if let Err(error) = self.replay() { + tracing::warn!(%error, "failed to restore Windows terminal startup input"); + } + } +} + +fn write_all(handle: HANDLE, mut bytes: &[u8]) -> io::Result<()> { + while !bytes.is_empty() { + let mut written = 0; + let result = unsafe { + WriteFile( + handle, + bytes.as_ptr().cast(), + bytes.len().min(u32::MAX as usize) as u32, + &mut written, + std::ptr::null_mut(), + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + if written == 0 { + return Err(io::Error::from(ErrorKind::WriteZero)); + } + bytes = &bytes[written as usize..]; + } + Ok(()) +} + +fn query_console_default_colors(output: HANDLE) -> io::Result> { + let mut info = unsafe { std::mem::zeroed::() }; + info.cbSize = std::mem::size_of::() as u32; + if unsafe { GetConsoleScreenBufferInfoEx(output, &mut info) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Some(decode_console_default_colors( + info.wAttributes, + &info.ColorTable, + ))) +} + +fn decode_console_default_colors(attributes: u16, color_table: &[u32; 16]) -> DefaultColors { + let fg_index = (attributes & 0x0f) as usize; + let bg_index = ((attributes >> 4) & 0x0f) as usize; + // COMMON_LVB_REVERSE_VIDEO changes how cells render, but this probe is discovering the + // configured default colors for palette blending. Keep the attribute fg/bg indices as-is. + DefaultColors { + fg: decode_color_ref(color_table[fg_index]), + bg: decode_color_ref(color_table[bg_index]), + } +} + +fn decode_color_ref(color_ref: u32) -> (u8, u8, u8) { + ( + (color_ref & 0xff) as u8, + ((color_ref >> 8) & 0xff) as u8, + ((color_ref >> 16) & 0xff) as u8, + ) +} + +fn std_handle(kind: u32) -> io::Result { + let handle = unsafe { GetStdHandle(kind) }; + if handle == 0 || handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + Ok(handle) +} + +#[cfg(test)] +#[path = "windows_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/terminal_probe/windows_replay.rs b/codex-rs/tui/src/terminal_probe/windows_replay.rs new file mode 100644 index 0000000000000000000000000000000000000000..a91ead179bbd6adb55bd2d1ab7f61f8fcca96f66 --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/windows_replay.rs @@ -0,0 +1,84 @@ +//! Identify terminal-color replies without changing unrelated Windows console input. + +use std::ops::Range; + +const MAX_TERMINAL_COLOR_RESPONSE_BYTES: usize = 1_024; +const PASTE_START: &[u8] = b"\x1b[200~"; +const PASTE_END: &[u8] = b"\x1b[201~"; + +/// Return the byte ranges occupied by complete, valid OSC 10/11 color responses. +/// +/// The Windows probe maps these byte ranges back to their original `INPUT_RECORD`s so keyboard +/// modifiers, UTF-16 input, mouse events, and focus changes can be replayed without reconstruction. +pub(super) fn terminal_color_response_ranges(input: &[u8]) -> Vec> { + let mut ranges = Vec::new(); + let mut cursor = 0; + + while cursor < input.len() { + if input[cursor..].starts_with(PASTE_START) { + let payload_start = cursor + PASTE_START.len(); + let Some(payload_end) = super::find_subslice(&input[payload_start..], PASTE_END) else { + break; + }; + cursor = payload_start + payload_end + PASTE_END.len(); + continue; + } + if !input[cursor..].starts_with(b"\x1b]") { + cursor += 1; + continue; + } + + let start = cursor; + let response = &input[start..]; + let Some((slot, prefix)) = [(10_u8, b"\x1b]10;"), (11_u8, b"\x1b]11;")] + .into_iter() + .find(|(_, prefix)| response.starts_with(*prefix)) + else { + cursor = start + b"\x1b]".len(); + continue; + }; + + let payload_start = start + prefix.len(); + let bounded_end = input + .len() + .min(start.saturating_add(MAX_TERMINAL_COLOR_RESPONSE_BYTES)); + let Some((payload_len, terminator_len)) = + super::osc_payload_end(&input[payload_start..bounded_end]) + else { + cursor = payload_start; + continue; + }; + let end = payload_start + payload_len + terminator_len; + if super::parse_osc_color(&input[start..end], slot).is_some() { + ranges.push(start..end); + } + cursor = end; + } + + ranges +} + +/// Parse only real probe replies, never OSC-looking content inside a bracketed paste. +pub(super) fn terminal_default_colors(input: &[u8]) -> Option { + let mut foreground = None; + let mut background = None; + + for range in terminal_color_response_ranges(input) { + let response = &input[range]; + if foreground.is_none() { + foreground = super::parse_osc_color(response, /*slot*/ 10); + } + if background.is_none() { + background = super::parse_osc_color(response, /*slot*/ 11); + } + } + + Some(super::DefaultColors { + fg: foreground?, + bg: background?, + }) +} + +#[cfg(test)] +#[path = "windows_replay_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/terminal_probe/windows_replay_tests.rs b/codex-rs/tui/src/terminal_probe/windows_replay_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..3b7a590a8a709bc16c2ac3d466f8d1575b91dde9 --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/windows_replay_tests.rs @@ -0,0 +1,94 @@ +use super::MAX_TERMINAL_COLOR_RESPONSE_BYTES; +use super::terminal_color_response_ranges; +use super::terminal_default_colors; +use crate::terminal_probe::DefaultColors; +use pretty_assertions::assert_eq; + +fn preserved_input(input: &[u8]) -> Vec { + let ranges = terminal_color_response_ranges(input); + input + .iter() + .enumerate() + .filter_map(|(index, byte)| { + (!ranges.iter().any(|range| range.contains(&index))).then_some(*byte) + }) + .collect() +} + +#[test] +fn preserves_typeahead_before_between_and_after_terminal_color_replies() { + let input = b"before\x1b]10;rgb:5555/5757/5353\x1b\\between\x1b]11;rgb:ffff/ffff/ffff\x07after"; + + assert_eq!(preserved_input(input), b"beforebetweenafter"); +} + +#[test] +fn preserves_unrelated_malformed_and_partial_osc_sequences() { + let input = b"\x1b]52;clipboard\x07\x1b]10;not-a-color\x07typed\x1b]11;rgb:ffff/ffff"; + + assert_eq!(preserved_input(input), input); +} + +#[test] +fn preserves_terminal_color_response_that_exceeds_the_byte_limit() { + let mut input = b"prefix\x1b]10;".to_vec(); + input.extend(std::iter::repeat_n(b'x', MAX_TERMINAL_COLOR_RESPONSE_BYTES)); + input.extend_from_slice(b"\x07suffix"); + + assert_eq!(preserved_input(&input), input); +} + +#[test] +fn finds_terminal_color_replies_after_unrelated_escape_sequences() { + let input = b"\x1b]52;clipboard\x07typed\x1b]11;rgba:ffff/eeee/dddd/ffff\x1b\\suffix"; + + assert_eq!(preserved_input(input), b"\x1b]52;clipboard\x07typedsuffix"); +} + +#[test] +fn consumes_one_and_three_digit_terminal_color_replies() { + let input = b"before\x1b]10;rgb:f/e/d\x07middle\x1b]11;rgb:fff/800/000\x1b\\after"; + + assert_eq!(preserved_input(input), b"beforemiddleafter"); + assert_eq!( + terminal_default_colors(input), + Some(DefaultColors { + fg: (255, 238, 221), + bg: (255, 127, 0), + }) + ); +} + +#[test] +fn preserves_complete_osc_looking_bracketed_paste() { + let input = b"before\x1b[200~\x1b]10;rgb:f/e/d\x07\x1b]11;rgb:fff/800/000\x07\x1b[201~after"; + + assert_eq!(preserved_input(input), input); + assert_eq!(terminal_default_colors(input), None); +} + +#[test] +fn preserves_unfinished_osc_looking_bracketed_paste() { + let input = b"before\x1b[200~\x1b]10;rgb:f/e/d\x07\x1b]11;rgb:fff/800/000\x07"; + + assert_eq!(preserved_input(input), input); + assert_eq!(terminal_default_colors(input), None); +} + +#[test] +fn parses_real_color_replies_around_bracketed_paste() { + let input = + b"\x1b]10;rgb:f/e/d\x07\x1b[200~\x1b]11;rgb:0/0/0\x07\x1b[201~\x1b]11;rgb:fff/800/000\x07"; + + assert_eq!( + preserved_input(input), + b"\x1b[200~\x1b]11;rgb:0/0/0\x07\x1b[201~" + ); + assert_eq!( + terminal_default_colors(input), + Some(DefaultColors { + fg: (255, 238, 221), + bg: (255, 127, 0), + }) + ); +} diff --git a/codex-rs/tui/src/terminal_probe/windows_tests.rs b/codex-rs/tui/src/terminal_probe/windows_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..60ca87bdc70390ddd55716fee8ad615bf470cc0b --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/windows_tests.rs @@ -0,0 +1,206 @@ +use super::BufferedConsoleInput; +use super::DefaultColors; +use super::decode_console_default_colors; +use pretty_assertions::assert_eq; +use windows_sys::Win32::System::Console::COMMON_LVB_REVERSE_VIDEO; +use windows_sys::Win32::System::Console::FOCUS_EVENT; +use windows_sys::Win32::System::Console::FOCUS_EVENT_RECORD; +use windows_sys::Win32::System::Console::INPUT_RECORD; +use windows_sys::Win32::System::Console::INPUT_RECORD_0; +use windows_sys::Win32::System::Console::KEY_EVENT; +use windows_sys::Win32::System::Console::KEY_EVENT_RECORD; +use windows_sys::Win32::System::Console::KEY_EVENT_RECORD_0; + +fn color_table() -> [u32; 16] { + [ + 0x00000000, 0x00000080, 0x00008000, 0x00008080, 0x00800000, 0x00800080, 0x00808000, + 0x00c0c0c0, 0x00808080, 0x000000ff, 0x0000ff00, 0x0000ffff, 0x00ff0000, 0x00ff00ff, + 0x00ffff00, 0x00ffffff, + ] +} + +fn key_record(character: u16, pressed: bool, control_key_state: u32) -> INPUT_RECORD { + INPUT_RECORD { + EventType: KEY_EVENT as u16, + Event: INPUT_RECORD_0 { + KeyEvent: KEY_EVENT_RECORD { + bKeyDown: i32::from(pressed), + wRepeatCount: 1, + wVirtualKeyCode: character, + wVirtualScanCode: 0, + uChar: KEY_EVENT_RECORD_0 { + UnicodeChar: character, + }, + dwControlKeyState: control_key_state, + }, + }, + } +} + +fn push_ascii(input: &mut BufferedConsoleInput, bytes: &[u8]) { + for byte in bytes { + input.push(key_record( + u16::from(*byte), + /*pressed*/ true, + /*control_key_state*/ 0, + )); + } +} + +#[test] +fn preserves_typeahead_records_and_modifier_state_around_color_responses() { + let mut input = BufferedConsoleInput::default(); + let before = key_record( + u16::from(b'a'), + /*pressed*/ true, + /*control_key_state*/ 8, + ); + let after = key_record( + u16::from(b'b'), + /*pressed*/ true, + /*control_key_state*/ 16, + ); + input.push(before); + push_ascii(&mut input, b"\x1b]10;rgb:5555/5757/5353\x1b\\"); + input.push(after); + push_ascii(&mut input, b"\x1b]11;rgb:ffff/ffff/ffff\x07"); + + let preserved = input.preserved_records(); + assert_eq!(preserved.len(), 2); + // SAFETY: Both preserved records are constructed as KEY_EVENT records above. + let states: Vec<_> = preserved + .iter() + .map(|record| unsafe { record.Event.KeyEvent.dwControlKeyState }) + .collect(); + assert_eq!(states, vec![8, 16]); +} + +#[test] +fn preserves_focus_unicode_and_key_release_console_records() { + let mut input = BufferedConsoleInput::default(); + let focus = INPUT_RECORD { + EventType: FOCUS_EVENT as u16, + Event: INPUT_RECORD_0 { + FocusEvent: FOCUS_EVENT_RECORD { bSetFocus: 1 }, + }, + }; + let unicode = key_record( + /*character*/ 0x00e9, /*pressed*/ true, /*control_key_state*/ 0, + ); + let release = key_record( + u16::from(b'x'), + /*pressed*/ false, + /*control_key_state*/ 2, + ); + input.push(focus); + push_ascii(&mut input, b"\x1b]10;rgb:5555/5757/5353\x1b\\"); + input.push(unicode); + input.push(release); + push_ascii(&mut input, b"\x1b]11;rgb:ffff/ffff/ffff\x07"); + + let preserved = input.preserved_records(); + assert_eq!(preserved.len(), 3); + assert_eq!(preserved[0].EventType, FOCUS_EVENT as u16); + // SAFETY: The final records were constructed as KEY_EVENT records above. + let unicode_character = unsafe { preserved[1].Event.KeyEvent.uChar.UnicodeChar }; + let release_pressed = unsafe { preserved[2].Event.KeyEvent.bKeyDown }; + assert_eq!(unicode_character, 0x00e9); + assert_eq!(release_pressed, 0); +} + +#[test] +fn preserves_fifo_order_when_later_console_records_are_buffered() { + let mut input = BufferedConsoleInput::default(); + input.push(key_record( + u16::from(b'a'), + /*pressed*/ true, + /*control_key_state*/ 8, + )); + push_ascii(&mut input, b"\x1b]10;rgb:5/5/5\x07"); + push_ascii(&mut input, b"\x1b]11;rgb:f/f/f\x07"); + + // replay() drains records that arrived after its last probe read before writing anything. + input.push(key_record( + u16::from(b'b'), + /*pressed*/ true, + /*control_key_state*/ 16, + )); + + let preserved = input.preserved_records(); + let characters: Vec<_> = preserved + .iter() + // SAFETY: Both surviving records are KEY_EVENT values constructed above. + .map(|record| unsafe { record.Event.KeyEvent.uChar.UnicodeChar }) + .collect(); + assert_eq!(characters, vec![u16::from(b'a'), u16::from(b'b')]); +} + +#[test] +fn preserves_osc_looking_bracketed_paste_records() { + let mut input = BufferedConsoleInput::default(); + push_ascii( + &mut input, + b"\x1b[200~\x1b]10;rgb:f/e/d\x07\x1b]11;rgb:0/0/0\x07\x1b[201~", + ); + + assert_eq!(input.preserved_records().len(), input.records.len()); +} + +#[test] +fn preserves_every_record_when_no_valid_color_response_arrives() { + let mut input = BufferedConsoleInput::default(); + push_ascii(&mut input, b"typed\x1b]10;incomplete"); + + assert_eq!(input.preserved_records().len(), input.records.len()); +} + +#[test] +fn decodes_console_color_attribute_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x21, &color_table()), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); +} + +#[test] +fn decodes_console_color_intensity_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0xe9, &color_table()), + DefaultColors { + fg: (255, 0, 0), + bg: (0, 255, 255), + } + ); +} + +#[test] +fn decodes_console_color_ref_byte_order() { + let mut colors = color_table(); + colors[3] = 0x00112233; + colors[4] = 0x00aabbcc; + + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x43, &colors), + DefaultColors { + fg: (0x33, 0x22, 0x11), + bg: (0xcc, 0xbb, 0xaa), + } + ); +} + +#[test] +fn ignores_reverse_video_when_decoding_default_colors() { + assert_eq!( + decode_console_default_colors( + /*attributes*/ COMMON_LVB_REVERSE_VIDEO | 0x21, + &color_table(), + ), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); +} diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs new file mode 100644 index 0000000000000000000000000000000000000000..5f2ce7f5c3f15c903753ae9171dcac02fc1a410b --- /dev/null +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -0,0 +1,639 @@ +//! Event stream plumbing for the TUI. +//! +//! - [`EventBroker`] holds the shared crossterm stream so multiple callers reuse the same +//! input source and can drop/recreate it on pause/resume without rebuilding consumers. +//! - [`TuiEventStream`] wraps a draw event subscription plus the shared [`EventBroker`] and maps crossterm +//! events into [`TuiEvent`]. The broker also owns the tmux size monitor; its samples +//! wake the draw subscription and become resize events before rendering. +//! - [`EventSource`] abstracts the underlying event producer; the real implementation is +//! [`CrosstermEventSource`] and tests can swap in [`FakeEventSource`]. +//! +//! The motivation for dropping/recreating the crossterm event stream is to enable the TUI to fully relinquish stdin. +//! If the stream is not dropped, it will continue to read from stdin even if it is not actively being polled +//! (due to how crossterm's EventStream is implemented), potentially stealing input from other processes reading stdin, +//! like terminal text editors. This race can cause missed input or capturing terminal query responses (for example, OSC palette/size queries) +//! that the other process expects to read. Stopping polling, instead of dropping the stream, is only sufficient when the +//! pause happens before the stream enters a pending state; otherwise the crossterm reader thread may keep reading +//! from stdin, so the safer approach is to drop and recreate the event stream when we need to hand off the terminal. +//! +//! See https://ratatui.rs/recipes/apps/spawn-vim/ and https://www.reddit.com/r/rust/comments/1f3o33u/myterious_crossterm_input_after_running_vim for more details. + +use std::pin::Pin; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::task::Context; +use std::task::Poll; + +use crossterm::event::Event; +use tokio::sync::broadcast; +use tokio::sync::watch; +use tokio_stream::Stream; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::wrappers::WatchStream; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; + +use super::TuiEvent; +use super::size_monitor::SizeMonitor; + +/// Result type produced by an event source. +pub type EventResult = std::io::Result; + +/// Abstraction over a source of terminal events. Allows swapping in a fake for tests. +/// Value in production is [`CrosstermEventSource`]. +pub trait EventSource: Send + 'static { + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>; +} + +/// Shared crossterm input state for all [`TuiEventStream`] instances. A single crossterm EventStream +/// is reused so all streams still see the same input source. +/// +/// This intermediate layer enables dropping/recreating the underlying EventStream (pause/resume) without rebuilding consumers. +pub struct EventBroker { + state: Mutex>, + resume_events_tx: watch::Sender<()>, + pub(super) size_monitor: Option, +} + +/// Tracks state of underlying [`EventSource`]. +enum EventBrokerState { + Paused, // Underlying event source (i.e., crossterm EventStream) dropped + Start, // A new event source will be created on next poll + Running(S), // Event source is currently running +} + +impl EventBrokerState { + /// Return the running event source, starting it if needed; None when paused. + fn active_event_source_mut(&mut self) -> Option<&mut S> { + match self { + EventBrokerState::Paused => None, + EventBrokerState::Start => { + *self = EventBrokerState::Running(S::default()); + match self { + EventBrokerState::Running(events) => Some(events), + EventBrokerState::Paused | EventBrokerState::Start => unreachable!(), + } + } + EventBrokerState::Running(events) => Some(events), + } + } +} + +impl EventBroker { + pub fn new() -> Self { + let (resume_events_tx, _resume_events_rx) = watch::channel(()); + Self { + state: Mutex::new(EventBrokerState::Start), + resume_events_tx, + size_monitor: None, + } + } + + /// Drop the underlying event source + pub fn pause_events(&self) { + if let Some(monitor) = &self.size_monitor { + monitor.set_active(/*active*/ false); + } + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *state = EventBrokerState::Paused; + } + + /// Create a new instance of the underlying event source + pub fn resume_events(&self) { + if let Some(monitor) = &self.size_monitor { + monitor.set_active(/*active*/ true); + } + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *state = EventBrokerState::Start; + let _ = self.resume_events_tx.send(()); + } + + /// Subscribe to a notification that fires whenever [`Self::resume_events`] is called. + /// + /// This is used to wake `poll_crossterm_event` when it is paused and waiting for the + /// underlying crossterm stream to be recreated. + pub fn resume_events_rx(&self) -> watch::Receiver<()> { + self.resume_events_tx.subscribe() + } +} + +/// Real crossterm-backed event source. +pub struct CrosstermEventSource(pub crossterm::event::EventStream); + +impl Default for CrosstermEventSource { + fn default() -> Self { + Self(crossterm::event::EventStream::new()) + } +} + +impl EventSource for CrosstermEventSource { + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + // Crossterm's Windows backend expects Win32 input records. If VT input is inherited or + // restored by another console client, navigation keys arrive as literal escape bytes. + #[cfg(windows)] + let _ = super::windows_console::ensure_input_record_mode(); + + let result = Pin::new(&mut self.get_mut().0).poll_next(cx); + + // EventStream starts its blocking reader before returning Pending, so reassert the mode + // after that transition as well. + #[cfg(windows)] + if result.is_pending() { + let _ = super::windows_console::ensure_input_record_mode(); + } + + result + } +} + +/// TuiEventStream is a struct for reading TUI events (draws and user input). +/// Each instance has its own draw subscription (the draw channel is broadcast, so +/// multiple receivers are fine), while crossterm input is funneled through a +/// single shared [`EventBroker`] because crossterm uses a global stdin reader and +/// does not support fan-out. Multiple TuiEventStream instances can exist during the app lifetime +/// (for nested or sequential screens), but only one should be polled at a time, +/// otherwise one instance can consume ("steal") input events and the other will miss them. +pub struct TuiEventStream { + broker: Arc>, + draw_stream: BroadcastStream<()>, + resume_stream: WatchStream<()>, + terminal_focused: Arc, + poll_draw_first: bool, + #[cfg(unix)] + suspend_context: crate::tui::job_control::SuspendContext, + #[cfg(unix)] + alt_screen_active: Arc, +} + +impl TuiEventStream { + pub fn new( + broker: Arc>, + draw_rx: broadcast::Receiver<()>, + terminal_focused: Arc, + #[cfg(unix)] suspend_context: crate::tui::job_control::SuspendContext, + #[cfg(unix)] alt_screen_active: Arc, + ) -> Self { + let resume_stream = WatchStream::from_changes(broker.resume_events_rx()); + Self { + broker, + draw_stream: BroadcastStream::new(draw_rx), + resume_stream, + terminal_focused, + poll_draw_first: false, + #[cfg(unix)] + suspend_context, + #[cfg(unix)] + alt_screen_active, + } + } + + /// Poll the shared crossterm stream for the next mapped `TuiEvent`. + /// + /// This skips events we don't use (mouse events, etc.) and keeps polling until it yields + /// a mapped event, hits `Pending`, or sees EOF/error. When the broker is paused, it drops + /// the underlying stream and returns `Pending` to fully release stdin. + pub fn poll_crossterm_event(&mut self, cx: &mut Context<'_>) -> Poll> { + // Some crossterm events map to None (e.g. mouse); loop so we keep polling + // until we return a mapped event, hit Pending, or see EOF/error. + loop { + let poll_result = { + let mut state = self + .broker + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let events = match state.active_event_source_mut() { + Some(events) => events, + None => { + drop(state); + // Poll resume_stream so resume_events wakes a stream paused here + match Pin::new(&mut self.resume_stream).poll_next(cx) { + Poll::Ready(Some(())) => continue, + Poll::Ready(None) => return Poll::Ready(None), + Poll::Pending => return Poll::Pending, + } + } + }; + match Pin::new(events).poll_next(cx) { + Poll::Ready(Some(Ok(event))) => Some(event), + Poll::Ready(Some(Err(_))) | Poll::Ready(None) => { + *state = EventBrokerState::Start; + return Poll::Ready(None); + } + Poll::Pending => { + drop(state); + // Poll resume_stream so resume_events can wake us even while waiting on stdin + match Pin::new(&mut self.resume_stream).poll_next(cx) { + Poll::Ready(Some(())) => continue, + Poll::Ready(None) => return Poll::Ready(None), + Poll::Pending => return Poll::Pending, + } + } + } + }; + + if let Some(mapped) = poll_result.and_then(|event| self.map_crossterm_event(event)) { + return Poll::Ready(Some(mapped)); + } + } + } + + /// Poll the draw broadcast stream for the next draw event. Draw events are used to trigger a redraw of the TUI. + pub fn poll_draw_event(&mut self, cx: &mut Context<'_>) -> Poll> { + match Pin::new(&mut self.draw_stream).poll_next(cx) { + Poll::Ready(Some(Ok(()))) + | Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(_)))) => { + let event = self + .broker + .size_monitor + .as_ref() + .and_then(SizeMonitor::take_resize) + .map_or(TuiEvent::Draw, TuiEvent::Resize); + Poll::Ready(Some(event)) + } + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } + + /// Map a crossterm event to a [`TuiEvent`], skipping events we don't use (mouse events, etc.). + fn map_crossterm_event(&mut self, event: Event) -> Option { + match event { + Event::Key(key_event) => { + #[cfg(unix)] + if crate::tui::job_control::SUSPEND_KEY.is_press(key_event) { + self.broker.pause_events(); + let suspend_result = self.suspend_context.suspend(&self.alt_screen_active); + self.broker.resume_events(); + if let Err(err) = suspend_result { + tracing::warn!( + event = "tui_suspend_failed", + error = %err, + "failed to suspend TUI process" + ); + } + return Some(TuiEvent::Resume); + } + Some(TuiEvent::Key(key_event)) + } + Event::Resize(width, height) => { + let size = ratatui::layout::Size { width, height }; + if let Some(monitor) = &self.broker.size_monitor { + monitor.observe(size); + } + Some(TuiEvent::Resize(size)) + } + Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)), + Event::FocusGained => { + self.terminal_focused.store(true, Ordering::Relaxed); + // Keep the startup-cached palette: querying terminal colors here blocks the + // input loop, and a direct probe would discard keys typed during the refresh. + Some(TuiEvent::FocusGained) + } + Event::FocusLost => { + self.terminal_focused.store(false, Ordering::Relaxed); + Some(TuiEvent::FocusLost) + } + _ => None, + } + } +} + +impl Unpin for TuiEventStream {} + +impl Stream for TuiEventStream { + type Item = TuiEvent; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + // approximate fairness + no starvation via round-robin. + let draw_first = self.poll_draw_first; + self.poll_draw_first = !self.poll_draw_first; + + if draw_first { + if let Poll::Ready(event) = self.poll_draw_event(cx) { + return Poll::Ready(event); + } + if let Poll::Ready(event) = self.poll_crossterm_event(cx) { + return Poll::Ready(event); + } + } else { + if let Poll::Ready(event) = self.poll_crossterm_event(cx) { + return Poll::Ready(event); + } + if let Poll::Ready(event) = self.poll_draw_event(cx) { + return Poll::Ready(event); + } + } + + Poll::Pending + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::Event; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use crossterm::event::MouseEvent; + use crossterm::event::MouseEventKind; + use pretty_assertions::assert_eq; + use std::task::Context; + use std::task::Poll; + use std::time::Duration; + use tokio::sync::broadcast; + use tokio::sync::mpsc; + use tokio::time::timeout; + use tokio_stream::StreamExt; + + /// Simple fake event source for tests; feed events via the handle. + struct FakeEventSource { + rx: mpsc::UnboundedReceiver, + tx: mpsc::UnboundedSender, + } + + struct FakeEventSourceHandle { + broker: Arc>, + } + + impl FakeEventSource { + fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + Self { rx, tx } + } + } + + impl Default for FakeEventSource { + fn default() -> Self { + Self::new() + } + } + + impl FakeEventSourceHandle { + fn new(broker: Arc>) -> Self { + Self { broker } + } + + fn send(&self, event: EventResult) { + let mut state = self + .broker + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(source) = state.active_event_source_mut() else { + return; + }; + let _ = source.tx.send(event); + } + } + + impl EventSource for FakeEventSource { + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().rx).poll_recv(cx) + } + } + + fn make_stream( + broker: Arc>, + draw_rx: broadcast::Receiver<()>, + terminal_focused: Arc, + ) -> TuiEventStream { + TuiEventStream::new( + broker, + draw_rx, + terminal_focused, + #[cfg(unix)] + crate::tui::job_control::SuspendContext::new(), + #[cfg(unix)] + Arc::new(AtomicBool::new(false)), + ) + } + + type SetupState = ( + Arc>, + FakeEventSourceHandle, + broadcast::Sender<()>, + broadcast::Receiver<()>, + Arc, + ); + + fn setup() -> SetupState { + let source = FakeEventSource::new(); + let broker = Arc::new(EventBroker::new()); + *broker.state.lock().unwrap() = EventBrokerState::Running(source); + let handle = FakeEventSourceHandle::new(broker.clone()); + + let (draw_tx, draw_rx) = broadcast::channel(1); + let terminal_focused = Arc::new(AtomicBool::new(true)); + (broker, handle, draw_tx, draw_rx, terminal_focused) + } + + #[tokio::test(flavor = "current_thread")] + async fn key_event_skips_unmapped() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker, draw_rx, terminal_focused); + + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }))); + handle.send(Ok(Event::Key(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::NONE, + )))); + + let next = stream.next().await.unwrap(); + match next { + TuiEvent::Key(key) => { + assert_eq!(key, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + } + other => panic!("expected key event, got {other:?}"), + } + } + + #[tokio::test(flavor = "current_thread")] + async fn focus_lost_is_forwarded_and_updates_terminal_state() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker, draw_rx, terminal_focused.clone()); + + handle.send(Ok(Event::FocusLost)); + + assert!(matches!(stream.next().await, Some(TuiEvent::FocusLost))); + assert!(!terminal_focused.load(Ordering::Relaxed)); + } + + #[tokio::test(flavor = "current_thread")] + async fn focus_lost_consumed_by_startup_remains_visible_to_the_tui() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); + tui.terminal_focused = terminal_focused.clone(); + let mut startup_events = make_stream(broker, draw_rx, terminal_focused); + + assert!(tui.is_terminal_focused()); + handle.send(Ok(Event::FocusLost)); + + assert!(matches!( + startup_events.next().await, + Some(TuiEvent::FocusLost) + )); + assert!(!tui.is_terminal_focused()); + } + + #[tokio::test(flavor = "current_thread")] + async fn focus_gained_preserves_already_queued_key() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + terminal_focused.store(false, Ordering::Relaxed); + let mut stream = make_stream(broker.clone(), draw_rx, terminal_focused.clone()); + let expected_key = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE); + + handle.send(Ok(Event::FocusGained)); + handle.send(Ok(Event::Key(expected_key))); + + assert!(matches!(stream.next().await, Some(TuiEvent::FocusGained))); + assert!(terminal_focused.load(Ordering::Relaxed)); + assert!(matches!( + &*broker + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + EventBrokerState::Running(_) + )); + + let next = timeout(Duration::from_millis(/*millis*/ 100), stream.next()) + .await + .expect("focus handling discarded an already queued key"); + + match next { + Some(TuiEvent::Key(key)) => assert_eq!(key, expected_key), + other => panic!("expected queued key event, got {other:?}"), + } + } + + #[tokio::test(flavor = "current_thread")] + async fn draw_and_key_events_yield_both() { + let (broker, handle, draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker, draw_rx, terminal_focused); + + let expected_key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE); + let _ = draw_tx.send(()); + handle.send(Ok(Event::Key(expected_key))); + + let first = stream.next().await.unwrap(); + let second = stream.next().await.unwrap(); + + let mut saw_draw = false; + let mut saw_key = false; + for event in [first, second] { + match event { + TuiEvent::Draw => { + saw_draw = true; + } + TuiEvent::Key(key) => { + assert_eq!(key, expected_key); + saw_key = true; + } + other => panic!("expected draw or key event, got {other:?}"), + } + } + + assert!(saw_draw && saw_key, "expected both draw and key events"); + } + + #[tokio::test(flavor = "current_thread")] + async fn lagged_draw_maps_to_draw() { + let (broker, _handle, draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker, draw_rx.resubscribe(), terminal_focused); + + // Fill channel to force Lagged on the receiver. + let _ = draw_tx.send(()); + let _ = draw_tx.send(()); + + let first = stream.next().await; + assert!(matches!(first, Some(TuiEvent::Draw))); + } + + #[tokio::test(flavor = "current_thread")] + async fn resize_event_maps_to_resize() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker, draw_rx, terminal_focused); + + handle.send(Ok(Event::Resize(80, 24))); + + let next = stream.next().await; + assert!(matches!( + next, + Some(TuiEvent::Resize(ratatui::layout::Size { + width: 80, + height: 24 + })) + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn error_or_eof_ends_stream() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker, draw_rx, terminal_focused); + + handle.send(Err(std::io::Error::other("boom"))); + + let next = stream.next().await; + assert!(next.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn resume_wakes_paused_stream() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker.clone(), draw_rx, terminal_focused); + + broker.pause_events(); + + let task = tokio::spawn(async move { stream.next().await }); + tokio::task::yield_now().await; + + broker.resume_events(); + let expected_key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE); + handle.send(Ok(Event::Key(expected_key))); + + let event = timeout(Duration::from_millis(100), task) + .await + .expect("timed out waiting for resumed event") + .expect("join failed"); + match event { + Some(TuiEvent::Key(key)) => assert_eq!(key, expected_key), + other => panic!("expected key event, got {other:?}"), + } + } + + #[tokio::test(flavor = "current_thread")] + async fn resume_wakes_pending_stream() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker.clone(), draw_rx, terminal_focused); + + let task = tokio::spawn(async move { stream.next().await }); + tokio::task::yield_now().await; + + broker.pause_events(); + broker.resume_events(); + let expected_key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE); + handle.send(Ok(Event::Key(expected_key))); + + let event = timeout(Duration::from_millis(100), task) + .await + .expect("timed out waiting for resumed event") + .expect("join failed"); + match event { + Some(TuiEvent::Key(key)) => assert_eq!(key, expected_key), + other => panic!("expected key event, got {other:?}"), + } + } +} diff --git a/codex-rs/tui/src/tui/frame_rate_limiter.rs b/codex-rs/tui/src/tui/frame_rate_limiter.rs new file mode 100644 index 0000000000000000000000000000000000000000..6f4ad8e7479167c153f3ac0fbaed23b3a90620c3 --- /dev/null +++ b/codex-rs/tui/src/tui/frame_rate_limiter.rs @@ -0,0 +1,62 @@ +//! Limits how frequently frame draw notifications may be emitted. +//! +//! Widgets sometimes call `FrameRequester::schedule_frame()` more frequently than a user can +//! perceive. This limiter clamps draw notifications to a maximum of 120 FPS to avoid wasted work. +//! +//! This is intentionally a small, pure helper so it can be unit-tested in isolation and used by +//! the async frame scheduler without adding complexity to the app/event loop. + +use std::time::Duration; +use std::time::Instant; + +/// A 120 FPS minimum frame interval (≈8.33ms). +pub(super) const MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(8_333_334); + +/// Remembers the most recent emitted draw, allowing deadlines to be clamped forward. +#[derive(Debug, Default)] +pub(super) struct FrameRateLimiter { + last_emitted_at: Option, +} + +impl FrameRateLimiter { + /// Returns `requested`, clamped forward if it would exceed the maximum frame rate. + pub(super) fn clamp_deadline(&self, requested: Instant) -> Instant { + let Some(last_emitted_at) = self.last_emitted_at else { + return requested; + }; + let min_allowed = last_emitted_at + .checked_add(MIN_FRAME_INTERVAL) + .unwrap_or(last_emitted_at); + requested.max(min_allowed) + } + + /// Records that a draw notification was emitted at `emitted_at`. + pub(super) fn mark_emitted(&mut self, emitted_at: Instant) { + self.last_emitted_at = Some(emitted_at); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn default_does_not_clamp() { + let t0 = Instant::now(); + let limiter = FrameRateLimiter::default(); + assert_eq!(limiter.clamp_deadline(t0), t0); + } + + #[test] + fn clamps_to_min_interval_since_last_emit() { + let t0 = Instant::now(); + let mut limiter = FrameRateLimiter::default(); + + assert_eq!(limiter.clamp_deadline(t0), t0); + limiter.mark_emitted(t0); + + let too_soon = t0 + Duration::from_millis(1); + assert_eq!(limiter.clamp_deadline(too_soon), t0 + MIN_FRAME_INTERVAL); + } +} diff --git a/codex-rs/tui/src/tui/frame_requester.rs b/codex-rs/tui/src/tui/frame_requester.rs new file mode 100644 index 0000000000000000000000000000000000000000..9dc117bd48c4a78f40d5c3249c32cbaf0926869a --- /dev/null +++ b/codex-rs/tui/src/tui/frame_requester.rs @@ -0,0 +1,400 @@ +//! Frame draw scheduling utilities for the TUI. +//! +//! This module exposes [`FrameRequester`], a lightweight handle that widgets and +//! background tasks can clone to request future redraws of the TUI. +//! +//! Internally it spawns a [`FrameScheduler`] task that coalesces many requests +//! into a single notification on a broadcast channel used by the main TUI event +//! loop. This keeps animations and status updates smooth without redrawing more +//! often than necessary. +//! +//! This follows the actor-style design from +//! [“Actors with Tokio”](https://ryhl.io/blog/actors-with-tokio/), with a +//! dedicated scheduler task and lightweight request handles. + +use std::time::Duration; +use std::time::Instant; + +use tokio::sync::broadcast; +use tokio::sync::mpsc; + +use super::frame_rate_limiter::FrameRateLimiter; + +/// A requester for scheduling future frame draws on the TUI event loop. +/// +/// This is the handler side of an actor/handler pair with `FrameScheduler`, which coalesces +/// multiple frame requests into a single draw operation. +/// +/// Clones of this type can be freely shared across tasks to make it possible to trigger frame draws +/// from anywhere in the TUI code. +#[derive(Clone, Debug)] +pub struct FrameRequester { + frame_schedule_tx: mpsc::UnboundedSender, +} + +impl FrameRequester { + /// Create a new FrameRequester and spawn its associated FrameScheduler task. + /// + /// The provided `draw_tx` is used to notify the TUI event loop of scheduled draws. + pub fn new(draw_tx: broadcast::Sender<()>) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let scheduler = FrameScheduler::new(rx, draw_tx); + tokio::spawn(scheduler.run()); + Self { + frame_schedule_tx: tx, + } + } + + /// Schedule a frame draw as soon as possible. + pub fn schedule_frame(&self) { + let _ = self.frame_schedule_tx.send(Instant::now()); + } + + /// Schedule a frame draw to occur after the specified duration. + pub fn schedule_frame_in(&self, dur: Duration) { + let _ = self.frame_schedule_tx.send(Instant::now() + dur); + } +} + +#[cfg(test)] +impl FrameRequester { + /// Create a no-op frame requester for tests. + pub(crate) fn test_dummy() -> Self { + let (tx, _rx) = mpsc::unbounded_channel(); + FrameRequester { + frame_schedule_tx: tx, + } + } +} + +/// A scheduler for coalescing frame draw requests and notifying the TUI event loop. +/// +/// This type is internal to `FrameRequester` and is spawned as a task to handle scheduling logic. +/// +/// To avoid wasted redraw work, draw notifications are clamped to a maximum of 120 FPS (see +/// [`FrameRateLimiter`]). +struct FrameScheduler { + receiver: mpsc::UnboundedReceiver, + draw_tx: broadcast::Sender<()>, + rate_limiter: FrameRateLimiter, +} + +impl FrameScheduler { + /// Create a new FrameScheduler with the provided receiver and draw notification sender. + fn new(receiver: mpsc::UnboundedReceiver, draw_tx: broadcast::Sender<()>) -> Self { + Self { + receiver, + draw_tx, + rate_limiter: FrameRateLimiter::default(), + } + } + + /// Run the scheduling loop, coalescing frame requests and notifying the TUI event loop. + /// + /// This method runs indefinitely until all senders are dropped. A single draw notification + /// is sent for multiple requests scheduled before the next draw deadline. + async fn run(mut self) { + const ONE_YEAR: Duration = Duration::from_secs(60 * 60 * 24 * 365); + let mut next_deadline: Option = None; + loop { + let target = next_deadline.unwrap_or_else(|| Instant::now() + ONE_YEAR); + let deadline = tokio::time::sleep_until(target.into()); + tokio::pin!(deadline); + + tokio::select! { + draw_at = self.receiver.recv() => { + let Some(draw_at) = draw_at else { + // All senders dropped; exit the scheduler. + break + }; + let draw_at = self.rate_limiter.clamp_deadline(draw_at); + next_deadline = Some(next_deadline.map_or(draw_at, |cur| cur.min(draw_at))); + + // Do not send a draw immediately here. By continuing the loop, + // we recompute the sleep target so the draw fires once via the + // sleep branch, coalescing multiple requests into a single draw. + continue; + } + _ = &mut deadline => { + if next_deadline.is_some() { + next_deadline = None; + self.rate_limiter.mark_emitted(Instant::now()); + let _ = self.draw_tx.send(()); + } + } + } + } + } +} +#[cfg(test)] +mod tests { + use super::super::frame_rate_limiter::MIN_FRAME_INTERVAL; + use super::*; + use tokio::time; + use tokio_util::time::FutureExt; + + impl FrameRequester { + /// Create a frame requester and expose its request channel for deterministic tests. + pub(crate) fn test_channel() -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + ( + FrameRequester { + frame_schedule_tx: tx, + }, + rx, + ) + } + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_schedule_frame_immediate_triggers_once() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + requester.schedule_frame(); + + // Advance time minimally to let the scheduler process and hit the deadline == now. + time::advance(Duration::from_millis(1)).await; + + // First draw should arrive. + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for first draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + // No second draw should arrive. + let second = draw_rx.recv().timeout(Duration::from_millis(20)).await; + assert!(second.is_err(), "unexpected extra draw received"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_schedule_frame_in_triggers_at_delay() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + requester.schedule_frame_in(Duration::from_millis(50)); + + // Advance less than the delay: no draw yet. + time::advance(Duration::from_millis(30)).await; + let early = draw_rx.recv().timeout(Duration::from_millis(10)).await; + assert!(early.is_err(), "draw fired too early"); + + // Advance past the deadline: one draw should fire. + time::advance(Duration::from_millis(25)).await; + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for scheduled draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + // No second draw should arrive. + let second = draw_rx.recv().timeout(Duration::from_millis(20)).await; + assert!(second.is_err(), "unexpected extra draw received"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_coalesces_multiple_requests_into_single_draw() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + // Schedule multiple immediate requests close together. + requester.schedule_frame(); + requester.schedule_frame(); + requester.schedule_frame(); + + // Allow the scheduler to process and hit the coalesced deadline. + time::advance(Duration::from_millis(1)).await; + + // Expect only a single draw notification despite three requests. + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for coalesced draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + // No additional draw should be sent for the same coalesced batch. + let second = draw_rx.recv().timeout(Duration::from_millis(20)).await; + assert!(second.is_err(), "unexpected extra draw received"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_coalesces_mixed_immediate_and_delayed_requests() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + // Schedule a delayed draw and then an immediate one; should coalesce and fire at the earliest (immediate). + requester.schedule_frame_in(Duration::from_millis(100)); + requester.schedule_frame(); + + time::advance(Duration::from_millis(1)).await; + + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for coalesced immediate draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + // The later delayed request should have been coalesced into the earlier one; no second draw. + let second = draw_rx.recv().timeout(Duration::from_millis(120)).await; + assert!(second.is_err(), "unexpected extra draw received"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_limits_draw_notifications_to_120fps() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + requester.schedule_frame(); + time::advance(Duration::from_millis(1)).await; + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for first draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + requester.schedule_frame(); + time::advance(Duration::from_millis(1)).await; + let early = draw_rx.recv().timeout(Duration::from_millis(1)).await; + assert!( + early.is_err(), + "draw fired too early; expected max 120fps (min interval {MIN_FRAME_INTERVAL:?})" + ); + + time::advance(MIN_FRAME_INTERVAL).await; + let second = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for second draw"); + assert!(second.is_ok(), "broadcast closed unexpectedly"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_late_draw_still_limits_the_next_frame() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + let delayed_deadline = Instant::now() - MIN_FRAME_INTERVAL.saturating_mul(2); + requester + .frame_schedule_tx + .send(delayed_deadline) + .expect("scheduler should accept the overdue draw"); + + draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for overdue draw") + .expect("broadcast closed unexpectedly"); + + requester.schedule_frame(); + draw_rx + .recv() + .timeout(Duration::from_millis(1)) + .await + .expect_err("a late draw must not trigger a redraw burst"); + + time::advance(MIN_FRAME_INTERVAL).await; + draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for rate-limited draw") + .expect("broadcast closed unexpectedly"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_rate_limit_clamps_early_delayed_requests() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + requester.schedule_frame(); + time::advance(Duration::from_millis(1)).await; + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for first draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + requester.schedule_frame_in(Duration::from_millis(1)); + + time::advance(MIN_FRAME_INTERVAL / 2).await; + let too_early = draw_rx.recv().timeout(Duration::from_millis(1)).await; + assert!( + too_early.is_err(), + "draw fired too early; expected max 120fps (min interval {MIN_FRAME_INTERVAL:?})" + ); + + time::advance(MIN_FRAME_INTERVAL).await; + let second = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for clamped draw"); + assert!(second.is_ok(), "broadcast closed unexpectedly"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_rate_limit_does_not_delay_future_draws() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + requester.schedule_frame(); + time::advance(Duration::from_millis(1)).await; + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for first draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + requester.schedule_frame_in(Duration::from_millis(50)); + + time::advance(Duration::from_millis(49)).await; + let early = draw_rx.recv().timeout(Duration::from_millis(1)).await; + assert!(early.is_err(), "draw fired too early"); + + time::advance(Duration::from_millis(1)).await; + let second = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for delayed draw"); + assert!(second.is_ok(), "broadcast closed unexpectedly"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_multiple_delayed_requests_coalesce_to_earliest() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + // Schedule multiple delayed draws; they should coalesce to the earliest (10ms). + requester.schedule_frame_in(Duration::from_millis(100)); + requester.schedule_frame_in(Duration::from_millis(20)); + requester.schedule_frame_in(Duration::from_millis(120)); + + // Advance to just before the earliest deadline: no draw yet. + time::advance(Duration::from_millis(10)).await; + let early = draw_rx.recv().timeout(Duration::from_millis(10)).await; + assert!(early.is_err(), "draw fired too early"); + + // Advance past the earliest deadline: one draw should fire. + time::advance(Duration::from_millis(20)).await; + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for earliest coalesced draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + // No additional draw should fire for the later delayed requests. + let second = draw_rx.recv().timeout(Duration::from_millis(120)).await; + assert!(second.is_err(), "unexpected extra draw received"); + } +} diff --git a/codex-rs/tui/src/tui/history_tail.rs b/codex-rs/tui/src/tui/history_tail.rs new file mode 100644 index 0000000000000000000000000000000000000000..46cebd7f788107b39d6bf3ac3eda728ca569a34f --- /dev/null +++ b/codex-rs/tui/src/tui/history_tail.rs @@ -0,0 +1,89 @@ +//! Update visible terminal-history tails without discarding terminal scrollback. + +use super::Tui; +use crate::custom_terminal::Terminal as CustomTerminal; +use crate::insert_history::HistoryLineWrapPolicy; +use crate::insert_history::InsertHistoryMode; +use crate::insert_history::insert_history_hyperlink_lines_with_mode_and_wrap_policy; +use crate::insert_history::wrap_history_hyperlink_lines; +use crate::terminal_hyperlinks::HyperlinkLine; +use ratatui::backend::Backend; +use ratatui::layout::Position; +use std::io; +use std::io::Write; + +impl Tui { + #[cfg(test)] + pub(crate) fn pending_history_lines_for_test(&self) -> Vec { + self.pending_history_lines + .iter() + .flat_map(|batch| batch.lines.iter().cloned()) + .collect() + } + + pub(crate) fn replace_visible_history_tail( + &mut self, + previous_lines: &[HyperlinkLine], + replacement: &[HyperlinkLine], + wrap_policy: HistoryLineWrapPolicy, + ) -> io::Result { + let screen_size = self.terminal.last_known_screen_size; + Self::flush_pending_history_lines( + &mut self.terminal, + &mut self.pending_history_lines, + self.scrollback, + screen_size, + )?; + let mode = self.scrollback.history_insertion_mode(wrap_policy); + let replaced = replace_visible_terminal_history_tail( + &mut self.terminal, + previous_lines, + replacement, + mode, + wrap_policy, + )?; + if replaced { + self.frame_requester().schedule_frame(); + } + Ok(replaced) + } +} + +fn replace_visible_terminal_history_tail( + terminal: &mut CustomTerminal, + previous_lines: &[HyperlinkLine], + replacement: &[HyperlinkLine], + mode: InsertHistoryMode, + wrap_policy: HistoryLineWrapPolicy, +) -> io::Result +where + B: Backend + Write, +{ + let screen_size = terminal.last_known_screen_size; + let mut viewport = terminal.viewport_area; + let wrap_width = usize::from(viewport.width.max(/*other*/ 1)); + let (_, previous_rows) = wrap_history_hyperlink_lines(previous_lines, wrap_width, wrap_policy); + let Ok(previous_rows) = u16::try_from(previous_rows) else { + return Ok(false); + }; + if previous_rows == 0 || previous_rows > viewport.top() { + return Ok(false); + } + + viewport.y -= previous_rows; + terminal.clear_after_position(Position::new(/*x*/ 0, viewport.y))?; + terminal.set_viewport_area(viewport); + terminal.invalidate_viewport(); + insert_history_hyperlink_lines_with_mode_and_wrap_policy( + terminal, + replacement, + mode, + wrap_policy, + screen_size, + )?; + Ok(true) +} + +#[cfg(test)] +#[path = "history_tail_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/tui/history_tail_tests.rs b/codex-rs/tui/src/tui/history_tail_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..a02621ca2d93cad158662db46a5d0524f4c1e9b9 --- /dev/null +++ b/codex-rs/tui/src/tui/history_tail_tests.rs @@ -0,0 +1,146 @@ +use super::replace_visible_terminal_history_tail; +use crate::custom_terminal::Terminal; +use crate::insert_history::HistoryLineWrapPolicy; +use crate::insert_history::InsertHistoryMode; +use crate::insert_history::insert_history_lines; +use crate::terminal_hyperlinks::plain_hyperlink_lines; +use crate::test_backend::VT100Backend; +use pretty_assertions::assert_eq; +use ratatui::layout::Rect; +use ratatui::text::Line; +use std::io::Write; + +#[test] +fn replacing_visible_history_tail_preserves_existing_terminal_scrollback() { + let width = 52; + let height = 8; + let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 32); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + for line in [ + "shell history that predates Codex", + "shell output one", + "shell output two", + "shell output three", + "shell output four", + "shell output five", + "shell output six", + "shell output seven", + ] { + writeln!(terminal.backend_mut(), "{line}\r").expect("seed shell history"); + } + terminal.set_viewport_area(Rect::new( + /*x*/ 0, + /*y*/ height - 2, + width, + /*height*/ 2, + )); + insert_history_lines( + &mut terminal, + vec![ + Line::from("/status"), + Line::from("Account: business"), + Line::from("old closing border"), + ], + ) + .expect("insert initial status card"); + + let previous_lines = plain_hyperlink_lines(vec![Line::from("old closing border")]); + let replacement = plain_hyperlink_lines(vec![ + Line::from("Thread usage: 50 credits"), + Line::from("new closing border"), + ]); + assert!( + replace_visible_terminal_history_tail( + &mut terminal, + &previous_lines, + &replacement, + InsertHistoryMode::Standard, + HistoryLineWrapPolicy::PreWrap, + ) + .expect("replace status-card tail") + ); + + let contents = terminal.backend().vt100().screen().contents(); + assert!(contents.contains("/status"), "{contents}"); + assert!(contents.contains("Account: business"), "{contents}"); + assert!(contents.contains("Thread usage: 50 credits"), "{contents}"); + assert!(!contents.contains("old closing border"), "{contents}"); + + let mut screen_with_scrollback = terminal.backend().vt100().screen().clone(); + screen_with_scrollback.set_scrollback(/*rows*/ usize::MAX); + let scrollback = screen_with_scrollback.contents(); + assert!( + scrollback.contains("shell history that predates Codex"), + "existing terminal scrollback was discarded: {scrollback}" + ); +} + +#[test] +fn inaccessible_history_tail_is_preserved_for_non_destructive_append() { + let width = 36; + let height = 6; + let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 16); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new( + /*x*/ 0, + /*y*/ 1, + width, + /*height*/ height - 1, + )); + let before = terminal.backend().vt100().screen().contents(); + let previous_lines = + plain_hyperlink_lines(vec![Line::from("first line"), Line::from("second line")]); + let replacement = plain_hyperlink_lines(vec![Line::from("Thread usage: 50 credits")]); + + assert!( + !replace_visible_terminal_history_tail( + &mut terminal, + &previous_lines, + &replacement, + InsertHistoryMode::Standard, + HistoryLineWrapPolicy::PreWrap, + ) + .expect("leave inaccessible history untouched") + ); + assert_eq!(terminal.backend().vt100().screen().contents(), before); +} + +#[test] +fn replacing_soft_wrapped_history_counts_physical_terminal_rows() { + let width = 12; + let height = 8; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new( + /*x*/ 0, + /*y*/ height - 2, + width, + /*height*/ 2, + )); + let previous_lines = plain_hyperlink_lines(vec![Line::from("old-long-line-spanning-two-rows")]); + let screen_size = terminal.last_known_screen_size; + crate::insert_history::insert_history_hyperlink_lines_with_mode_and_wrap_policy( + &mut terminal, + &previous_lines, + InsertHistoryMode::Standard, + HistoryLineWrapPolicy::Terminal, + screen_size, + ) + .expect("insert soft-wrapped history"); + let replacement = plain_hyperlink_lines(vec![Line::from("new billing")]); + + assert!( + replace_visible_terminal_history_tail( + &mut terminal, + &previous_lines, + &replacement, + InsertHistoryMode::Standard, + HistoryLineWrapPolicy::Terminal, + ) + .expect("replace physically wrapped rows") + ); + let contents = terminal.backend().vt100().screen().contents(); + assert!(contents.contains("new billing"), "{contents}"); + assert!(!contents.contains("old-long"), "{contents}"); + assert!(!contents.contains("spanning"), "{contents}"); +} diff --git a/codex-rs/tui/src/tui/input_boundary.rs b/codex-rs/tui/src/tui/input_boundary.rs new file mode 100644 index 0000000000000000000000000000000000000000..8930959adefb31851a7a2bb340c759fecc3f52e9 --- /dev/null +++ b/codex-rs/tui/src/tui/input_boundary.rs @@ -0,0 +1,117 @@ +//! Restore terminal ownership on initialization failures and quarantine protected-screen input. + +use std::io; +#[cfg(unix)] +use std::io::IsTerminal; +use std::io::Result; +#[cfg(unix)] +use std::io::stdin; +use std::time::Duration; +use std::time::Instant; + +/// Restores terminal modes when initialization fails before ownership is transferred. +pub(super) struct TerminalInitializationGuard { + pub(super) active: bool, +} + +impl Drop for TerminalInitializationGuard { + fn drop(&mut self) { + if self.active { + let _ = super::restore_after_exit(); + } + } +} + +/// Drain decoded and unread input before a screen can accept a security-sensitive decision. +/// +/// Partial control sequences remain quarantined; an unresolved boundary fails after one second. +pub(crate) fn discard_pending_terminal_input() -> Result<()> { + #[cfg(unix)] + let _ = crossterm::event::discard_buffered_input()?; + + let deadline = Instant::now() + Duration::from_secs(/*secs*/ 1); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "timed out discarding buffered terminal input", + )); + } + + if crossterm::event::poll(remaining.min(Duration::from_millis(/*millis*/ 10)))? { + let _ = crossterm::event::read()?; + continue; + } + + #[cfg(unix)] + if crossterm::event::discard_buffered_input()? + != crossterm::event::InputDiscardStatus::Complete + { + continue; + } + + #[cfg(any(unix, windows))] + match terminal_input_is_readable() { + Ok(true) => continue, + Ok(false) => {} + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err), + } + + return Ok(()); + } +} + +/// Check Crossterm's terminal input source without consuming input or changing descriptor flags. +#[cfg(unix)] +pub(super) fn terminal_input_is_readable() -> Result { + use std::os::fd::AsRawFd; + + let terminal = if stdin().is_terminal() { + None + } else { + Some(std::fs::File::open("/dev/tty")?) + }; + let mut fd = libc::pollfd { + fd: terminal + .as_ref() + .map_or(libc::STDIN_FILENO, AsRawFd::as_raw_fd), + events: libc::POLLIN, + revents: 0, + }; + // Safety: `fd` is valid for the duration of this zero-timeout poll. + let result = unsafe { + libc::poll(&mut fd, /*nfds*/ 1, /*timeout*/ 0) + }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + if fd.revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0 { + return Err(io::Error::other(format!( + "terminal input poll failed with events {:#x}", + fd.revents + ))); + } + Ok(result > 0 && fd.revents & libc::POLLIN != 0) +} + +/// Check the same Windows console input queue consumed by Crossterm. +#[cfg(windows)] +fn terminal_input_is_readable() -> Result { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::GetNumberOfConsoleInputEvents; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; + + let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if handle == INVALID_HANDLE_VALUE || handle == 0 { + return Err(io::Error::other("terminal input handle is unavailable")); + } + + let mut pending = 0; + if unsafe { GetNumberOfConsoleInputEvents(handle, &mut pending) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(pending != 0) +} diff --git a/codex-rs/tui/src/tui/job_control.rs b/codex-rs/tui/src/tui/job_control.rs new file mode 100644 index 0000000000000000000000000000000000000000..2db1fc04f58fc9d701cf2c3a6719ad5658b0ae12 --- /dev/null +++ b/codex-rs/tui/src/tui/job_control.rs @@ -0,0 +1,210 @@ +use std::io::Result; +use std::io::stdout; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::PoisonError; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU16; +use std::sync::atomic::Ordering; + +use crossterm::cursor::MoveTo; +use crossterm::cursor::Show; +use crossterm::event::KeyCode; +use crossterm::terminal::EnterAlternateScreen; +use crossterm::terminal::LeaveAlternateScreen; +use ratatui::crossterm::execute; +use ratatui::layout::Rect; +use ratatui::layout::Size; + +use crate::key_hint; + +use super::DisableAlternateScroll; +use super::EnableAlternateScroll; +use super::Terminal; + +pub const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z')); + +/// Coordinates suspend/resume handling so the TUI can restore terminal context after SIGTSTP. +/// +/// On suspend, it records which resume path to take (realign inline viewport vs. restore alt +/// screen) and caches the inline cursor row so the cursor can be placed meaningfully before +/// yielding. +/// +/// After resume, `prepare_resume_action` consumes the pending intent and returns a +/// `PreparedResumeAction` describing any viewport adjustments to apply inside the synchronized +/// draw. +/// +/// Callers keep `suspend_cursor_y` up to date during normal drawing so the suspend step always +/// has the latest cursor position. +/// +/// The type is `Clone`, using Arc/atomic internals so bookkeeping can be shared across tasks +/// and moved into the boxed `'static` event stream without borrowing `self`. +#[derive(Clone)] +pub struct SuspendContext { + /// Resume intent captured at suspend time; cleared once applied after resume. + resume_pending: Arc>>, + /// Inline viewport cursor row used to place the cursor before yielding during suspend. + suspend_cursor_y: Arc, +} + +impl SuspendContext { + pub(crate) fn new() -> Self { + Self { + resume_pending: Arc::new(Mutex::new(None)), + suspend_cursor_y: Arc::new(AtomicU16::new(0)), + } + } + + /// Capture how to resume, stash cursor position, and temporarily yield during SIGTSTP. + /// + /// - If the alt screen is active, exit alt-scroll/alt-screen and record `RestoreAlt`; + /// otherwise record `RealignInline`. + /// - Update the cached inline cursor row so suspend can place the cursor meaningfully. + /// - Trigger SIGTSTP so the process can be resumed and continue drawing with the saved state. + pub(crate) fn suspend(&self, alt_screen_active: &Arc) -> Result<()> { + if alt_screen_active.load(Ordering::Relaxed) { + // Leave alt-screen so the terminal returns to the normal buffer while suspended; also turn off alt-scroll. + let _ = execute!(stdout(), DisableAlternateScroll); + let _ = execute!(stdout(), LeaveAlternateScreen); + self.set_resume_action(ResumeAction::RestoreAlt); + } else { + self.set_resume_action(ResumeAction::RealignInline); + } + let y = self.suspend_cursor_y.load(Ordering::Relaxed); + let _ = execute!(stdout(), MoveTo(0, y), Show); + suspend_process()?; + super::reapply_raw_mode_after_resume()?; + + // The shell writes its job-control status and the resumed command after `fg`, so the + // cursor may no longer be on the row cached before suspending. The event stream remains + // paused until this method returns, which makes it safe for the probe to consume both an + // interleaved focus report and the cursor-position response without racing the background + // input reader. + match crate::terminal_probe::cursor_position(crate::terminal_probe::DEFAULT_TIMEOUT) { + Ok(Some(position)) => self.set_cursor_y(position.y), + Ok(None) => tracing::debug!("terminal cursor position unavailable after resume"), + Err(err) => tracing::debug!( + error = %err, + "failed to read terminal cursor position after resume" + ), + } + super::flush_terminal_input_buffer(); + tracing::trace!( + event = "tui_suspend_resumed", + cursor_y = self.cursor_y(), + "restored terminal state after resume" + ); + + Ok(()) + } + + /// Consume the pending resume intent and precompute any viewport changes needed post-resume. + /// + /// Returns a `PreparedResumeAction` describing how to realign the viewport once drawing + /// resumes; returns `None` when there was no pending suspend intent. + pub(crate) fn prepare_resume_action( + &self, + alt_saved_viewport: &mut Option, + ) -> Option { + let action = self.take_resume_action()?; + match action { + ResumeAction::RealignInline => { + let viewport = Rect::new( + /*x*/ 0, + self.cursor_y(), + /*width*/ 0, + /*height*/ 0, + ); + Some(PreparedResumeAction::RealignViewport(viewport)) + } + ResumeAction::RestoreAlt => { + if let Some(saved) = alt_saved_viewport.as_mut() { + saved.y = self.cursor_y(); + } + Some(PreparedResumeAction::RestoreAltScreen) + } + } + } + + /// Set the cached inline cursor row so suspend can place the cursor meaningfully. + /// + /// Call during normal drawing when the inline viewport moves so suspend has a fresh cursor + /// position to restore before yielding. + pub(crate) fn set_cursor_y(&self, value: u16) { + self.suspend_cursor_y.store(value, Ordering::Relaxed); + } + + fn cursor_y(&self) -> u16 { + self.suspend_cursor_y.load(Ordering::Relaxed) + } + + /// Record a pending resume action to apply after SIGTSTP returns control. + fn set_resume_action(&self, value: ResumeAction) { + *self + .resume_pending + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(value); + } + + /// Take and clear any pending resume action captured at suspend time. + fn take_resume_action(&self) -> Option { + self.resume_pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take() + } +} + +/// Captures what should happen when returning from suspend. +/// +/// Either realign the inline viewport to keep the cursor position, or re-enter the alt screen +/// to restore the overlay UI. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(crate) enum ResumeAction { + /// Shift the inline viewport to keep the cursor anchored after resume. + RealignInline, + /// Re-enter the alt screen and restore the overlay UI. + RestoreAlt, +} + +/// Describes the viewport change to apply when resuming from suspend during the synchronized draw. +/// +/// Either restore the alt screen (with viewport reset) or realign the inline viewport. +#[derive(Clone, Debug)] +pub(crate) enum PreparedResumeAction { + /// Re-enter the alt screen and reset the viewport to the terminal dimensions. + RestoreAltScreen, + /// Apply a viewport shift to keep the inline cursor position stable. + RealignViewport(Rect), +} + +impl PreparedResumeAction { + pub(crate) fn apply(self, terminal: &mut Terminal, screen_size: Size) -> Result<()> { + match self { + PreparedResumeAction::RealignViewport(area) => { + terminal.set_viewport_area(area); + } + PreparedResumeAction::RestoreAltScreen => { + execute!(terminal.backend_mut(), EnterAlternateScreen)?; + // Enable "alternate scroll" so terminals may translate wheel to arrows + execute!(terminal.backend_mut(), EnableAlternateScroll)?; + terminal.set_viewport_area(Rect::from(screen_size)); + terminal.clear()?; + } + } + Ok(()) + } +} + +/// Deliver SIGTSTP after restoring terminal state, then re-applies terminal modes once resumed. +fn suspend_process() -> Result<()> { + super::restore()?; + super::terminal_stderr::pause()?; + unsafe { + libc::kill(/*pid*/ 0, libc::SIGTSTP) + }; + // After the process resumes, reapply terminal modes so drawing can continue. + super::terminal_stderr::resume()?; + super::set_modes()?; + Ok(()) +} diff --git a/codex-rs/tui/src/tui/keyboard_modes.rs b/codex-rs/tui/src/tui/keyboard_modes.rs new file mode 100644 index 0000000000000000000000000000000000000000..83e409836cbc99a1fa1ef423d143836b675a7bfc --- /dev/null +++ b/codex-rs/tui/src/tui/keyboard_modes.rs @@ -0,0 +1,620 @@ +//! Terminal keyboard enhancement setup and teardown helpers. +//! +//! The TUI uses crossterm's keyboard enhancement stack while it owns the terminal, but +//! process exit gets a stronger reset so the parent shell does not inherit enhanced key +//! reporting if a terminal misses the normal stack pop. +//! Windows terminal detection has a deadline, including WSL interop process launch. +//! Inconclusive detection keeps keyboard enhancements disabled on WSL. + +use std::fmt; +use std::io::stdout; + +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; +use crossterm::Command; +use crossterm::event::KeyboardEnhancementFlags; +use crossterm::event::PopKeyboardEnhancementFlags; +use crossterm::event::PushKeyboardEnhancementFlags; +use ratatui::crossterm::execute; + +const DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR: &str = "CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum VscodeDetection { + VsCode, + Other, + Unknown, +} + +pub(super) fn keyboard_enhancement_disabled() -> bool { + let disable_env = std::env::var(DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR).ok(); + let is_wsl = running_in_wsl(); + let vscode_detection = if is_wsl { + detect_vscode_terminal() + } else { + VscodeDetection::Other + }; + keyboard_enhancement_disabled_for(disable_env.as_deref(), is_wsl, vscode_detection) +} + +fn keyboard_enhancement_disabled_for( + disable_env: Option<&str>, + is_wsl: bool, + vscode_detection: VscodeDetection, +) -> bool { + if let Some(disabled) = parse_bool_env(disable_env) { + return disabled; + } + + // VS Code running a WSL shell can hide TERM_PROGRAM from the Linux process + // environment. If the Windows-side probe is inconclusive, avoid enabling the + // keyboard mode that can break dead-key composition in VS Code on WSL. + is_wsl + && matches!( + vscode_detection, + VscodeDetection::VsCode | VscodeDetection::Unknown + ) +} + +fn parse_bool_env(value: Option<&str>) -> Option { + match value.map(str::trim) { + Some("1") => Some(true), + Some(value) if value.eq_ignore_ascii_case("true") => Some(true), + Some(value) if value.eq_ignore_ascii_case("yes") => Some(true), + Some("0") => Some(false), + Some(value) if value.eq_ignore_ascii_case("false") => Some(false), + Some(value) if value.eq_ignore_ascii_case("no") => Some(false), + _ => None, + } +} + +fn running_in_wsl() -> bool { + #[cfg(target_os = "linux")] + { + crate::clipboard_paste::is_probably_wsl() + } + + #[cfg(not(target_os = "linux"))] + { + false + } +} + +pub(super) fn running_in_vscode_terminal() -> bool { + detect_vscode_terminal() == VscodeDetection::VsCode +} + +fn detect_vscode_terminal() -> VscodeDetection { + if term_program_is_vscode(std::env::var("TERM_PROGRAM").ok().as_deref()) { + return VscodeDetection::VsCode; + } + vscode_terminal_detected( + std::env::var("TERM_PROGRAM").ok().as_deref(), + windows_vscode_detection(), + ) +} + +fn vscode_terminal_detected( + linux_term_program: Option<&str>, + windows_detection: VscodeDetection, +) -> VscodeDetection { + if term_program_is_vscode(linux_term_program) { + VscodeDetection::VsCode + } else { + windows_detection + } +} + +fn term_program_is_vscode(value: Option<&str>) -> bool { + value.is_some_and(|value| value.eq_ignore_ascii_case("vscode")) +} + +fn windows_vscode_detection() -> VscodeDetection { + #[cfg(target_os = "linux")] + { + static WINDOWS_VSCODE_DETECTION: std::sync::OnceLock = + std::sync::OnceLock::new(); + *WINDOWS_VSCODE_DETECTION.get_or_init(read_windows_vscode_detection) + } + + #[cfg(not(target_os = "linux"))] + { + VscodeDetection::Unknown + } +} + +#[cfg(target_os = "linux")] +fn read_windows_vscode_detection() -> VscodeDetection { + if !running_in_wsl() { + return VscodeDetection::Other; + } + read_windows_vscode_detection_with_timeout( + || { + let executable = codex_utils_path::system_executable("cmd.exe")?; + std::process::Command::new(executable) + .args(["/d", "/s", "/c", "set TERM_PROGRAM"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok() + }, + std::time::Duration::from_secs(/*secs*/ 1), + ) +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +fn read_windows_vscode_detection_with_timeout( + spawn: impl FnOnce() -> Option + Send + 'static, + timeout: std::time::Duration, +) -> VscodeDetection { + let deadline = std::time::Instant::now() + timeout; + let (sender, receiver) = std::sync::mpsc::channel(); + // WSL interop can block inside spawn itself. Keep launch and cleanup off the + // startup thread; the OnceLock caches a timeout so this worker is not retried. + let worker = std::thread::Builder::new() + .name("windows-term-program".into()) + .spawn(move || { + let output = (|| { + let mut child = spawn()?; + while std::time::Instant::now() < deadline { + match child.try_wait() { + Ok(Some(_)) => return child.wait_with_output().ok(), + Ok(None) => { + std::thread::sleep(std::time::Duration::from_millis(/*millis*/ 10)) + } + Err(_) => break, + } + } + let _ = child.kill(); + let _ = child.wait(); + None + })(); + let _ = sender.send(output); + }); + if worker.is_err() { + return VscodeDetection::Unknown; + } + + let Ok(Some(output)) = + receiver.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) + else { + return VscodeDetection::Unknown; + }; + if !output.status.success() { + // `cmd.exe /c set TERM_PROGRAM` exits with 1 when the variable is absent. + return if output.status.code() == Some(1) { + VscodeDetection::Other + } else { + VscodeDetection::Unknown + }; + } + + let output = String::from_utf8_lossy(&output.stdout); + let term_program = output + .lines() + .find_map(|line| line.trim_end_matches('\r').strip_prefix("TERM_PROGRAM=")) + .filter(|value| !value.trim().is_empty()); + if term_program_is_vscode(term_program) { + VscodeDetection::VsCode + } else { + VscodeDetection::Other + } +} + +#[cfg(all(test, unix))] +#[path = "windows_term_program_tests.rs"] +mod windows_term_program_tests; + +pub(super) fn enable_keyboard_enhancement() { + if keyboard_enhancement_disabled() { + return; + } + + let running_in_tmux_session = running_in_tmux_session(); + let tmux_extended_keys_format = if running_in_tmux_session { + read_tmux_extended_keys_format() + } else { + None + }; + + let _ = execute!( + stdout(), + DisableModifyOtherKeys, + PushKeyboardEnhancementFlags(keyboard_enhancement_flags( + terminal_info().name, + running_in_tmux_session, + tmux_extended_keys_format.as_deref() + )) + ); + + if tmux_should_enable_modify_other_keys_for( + running_in_tmux_session, + tmux_extended_keys_format.as_deref(), + ) { + let _ = execute!(stdout(), EnableModifyOtherKeys); + } +} + +fn keyboard_enhancement_flags( + terminal_name: TerminalName, + running_in_tmux_session: bool, + tmux_extended_keys_format: Option<&str>, +) -> KeyboardEnhancementFlags { + let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS; + + // iTerm and Ghostty can leak shortcut release events that the terminal consumes. + // tmux's xterm key format also loses Shift-Enter when event types are + // reported. An unavailable/unrecognized tmux probe must take the same safe + // fallback. Preserve repeat classification on confirmed csi-u transports. + if matches!(terminal_name, TerminalName::Ghostty | TerminalName::Iterm2) + || (running_in_tmux_session && !matches!(tmux_extended_keys_format, Some("csi-u"))) + { + flags + } else { + flags | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + } +} + +fn running_in_tmux_session() -> bool { + tmux_session_detected( + std::env::var("TMUX").ok().as_deref(), + std::env::var("TMUX_PANE").ok().as_deref(), + ) +} + +fn tmux_session_detected(tmux: Option<&str>, tmux_pane: Option<&str>) -> bool { + tmux.is_some() || tmux_pane.is_some() +} + +fn tmux_should_enable_modify_other_keys_for( + running_in_tmux_session: bool, + extended_keys_format: Option<&str>, +) -> bool { + // Only request mode 2 when tmux confirms csi-u formatting. Older tmux + // versions do not expose this option and may emit xterm-style sequences, + // which crossterm does not parse consistently for modified keys. + running_in_tmux_session && matches!(extended_keys_format, Some("csi-u")) +} + +fn read_tmux_extended_keys_format() -> Option { + let executable = codex_utils_path::system_executable("tmux")?; + let path = codex_utils_path::system_path().ok()?; + for args in [ + ["display-message", "-p", "#{extended-keys-format}"], + ["show-options", "-gqv", "extended-keys-format"], + ] { + let output = std::process::Command::new(&executable) + .env("PATH", &path) + .args(args) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + + if !output.status.success() { + continue; + } + + if let Some(value) = String::from_utf8(output.stdout) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return Some(value); + } + } + + None +} + +pub(super) fn restore_keyboard_enhancement_stack() { + let _ = execute!( + stdout(), + PopKeyboardEnhancementFlags, + DisableModifyOtherKeys + ); +} + +pub(super) fn reset_keyboard_reporting_after_exit() { + let _ = execute!( + stdout(), + PopKeyboardEnhancementFlags, + ResetKeyboardEnhancementFlags, + DisableModifyOtherKeys + ); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ResetKeyboardEnhancementFlags; + +impl Command for ResetKeyboardEnhancementFlags { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str("\x1b[ std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "keyboard enhancement reset is not implemented for the legacy Windows API", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + false + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EnableModifyOtherKeys; + +impl Command for EnableModifyOtherKeys { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str("\x1b[>4;2m") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "modifyOtherKeys enable is not implemented for the legacy Windows API", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + false + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DisableModifyOtherKeys; + +impl Command for DisableModifyOtherKeys { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str("\x1b[>4;0m") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "modifyOtherKeys reset is not implemented for the legacy Windows API", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::DisableModifyOtherKeys; + use super::EnableModifyOtherKeys; + use super::ResetKeyboardEnhancementFlags; + use super::VscodeDetection; + use super::keyboard_enhancement_disabled_for; + use super::keyboard_enhancement_flags; + use super::parse_bool_env; + use super::tmux_session_detected; + use super::tmux_should_enable_modify_other_keys_for; + use super::vscode_terminal_detected; + use codex_terminal_detection::TerminalName; + use crossterm::Command; + use crossterm::event::PushKeyboardEnhancementFlags; + use pretty_assertions::assert_eq; + + fn ansi_for(command: impl Command) -> String { + let mut out = String::new(); + command.write_ansi(&mut out).unwrap(); + out + } + + #[test] + fn keyboard_enhancement_suppresses_release_reporting_for_iterm() { + assert_eq!( + ansi_for(PushKeyboardEnhancementFlags(keyboard_enhancement_flags( + TerminalName::Iterm2, + /*running_in_tmux_session*/ false, + /*tmux_extended_keys_format*/ None + ))), + "\x1b[>5u" + ); + } + + #[test] + fn keyboard_enhancement_suppresses_release_reporting_for_ghostty() { + assert_eq!( + ansi_for(PushKeyboardEnhancementFlags(keyboard_enhancement_flags( + TerminalName::Ghostty, + /*running_in_tmux_session*/ false, + /*tmux_extended_keys_format*/ None + ))), + "\x1b[>5u" + ); + } + + #[test] + fn keyboard_enhancement_preserves_repeat_reporting_for_kitty() { + assert_eq!( + ansi_for(PushKeyboardEnhancementFlags(keyboard_enhancement_flags( + TerminalName::Kitty, + /*running_in_tmux_session*/ false, + /*tmux_extended_keys_format*/ None + ))), + "\x1b[>7u" + ); + } + + #[test] + fn keyboard_enhancement_preserves_repeat_reporting_for_csi_u_tmux() { + assert_eq!( + ansi_for(PushKeyboardEnhancementFlags(keyboard_enhancement_flags( + TerminalName::Kitty, + /*running_in_tmux_session*/ true, + Some("csi-u") + ))), + "\x1b[>7u" + ); + } + + #[test] + fn keyboard_enhancement_preserves_shift_enter_for_xterm_tmux() { + assert_eq!( + ansi_for(PushKeyboardEnhancementFlags(keyboard_enhancement_flags( + TerminalName::Kitty, + /*running_in_tmux_session*/ true, + Some("xterm") + ))), + "\x1b[>5u" + ); + } + + #[test] + fn keyboard_enhancement_preserves_repeat_reporting_for_unknown_terminals() { + assert_eq!( + ansi_for(PushKeyboardEnhancementFlags(keyboard_enhancement_flags( + TerminalName::Unknown, + /*running_in_tmux_session*/ false, + /*tmux_extended_keys_format*/ None + ))), + "\x1b[>7u" + ); + } + + #[test] + fn keyboard_enhancement_uses_conservative_flags_when_tmux_format_is_unknown() { + assert_eq!( + ansi_for(PushKeyboardEnhancementFlags(keyboard_enhancement_flags( + TerminalName::Unknown, + /*running_in_tmux_session*/ true, + /*tmux_extended_keys_format*/ None, + ))), + "\x1b[>5u" + ); + } + + #[test] + fn keyboard_enhancement_env_flag_parses_common_values() { + assert_eq!(parse_bool_env(Some("1")), Some(true)); + assert_eq!(parse_bool_env(Some("true")), Some(true)); + assert_eq!(parse_bool_env(Some("YES")), Some(true)); + assert_eq!(parse_bool_env(Some("0")), Some(false)); + assert_eq!(parse_bool_env(Some("false")), Some(false)); + assert_eq!(parse_bool_env(Some("NO")), Some(false)); + assert_eq!(parse_bool_env(Some("unexpected")), None); + assert_eq!(parse_bool_env(/*value*/ None), None); + } + + #[test] + fn keyboard_enhancement_auto_disables_for_vscode_or_unknown_in_wsl() { + for detection in [ + VscodeDetection::VsCode, + VscodeDetection::Other, + VscodeDetection::Unknown, + ] { + assert_eq!( + keyboard_enhancement_disabled_for( + /*disable_env*/ None, /*is_wsl*/ true, detection + ), + detection != VscodeDetection::Other, + ); + assert!(!keyboard_enhancement_disabled_for( + /*disable_env*/ None, /*is_wsl*/ false, detection + )); + } + } + + #[test] + fn keyboard_enhancement_env_flag_overrides_auto_detection() { + for detection in [ + VscodeDetection::VsCode, + VscodeDetection::Other, + VscodeDetection::Unknown, + ] { + assert!(!keyboard_enhancement_disabled_for( + Some("0"), + /*is_wsl*/ true, + detection + )); + assert!(keyboard_enhancement_disabled_for( + Some("1"), + /*is_wsl*/ false, + detection + )); + } + } + + #[test] + fn vscode_terminal_detection_uses_linux_and_windows_term_program() { + for detection in [ + VscodeDetection::VsCode, + VscodeDetection::Other, + VscodeDetection::Unknown, + ] { + assert_eq!( + vscode_terminal_detected(Some("vscode"), detection), + VscodeDetection::VsCode, + ); + assert_eq!( + vscode_terminal_detected(/*linux_term_program*/ None, detection), + detection, + ); + } + } + + #[test] + fn tmux_session_detection_accepts_tmux_or_tmux_pane() { + assert!(tmux_session_detected( + Some("/tmp/tmux-501/default,1,0"), + /*tmux_pane*/ None + )); + assert!(tmux_session_detected(/*tmux*/ None, Some("%0"))); + assert!(!tmux_session_detected( + /*tmux*/ None, /*tmux_pane*/ None + )); + } + + #[test] + fn tmux_modify_other_keys_only_requests_confirmed_csi_u_format() { + assert!(tmux_should_enable_modify_other_keys_for( + /*running_in_tmux_session*/ true, + Some("csi-u") + )); + assert!(!tmux_should_enable_modify_other_keys_for( + /*running_in_tmux_session*/ true, /*extended_keys_format*/ None + )); + assert!(!tmux_should_enable_modify_other_keys_for( + /*running_in_tmux_session*/ true, + Some("xterm") + )); + assert!(!tmux_should_enable_modify_other_keys_for( + /*running_in_tmux_session*/ true, + Some("") + )); + assert!(!tmux_should_enable_modify_other_keys_for( + /*running_in_tmux_session*/ false, + Some("csi-u") + )); + } + + #[test] + fn reset_keyboard_enhancement_flags_clears_all_pushed_levels() { + assert_eq!(ansi_for(ResetKeyboardEnhancementFlags), "\x1b[4;2m"); + } + + #[test] + fn disable_modify_other_keys_resets_xterm_keyboard_reporting() { + assert_eq!(ansi_for(DisableModifyOtherKeys), "\x1b[>4;0m"); + } +} diff --git a/codex-rs/tui/src/tui/screen_size.rs b/codex-rs/tui/src/tui/screen_size.rs new file mode 100644 index 0000000000000000000000000000000000000000..77d5f5c4062d80484b44cc921ebaa54dea6038dc --- /dev/null +++ b/codex-rs/tui/src/tui/screen_size.rs @@ -0,0 +1,91 @@ +//! Terminal geometry policy shared by the main app and standalone screens. + +use std::io; +use std::time::Duration; +use std::time::Instant; + +use ratatui::layout::Size; + +use super::Tui; +use super::TuiEvent; +use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE; + +#[derive(Default)] +pub(super) struct ScreenSizePolicy { + pending_recheck_at: Option, + pending_draw_size: Option, + deferred_size: Option, +} + +impl Tui { + /// Resolve event geometry while avoiding backend queries on ordinary repaint frames. + pub(crate) fn screen_size_for_event(&mut self, event: &TuiEvent) -> io::Result { + if matches!(event, TuiEvent::Resize(_)) { + self.schedule_screen_size_recheck(TRANSCRIPT_REFLOW_DEBOUNCE); + } else if matches!(event, TuiEvent::Draw | TuiEvent::FocusGained) + && let Some(deadline) = self.screen_size.pending_recheck_at + { + let now = Instant::now(); + if now < deadline { + self.frame_requester.schedule_frame_in(deadline - now); + } else { + return self.screen_size_for_event(&TuiEvent::Resume); + } + } + + let cached = self.terminal.last_known_screen_size; + let size = match event { + TuiEvent::Resize(size) => { + self.screen_size.deferred_size = None; + *size + } + TuiEvent::Resume => { + self.screen_size.pending_recheck_at = None; + self.screen_size.deferred_size = None; + self.terminal.size()? + } + TuiEvent::Draw | TuiEvent::FocusGained => { + self.screen_size.deferred_size.take().unwrap_or(cached) + } + TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost => cached, + }; + if matches!(event, TuiEvent::Resize(_) | TuiEvent::Resume) + && let Some(monitor) = &self.event_broker.size_monitor + { + monitor.observe(size); + } + self.screen_size.pending_draw_size = (!matches!( + event, + TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost + )) + .then_some(size); + Ok(size) + } + + /// Schedule one backend sample after a resize, reflow, or external program. + pub(crate) fn schedule_screen_size_recheck(&mut self, delay: Duration) { + self.screen_size.pending_recheck_at = Some(Instant::now() + delay); + self.frame_requester.schedule_frame_in(delay); + } + + pub(crate) fn defer_screen_size(&mut self, size: Size) { + self.screen_size.deferred_size = Some(size); + } + + pub(super) fn take_event_screen_size(&mut self) -> io::Result { + if let Some(size) = self.screen_size.pending_draw_size.take() { + return Ok(size); + } + let size = self.terminal.size()?; + if let Some(monitor) = &self.event_broker.size_monitor { + monitor.observe(size); + } + self.screen_size.pending_recheck_at = None; + self.screen_size.deferred_size = None; + Ok(size) + } +} + +#[cfg(test)] +#[path = "screen_size_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/tui/screen_size_tests.rs b/codex-rs/tui/src/tui/screen_size_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..248f69b0d1a4067dffd47bf7960d7057e206144e --- /dev/null +++ b/codex-rs/tui/src/tui/screen_size_tests.rs @@ -0,0 +1,65 @@ +use std::time::Duration; + +use pretty_assertions::assert_eq; +use ratatui::layout::Size; + +use crate::tui::TuiEvent; + +#[tokio::test] +async fn draw_size_policy_refreshes_only_after_resume() { + let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); + let cached = Size::new(/*width*/ 120, /*height*/ 40); + let resumed = tui.terminal.size().expect("backend size"); + tui.terminal.last_known_screen_size = cached; + let resized = Size::new(/*width*/ 100, /*height*/ 30); + for (event, expected) in [ + (TuiEvent::Draw, cached), + (TuiEvent::Resume, resumed), + (TuiEvent::Resize(resized), resized), + (TuiEvent::Paste(String::new()), cached), + (TuiEvent::Draw, resized), + (TuiEvent::Paste(String::new()), cached), + ] { + assert_eq!(tui.screen_size_for_event(&event).expect("size"), expected); + if matches!(event, TuiEvent::Resize(_)) { + tui.defer_screen_size(resized); + } + } + assert_eq!(tui.take_event_screen_size().expect("size"), resumed); + assert!(tui.screen_size.pending_recheck_at.is_none()); +} + +#[tokio::test] +async fn standalone_resize_draw_rechecks_settled_screen_size_once() { + let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); + let resize_size = Size::new(/*width*/ 120, /*height*/ 40); + + tui.screen_size_for_event(&TuiEvent::Resize(resize_size)) + .expect("resolve resize"); + tui.terminal.resize(resize_size).expect("apply resize"); + assert_eq!( + tui.screen_size_for_event(&TuiEvent::Draw) + .expect("resolve early draw"), + resize_size + ); + + tui.schedule_screen_size_recheck(Duration::ZERO); + assert_eq!( + tui.screen_size_for_event(&TuiEvent::Draw) + .expect("resolve settled draw"), + tui.terminal.size().expect("terminal size") + ); + assert!(tui.screen_size.pending_recheck_at.is_none()); +} + +#[tokio::test] +async fn entering_alternate_screen_updates_cached_screen_size() { + let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); + let screen_size = tui.terminal.size().expect("terminal size"); + tui.terminal.last_known_screen_size = Size::new(/*width*/ 120, /*height*/ 40); + + tui.enter_alt_screen().expect("enter alternate screen"); + + assert_eq!(tui.terminal.last_known_screen_size, screen_size); + tui.leave_alt_screen().expect("leave alternate screen"); +} diff --git a/codex-rs/tui/src/tui/scrollback.rs b/codex-rs/tui/src/tui/scrollback.rs new file mode 100644 index 0000000000000000000000000000000000000000..b0ee6c5c7a458e1a172e9f5d87c5f26430e15c22 --- /dev/null +++ b/codex-rs/tui/src/tui/scrollback.rs @@ -0,0 +1,108 @@ +//! Choose terminal-safe strategies for growing the viewport and inserting history. + +use crate::custom_terminal::Terminal; +use crate::insert_history::HistoryLineWrapPolicy; +use crate::insert_history::InsertHistoryMode; +use crate::insert_history::ResetScrollRegion; +use crate::insert_history::SetScrollRegion; +use codex_terminal_detection::TerminalInfo; +use codex_terminal_detection::TerminalName; +use crossterm::cursor::MoveTo; +use crossterm::queue; +use crossterm::style::Print; +use ratatui::backend::Backend; +use ratatui::layout::Position; +use ratatui::layout::Size; +use std::io; +use std::io::Write; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ScrollbackStrategy { + Standard, + Zellij, + FullScreen, +} + +impl ScrollbackStrategy { + pub(super) fn detect(terminal: &TerminalInfo) -> Self { + if terminal.is_zellij() { + Self::Zellij + } else if terminal.name == TerminalName::WindowsTerminal + || std::env::var_os("WT_SESSION").is_some() + { + Self::FullScreen + } else { + Self::Standard + } + } + + pub(super) fn history_insertion_mode( + self, + wrap_policy: HistoryLineWrapPolicy, + ) -> InsertHistoryMode { + match self { + Self::FullScreen => InsertHistoryMode::FullScreen, + Self::Zellij if wrap_policy == HistoryLineWrapPolicy::Terminal => { + InsertHistoryMode::FullScreen + } + Self::Standard | Self::Zellij => InsertHistoryMode::Standard, + } + } + + pub(super) fn grow_viewport( + self, + terminal: &mut Terminal, + viewport_top: u16, + screen_size: Size, + scroll_by: u16, + ) -> io::Result<()> + where + B: Backend + Write, + { + if scroll_by == 0 || viewport_top == 0 { + return Ok(()); + } + + match self { + Self::Standard if viewport_top > 1 => { + // CSI S can discard departing rows in QTermWidget and xterm.js. Newlines at the + // bottom of the history region preserve native scrollback while protecting the + // composer. Restore the cursor without changing the renderer's cached position. + let cursor = terminal.last_known_cursor_pos; + let writer = terminal.backend_mut(); + queue!( + writer, + SetScrollRegion(1..viewport_top), + MoveTo(/*x*/ 0, viewport_top - 1) + )?; + for _ in 0..scroll_by { + queue!(writer, Print("\r\n"))?; + } + queue!(writer, ResetScrollRegion, MoveTo(cursor.x, cursor.y))?; + Ok(()) + } + Self::FullScreen | Self::Standard => { + // Partial DEC scroll regions can discard rows instead of moving them into Windows + // Terminal's scrollback. Clear the stale composer, then scroll the entire screen. + // Also use this path for a single history row: DECSTBM requires two distinct rows. + terminal.clear_after_position(Position::new(/*x*/ 0, viewport_top))?; + let writer = terminal.backend_mut(); + queue!( + writer, + MoveTo(/*x*/ 0, screen_size.height.saturating_sub(/*rhs*/ 1)) + )?; + for _ in 0..scroll_by { + queue!(writer, Print("\r\n"))?; + } + Ok(()) + } + Self::Zellij => terminal + .backend_mut() + .scroll_region_up(0..viewport_top, scroll_by), + } + } +} + +#[cfg(test)] +#[path = "scrollback_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/tui/scrollback_tests.rs b/codex-rs/tui/src/tui/scrollback_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..33fdeb70527d2541e5ba671885e85d42579cf6f2 --- /dev/null +++ b/codex-rs/tui/src/tui/scrollback_tests.rs @@ -0,0 +1,329 @@ +use super::ScrollbackStrategy; +use crate::custom_terminal::Terminal; +use crate::insert_history::HistoryLineWrapPolicy; +use crate::insert_history::InsertHistoryMode; +use crate::insert_history::insert_history_lines_with_mode_and_wrap_policy; +use crate::test_backend::VT100Backend; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; +use codex_terminal_detection::TerminalName; +use crossterm::cursor::MoveTo; +use crossterm::queue; +use crossterm::style::Print; +use pretty_assertions::assert_eq; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Position; +use ratatui::layout::Rect; +use ratatui::layout::Size; +use ratatui::text::Line; + +#[test] +fn windows_terminal_uses_full_screen_unless_zellij_is_active() { + let mut terminal = TerminalInfo { + name: TerminalName::WindowsTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }; + let mut strategy = ScrollbackStrategy::detect(&terminal); + + assert_eq!( + [ + strategy.history_insertion_mode(HistoryLineWrapPolicy::PreWrap), + strategy.history_insertion_mode(HistoryLineWrapPolicy::Terminal), + ], + [InsertHistoryMode::FullScreen, InsertHistoryMode::FullScreen] + ); + + terminal.multiplexer = Some(Multiplexer::Zellij { version: None }); + strategy = ScrollbackStrategy::detect(&terminal); + assert_eq!(strategy, ScrollbackStrategy::Zellij); +} + +#[test] +fn zellij_only_uses_full_screen_insertion_for_terminal_wrapped_history() { + assert_eq!( + [ + ScrollbackStrategy::Zellij.history_insertion_mode(HistoryLineWrapPolicy::PreWrap), + ScrollbackStrategy::Zellij.history_insertion_mode(HistoryLineWrapPolicy::Terminal), + ], + [InsertHistoryMode::Standard, InsertHistoryMode::FullScreen] + ); +} + +#[test] +fn full_screen_history_insertion_preserves_terminal_scrollback() { + let width = 24; + let height = 6; + let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 32); + let mut terminal = Terminal::with_options(backend).expect("terminal with scrollback"); + terminal.set_viewport_area(Rect::new( + /*x*/ 0, + /*y*/ height - 2, + width, + /*height*/ 2, + )); + + for (row, line) in [ + "oldest-history-row", + "history-row-2", + "history-row-3", + "history-row-4", + "stale-composer-1", + "stale-composer-2", + ] + .into_iter() + .enumerate() + { + queue!( + terminal.backend_mut(), + MoveTo(/*x*/ 0, row as u16), + Print(line) + ) + .expect("seed terminal row"); + } + + insert_history_lines_with_mode_and_wrap_policy( + &mut terminal, + vec![Line::from("new-history-row")], + ScrollbackStrategy::FullScreen.history_insertion_mode(HistoryLineWrapPolicy::PreWrap), + HistoryLineWrapPolicy::PreWrap, + ) + .expect("insert history through the full screen"); + + let visible = terminal.backend().vt100().screen().contents(); + let mut scrollback_screen = terminal.backend().vt100().screen().clone(); + scrollback_screen.set_scrollback(/*rows*/ usize::MAX); + let scrollback = scrollback_screen.contents(); + + insta::assert_snapshot!(format!("SCROLLBACK:\n{scrollback}\nVISIBLE:\n{visible}"), @r" + SCROLLBACK: + oldest-history-row + history-row-2 + history-row-3 + history-row-4 + new-history-row + VISIBLE: + history-row-2 + history-row-3 + history-row-4 + new-history-row + "); +} + +#[test] +fn full_screen_viewport_growth_preserves_terminal_scrollback() { + let width = 24; + let height = 6; + let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 32); + let mut terminal = Terminal::with_options(backend).expect("terminal with scrollback"); + terminal.set_viewport_area(Rect::new( + /*x*/ 0, + /*y*/ height - 2, + width, + /*height*/ 2, + )); + + for (row, line) in [ + "oldest-history-row", + "history-row-2", + "history-row-3", + "history-row-4", + "stale-composer-1", + "stale-composer-2", + ] + .into_iter() + .enumerate() + { + queue!( + terminal.backend_mut(), + MoveTo(/*x*/ 0, row as u16), + Print(line) + ) + .expect("seed terminal row"); + } + + ScrollbackStrategy::FullScreen + .grow_viewport( + &mut terminal, + /*viewport_top*/ height - 2, + Size::new(width, height), + /*scroll_by*/ 2, + ) + .expect("grow viewport through full-screen scrolling"); + + let visible = terminal.backend().vt100().screen().contents(); + let mut scrollback_screen = terminal.backend().vt100().screen().clone(); + scrollback_screen.set_scrollback(/*rows*/ usize::MAX); + let scrollback = scrollback_screen.contents(); + + insta::assert_snapshot!(format!("SCROLLBACK:\n{scrollback}\nVISIBLE:\n{visible}"), @r" + SCROLLBACK: + oldest-history-row + history-row-2 + history-row-3 + history-row-4 + VISIBLE: + history-row-3 + history-row-4 + "); +} + +#[test] +fn standard_viewport_growth_preserves_composer_and_cursor() { + for scroll_by in [1, 3, 4] { + let width = 24; + let height = 6; + let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 32); + let mut terminal = Terminal::with_options(backend).expect("terminal with scrollback"); + let viewport_top = 4; + terminal.set_viewport_area(Rect::new( + /*x*/ 0, + viewport_top, + width, + /*height*/ 2, + )); + for (row, text) in [ + "history-1", + "history-2", + "history-3", + "history-4", + "composer-1", + "composer-2", + ] + .into_iter() + .enumerate() + { + queue!( + terminal.backend_mut(), + MoveTo(/*x*/ 0, row as u16), + Print(text) + ) + .expect("seed terminal row"); + } + let cursor = Position::new(/*x*/ 7, /*y*/ 5); + terminal + .set_cursor_position(cursor) + .expect("position cursor"); + + ScrollbackStrategy::Standard + .grow_viewport( + &mut terminal, + viewport_top, + Size::new(width, height), + scroll_by, + ) + .expect("grow standard viewport"); + + let screen = terminal.backend().vt100().screen(); + assert_eq!( + (screen.cursor_position(), terminal.last_known_cursor_pos), + ((cursor.y, cursor.x), cursor) + ); + // vt100 intentionally omits scrollback for partial regions, even for newlines. Snapshot + // visible geometry here; the wire test below and real-terminal smoke cover scrollback. + insta::assert_snapshot!( + format!("standard_viewport_growth_{scroll_by}"), + screen.contents() + ); + + // A newline at the physical bottom must scroll the full screen after the region reset. + queue!( + terminal.backend_mut(), + MoveTo(/*x*/ 0, height - 1), + Print("\r\nnext-row") + ) + .expect("write after restoring the scroll region"); + assert_eq!( + terminal + .backend() + .vt100() + .screen() + .rows(/*start*/ 0, width) + .collect::>()[4..], + ["composer-2", "next-row"] + ); + } +} + +#[test] +fn standard_viewport_growth_uses_scrollback_preserving_newlines() { + for scroll_by in [1, 3, 4] { + let screen_size = Size::new(/*width*/ 24, /*height*/ 6); + let cursor = Position::new(/*x*/ 7, /*y*/ 5); + let backend = CrosstermBackend::new(Vec::::new()); + let mut terminal = + Terminal::with_screen_size_and_cursor_position_for_test(backend, screen_size, cursor); + ScrollbackStrategy::Standard + .grow_viewport( + &mut terminal, + /*viewport_top*/ 4, + screen_size, + scroll_by, + ) + .expect("grow standard viewport"); + assert_eq!( + terminal.backend().writer(), + format!( + "\x1b[1;4r\x1b[4;1H{}\x1b[r\x1b[6;8H", + "\r\n".repeat(usize::from(scroll_by)) + ) + .as_bytes() + ); + } +} + +#[test] +fn standard_viewport_growth_with_one_history_row_preserves_scrollback() { + let width = 24; + let height = 3; + let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 32); + let mut terminal = Terminal::with_options(backend).expect("terminal with scrollback"); + queue!( + terminal.backend_mut(), + Print("history\r\nstale-composer\r\nstale-footer") + ) + .expect("seed terminal"); + ScrollbackStrategy::Standard + .grow_viewport( + &mut terminal, + /*viewport_top*/ 1, + Size::new(width, height), + /*scroll_by*/ 1, + ) + .expect("grow past the single history row"); + let screen = terminal.backend().vt100().screen(); + let mut history = screen.clone(); + history.set_scrollback(/*rows*/ usize::MAX); + insta::assert_snapshot!(format!("SCROLLBACK:\n{}\nVISIBLE:\n{}", history.contents(), screen.contents()), @r" + SCROLLBACK: + history + VISIBLE: + "); +} + +#[test] +fn standard_viewport_growth_without_rows_is_cursor_neutral() { + for (viewport_top, scroll_by) in [(4, 0), (0, 1)] { + let backend = VT100Backend::new(/*width*/ 24, /*height*/ 6); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + let cursor = Position::new(/*x*/ 7, /*y*/ 5); + terminal + .set_cursor_position(cursor) + .expect("position cursor"); + let before = terminal.backend().vt100().screen().state_formatted(); + ScrollbackStrategy::Standard + .grow_viewport( + &mut terminal, + viewport_top, + Size::new(/*width*/ 24, /*height*/ 6), + scroll_by, + ) + .expect("no-op viewport growth"); + assert_eq!( + terminal.backend().vt100().screen().state_formatted(), + before + ); + } +} diff --git a/codex-rs/tui/src/tui/size_monitor.rs b/codex-rs/tui/src/tui/size_monitor.rs new file mode 100644 index 0000000000000000000000000000000000000000..cf50fd8e3b0aa69bc2b3048d3d2102fd9024b6bb --- /dev/null +++ b/codex-rs/tui/src/tui/size_monitor.rs @@ -0,0 +1,148 @@ +//! Recovers lost tmux resize notifications without querying geometry on the UI thread. +//! +//! One worker belongs to the event broker, including across nested screens. Queries never +//! hold the shared lock. Pauses and newer geometry invalidate both in-flight and queued +//! samples. Dropping the monitor wakes the worker without joining a possibly blocked OS call; +//! that single in-flight call may finish later, but cannot publish or start another query. + +use std::io; +use std::sync::Arc; +use std::sync::Mutex; +use std::thread; +use std::time::Duration; + +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; +use ratatui::layout::Size; +use tokio::sync::broadcast; + +const CHECK_INTERVAL: Duration = Duration::from_millis(500); + +struct State { + size: Size, + generation: u64, + pending: Option, + active: bool, + stopped: bool, +} + +pub(super) struct SizeMonitor { + state: Arc>, + worker: thread::JoinHandle<()>, +} + +impl SizeMonitor { + pub(super) fn start( + terminal_info: &TerminalInfo, + size: Size, + draw_tx: broadcast::Sender<()>, + ) -> Option { + if !cfg!(unix) || !matches!(terminal_info.multiplexer, Some(Multiplexer::Tmux { .. })) { + return None; + } + Self::spawn(size, draw_tx, || { + // Unlike terminal::size(), window_size() has no tput subprocess fallback. + crossterm::terminal::window_size().map(|size| Size::new(size.columns, size.rows)) + }) + .inspect_err(|error| tracing::warn!(%error, "Failed to start terminal size monitor")) + .ok() + } + + fn spawn( + size: Size, + draw_tx: broadcast::Sender<()>, + mut query: impl FnMut() -> io::Result + Send + 'static, + ) -> io::Result { + let state = Arc::new(Mutex::new(State { + size, + generation: 0, + pending: None, + active: true, + stopped: false, + })); + let shared = state.clone(); + let worker = thread::Builder::new() + .name("codex-terminal-size".into()) + .spawn(move || { + loop { + thread::park_timeout(CHECK_INTERVAL); + let generation = { + let state = shared + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.stopped { + break; + } + if !state.active { + continue; + } + state.generation + }; + let sampled = query(); + let mut state = shared + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.stopped { + break; + } + if state.generation != generation { + continue; + } + state.pending = sampled + .ok() + .filter(|size| size.width > 0 && size.height > 0 && *size != state.size); + if state.pending.is_some() { + drop(state); + let _ = draw_tx.send(()); + } + } + })?; + Ok(Self { state, worker }) + } + + pub(super) fn observe(&self, size: Size) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.size = size; + state.generation = state.generation.wrapping_add(1); + state.pending = None; + } + + pub(super) fn set_active(&self, active: bool) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.active = active; + state.generation = state.generation.wrapping_add(1); + state.pending = None; + } + + pub(super) fn take_resize(&self) -> Option { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let size = state.pending.take()?; + state.size = size; + state.generation = state.generation.wrapping_add(1); + tracing::debug!(?size, "Recovered terminal size from background check"); + Some(size) + } +} + +impl Drop for SizeMonitor { + fn drop(&mut self) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .stopped = true; + self.worker.thread().unpark(); + } +} + +#[cfg(test)] +#[path = "size_monitor_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/tui/size_monitor_tests.rs b/codex-rs/tui/src/tui/size_monitor_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..f15fe04730d0689663e7771f8d3985d50afabae8 --- /dev/null +++ b/codex-rs/tui/src/tui/size_monitor_tests.rs @@ -0,0 +1,263 @@ +use super::*; +use std::pin::Pin; +use std::sync::atomic::AtomicBool; +use std::task::Context; +use std::task::Poll; + +use codex_terminal_detection::TerminalName; +use crossterm::event::Event; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use ratatui::layout::Position; +use ratatui::layout::Rect; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::Wrap; +use tokio::time::timeout; +use tokio_stream::StreamExt; + +use crate::custom_terminal::Terminal; +use crate::test_backend::VT100Backend; +use crate::tui::TuiEvent; +use crate::tui::event_stream::EventBroker; +use crate::tui::event_stream::EventResult; +use crate::tui::event_stream::EventSource; +use crate::tui::event_stream::TuiEventStream; + +#[derive(Default)] +struct KeySource { + sent: bool, +} + +impl EventSource for KeySource { + fn poll_next(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + if self.sent { + return Poll::Pending; + } + self.sent = true; + Poll::Ready(Some(Ok(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))))) + } +} + +struct Harness { + broker: Arc>, + queries: tokio::sync::mpsc::UnboundedReceiver<()>, + replies: std::sync::mpsc::Sender>, + draws: broadcast::Receiver<()>, +} + +impl Harness { + fn new(size: Size) -> Self { + let (draw_tx, draws) = broadcast::channel(/*capacity*/ 1); + let (started, queries) = tokio::sync::mpsc::unbounded_channel(); + let (replies, responses) = std::sync::mpsc::channel(); + let monitor = SizeMonitor::spawn(size, draw_tx, move || { + let _ = started.send(()); + responses + .recv_timeout(Duration::from_secs(5)) + .unwrap_or_else(|_| Err(io::Error::other("test query was not released"))) + }) + .expect("monitor"); + let mut broker = EventBroker::new(); + broker.size_monitor = Some(monitor); + Self { + broker: Arc::new(broker), + queries, + replies, + draws, + } + } + + fn monitor(&self) -> &SizeMonitor { + self.broker.size_monitor.as_ref().unwrap() + } + + async fn next_query(&mut self) { + self.monitor().worker.thread().unpark(); + timeout(Duration::from_secs(2), self.queries.recv()) + .await + .unwrap() + .unwrap(); + } + + fn stream(&self) -> TuiEventStream { + TuiEventStream::new( + self.broker.clone(), + self.draws.resubscribe(), + Arc::new(AtomicBool::new(/*v*/ false)), + #[cfg(unix)] + crate::tui::job_control::SuspendContext::new(), + #[cfg(unix)] + Arc::new(AtomicBool::new(/*v*/ false)), + ) + } +} + +#[tokio::test] +async fn missed_resize_recovers_and_slow_query_does_not_block_input() { + let initial = Size::new(/*width*/ 12, /*height*/ 4); + let resized = Size::new(/*width*/ 8, /*height*/ 4); + let mut harness = Harness::new(initial); + let mut stream = harness.stream(); + harness.next_query().await; + // The worker is blocked waiting for a reply. Input still traverses the real event stream. + assert!(matches!( + timeout(Duration::from_millis(100), stream.next()) + .await + .unwrap(), + Some(TuiEvent::Key(KeyEvent { + code: KeyCode::Char('x'), + .. + })) + )); + assert!( + timeout( + CHECK_INTERVAL + Duration::from_millis(50), + harness.queries.recv() + ) + .await + .is_err() + ); + harness.replies.send(Ok(resized)).unwrap(); + let event = timeout(Duration::from_secs(2), stream.next()) + .await + .unwrap() + .unwrap(); + let TuiEvent::Resize(size) = event else { + panic!("expected recovered resize: {event:?}") + }; + assert_eq!(size, resized); + + let mut terminal = Terminal::with_screen_size_and_cursor_position_for_test( + VT100Backend::new(resized.width, resized.height), + initial, + Position { x: 0, y: 0 }, + ); + let area = Rect::new(/*x*/ 0, /*y*/ 0, size.width, size.height); + terminal.set_viewport_area(area); + terminal + .draw_with_size(size, |frame| { + Paragraph::new("alpha beta") + .wrap(Wrap { trim: false }) + .render(area, frame.buffer_mut()); + }) + .unwrap(); + assert_eq!(terminal.last_known_screen_size, resized); + insta::assert_snapshot!(terminal.backend().vt100().screen().contents(), @"alpha\nbeta"); +} + +#[tokio::test] +async fn newer_resize_invalidates_in_flight_and_queued_samples() { + let initial = Size::new(/*width*/ 80, /*height*/ 24); + let newer = Size::new(/*width*/ 100, /*height*/ 30); + let mut harness = Harness::new(initial); + harness.next_query().await; + harness.monitor().observe(newer); + harness.replies.send(Ok(initial)).unwrap(); + harness.next_query().await; // The previous result has been processed. + assert_eq!(harness.monitor().take_resize(), None); + assert_eq!( + harness.draws.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + ); + + harness.replies.send(Ok(initial)).unwrap(); + timeout(Duration::from_secs(2), harness.draws.recv()) + .await + .unwrap() + .unwrap(); + harness.monitor().observe(newer); // Also discard a result already queued for the UI. + assert_eq!(harness.monitor().take_resize(), None); +} + +#[tokio::test] +async fn unchanged_invalid_and_failed_samples_do_not_redraw() { + let initial = Size::new(/*width*/ 80, /*height*/ 24); + let mut harness = Harness::new(initial); + harness.next_query().await; + for sample in [ + Ok(initial), + Ok(Size::new(/*width*/ 0, /*height*/ 0)), + Err(io::Error::other("query failed")), + ] { + harness.replies.send(sample).unwrap(); + harness.next_query().await; + assert_eq!(harness.monitor().take_resize(), None); + assert_eq!( + harness.draws.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + ); + } +} + +#[tokio::test] +async fn terminal_handoff_pauses_queries_and_discards_old_results() { + let initial = Size::new(/*width*/ 80, /*height*/ 24); + let resized = Size::new(/*width*/ 40, /*height*/ 12); + let mut harness = Harness::new(initial); + harness.next_query().await; + harness.broker.pause_events(); + harness.replies.send(Ok(resized)).unwrap(); + harness.monitor().worker.thread().unpark(); + assert!( + timeout(Duration::from_millis(100), harness.queries.recv()) + .await + .is_err() + ); + assert_eq!(harness.monitor().take_resize(), None); + harness.broker.resume_events(); + harness.next_query().await; + harness.replies.send(Ok(resized)).unwrap(); + timeout(Duration::from_secs(2), harness.draws.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(harness.monitor().take_resize(), Some(resized)); +} + +#[tokio::test] +async fn drop_does_not_wait_for_a_blocked_query_or_start_another() { + let initial = Size::new(/*width*/ 80, /*height*/ 24); + let mut harness = Harness::new(initial); + harness.next_query().await; + let state = harness.monitor().state.clone(); + drop(harness.broker); + assert!(state.lock().unwrap().stopped); + harness + .replies + .send(Ok(Size::new(/*width*/ 40, /*height*/ 12))) + .unwrap(); + assert_eq!( + timeout(Duration::from_secs(2), harness.queries.recv()) + .await + .unwrap(), + None + ); + assert_eq!(state.lock().unwrap().pending, None); +} + +#[test] +fn monitor_requires_tmux_detection() { + let (draw_tx, _) = broadcast::channel(/*capacity*/ 1); + let mut info = TerminalInfo { + name: TerminalName::Ghostty, + term_program: None, + version: None, + term: None, + multiplexer: None, + }; + let size = Size::new(/*width*/ 80, /*height*/ 24); + assert!(SizeMonitor::start(&info, size, draw_tx.clone()).is_none()); + info.multiplexer = Some(Multiplexer::Zellij { version: None }); + assert!(SizeMonitor::start(&info, size, draw_tx.clone()).is_none()); + info.multiplexer = Some(Multiplexer::Tmux { version: None }); + assert_eq!( + SizeMonitor::start(&info, size, draw_tx).is_some(), + cfg!(unix) + ); +} diff --git a/codex-rs/tui/src/tui/terminal_stderr.rs b/codex-rs/tui/src/tui/terminal_stderr.rs new file mode 100644 index 0000000000000000000000000000000000000000..dc1f95029e9d5e293b8caf4bb823cb1d151136f3 --- /dev/null +++ b/codex-rs/tui/src/tui/terminal_stderr.rs @@ -0,0 +1,300 @@ +//! Protect the inline viewport from unmanaged macOS writes to stderr. +//! +//! Some macOS frameworks and runtime diagnostics write directly to file +//! descriptor 2. While the inline TUI is active, those writes paint into the +//! same terminal region as the composer without going through the renderer. +//! Keep them off the terminal until the TUI releases terminal ownership. + +use std::io; + +#[cfg(target_os = "macos")] +use std::fs::OpenOptions; +#[cfg(target_os = "macos")] +use std::io::IsTerminal; +#[cfg(target_os = "macos")] +use std::mem::MaybeUninit; +#[cfg(target_os = "macos")] +use std::os::fd::AsRawFd; +#[cfg(target_os = "macos")] +use std::os::fd::FromRawFd; +#[cfg(target_os = "macos")] +use std::os::fd::OwnedFd; +#[cfg(target_os = "macos")] +use std::sync::Mutex; +#[cfg(target_os = "macos")] +use std::sync::MutexGuard; + +#[cfg(target_os = "macos")] +static STDERR_STATE: Mutex = Mutex::new(StderrState { + owner_active: false, + saved_stderr: None, +}); + +#[cfg(target_os = "macos")] +struct StderrState { + owner_active: bool, + saved_stderr: Option, +} + +/// Keeps unmanaged stderr output away from the terminal while the TUI owns it. +pub(crate) struct TerminalStderrGuard { + active: bool, +} + +impl TerminalStderrGuard { + pub(super) fn install() -> io::Result { + #[cfg(target_os = "macos")] + { + if stderr_targets_stdout_terminal() { + return Self::install_suppression(); + } + } + + Ok(Self { active: false }) + } + + /// Reclaim stderr after a caught panic temporarily restored terminal ownership. + pub(super) fn recover_after_caught_panic(&mut self) -> io::Result<()> { + if !self.active { + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + let mut state = lock_state()?; + if !state.owner_active { + suppress_locked(&mut state)?; + state.owner_active = true; + } + } + + Ok(()) + } + + #[cfg(target_os = "macos")] + fn install_suppression() -> io::Result { + let mut state = lock_state()?; + if state.owner_active { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "terminal stderr suppression is already active", + )); + } + suppress_locked(&mut state)?; + state.owner_active = true; + Ok(Self { active: true }) + } +} + +impl Drop for TerminalStderrGuard { + fn drop(&mut self) { + if self.active { + let _ = finish(); + self.active = false; + } + } +} + +/// Restores stderr while terminal ownership is temporarily released. +pub(super) fn pause() -> io::Result<()> { + #[cfg(target_os = "macos")] + { + let mut state = lock_state()?; + if state.owner_active { + restore_locked(&mut state)?; + } + } + + Ok(()) +} + +/// Suppresses stderr again when terminal ownership returns to the TUI. +pub(super) fn resume() -> io::Result<()> { + #[cfg(target_os = "macos")] + { + let mut state = lock_state()?; + if state.owner_active { + suppress_locked(&mut state)?; + } + } + + Ok(()) +} + +/// Restores stderr permanently when the TUI session ends. +pub(super) fn finish() -> io::Result<()> { + #[cfg(target_os = "macos")] + { + let mut state = lock_state()?; + if state.owner_active { + restore_locked(&mut state)?; + state.owner_active = false; + } + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn lock_state() -> io::Result> { + STDERR_STATE + .lock() + .map_err(|_| io::Error::other("terminal stderr suppression lock poisoned")) +} + +#[cfg(target_os = "macos")] +fn stderr_targets_stdout_terminal() -> bool { + if !io::stdout().is_terminal() || !io::stderr().is_terminal() { + return false; + } + + let mut stdout_stat = MaybeUninit::::uninit(); + let mut stderr_stat = MaybeUninit::::uninit(); + // SAFETY: both output pointers reference valid storage for libc to initialize. + if unsafe { + libc::fstat(libc::STDOUT_FILENO, stdout_stat.as_mut_ptr()) != 0 + || libc::fstat(libc::STDERR_FILENO, stderr_stat.as_mut_ptr()) != 0 + } { + return false; + } + // SAFETY: both fstat calls above returned successfully. + let (stdout_stat, stderr_stat) = + unsafe { (stdout_stat.assume_init(), stderr_stat.assume_init()) }; + stdout_stat.st_dev == stderr_stat.st_dev && stdout_stat.st_ino == stderr_stat.st_ino +} + +#[cfg(target_os = "macos")] +fn suppress_locked(state: &mut StderrState) -> io::Result<()> { + if state.saved_stderr.is_some() { + return Ok(()); + } + + // SAFETY: dup returns a newly owned file descriptor on success. + let saved_stderr = unsafe { libc::dup(libc::STDERR_FILENO) }; + if saved_stderr == -1 { + return Err(io::Error::last_os_error()); + } + // SAFETY: saved_stderr is a fresh descriptor returned by dup above. + let saved_stderr = unsafe { OwnedFd::from_raw_fd(saved_stderr) }; + let devnull = OpenOptions::new().write(true).open("/dev/null")?; + // SAFETY: both descriptors are valid for the duration of this call. + if unsafe { libc::dup2(devnull.as_raw_fd(), libc::STDERR_FILENO) } == -1 { + return Err(io::Error::last_os_error()); + } + state.saved_stderr = Some(saved_stderr); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn restore_locked(state: &mut StderrState) -> io::Result<()> { + let Some(saved_stderr) = state.saved_stderr.as_ref() else { + return Ok(()); + }; + + // SAFETY: saved_stderr was duplicated from stderr and remains owned here. + if unsafe { libc::dup2(saved_stderr.as_raw_fd(), libc::STDERR_FILENO) } == -1 { + return Err(io::Error::last_os_error()); + } + state.saved_stderr = None; + Ok(()) +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use std::fs::File; + use std::io::Read; + use std::io::Seek; + use std::io::Write; + use std::os::fd::AsRawFd; + use std::os::fd::FromRawFd; + use std::os::fd::OwnedFd; + + use pretty_assertions::assert_eq; + use serial_test::serial; + + use super::TerminalStderrGuard; + use super::finish; + use super::pause; + use super::resume; + + struct CapturedStderr { + saved_stderr: OwnedFd, + } + + impl CapturedStderr { + fn start(file: &File) -> std::io::Result { + // SAFETY: dup returns a newly owned file descriptor on success. + let saved_stderr = unsafe { libc::dup(libc::STDERR_FILENO) }; + if saved_stderr == -1 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: saved_stderr is a fresh descriptor returned by dup above. + let saved_stderr = unsafe { OwnedFd::from_raw_fd(saved_stderr) }; + // SAFETY: both descriptors are valid for the duration of this call. + if unsafe { libc::dup2(file.as_raw_fd(), libc::STDERR_FILENO) } == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(Self { saved_stderr }) + } + } + + impl Drop for CapturedStderr { + fn drop(&mut self) { + // SAFETY: saved_stderr remains owned for the duration of this call. + let _ = unsafe { libc::dup2(self.saved_stderr.as_raw_fd(), libc::STDERR_FILENO) }; + } + } + + fn write_stderr(message: &str) -> std::io::Result<()> { + let mut stderr = std::io::stderr().lock(); + stderr.write_all(message.as_bytes())?; + stderr.flush() + } + + #[test] + #[serial] + fn suppresses_stderr_only_while_terminal_is_owned() -> std::io::Result<()> { + let mut output = tempfile::tempfile()?; + let capture = CapturedStderr::start(&output)?; + + let mut guard = TerminalStderrGuard::install_suppression()?; + write_stderr("hidden while active\n")?; + pause()?; + write_stderr("visible while paused\n")?; + resume()?; + write_stderr("hidden after resume\n")?; + finish()?; + write_stderr("visible after finish\n")?; + guard.recover_after_caught_panic()?; + write_stderr("hidden after caught panic\n")?; + finish()?; + write_stderr("visible after recovered ownership\n")?; + + drop(capture); + output.rewind()?; + let mut captured = String::new(); + output.read_to_string(&mut captured)?; + assert_eq!( + captured, + "visible while paused\nvisible after finish\nvisible after recovered ownership\n" + ); + Ok(()) + } + + #[test] + #[serial] + fn preserves_stderr_when_already_redirected() -> std::io::Result<()> { + let mut output = tempfile::tempfile()?; + let capture = CapturedStderr::start(&output)?; + + let _guard = TerminalStderrGuard::install()?; + write_stderr("visible while redirected\n")?; + + drop(capture); + output.rewind()?; + let mut captured = String::new(); + output.read_to_string(&mut captured)?; + assert_eq!(captured, "visible while redirected\n"); + Ok(()) + } +} diff --git a/codex-rs/tui/src/tui/test_support.rs b/codex-rs/tui/src/tui/test_support.rs new file mode 100644 index 0000000000000000000000000000000000000000..c3f9f6bc3bf487b73d9c2a415e90571b83295b70 --- /dev/null +++ b/codex-rs/tui/src/tui/test_support.rs @@ -0,0 +1,28 @@ +use std::io; +use std::io::stdout; + +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Position; +use ratatui::layout::Size; + +use super::Tui; +use super::terminal_stderr::TerminalStderrGuard; +use crate::custom_terminal::Terminal; + +pub(crate) fn make_test_tui() -> io::Result { + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::with_screen_size_and_cursor_position_for_test( + backend, + Size { + width: 80, + height: 24, + }, + Position { x: 0, y: 0 }, + ); + let stderr_guard = TerminalStderrGuard::install()?; + Ok(Tui::new( + terminal, + /*enhanced_keys_supported*/ false, + stderr_guard, + )) +} diff --git a/codex-rs/tui/src/tui/windows_console.rs b/codex-rs/tui/src/tui/windows_console.rs new file mode 100644 index 0000000000000000000000000000000000000000..e328f4ef506dc320763410aac211309cb5852a47 --- /dev/null +++ b/codex-rs/tui/src/tui/windows_console.rs @@ -0,0 +1,104 @@ +const ENABLE_VIRTUAL_TERMINAL_INPUT: u32 = 0x0200; + +#[derive(Clone, Copy)] +pub(super) enum VirtualTerminalInput { + Enabled, + Disabled, +} + +pub(super) fn input_record_mode(mode: u32) -> u32 { + mode & !ENABLE_VIRTUAL_TERMINAL_INPUT +} + +pub(super) fn restored_input_mode(mode: u32, original: VirtualTerminalInput) -> u32 { + match original { + VirtualTerminalInput::Enabled => mode | ENABLE_VIRTUAL_TERMINAL_INPUT, + VirtualTerminalInput::Disabled => input_record_mode(mode), + } +} + +#[cfg(windows)] +static ORIGINAL_VT_INPUT: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(windows)] +fn current_input_mode() -> Option<(windows_sys::Win32::Foundation::HANDLE, u32)> { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::GetConsoleMode; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; + + let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if handle == INVALID_HANDLE_VALUE || handle == 0 { + return None; + } + + let mut mode = 0; + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + return None; + } + + Some((handle, mode)) +} + +#[cfg(windows)] +pub(super) fn set_input_record_mode() -> std::io::Result<()> { + use windows_sys::Win32::System::Console::SetConsoleMode; + + let Some((handle, mode)) = current_input_mode() else { + return Ok(()); + }; + let requested_mode = input_record_mode(mode); + if requested_mode != mode && unsafe { SetConsoleMode(handle, requested_mode) } == 0 { + return Err(std::io::Error::last_os_error()); + } + + let original = if mode & ENABLE_VIRTUAL_TERMINAL_INPUT != 0 { + VirtualTerminalInput::Enabled + } else { + VirtualTerminalInput::Disabled + }; + ORIGINAL_VT_INPUT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(original); + Ok(()) +} + +#[cfg(windows)] +pub(super) fn ensure_input_record_mode() -> std::io::Result<()> { + use windows_sys::Win32::System::Console::SetConsoleMode; + + let Some((handle, mode)) = current_input_mode() else { + return Ok(()); + }; + let requested_mode = input_record_mode(mode); + if requested_mode != mode && unsafe { SetConsoleMode(handle, requested_mode) } == 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) +} + +#[cfg(windows)] +pub(super) fn restore_input_mode() -> std::io::Result<()> { + use windows_sys::Win32::System::Console::SetConsoleMode; + + let mut original_modes = ORIGINAL_VT_INPUT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(original) = original_modes.last().copied() else { + return Ok(()); + }; + let Some((handle, mode)) = current_input_mode() else { + original_modes.pop(); + return Ok(()); + }; + let requested_mode = restored_input_mode(mode, original); + if requested_mode != mode && unsafe { SetConsoleMode(handle, requested_mode) } == 0 { + return Err(std::io::Error::last_os_error()); + } + + original_modes.pop(); + Ok(()) +} diff --git a/codex-rs/tui/src/tui/windows_term_program_tests.rs b/codex-rs/tui/src/tui/windows_term_program_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..01679a3acc99edd4279684a2f8d95cbcfc5fcf59 --- /dev/null +++ b/codex-rs/tui/src/tui/windows_term_program_tests.rs @@ -0,0 +1,128 @@ +//! Regression coverage for bounded Windows terminal detection and child cleanup. + +use super::VscodeDetection; +use super::keyboard_enhancement_disabled_for; +use super::read_windows_vscode_detection_with_timeout; +use pretty_assertions::assert_eq; +use std::process::Command; +use std::process::Stdio; +use std::sync::mpsc; +use std::time::Duration; +use std::time::Instant; + +#[test] +fn reads_term_program_only_from_successful_probe() { + for (script, expected) in [ + ("printf 'TERM_PROGRAM=vscode\r\n'", VscodeDetection::VsCode), + ( + "printf 'TERM_PROGRAM=vscode\r\n'; exit 1", + VscodeDetection::Other, + ), + ( + "printf 'TERM_PROGRAM=WindowsTerminal'", + VscodeDetection::Other, + ), + ("exit 1", VscodeDetection::Other), + ("exit 2", VscodeDetection::Unknown), + ] { + assert_eq!( + read_windows_vscode_detection_with_timeout( + move || { + Command::new("/bin/sh") + .args(["-c", script]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok() + }, + Duration::from_secs(/*secs*/ 5), + ), + expected, + ); + } +} + +#[test] +fn failed_launch_is_unknown() { + assert_eq!( + read_windows_vscode_detection_with_timeout(|| None, Duration::from_secs(/*secs*/ 5)), + VscodeDetection::Unknown, + ); +} + +#[test] +fn times_out_and_reaps_running_probe() { + let (sender, receiver) = mpsc::channel(); + let started = Instant::now(); + let result = read_windows_vscode_detection_with_timeout( + move || { + let child = Command::new("sleep").arg("30").spawn().unwrap(); + sender.send(child.id()).unwrap(); + Some(child) + }, + Duration::from_millis(/*millis*/ 100), + ); + assert_eq!(result, VscodeDetection::Unknown); + assert!(keyboard_enhancement_disabled_for( + /*disable_env*/ None, /*is_wsl*/ true, result + )); + assert!(started.elapsed() < Duration::from_secs(/*secs*/ 2)); + assert_child_reaped( + receiver + .recv_timeout(Duration::from_secs(/*secs*/ 5)) + .unwrap(), + ); +} + +#[test] +fn times_out_during_launch_and_reaps_child_when_launch_finishes() { + let (release, blocked) = mpsc::channel(); + let (sender, receiver) = mpsc::channel(); + let started = Instant::now(); + let result = read_windows_vscode_detection_with_timeout( + move || { + // Model a WSL launch that cannot return a Child until interop recovers. + blocked + .recv_timeout(Duration::from_secs(/*secs*/ 5)) + .unwrap(); + let child = Command::new("sleep").arg("30").spawn().unwrap(); + sender.send(child.id()).unwrap(); + Some(child) + }, + Duration::from_millis(/*millis*/ 100), + ); + release.send(()).unwrap(); + assert_eq!(result, VscodeDetection::Unknown); + assert!(keyboard_enhancement_disabled_for( + /*disable_env*/ None, /*is_wsl*/ true, result + )); + assert!(started.elapsed() < Duration::from_secs(/*secs*/ 2)); + assert_child_reaped( + receiver + .recv_timeout(Duration::from_secs(/*secs*/ 5)) + .unwrap(), + ); +} + +fn assert_child_reaped(pid: u32) { + let started = Instant::now(); + loop { + // SAFETY: signal 0 only checks whether this process still exists. + if unsafe { + libc::kill(pid as libc::pid_t, /*sig*/ 0) + } == -1 + { + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH) + ); + return; + } + assert!( + started.elapsed() < Duration::from_secs(/*secs*/ 5), + "timed-out terminal probe was not reaped" + ); + std::thread::sleep(Duration::from_millis(/*millis*/ 10)); + } +}