From e6312d44f0732c87180a7067a0425ca9aa5e6047 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 8 May 2026 08:03:11 -0700 Subject: [PATCH] Show permissions and approval mode in the TUI status line (#21677) Fixes #21665. ## Why The TUI status line is the right place for compact, glanceable session state. The original request was motivated by the need to see the active permission posture without opening `/permissions` or `/status`, especially when switching between safer and more permissive modes during a session. This PR intentionally separates `permissions` from `approval-mode` instead of combining them into one status-line item. They answer related but different questions: `permissions` describes the active sandbox/profile shape, while `approval-mode` describes how command approvals are handled. Keeping them separate makes each item independently configurable and avoids long combined labels in an already space-constrained status line. The tradeoff is that users who want the full permission posture in the status line need to opt into both items. In exchange, users can show only the sandbox/profile label, only the approval behavior, or both, and named user-defined profiles remain concise. Non-standard permission shapes are rendered as `Custom permissions` rather than trying to squeeze detailed profile contents into the status line; `/status` remains the fuller explanatory surface. ## What changed - Added a configurable `permissions` status-line item. - Added a separate `approval-mode` status-line item, with `approval` as an alias. - Render standard permission states compactly as `Read Only`, `Workspace`, or `Full Access`. - Preserve user-defined permission profile names directly in the status line. - Render unnamed non-standard permission shapes as `Custom permissions`. - Refresh status surfaces when `/permissions` updates the permission profile, approval policy, or approval reviewer. - Updated status-line preview snapshot coverage for the new items. ## Verification - `cargo test -p codex-tui status_permissions_non_default_workspace_write_uses_workspace_label` - `cargo test -p codex-tui permissions_selection_emits_history_cell_when_selection_changes` - `cargo insta pending-snapshots --manifest-path tui/Cargo.toml` --- .../tui/src/bottom_pane/status_line_setup.rs | 13 +++++ .../tui/src/bottom_pane/status_line_style.rs | 2 + .../src/bottom_pane/status_surface_preview.rs | 6 +++ codex-rs/tui/src/chatwidget.rs | 7 ++- ...tatus_surface_previews_hardcoded_only.snap | 2 +- .../tui/src/chatwidget/status_surfaces.rs | 47 +++++++++++++++++++ .../tests/status_surface_previews.rs | 2 + 7 files changed, 77 insertions(+), 2 deletions(-) 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 db789a11f..28107eea9 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -12,6 +12,8 @@ //! - Model information (name, reasoning level) //! - Directory paths (current dir, project root) //! - Git information (branch name) +//! - Permissions profile +//! - Approval mode //! - Context usage (remaining %, used %, window size) //! - Usage limits (5-hour, weekly) //! - Session info (thread title, ID, tokens used) @@ -81,6 +83,13 @@ pub(crate) enum StatusLineItem { #[strum(to_string = "run-state", serialize = "status")] Status, + /// Active permission profile or sandbox summary. + Permissions, + + /// Active command approval mode. + #[strum(to_string = "approval-mode", serialize = "approval")] + ApprovalMode, + /// Percentage of context window remaining. ContextRemaining, @@ -143,6 +152,8 @@ impl StatusLineItem { "Committed branch changes against the default branch (omitted when unavailable)" } StatusLineItem::Status => "Compact session run-state text (Ready, Working, Thinking)", + StatusLineItem::Permissions => "Active permission profile or sandbox mode", + StatusLineItem::ApprovalMode => "Active command approval mode", StatusLineItem::ContextRemaining => { "Percentage of context window remaining (omitted when unknown)" } @@ -184,6 +195,8 @@ impl StatusLineItem { StatusLineItem::PullRequestNumber => StatusSurfacePreviewItem::PullRequestNumber, StatusLineItem::BranchChanges => StatusSurfacePreviewItem::BranchChanges, StatusLineItem::Status => StatusSurfacePreviewItem::Status, + StatusLineItem::Permissions => StatusSurfacePreviewItem::Permissions, + StatusLineItem::ApprovalMode => StatusSurfacePreviewItem::ApprovalMode, StatusLineItem::ContextRemaining => StatusSurfacePreviewItem::ContextRemaining, StatusLineItem::ContextUsed => StatusSurfacePreviewItem::ContextUsed, StatusLineItem::FiveHourLimit => StatusSurfacePreviewItem::FiveHourLimit, 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 07018bff0..ffb2a289e 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -45,6 +45,8 @@ impl StatusLineAccent { StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => Self::Limit, StatusLineItem::CodexVersion | StatusLineItem::SessionId => Self::Metadata, StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode, + StatusLineItem::Permissions => Self::Mode, + StatusLineItem::ApprovalMode => Self::Mode, StatusLineItem::ThreadTitle => 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 1f23742a5..9f0f8ed5f 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -16,6 +16,8 @@ pub(crate) enum StatusSurfacePreviewItem { GitBranch, PullRequestNumber, BranchChanges, + Permissions, + ApprovalMode, ContextRemaining, ContextUsed, FiveHourLimit, @@ -45,6 +47,8 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::GitBranch => "feat/awesome-feature", StatusSurfacePreviewItem::PullRequestNumber => "PR #123", StatusSurfacePreviewItem::BranchChanges => "+12 -3", + StatusSurfacePreviewItem::Permissions => "Workspace", + StatusSurfacePreviewItem::ApprovalMode => "on-request", StatusSurfacePreviewItem::ContextRemaining => "Context 0% left", StatusSurfacePreviewItem::ContextUsed => "Context 0% used", StatusSurfacePreviewItem::FiveHourLimit => "5h 0%", @@ -74,6 +78,8 @@ impl StatusSurfacePreviewItem { Self::GitBranch, Self::PullRequestNumber, Self::BranchChanges, + Self::Permissions, + Self::ApprovalMode, Self::ContextRemaining, Self::ContextUsed, Self::FiveHourLimit, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 35d523aa5..b65f8b1c4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -9141,6 +9141,8 @@ impl ChatWidget { .set(policy.to_core()) { tracing::warn!(%err, "failed to set approval_policy on chat config"); + } else { + self.refresh_status_surfaces(); } } @@ -9150,7 +9152,9 @@ impl ChatWidget { &mut self, profile: PermissionProfile, ) -> ConstraintResult<()> { - self.config.permissions.set_permission_profile(profile) + self.config.permissions.set_permission_profile(profile)?; + self.refresh_status_surfaces(); + Ok(()) } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] @@ -9231,6 +9235,7 @@ impl ChatWidget { pub(crate) fn set_approvals_reviewer(&mut self, policy: ApprovalsReviewer) { self.config.approvals_reviewer = policy; + self.refresh_status_surfaces(); } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap index abf3df3c2..250d56ff4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap @@ -2,5 +2,5 @@ source: tui/src/chatwidget/tests/status_surface_previews.rs expression: snapshot --- -status line: my-project · feat/awesome-feature · thread title +status line: my-project · feat/awesome-feature · thread title · Read Only · on-request terminal title: thread title | feat/awesome-feature | Tasks 0/0 diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index fe010de48..a862bcabe 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -6,7 +6,12 @@ use super::*; use crate::bottom_pane::status_line_from_segments; use crate::branch_summary; +use crate::legacy_core::config::Config; use crate::status::format_tokens_compact; +use codex_app_server_protocol::AskForApproval; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::PermissionProfile; +use codex_utils_sandbox_summary::summarize_permission_profile; /// Items shown in the terminal title when the user has not configured a /// custom selection. Intentionally minimal: activity indicator + project name. @@ -590,6 +595,8 @@ impl ChatWidget { } }), StatusLineItem::Status => Some(self.run_state_status_text()), + StatusLineItem::Permissions => Some(permissions_display(&self.config)), + StatusLineItem::ApprovalMode => Some(approval_mode_display(&self.config)), StatusLineItem::UsedTokens => { let usage = self.status_line_total_usage(); let total = usage.blended_total(); @@ -678,6 +685,8 @@ impl ChatWidget { StatusSurfacePreviewItem::GitBranch => StatusLineItem::GitBranch, StatusSurfacePreviewItem::PullRequestNumber => StatusLineItem::PullRequestNumber, StatusSurfacePreviewItem::BranchChanges => StatusLineItem::BranchChanges, + StatusSurfacePreviewItem::Permissions => StatusLineItem::Permissions, + StatusSurfacePreviewItem::ApprovalMode => StatusLineItem::ApprovalMode, StatusSurfacePreviewItem::ContextRemaining => StatusLineItem::ContextRemaining, StatusSurfacePreviewItem::ContextUsed => StatusLineItem::ContextUsed, StatusSurfacePreviewItem::FiveHourLimit => StatusLineItem::FiveHourLimit, @@ -890,6 +899,44 @@ impl ChatWidget { } } +fn permissions_display(config: &Config) -> String { + let active_permission_profile = config.permissions.active_permission_profile(); + if let Some(active_permission_profile) = active_permission_profile.as_ref() + && !active_permission_profile.id.starts_with(':') + { + return active_permission_profile.id.clone(); + } + + let permission_profile = config.permissions.permission_profile(); + let summary = summarize_permission_profile(&permission_profile, config.cwd.as_path()); + if let Some(details) = summary.strip_prefix("read-only") + && !details.contains("(network access enabled)") + { + return "Read Only".to_string(); + } + if let Some(details) = summary.strip_prefix("workspace-write") + && !details.contains("(network access enabled)") + { + return "Workspace".to_string(); + } + if permission_profile == PermissionProfile::Disabled { + return "Full Access".to_string(); + } + + "Custom permissions".to_string() +} + +fn approval_mode_display(config: &Config) -> String { + let approval_policy = AskForApproval::from(config.permissions.approval_policy.value()); + if approval_policy == AskForApproval::OnRequest + && config.approvals_reviewer == ApprovalsReviewer::AutoReview + { + "auto-review".to_string() + } else { + config.permissions.approval_policy.value().to_string() + } +} + fn parse_items_with_invalids(ids: impl IntoIterator) -> (Vec, Vec) where T: std::str::FromStr, 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 4b080794b..a4fdf62f0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -112,6 +112,8 @@ async fn status_surface_preview_lines_hardcoded_only_snapshot() { StatusLineItem::ProjectRoot, StatusLineItem::GitBranch, StatusLineItem::ThreadTitle, + StatusLineItem::Permissions, + StatusLineItem::ApprovalMode, ], &[ TerminalTitleItem::Thread,