diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index f9a0f686a0..3bb6d48126 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -458,6 +458,27 @@ impl App { .add_error_message(format!("Failed to read workspace: {err}")), } } + AppEvent::ReadThreadWorkspaceForStatus { + refreshing_rate_limits, + request_id, + } => { + let workspace = match self.active_thread_id { + Some(thread_id) => match app_server.thread_workspace_read(thread_id).await { + Ok(response) => Some(response), + Err(err) => { + tracing::warn!("failed to read workspace for /status: {err}"); + None + } + }, + None => None, + }; + self.chat_widget.add_status_output_with_workspace( + refreshing_rate_limits, + request_id, + workspace.as_ref(), + /*workspace_state_stale*/ workspace.is_none(), + ); + } AppEvent::AppendMessageHistoryEntry { thread_id, text } => { self.append_message_history_entry(thread_id, text); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8c8c854772..aa333f7288 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -268,6 +268,12 @@ pub(crate) enum AppEvent { /// Show the authoritative runtime cwd and workspace roots. ReadThreadWorkspace, + /// Read the authoritative runtime workspace before rendering `/status`. + ReadThreadWorkspaceForStatus { + refreshing_rate_limits: bool, + request_id: Option, + }, + /// Approve one retry of a recent auto-review denial selected in the TUI. ApproveRecentAutoReviewDenial { thread_id: ThreadId, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 32c9b0e986..cc4e0804e8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -115,6 +115,7 @@ use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadWorkspaceReadResponse; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 8ef40ffba9..cf70cf15ec 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -439,18 +439,23 @@ impl ChatWidget { self.add_hooks_output(); } SlashCommand::Status => { - if self.should_prefetch_rate_limits() { + let (refreshing_rate_limits, request_id) = if self.should_prefetch_rate_limits() { let request_id = self.next_status_refresh_request_id; self.next_status_refresh_request_id = self.next_status_refresh_request_id.wrapping_add(1); - self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + (true, Some(request_id)) + } else { + (false, None) + }; + self.app_event_tx + .send(AppEvent::ReadThreadWorkspaceForStatus { + refreshing_rate_limits, + request_id, + }); + if let Some(request_id) = request_id { self.app_event_tx.send(AppEvent::RefreshRateLimits { origin: RateLimitRefreshOrigin::StatusCommand { request_id }, }); - } else { - self.add_status_output( - /*refreshing_rate_limits*/ false, /*request_id*/ None, - ); } } SlashCommand::Ide => { diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index d4de738314..8a3fd2a879 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -188,11 +188,35 @@ impl ChatWidget { self.refresh_status_surfaces(); } + #[cfg(test)] pub(crate) fn add_status_output( &mut self, refreshing_rate_limits: bool, request_id: Option, ) { + self.add_status_output_with_workspace( + refreshing_rate_limits, + request_id, + /*workspace*/ None, + /*workspace_state_stale*/ false, + ); + } + + pub(crate) fn add_status_output_with_workspace( + &mut self, + refreshing_rate_limits: bool, + request_id: Option, + workspace: Option<&ThreadWorkspaceReadResponse>, + workspace_state_stale: bool, + ) { + let mut config = self.config.clone(); + if let Some(workspace) = workspace { + config.cwd = workspace.cwd.clone(); + config.workspace_roots = workspace.runtime_workspace_roots.clone(); + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); + } let default_usage = TokenUsage::default(); let token_info = self.token_info.as_ref(); let total_usage = token_info @@ -221,9 +245,9 @@ impl ChatWidget { .cloned() .collect(); let agents_summary = - crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths); + crate::status::compose_agents_summary(&config, &self.instruction_source_paths); let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle( - &self.config, + &config, self.runtime_model_provider_base_url.as_deref(), self.remote_connection.as_ref(), self.status_account_display.as_ref(), @@ -239,6 +263,7 @@ impl ChatWidget { collaboration_mode, reasoning_effort_override, agents_summary, + workspace_state_stale, refreshing_rate_limits, ); if let Some(request_id) = request_id { diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index c206313549..e3779290f1 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -1,30 +1,50 @@ use super::*; use assert_matches::assert_matches; +fn render_requested_status( + chat: &mut ChatWidget, + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> (String, Option) { + let (refreshing_rate_limits, request_id) = match rx.try_recv() { + Ok(AppEvent::ReadThreadWorkspaceForStatus { + refreshing_rate_limits, + request_id, + }) => (refreshing_rate_limits, request_id), + other => panic!("expected workspace read request for /status, got {other:?}"), + }; + if let Some(request_id) = request_id { + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::StatusCommand { + request_id: event_request_id, + }, + }) if event_request_id == request_id + ); + } + chat.add_status_output(refreshing_rate_limits, request_id); + let rendered = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => { + lines_to_single_string(&cell.display_lines(/*width*/ 80)) + } + other => panic!("expected status output after workspace read, got {other:?}"), + }; + (rendered, request_id) +} + #[tokio::test] -async fn status_command_renders_immediately_and_refreshes_rate_limits_for_chatgpt_auth() { +async fn status_command_reads_workspace_and_refreshes_rate_limits_for_chatgpt_auth() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.dispatch_command(SlashCommand::Status); - let rendered = match rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => { - lines_to_single_string(&cell.display_lines(/*width*/ 80)) - } - other => panic!("expected status output before refresh request, got {other:?}"), - }; + let (rendered, request_id) = render_requested_status(&mut chat, &mut rx); assert!( !rendered.contains("refreshing limits"), "expected /status to avoid transient refresh text in terminal history, got: {rendered}" ); - let request_id = match rx.try_recv() { - Ok(AppEvent::RefreshRateLimits { - origin: RateLimitRefreshOrigin::StatusCommand { request_id }, - }) => request_id, - other => panic!("expected rate-limit refresh request, got {other:?}"), - }; - pretty_assertions::assert_eq!(request_id, 0); + pretty_assertions::assert_eq!(request_id, Some(0)); } #[tokio::test] @@ -34,28 +54,15 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs( chat.dispatch_command(SlashCommand::Status); - match rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(_)) => {} - other => panic!("expected status output before refresh request, got {other:?}"), - } - let first_request_id = match rx.try_recv() { - Ok(AppEvent::RefreshRateLimits { - origin: RateLimitRefreshOrigin::StatusCommand { request_id }, - }) => request_id, - other => panic!("expected rate-limit refresh request, got {other:?}"), - }; + let (_, first_request_id) = render_requested_status(&mut chat, &mut rx); + let first_request_id = first_request_id.expect("ChatGPT status should refresh limits"); chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); chat.finish_status_rate_limit_refresh(first_request_id); drain_insert_history(&mut rx); chat.dispatch_command(SlashCommand::Status); - let refreshed = match rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => { - lines_to_single_string(&cell.display_lines(/*width*/ 80)) - } - other => panic!("expected refreshed status output, got {other:?}"), - }; + let (refreshed, _) = render_requested_status(&mut chat, &mut rx); assert!( refreshed.contains("8% left"), "expected a future /status output to use refreshed cached limits, got: {refreshed}" @@ -63,12 +70,13 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs( } #[tokio::test] -async fn status_command_renders_immediately_without_rate_limit_refresh() { +async fn status_command_reads_workspace_without_rate_limit_refresh() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Status); - assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + let (_, request_id) = render_requested_status(&mut chat, &mut rx); + pretty_assertions::assert_eq!(request_id, None); assert!( !std::iter::from_fn(|| rx.try_recv().ok()) .any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })), @@ -83,12 +91,7 @@ async fn status_command_uses_catalog_default_reasoning_when_config_empty() { chat.dispatch_command(SlashCommand::Status); - let rendered = match rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => { - lines_to_single_string(&cell.display_lines(/*width*/ 80)) - } - other => panic!("expected status output, got {other:?}"), - }; + let (rendered, _) = render_requested_status(&mut chat, &mut rx); assert!( rendered.contains("gpt-5.4 (reasoning medium, summaries auto)"), "expected /status to render the catalog default reasoning effort, got: {rendered}" @@ -102,12 +105,7 @@ async fn status_command_renders_instruction_sources_from_thread_session() { chat.dispatch_command(SlashCommand::Status); - let rendered = match rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => { - lines_to_single_string(&cell.display_lines(/*width*/ 80)) - } - other => panic!("expected status output, got {other:?}"), - }; + let (rendered, _) = render_requested_status(&mut chat, &mut rx); assert!( rendered.contains("Agents.md"), "expected /status to render app-server instruction sources, got: {rendered}" @@ -124,30 +122,12 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() { set_chatgpt_auth(&mut chat); chat.dispatch_command(SlashCommand::Status); - match rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(_)) => {} - other => panic!("expected first status output, got {other:?}"), - } - let first_request_id = match rx.try_recv() { - Ok(AppEvent::RefreshRateLimits { - origin: RateLimitRefreshOrigin::StatusCommand { request_id }, - }) => request_id, - other => panic!("expected first refresh request, got {other:?}"), - }; + let (_, first_request_id) = render_requested_status(&mut chat, &mut rx); + let first_request_id = first_request_id.expect("ChatGPT status should refresh limits"); chat.dispatch_command(SlashCommand::Status); - let second_rendered = match rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => { - lines_to_single_string(&cell.display_lines(/*width*/ 80)) - } - other => panic!("expected second status output, got {other:?}"), - }; - let second_request_id = match rx.try_recv() { - Ok(AppEvent::RefreshRateLimits { - origin: RateLimitRefreshOrigin::StatusCommand { request_id }, - }) => request_id, - other => panic!("expected second refresh request, got {other:?}"), - }; + let (second_rendered, second_request_id) = render_requested_status(&mut chat, &mut rx); + let second_request_id = second_request_id.expect("ChatGPT status should refresh limits"); assert_ne!(first_request_id, second_request_id); assert!( @@ -162,3 +142,53 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() { chat.finish_status_rate_limit_refresh(second_request_id); assert!(chat.refreshing_status_outputs.is_empty()); } + +#[tokio::test] +async fn status_output_uses_authoritative_runtime_workspace() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let cwd = test_path_buf("/workspace/runtime").abs(); + let extra_root = test_path_buf("/workspace/shared").abs(); + chat.instruction_source_paths = vec![cwd.join("AGENTS.md")]; + + chat.add_status_output_with_workspace( + /*refreshing_rate_limits*/ false, + /*request_id*/ None, + Some(&codex_app_server_protocol::ThreadWorkspaceReadResponse { + cwd: cwd.clone(), + runtime_workspace_roots: vec![cwd, extra_root], + }), + /*workspace_state_stale*/ false, + ); + + let rendered = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => { + lines_to_single_string(&cell.display_lines(/*width*/ 120)) + } + other => panic!("expected status output, got {other:?}"), + }; + assert!(rendered.contains("Directory:")); + assert!(rendered.contains("Workspace roots:")); + assert!(rendered.contains("/workspace/runtime")); + assert!(rendered.contains("/workspace/shared")); + assert!(rendered.contains("Agents.md:")); + assert!(rendered.contains("AGENTS.md")); +} + +#[tokio::test] +async fn status_output_warns_when_workspace_state_may_be_stale() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.add_status_output_with_workspace( + /*refreshing_rate_limits*/ false, /*request_id*/ None, /*workspace*/ None, + /*workspace_state_stale*/ true, + ); + + let rendered = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => { + lines_to_single_string(&cell.display_lines(/*width*/ 120)) + } + other => panic!("expected status output, got {other:?}"), + }; + assert!(rendered.contains("Warning:")); + assert!(rendered.contains("workspace state may be stale")); +} diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 61ffd3da7d..4e7245f28a 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -107,6 +107,8 @@ struct StatusHistoryCell { model_name: String, model_details: Vec, directory: PathBuf, + workspace_roots: Vec, + workspace_state_stale: bool, permissions: String, agents_summary: Arc>, collaboration_mode: Option, @@ -192,6 +194,7 @@ pub(crate) fn new_status_output_with_rate_limits( collaboration_mode, reasoning_effort_override, "".to_string(), + /*workspace_state_stale*/ false, refreshing_rate_limits, ) .0 @@ -215,6 +218,7 @@ pub(crate) fn new_status_output_with_rate_limits_handle( collaboration_mode: Option<&str>, reasoning_effort_override: Option>, agents_summary: String, + workspace_state_stale: bool, refreshing_rate_limits: bool, ) -> (CompositeHistoryCell, StatusHistoryHandle) { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); @@ -235,6 +239,7 @@ pub(crate) fn new_status_output_with_rate_limits_handle( collaboration_mode, reasoning_effort_override, agents_summary, + workspace_state_stale, refreshing_rate_limits, ); @@ -263,6 +268,7 @@ impl StatusHistoryCell { collaboration_mode: Option<&str>, reasoning_effort_override: Option>, agents_summary: String, + workspace_state_stale: bool, refreshing_rate_limits: bool, ) -> (Self, StatusHistoryHandle) { let approval_policy = AskForApproval::from(config.permissions.approval_policy.value()); @@ -308,7 +314,6 @@ impl StatusHistoryCell { let active_permission_profile = config.permissions.active_permission_profile(); let sandbox = status_permission_summary(&permission_profile, &config.cwd, workspace_roots.as_slice()); - let workspace_root_suffix = workspace_root_suffix(workspace_roots.as_slice(), &config.cwd); let approval = status_approval_label(approval_policy, config.approvals_reviewer, &approval); let permissions = status_permissions_label( active_permission_profile.as_ref(), @@ -316,7 +321,6 @@ impl StatusHistoryCell { approval_policy, &sandbox, &approval, - workspace_root_suffix.as_deref(), ); let model_provider = format_model_provider(config, runtime_model_provider_base_url); let show_chatgpt_usage_link = config.model_provider.requires_openai_auth; @@ -356,6 +360,8 @@ impl StatusHistoryCell { model_name, model_details, directory: config.cwd.to_path_buf(), + workspace_roots: config.workspace_roots.clone(), + workspace_state_stale, permissions, collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, @@ -600,29 +606,12 @@ fn status_permission_summary( summary } -fn workspace_root_suffix( - workspace_roots: &[AbsolutePathBuf], - cwd: &AbsolutePathBuf, -) -> Option { - let extra_roots = workspace_roots - .iter() - .filter(|root| *root != cwd) - .map(|root| root.to_string_lossy().to_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 { @@ -635,17 +624,9 @@ fn status_permissions_label( return format!("{label} ({approval})"); } Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) => match sandbox { - "workspace" => { - return format!( - "Workspace{} ({approval})", - workspace_root_suffix.unwrap_or("") - ); - } + "workspace" => return format!("Workspace ({approval})"), "workspace with network access" => { - return format!( - "Workspace with network access{} ({approval})", - workspace_root_suffix.unwrap_or("") - ); + return format!("Workspace with network access ({approval})"); } _ => {} }, @@ -659,7 +640,6 @@ fn status_permissions_label( }; } Some(id) => { - let sandbox = decorate_workspace_sandbox_label(sandbox, workspace_root_suffix); return format!("Profile {id} ({sandbox}, {approval})"); } None => {} @@ -669,27 +649,16 @@ fn status_permissions_label( return format!("Read Only ({approval})"); } if approval_policy == AskForApproval::OnRequest && sandbox == "workspace" { - return format!( - "Workspace{} ({approval})", - workspace_root_suffix.unwrap_or("") - ); + return format!("Workspace ({approval})"); } 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, @@ -732,10 +701,16 @@ impl HistoryCell for StatusHistoryCell { } }); - let mut labels: Vec = vec!["Model", "Directory", "Permissions", "Agents.md"] - .into_iter() - .map(str::to_string) - .collect(); + let mut labels: Vec = vec![ + "Model", + "Directory", + "Workspace roots", + "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)] @@ -768,6 +743,9 @@ impl HistoryCell for StatusHistoryCell { if self.collaboration_mode.is_some() { push_label(&mut labels, &mut seen, "Collaboration mode"); } + if self.workspace_state_stale { + push_label(&mut labels, &mut seen, "Warning"); + } push_label(&mut labels, &mut seen, "Token usage"); if self.token_usage.context_window.is_some() { push_label(&mut labels, &mut seen, "Context window"); @@ -829,8 +807,26 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Model provider", vec![Span::from(model_provider.clone())])); } lines.push(formatter.line("Directory", vec![Span::from(directory_value)])); + let mut workspace_roots = self.workspace_roots.iter(); + let first_root = workspace_roots + .next() + .map(|root| format_directory_display(root.as_path(), Some(value_width))) + .unwrap_or_else(|| "(none)".to_string()); + lines.push(formatter.line("Workspace roots", vec![Span::from(first_root)])); + lines.extend(workspace_roots.map(|root| { + formatter.continuation(vec![Span::from(format_directory_display( + root.as_path(), + Some(value_width), + ))]) + })); lines.push(formatter.line("Permissions", vec![Span::from(self.permissions.clone())])); lines.push(formatter.line("Agents.md", vec![Span::from(agents_summary)])); + if self.workspace_state_stale { + lines.push(formatter.line( + "Warning", + vec![Span::from("workspace state may be stale").dim()], + )); + } if let Some(account_value) = account_value { lines.push(formatter.line("Account", vec![Span::from(account_value)])); diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 4ab7a2cfa2..108b85c98c 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -178,26 +178,27 @@ fn sanitize_directory(lines: Vec) -> Vec { 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)); + for label in ["Directory: ", "Workspace roots: "] { + if let (Some(frame_width), Some(label_pos), Some(pipe_idx)) = + (frame_width, line.find(label), line.rfind('│')) + { + let prefix = &line[..label_pos + label.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); + return rebuilt; } - rebuilt.push_str(suffix); - rebuilt - } else { - line } + line }) .collect() } @@ -211,7 +212,10 @@ fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> fn permissions_text_for(config: &Config) -> Option { let usage = TokenUsage::default(); let captured_at = chrono::Local - .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .with_ymd_and_hms( + /*year*/ 2024, /*month*/ 1, /*day*/ 2, /*hour*/ 3, /*min*/ 4, + /*sec*/ 5, + ) .single() .expect("timestamp"); let model_slug = get_model_offline_for_tests(config.model.as_deref()); @@ -220,11 +224,11 @@ fn permissions_text_for(config: &Config) -> Option { test_status_account_display().as_ref(), /*token_info*/ None, &usage, - &None, + /*session_id*/ &None, /*thread_name*/ None, /*forked_from*/ None, /*rate_limits*/ None, - None, + /*_plan_type*/ None, captured_at, &model_slug, /*collaboration_mode*/ None, @@ -243,6 +247,34 @@ fn permissions_text_for(config: &Config) -> Option { }) } +fn rendered_status_for(config: &Config) -> String { + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms( + /*year*/ 2024, /*month*/ 1, /*day*/ 2, /*hour*/ 3, /*min*/ 4, + /*sec*/ 5, + ) + .single() + .expect("timestamp"); + let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let composite = new_status_output( + config, + test_status_account_display().as_ref(), + /*token_info*/ None, + &usage, + /*session_id*/ &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + /*_plan_type*/ None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + render_lines(&composite.display_lines(/*width*/ 120)).join("\n") +} + #[tokio::test] async fn status_snapshot_includes_reasoning_details() { let temp_home = TempDir::new().expect("temp home"); @@ -473,7 +505,7 @@ async fn status_permissions_named_profile_shows_additional_writable_roots() { } #[tokio::test] -async fn status_permissions_workspace_roots_show_additional_directories() { +async fn status_permissions_workspace_roots_do_not_repeat_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()); @@ -483,7 +515,7 @@ async fn status_permissions_workspace_roots_show_additional_directories() { .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.workspace_roots = vec![config.cwd.clone(), extra_root]; config .permissions .set_workspace_roots(config.workspace_roots.clone()); @@ -505,7 +537,42 @@ async fn status_permissions_workspace_roots_show_additional_directories() { } #[tokio::test] -async fn status_permissions_workspace_roots_include_profile_defined_directories() { +async fn status_workspace_roots_list_all_runtime_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()); + let extra_root = test_path_buf("/workspace/extra").abs(); + config.workspace_roots.push(extra_root); + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); + + let rendered = rendered_status_for(&config); + + assert!( + rendered + .lines() + .any(|line| line.contains("Workspace roots:") && line.contains("/workspace/tests")) + ); + assert!(rendered.contains("/workspace/extra")); +} + +#[tokio::test] +async fn status_workspace_roots_show_none_when_runtime_list_is_empty() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.workspace_roots.clear(); + config.permissions.set_workspace_roots(Vec::new()); + + assert!( + rendered_status_for(&config) + .lines() + .any(|line| line.contains("Workspace roots:") && line.contains("(none)")) + ); +} + +#[tokio::test] +async fn status_permissions_workspace_roots_do_not_repeat_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()); @@ -538,6 +605,9 @@ async fn status_permissions_workspace_roots_include_profile_defined_directories( profile_root.display() )) ); + assert!( + !rendered_status_for(&config).contains(profile_root.as_path().to_string_lossy().as_ref()) + ); } #[tokio::test] @@ -670,6 +740,7 @@ async fn status_model_provider_uses_bedrock_runtime_base_url_and_gates_usage_lin /*collaboration_mode*/ None, /*reasoning_effort_override*/ None, "".to_string(), + /*workspace_state_stale*/ false, /*refreshing_rate_limits*/ false, ); let rendered = render_lines(&composite.display_lines(/*width*/ 120)).join("\n"); @@ -711,6 +782,7 @@ async fn status_model_provider_uses_bedrock_runtime_base_url_and_gates_usage_lin /*collaboration_mode*/ None, /*reasoning_effort_override*/ None, "".to_string(), + /*workspace_state_stale*/ false, /*refreshing_rate_limits*/ false, ); let rendered = render_lines(&composite.display_lines(/*width*/ 120)).join("\n"); @@ -1501,6 +1573,7 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() { /*collaboration_mode*/ None, /*reasoning_effort_override*/ Some(Some(ReasoningEffort::Medium)), "".to_string(), + /*workspace_state_stale*/ false, /*refreshing_rate_limits*/ false, ); let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80));