mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
feat(tui): show workspace roots in status
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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)]));
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user