feat(tui): show workspace roots in status

This commit is contained in:
Felipe Coury
2026-06-12 00:34:00 -07:00
parent 43734ec5c9
commit 0543eb759e
8 changed files with 301 additions and 144 deletions
+21
View File
@@ -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);
}
+6
View File
@@ -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<u64>,
},
/// Approve one retry of a recent auto-review denial selected in the TUI.
ApproveRecentAutoReviewDenial {
thread_id: ThreadId,
+1
View File
@@ -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;
+11 -6
View File
@@ -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 => {
+27 -2
View File
@@ -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<u64>,
) {
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<u64>,
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 {
@@ -1,30 +1,50 @@
use super::*;
use assert_matches::assert_matches;
fn render_requested_status(
chat: &mut ChatWidget,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> (String, Option<u64>) {
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"));
}
+42 -46
View File
@@ -107,6 +107,8 @@ struct StatusHistoryCell {
model_name: String,
model_details: Vec<String>,
directory: PathBuf,
workspace_roots: Vec<AbsolutePathBuf>,
workspace_state_stale: bool,
permissions: String,
agents_summary: Arc<RwLock<String>>,
collaboration_mode: Option<String>,
@@ -192,6 +194,7 @@ pub(crate) fn new_status_output_with_rate_limits(
collaboration_mode,
reasoning_effort_override,
"<none>".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<Option<ReasoningEffort>>,
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<Option<ReasoningEffort>>,
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<String> {
let extra_roots = workspace_roots
.iter()
.filter(|root| *root != cwd)
.map(|root| root.to_string_lossy().to_string())
.collect::<Vec<_>>();
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<String> = vec!["Model", "Directory", "Permissions", "Agents.md"]
.into_iter()
.map(str::to_string)
.collect();
let mut labels: Vec<String> = vec![
"Model",
"Directory",
"Workspace roots",
"Permissions",
"Agents.md",
]
.into_iter()
.map(str::to_string)
.collect();
let mut seen: BTreeSet<String> = 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)]));
+97 -24
View File
@@ -178,26 +178,27 @@ fn sanitize_directory(lines: Vec<String>) -> Vec<String> {
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<chrono::Local>, seconds: i64) ->
fn permissions_text_for(config: &Config) -> Option<String> {
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<String> {
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<String> {
})
}
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,
"<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,
"<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)),
"<none>".to_string(),
/*workspace_state_stale*/ false,
/*refreshing_rate_limits*/ false,
);
let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80));