diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index bb05e1a3f4e6..08e7f9b07118 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -31,6 +31,8 @@ const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); +const WORKSPACE_HEADLINE_FETCH_TIMEOUT: std::time::Duration = + std::time::Duration::from_millis(/*millis*/ 2000); impl App { pub(super) fn fetch_mcp_inventory( @@ -158,6 +160,29 @@ impl App { }); } + pub(super) fn refresh_status_line_workspace_headline( + &mut self, + app_server: &AppServerSession, + request_id: u64, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = tokio::time::timeout( + WORKSPACE_HEADLINE_FETCH_TIMEOUT, + fetch_workspace_messages(request_handle), + ) + .await + .map_err(|_| "account/workspaceMessages/read timed out in TUI".to_string()) + .and_then(|result| { + result + .map(crate::workspace_messages::workspace_headline_from_response) + .map_err(|err| err.to_string()) + }); + app_event_tx.send(AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result }); + }); + } + pub(super) fn send_add_credits_nudge_email( &mut self, app_server: &AppServerSession, @@ -796,6 +821,19 @@ pub(super) async fn consume_rate_limit_reset_credit_request( .wrap_err("account/rateLimitResetCredit/consume failed in TUI") } +pub(super) async fn fetch_workspace_messages( + request_handle: AppServerRequestHandle, +) -> Result { + let request_id = RequestId::String(format!("workspace-messages-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::GetWorkspaceMessages { + request_id, + params: None, + }) + .await + .wrap_err("account/workspaceMessages/read failed in TUI") +} + pub(super) async fn send_add_credits_nudge_email( request_handle: AppServerRequestHandle, credit_type: AddCreditsNudgeCreditType, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 16e4aaa62747..c4c380c67ae1 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -716,6 +716,9 @@ impl App { AppEvent::RefreshTokenActivity { request_id } => { self.refresh_token_activity(app_server, request_id); } + AppEvent::RefreshStatusLineWorkspaceHeadline { request_id } => { + self.refresh_status_line_workspace_headline(app_server, request_id); + } AppEvent::OpenThreadGoalMenu { thread_id } => { self.open_thread_goal_menu(app_server, thread_id).await; } @@ -2010,6 +2013,14 @@ impl App { self.chat_widget.set_status_line_git_summary(cwd, summary); self.refresh_status_line(); } + AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result } => { + if self + .chat_widget + .set_status_line_workspace_headline(request_id, result) + { + tui.frame_requester().schedule_frame(); + } + } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1143848d1377..61642d5791e1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -341,6 +341,11 @@ pub(crate) enum AppEvent { result: Result, }, + /// Fetch workspace messages for the status-line headline item. + RefreshStatusLineWorkspaceHeadline { + request_id: u64, + }, + /// Commit settled asynchronous usage output after active-output barriers clear. CommitPendingUsageOutput, @@ -977,6 +982,11 @@ pub(crate) enum AppEvent { cwd: PathBuf, summary: crate::chatwidget::StatusLineGitSummary, }, + /// Async update of the workspace notification headline for status line rendering. + StatusLineWorkspaceHeadlineUpdated { + request_id: u64, + result: Result, + }, /// Apply a user-confirmed status-line item ordering/selection. StatusLineSetup { items: Vec, diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 4dd62a7d20f5..63ce4237dbe4 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -137,6 +137,9 @@ pub(crate) enum StatusLineItem { /// Current thread title (if set by user). ThreadTitle, + /// Current workspace notification headline. + WorkspaceHeadline, + /// Latest checklist task progress from `update_plan` (if available). TaskProgress, } @@ -185,6 +188,9 @@ impl StatusLineItem { 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)" } @@ -217,6 +223,7 @@ impl StatusLineItem { StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput, StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, + StatusLineItem::WorkspaceHeadline => StatusSurfacePreviewItem::WorkspaceHeadline, StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress, } } diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index 170c4641d2b2..4198943a4f79 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -49,7 +49,7 @@ impl StatusLineAccent { StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode, StatusLineItem::Permissions => Self::Mode, StatusLineItem::ApprovalMode => Self::Mode, - StatusLineItem::ThreadTitle => Self::Thread, + StatusLineItem::ThreadTitle | StatusLineItem::WorkspaceHeadline => Self::Thread, StatusLineItem::TaskProgress => Self::Progress, } } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index bd0a94a4d40c..b8b7a6fbd4a9 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -30,6 +30,7 @@ pub(crate) enum StatusSurfacePreviewItem { SessionId, FastMode, RawOutput, + WorkspaceHeadline, Model, ModelWithReasoning, Reasoning, @@ -62,6 +63,7 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4", StatusSurfacePreviewItem::FastMode => "Fast on", StatusSurfacePreviewItem::RawOutput => "raw output", + StatusSurfacePreviewItem::WorkspaceHeadline => "Workspace headline", StatusSurfacePreviewItem::Model => "gpt-5.2-codex", StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", StatusSurfacePreviewItem::Reasoning => "medium", @@ -94,6 +96,7 @@ impl StatusSurfacePreviewItem { Self::SessionId, Self::FastMode, Self::RawOutput, + Self::WorkspaceHeadline, Self::Model, Self::ModelWithReasoning, Self::Reasoning, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fd3c140e3fba..20bef58a1a29 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -727,6 +727,16 @@ pub(crate) struct ChatWidget { status_line_git_summary_pending: bool, // True once we've attempted a Git summary lookup for the current CWD. status_line_git_summary_lookup_complete: bool, + // Cached workspace notification headline for the status line. + status_line_workspace_headline: Option, + // Request ID for the async workspace headline fetch currently in flight. + status_line_workspace_headline_pending_request_id: Option, + // Request ID to assign to the next workspace headline fetch. + next_status_line_workspace_headline_request_id: u64, + // Last time a workspace headline fetch was requested. + status_line_workspace_headline_last_requested_at: Option, + // Set after the backend reports the workspace-message feature gate is disabled. + status_line_workspace_messages_disabled: bool, // Current thread-goal status shown in the status line when plan mode is inactive. current_goal_status_indicator: Option, current_goal_status: Option, @@ -1188,6 +1198,7 @@ impl ChatWidget { { self.refresh_terminal_title(); } + self.refresh_status_line_if_workspace_headline_due(); } fn flush_active_cell(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 77fdd74e5240..17f2c8c3b0f4 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -229,6 +229,11 @@ impl ChatWidget { status_line_git_summary_cwd: None, status_line_git_summary_pending: false, status_line_git_summary_lookup_complete: false, + status_line_workspace_headline: None, + status_line_workspace_headline_pending_request_id: None, + next_status_line_workspace_headline_request_id: 0, + status_line_workspace_headline_last_requested_at: None, + status_line_workspace_messages_disabled: false, current_goal_status_indicator: None, current_goal_status: None, external_editor_state: ExternalEditorState::Closed, diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 75f8d1ad4b5b..75c4cc66e431 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -224,6 +224,10 @@ impl ChatWidget { self.clear_pending_token_activity_refreshes(); self.clear_pending_rate_limit_reset_requests(); } + self.status_line_workspace_headline = None; + self.status_line_workspace_headline_pending_request_id = None; + self.status_line_workspace_headline_last_requested_at = None; + self.status_line_workspace_messages_disabled = false; self.status_account_display = status_account_display; self.plan_type = plan_type; self.has_chatgpt_account = has_chatgpt_account; @@ -232,6 +236,7 @@ impl ChatWidget { .set_connectors_enabled(self.connectors_enabled()); self.bottom_pane .set_token_activity_command_enabled(has_codex_backend_auth); + self.refresh_status_line(); } /// Set the syntax theme override in the widget's config copy. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap new file mode 100644 index 000000000000..32ad18ec6537 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] Use theme colors Apply colors from the active /theme + ─────────────────────── + [x] workspace-headline Workspace notification headline (Enterprise workspaces only; omitted … + [ ] model Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] reasoning Current reasoning level + [ ] current-dir Current working directory + [ ] project-name Project name (omitted when unavailable) + + Workspace maintenance starts at 5pm + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 592dd0438172..5f02dd8d690a 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -65,6 +65,11 @@ impl StatusSurfaceSelections { .status_line_items .contains(&StatusLineItem::BranchChanges) } + + fn uses_workspace_headline(&self) -> bool { + self.status_line_items + .contains(&StatusLineItem::WorkspaceHeadline) + } } /// Cached project-root display name keyed by the cwd used for the last lookup. @@ -157,6 +162,15 @@ impl ChatWidget { self.request_status_line_git_summary(cwd); } } + + if !selections.uses_workspace_headline() { + self.status_line_workspace_headline = None; + self.status_line_workspace_headline_pending_request_id = None; + self.status_line_workspace_headline_last_requested_at = None; + self.status_line_workspace_messages_disabled = false; + } else { + self.request_status_line_workspace_headline_if_due(Instant::now()); + } } fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) { @@ -553,6 +567,85 @@ impl ChatWidget { }); } + fn request_status_line_workspace_headline_if_due(&mut self, now: Instant) { + if !self.status_line_workspace_headline_should_fetch(now) { + return; + } + let request_id = self.next_status_line_workspace_headline_request_id; + self.next_status_line_workspace_headline_request_id = self + .next_status_line_workspace_headline_request_id + .wrapping_add(/*rhs*/ 1); + self.status_line_workspace_headline_pending_request_id = Some(request_id); + self.status_line_workspace_headline_last_requested_at = Some(now); + self.app_event_tx + .send(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }); + } + + fn status_line_workspace_headline_should_fetch(&self, now: Instant) -> bool { + if self + .status_line_workspace_headline_pending_request_id + .is_some() + || self.status_line_workspace_messages_disabled + || !self.has_codex_backend_auth + { + return false; + } + + self.status_line_workspace_headline_last_requested_at + .is_none_or(|last_requested_at| { + now.saturating_duration_since(last_requested_at) + >= crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL + }) + } + + pub(super) fn refresh_status_line_if_workspace_headline_due(&mut self) { + let now = Instant::now(); + if self.status_line_workspace_headline_should_fetch(now) + && self + .status_line_items_with_invalids() + .0 + .contains(&StatusLineItem::WorkspaceHeadline) + { + self.refresh_status_line(); + } + } + + pub(crate) fn set_status_line_workspace_headline( + &mut self, + request_id: u64, + result: Result, + ) -> bool { + if self.status_line_workspace_headline_pending_request_id != Some(request_id) { + return false; + } + self.status_line_workspace_headline_pending_request_id = None; + match result { + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(headline)) => { + self.status_line_workspace_messages_disabled = false; + self.status_line_workspace_headline = headline; + } + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled) => { + self.status_line_workspace_messages_disabled = true; + self.status_line_workspace_headline = None; + } + Err(err) => { + tracing::debug!(error = %err, "failed to fetch workspace headline"); + } + } + + if !self.status_line_workspace_messages_disabled + && self + .status_line_items_with_invalids() + .0 + .contains(&StatusLineItem::WorkspaceHeadline) + { + self.frame_requester + .schedule_frame_in(crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL); + } + self.refresh_status_line(); + true + } + /// Resolves a display string for one configured status-line item. /// /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on @@ -653,6 +746,7 @@ impl ChatWidget { } }, ), + StatusLineItem::WorkspaceHeadline => self.status_line_workspace_headline.clone(), StatusLineItem::TaskProgress => self.terminal_title_task_progress(), } } @@ -693,6 +787,7 @@ impl ChatWidget { StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId, StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput, + StatusSurfacePreviewItem::WorkspaceHeadline => StatusLineItem::WorkspaceHeadline, StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, StatusSurfacePreviewItem::Reasoning => StatusLineItem::Reasoning, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 583377bace10..9e528fb6c207 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -14,6 +14,15 @@ fn enable_test_ambient_pet(chat: &mut ChatWidget) { chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false); } +fn take_workspace_headline_request_id( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> u64 { + match rx.try_recv() { + Ok(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }) => request_id, + event => panic!("expected workspace headline refresh, got {event:?}"), + } +} + /// Receiving a token usage update without usage clears the context indicator. #[tokio::test] async fn token_count_none_resets_context_indicator() { @@ -2141,6 +2150,192 @@ async fn status_line_legacy_context_usage_renders_context_used_percent() { ); } +#[tokio::test] +async fn status_line_workspace_headline_renders_cached_value() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); + + chat.refresh_status_line(); + + assert_eq!( + status_line_text(&chat), + Some("Workspace maintenance starts at 5pm".to_string()) + ); + assert!( + drain_insert_history(&mut rx).is_empty(), + "workspace-headline should be a valid status line item" + ); +} + +#[tokio::test] +async fn status_line_workspace_headline_omits_when_unavailable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec![ + "workspace-headline".to_string(), + "run-state".to_string(), + ]); + + chat.refresh_status_line(); + + assert_eq!(status_line_text(&chat), Some("Ready".to_string())); + assert!( + drain_insert_history(&mut rx).is_empty(), + "workspace-headline should be omitted without warning when no headline is cached" + ); +} + +#[tokio::test] +async fn workspace_headline_update_applies_feature_disabled_result() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Old headline".to_string()); + let request_id = 3; + chat.status_line_workspace_headline_pending_request_id = Some(request_id); + + assert!(chat.set_status_line_workspace_headline( + request_id, + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled), + )); + + assert_eq!(status_line_text(&chat), None); + assert!(chat.status_line_workspace_messages_disabled); +} + +#[tokio::test] +async fn workspace_headline_update_applies_available_headline() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + let request_id = 4; + chat.status_line_workspace_headline_pending_request_id = Some(request_id); + + assert!(chat.set_status_line_workspace_headline( + request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "Fresh workspace headline".to_string(), + )) + ), + )); + + assert_eq!( + status_line_text(&chat), + Some("Fresh workspace headline".to_string()) + ); + assert!(!chat.status_line_workspace_messages_disabled); +} + +#[tokio::test] +async fn account_update_clears_workspace_headline_state() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Old workspace headline".to_string()); + chat.status_line_workspace_headline_pending_request_id = Some(5); + chat.status_line_workspace_headline_last_requested_at = Some(Instant::now()); + chat.status_line_workspace_messages_disabled = true; + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, + ); + + assert_eq!( + ( + status_line_text(&chat), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_headline_last_requested_at, + chat.status_line_workspace_messages_disabled, + ), + (None, None, None, false) + ); +} + +#[tokio::test] +async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, + ); + + let request_id = take_workspace_headline_request_id(&mut rx); + assert_eq!( + chat.status_line_workspace_headline_pending_request_id, + Some(request_id) + ); +} + +#[tokio::test] +async fn account_update_discards_stale_workspace_headline_results() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + chat.update_account_state( + Some(StatusAccountDisplay::ChatGpt { + email: Some("first@example.com".to_string()), + plan: None, + }), + /*plan_type*/ None, + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + let stale_request_id = take_workspace_headline_request_id(&mut rx); + + chat.update_account_state( + Some(StatusAccountDisplay::ChatGpt { + email: Some("second@example.com".to_string()), + plan: None, + }), + /*plan_type*/ None, + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + let current_request_id = take_workspace_headline_request_id(&mut rx); + + assert_ne!(stale_request_id, current_request_id); + assert!(!chat.set_status_line_workspace_headline( + stale_request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "First account headline".to_string(), + )) + ), + )); + assert_eq!( + ( + chat.status_line_workspace_headline.clone(), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_messages_disabled, + ), + (None, Some(current_request_id), false) + ); + + assert!(chat.set_status_line_workspace_headline( + current_request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "Second account headline".to_string(), + )) + ), + )); + assert!(!chat.set_status_line_workspace_headline( + stale_request_id, + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled), + )); + assert_eq!( + ( + status_line_text(&chat), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_messages_disabled, + ), + (Some("Second account headline".to_string()), None, false,) + ); +} + #[tokio::test] async fn status_line_branch_state_resets_when_git_branch_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 8654b37d24fd..63005cb95759 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -180,6 +180,18 @@ async fn status_line_setup_popup_hardcoded_only_snapshot() { ); } +#[tokio::test] +async fn status_line_setup_popup_workspace_headline_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_workspace_headline", + status_line_popup_snapshot(&mut chat) + ); +} + #[tokio::test] async fn status_surface_preview_lines_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f189b231497f..84c59b97cf35 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -200,6 +200,7 @@ mod width; #[cfg(any(target_os = "windows", test))] mod windows_sandbox; mod workspace_command; +mod workspace_messages; mod wrapping; diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs new file mode 100644 index 000000000000..b31f6d437367 --- /dev/null +++ b/codex-rs/tui/src/workspace_messages.rs @@ -0,0 +1,29 @@ +use codex_app_server_protocol::GetWorkspaceMessagesResponse; +use codex_app_server_protocol::WorkspaceMessageType; +use std::time::Duration; + +pub(crate) const WORKSPACE_HEADLINE_REFRESH_INTERVAL: Duration = Duration::from_secs(60); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum WorkspaceHeadlineFetchResult { + Available(Option), + FeatureDisabled, +} + +pub(crate) fn workspace_headline_from_response( + response: GetWorkspaceMessagesResponse, +) -> WorkspaceHeadlineFetchResult { + if !response.feature_enabled { + return WorkspaceHeadlineFetchResult::FeatureDisabled; + } + + WorkspaceHeadlineFetchResult::Available(response.messages.into_iter().find_map(|message| { + (message.message_type == WorkspaceMessageType::Headline) + .then(|| message.message_body.trim().to_string()) + .filter(|headline| !headline.is_empty()) + })) +} + +#[cfg(test)] +#[path = "workspace_messages_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/workspace_messages_tests.rs b/codex-rs/tui/src/workspace_messages_tests.rs new file mode 100644 index 000000000000..962d2ceba103 --- /dev/null +++ b/codex-rs/tui/src/workspace_messages_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use codex_app_server_protocol::WorkspaceMessage; +use pretty_assertions::assert_eq; + +#[test] +fn workspace_headline_from_response_uses_first_non_empty_headline() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: true, + messages: vec![ + WorkspaceMessage { + message_id: "announcement-id".to_string(), + message_type: WorkspaceMessageType::Announcement, + message_body: "Announcement body".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "empty-headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " ".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " Workspace headline ".to_string(), + created_at: None, + archived_at: None, + }, + ], + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::Available(Some("Workspace headline".to_string())) + ); +} + +#[test] +fn workspace_headline_from_response_reports_feature_disabled() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: false, + messages: Vec::new(), + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::FeatureDisabled + ); +}