mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
chore: sync local changes (core + TUI)
- Update core: codex.rs, config.rs, safety.rs - Update TUI: app.rs, app_event.rs, bottom_pane/*, chatwidget.rs, lib.rs, user_approval_widget.rs - Update README
This commit is contained in:
@@ -125,3 +125,4 @@ This folder is the root of a Cargo workspace. It contains quite a bit of experim
|
||||
- [`exec/`](./exec) "headless" CLI for use in automation.
|
||||
- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/).
|
||||
- [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands.
|
||||
|
||||
|
||||
@@ -5244,12 +5244,21 @@ async fn handle_sandbox_error(
|
||||
// we're letting the model manage escalation requests. Otherwise, continue
|
||||
match sess.approval_policy {
|
||||
AskForApproval::Never | AskForApproval::OnRequest => {
|
||||
// Clarify when Read Only mode is the reason a command cannot proceed.
|
||||
let content = if matches!(sess.sandbox_policy, SandboxPolicy::ReadOnly) {
|
||||
format!(
|
||||
"command blocked by Read Only mode: {}",
|
||||
error
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"failed in sandbox {sandbox_type:?} with execution error: {error}"
|
||||
)
|
||||
};
|
||||
return ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: format!(
|
||||
"failed in sandbox {sandbox_type:?} with execution error: {error}"
|
||||
),
|
||||
content,
|
||||
success: Some(false),
|
||||
},
|
||||
};
|
||||
|
||||
+116
-6
@@ -426,6 +426,73 @@ pub fn set_github_check_on_push(codex_home: &Path, enabled: bool) -> anyhow::Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist per-project access mode under `[projects."<path>"]` with
|
||||
/// `approval_policy` and `sandbox_mode`.
|
||||
pub fn set_project_access_mode(
|
||||
codex_home: &Path,
|
||||
project_path: &Path,
|
||||
approval: AskForApproval,
|
||||
sandbox_mode: SandboxMode,
|
||||
) -> anyhow::Result<()> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
// Parse existing config if present; otherwise start a new document.
|
||||
let mut doc = match std::fs::read_to_string(config_path.clone()) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Ensure projects table and the per-project table exist
|
||||
let project_key = project_path.to_string_lossy().to_string();
|
||||
if !doc.as_table().contains_key("projects") {
|
||||
doc["projects"] = TomlItem::Table(toml_edit::Table::new());
|
||||
}
|
||||
let projects_tbl = doc["projects"].as_table_mut().unwrap();
|
||||
if !projects_tbl.contains_key(project_key.as_str()) {
|
||||
projects_tbl.insert(project_key.as_str(), TomlItem::Table(toml_edit::Table::new()));
|
||||
}
|
||||
let proj_tbl = projects_tbl
|
||||
.get_mut(project_key.as_str())
|
||||
.and_then(|i| i.as_table_mut())
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to create projects.{} table", project_key))?;
|
||||
|
||||
// Write fields
|
||||
proj_tbl.insert(
|
||||
"approval_policy",
|
||||
TomlItem::Value(toml_edit::Value::from(format!("{}", approval))),
|
||||
);
|
||||
proj_tbl.insert(
|
||||
"sandbox_mode",
|
||||
TomlItem::Value(toml_edit::Value::from(format!("{}", sandbox_mode))),
|
||||
);
|
||||
|
||||
// Harmonize trust_level with selected access mode:
|
||||
// - Full Access (Never + DangerFullAccess): set trust_level = "trusted" so future runs
|
||||
// default to non-interactive behavior when no overrides are present.
|
||||
// - Other modes: remove trust_level to avoid conflicting with per-project policy.
|
||||
let full_access = matches!(
|
||||
(approval, sandbox_mode),
|
||||
(AskForApproval::Never, SandboxMode::DangerFullAccess)
|
||||
);
|
||||
if full_access {
|
||||
proj_tbl.insert(
|
||||
"trust_level",
|
||||
TomlItem::Value(toml_edit::Value::from("trusted")),
|
||||
);
|
||||
} else {
|
||||
proj_tbl.remove("trust_level");
|
||||
}
|
||||
|
||||
// Ensure home exists; write atomically
|
||||
std::fs::create_dir_all(codex_home)?;
|
||||
let tmp = NamedTempFile::new_in(codex_home)?;
|
||||
std::fs::write(tmp.path(), doc.to_string())?;
|
||||
tmp.persist(config_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List MCP servers from `CODEX_HOME/config.toml`.
|
||||
/// Returns `(enabled, disabled)` lists of `(name, McpServerConfig)`.
|
||||
pub fn list_mcp_servers(codex_home: &Path) -> anyhow::Result<(
|
||||
@@ -766,9 +833,11 @@ pub struct ConfigToml {
|
||||
pub github: Option<GithubConfig>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct ProjectConfig {
|
||||
pub trust_level: Option<String>,
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
pub sandbox_mode: Option<SandboxMode>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
@@ -792,6 +861,7 @@ pub struct ToolsToml {
|
||||
|
||||
impl ConfigToml {
|
||||
/// Derive the effective sandbox policy from the configuration.
|
||||
#[cfg(test)]
|
||||
fn derive_sandbox_policy(&self, sandbox_mode_override: Option<SandboxMode>) -> SandboxPolicy {
|
||||
let resolved_sandbox_mode = sandbox_mode_override
|
||||
.or(self.sandbox_mode)
|
||||
@@ -925,7 +995,7 @@ impl Config {
|
||||
None => ConfigProfile::default(),
|
||||
};
|
||||
|
||||
let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode);
|
||||
// (removed placeholder) sandbox_policy computed below after resolving project overrides.
|
||||
|
||||
let mut model_providers = built_in_model_providers();
|
||||
// Merge user-defined providers into the built-in list.
|
||||
@@ -947,6 +1017,9 @@ impl Config {
|
||||
})?
|
||||
.clone();
|
||||
|
||||
// Capture workspace-write details early to avoid borrow after partial moves
|
||||
let cfg_workspace = cfg.sandbox_workspace_write.clone();
|
||||
|
||||
let shell_environment_policy = cfg.shell_environment_policy.into();
|
||||
|
||||
let mut resolved_cwd = {
|
||||
@@ -983,6 +1056,46 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Project-specific overrides based on final resolved cwd (exact match)
|
||||
let project_key = resolved_cwd.to_string_lossy().to_string();
|
||||
let project_override = cfg
|
||||
.projects
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&project_key));
|
||||
// Resolve sandbox mode with correct precedence:
|
||||
// CLI override > per-project override > global config.toml > default
|
||||
let effective_sandbox_mode = sandbox_mode
|
||||
.or(project_override.and_then(|p| p.sandbox_mode))
|
||||
.or(cfg.sandbox_mode)
|
||||
.unwrap_or_default();
|
||||
let sandbox_policy = match effective_sandbox_mode {
|
||||
SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(),
|
||||
SandboxMode::WorkspaceWrite => match cfg_workspace {
|
||||
Some(SandboxWorkspaceWrite {
|
||||
writable_roots,
|
||||
network_access,
|
||||
exclude_tmpdir_env_var,
|
||||
exclude_slash_tmp,
|
||||
allow_git_writes,
|
||||
}) => SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots,
|
||||
network_access,
|
||||
exclude_tmpdir_env_var,
|
||||
exclude_slash_tmp,
|
||||
allow_git_writes,
|
||||
},
|
||||
None => SandboxPolicy::new_workspace_write_policy(),
|
||||
},
|
||||
SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess,
|
||||
};
|
||||
// Resolve approval policy with precedence:
|
||||
// CLI override > profile override > per-project override > global config.toml > default
|
||||
let effective_approval = approval_policy
|
||||
.or(config_profile.approval_policy)
|
||||
.or(project_override.and_then(|p| p.approval_policy))
|
||||
.or(cfg.approval_policy)
|
||||
.unwrap_or_else(AskForApproval::default);
|
||||
|
||||
let history = cfg.history.unwrap_or_default();
|
||||
|
||||
let tools_web_search_request = override_tools_web_search_request
|
||||
@@ -1054,10 +1167,7 @@ impl Config {
|
||||
model_provider_id,
|
||||
model_provider,
|
||||
cwd: resolved_cwd,
|
||||
approval_policy: approval_policy
|
||||
.or(config_profile.approval_policy)
|
||||
.or(cfg.approval_policy)
|
||||
.unwrap_or_else(AskForApproval::default),
|
||||
approval_policy: effective_approval,
|
||||
sandbox_policy,
|
||||
shell_environment_policy,
|
||||
disable_response_storage: config_profile
|
||||
|
||||
@@ -30,6 +30,14 @@ pub fn assess_patch_safety(
|
||||
};
|
||||
}
|
||||
|
||||
// In Read Only mode, write operations are not permitted. Reject immediately
|
||||
// so the agent receives a clear failure without prompting for approval.
|
||||
if matches!(sandbox_policy, SandboxPolicy::ReadOnly) {
|
||||
return SafetyCheck::Reject {
|
||||
reason: "write operations are disabled in read-only mode".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
match policy {
|
||||
AskForApproval::OnFailure | AskForApproval::Never | AskForApproval::OnRequest => {
|
||||
// Continue to see if this can be auto-approved.
|
||||
|
||||
+11
-3
@@ -367,10 +367,12 @@ impl App<'_> {
|
||||
},
|
||||
AppState::Onboarding { .. } => {}
|
||||
},
|
||||
AppEvent::InsertBackgroundEvent(message) => match &mut self.app_state {
|
||||
// InsertBackgroundEvent removed; use InsertBackgroundEventEarly for
|
||||
// approval decisions to appear above command begin.
|
||||
AppEvent::InsertBackgroundEventEarly(message) => match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
tracing::debug!("app: InsertBackgroundEvent len={}", message.len());
|
||||
widget.push_background_event(message);
|
||||
tracing::debug!("app: InsertBackgroundEventEarly len={}", message.len());
|
||||
widget.insert_background_event_early(message);
|
||||
}
|
||||
AppState::Onboarding { .. } => {}
|
||||
},
|
||||
@@ -1028,6 +1030,12 @@ impl App<'_> {
|
||||
}
|
||||
self.schedule_redraw();
|
||||
}
|
||||
AppEvent::CycleAccessMode => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.cycle_access_mode();
|
||||
}
|
||||
self.schedule_redraw();
|
||||
}
|
||||
AppEvent::OnboardingAuthComplete(result) => {
|
||||
if let AppState::Onboarding { screen } = &mut self.app_state {
|
||||
screen.on_auth_complete(result);
|
||||
|
||||
@@ -91,6 +91,8 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Preview theme (no history event)
|
||||
PreviewTheme(ThemeName),
|
||||
/// Rotate access/safety preset (Read Only → Write with Approval → Full Access)
|
||||
CycleAccessMode,
|
||||
/// Bottom composer expanded (e.g., slash command popup opened)
|
||||
ComposerExpanded,
|
||||
|
||||
@@ -115,10 +117,9 @@ pub(crate) enum AppEvent {
|
||||
InsertHistoryWithKind { id: Option<String>, kind: StreamKind, lines: Vec<Line<'static>> },
|
||||
/// Finalized assistant answer with raw markdown for re-rendering under theme changes.
|
||||
InsertFinalAnswer { id: Option<String>, lines: Vec<Line<'static>>, source: String },
|
||||
/// Insert a background event (status/log-style) message into history.
|
||||
/// These are non-streaming, non-assistant lines rendered with the
|
||||
/// BackgroundEvent style ("event" header, dim text).
|
||||
InsertBackgroundEvent(String),
|
||||
/// Insert a background event near the top of the current request so it
|
||||
/// appears above imminent provider output (e.g. above Exec begin).
|
||||
InsertBackgroundEventEarly(String),
|
||||
|
||||
#[allow(dead_code)]
|
||||
StartCommitAnimation,
|
||||
|
||||
@@ -114,6 +114,10 @@ pub(crate) struct ChatComposer {
|
||||
using_chatgpt_auth: bool,
|
||||
// Ephemeral footer notice and its expiry
|
||||
footer_notice: Option<(String, std::time::Instant)>,
|
||||
// Persistent/ephemeral access-mode indicator shown on the left
|
||||
access_mode_label: Option<String>,
|
||||
access_mode_label_expiry: Option<std::time::Instant>,
|
||||
access_mode_hint_expiry: Option<std::time::Instant>,
|
||||
// Footer hint visibility flags
|
||||
show_reasoning_hint: bool,
|
||||
show_diffs_hint: bool,
|
||||
@@ -164,6 +168,9 @@ impl ChatComposer {
|
||||
animation_running: None,
|
||||
using_chatgpt_auth,
|
||||
footer_notice: None,
|
||||
access_mode_label: None,
|
||||
access_mode_label_expiry: None,
|
||||
access_mode_hint_expiry: None,
|
||||
show_reasoning_hint: false,
|
||||
show_diffs_hint: false,
|
||||
reasoning_shown: false,
|
||||
@@ -231,6 +238,20 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_access_mode_label(&mut self, label: Option<String>) {
|
||||
self.access_mode_label = label;
|
||||
self.access_mode_label_expiry = None;
|
||||
self.access_mode_hint_expiry = None;
|
||||
}
|
||||
pub fn set_access_mode_label_ephemeral(&mut self, label: String, dur: std::time::Duration) {
|
||||
self.access_mode_label = Some(label);
|
||||
self.access_mode_label_expiry = Some(std::time::Instant::now() + dur);
|
||||
self.access_mode_hint_expiry = self.access_mode_label_expiry;
|
||||
}
|
||||
pub fn set_access_mode_hint_for(&mut self, dur: std::time::Duration) {
|
||||
self.access_mode_hint_expiry = Some(std::time::Instant::now() + dur);
|
||||
}
|
||||
|
||||
pub fn set_reasoning_state(&mut self, shown: bool) {
|
||||
self.reasoning_shown = shown;
|
||||
}
|
||||
@@ -1057,6 +1078,13 @@ impl ChatComposer {
|
||||
(InputResult::None, true)
|
||||
}
|
||||
// -------------------------------------------------------------
|
||||
// Shift+Tab — rotate access preset (Read Only → Write with Approval → Full Access)
|
||||
// -------------------------------------------------------------
|
||||
KeyEvent { code: KeyCode::BackTab, .. } => {
|
||||
self.app_event_tx.send(crate::app_event::AppEvent::CycleAccessMode);
|
||||
(InputResult::None, true)
|
||||
}
|
||||
// -------------------------------------------------------------
|
||||
// Tab-press file search when not using @ or ./ and not in slash cmd
|
||||
// -------------------------------------------------------------
|
||||
KeyEvent { code: KeyCode::Tab, .. } => {
|
||||
@@ -1435,8 +1463,32 @@ impl WidgetRef for ChatComposer {
|
||||
let mut left_spans: Vec<Span> = Vec::new();
|
||||
left_spans.push(Span::from(" "));
|
||||
|
||||
// Access mode indicator (Read Only / Write with Approval / Full Access)
|
||||
let show_access_label = if let Some(until) = self.access_mode_label_expiry {
|
||||
std::time::Instant::now() <= until
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if show_access_label { if let Some(label) = &self.access_mode_label {
|
||||
// Keep the access mode label bold so it stands out even with a dim footer.
|
||||
left_spans.push(Span::from(label.clone()).style(label_style.add_modifier(Modifier::BOLD)));
|
||||
// Hide the hint after the expiry window (or keep while label is ephemeral)
|
||||
let show_suffix = if let Some(until) = self.access_mode_hint_expiry {
|
||||
std::time::Instant::now() <= until
|
||||
} else {
|
||||
// If whole label is ephemeral, show suffix while visible
|
||||
self.access_mode_label_expiry.is_some()
|
||||
};
|
||||
if show_suffix {
|
||||
left_spans.push(Span::from(" (").style(label_style));
|
||||
left_spans.push(Span::from("Shift+Tab").style(key_hint_style));
|
||||
left_spans.push(Span::from(" change)").style(label_style));
|
||||
}
|
||||
}}
|
||||
|
||||
if self.ctrl_c_quit_hint {
|
||||
// Treat as a notice; keep on the left
|
||||
if !self.access_mode_label.is_none() { left_spans.push(Span::from(" ")); }
|
||||
left_spans.push(Span::from("Ctrl+C").style(key_hint_style));
|
||||
left_spans.push(Span::from(" again to quit").style(label_style));
|
||||
}
|
||||
@@ -1550,8 +1602,13 @@ impl WidgetRef for ChatComposer {
|
||||
line_spans.extend(right_spans);
|
||||
line_spans.push(Span::from(" "));
|
||||
|
||||
// Render footer slightly dimmer than normal content across themes
|
||||
Line::from(line_spans)
|
||||
.style(Style::default().fg(crate::colors::text_dim()))
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(crate::colors::text_dim())
|
||||
.add_modifier(Modifier::DIM),
|
||||
)
|
||||
.render_ref(bottom_line_rect, buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,6 +593,31 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_access_mode_label(&mut self, label: Option<String>) {
|
||||
self.composer.set_access_mode_label(label);
|
||||
// Hide the "(Shift+Tab change)" suffix after a short time for persistent modes.
|
||||
// Avoid using ScheduleFrameIn (which coalesces timers and can stall animations);
|
||||
// instead, spawn a lightweight timer that triggers a redraw when it expires.
|
||||
let dur = Duration::from_secs(4);
|
||||
self.composer.set_access_mode_hint_for(dur);
|
||||
let tx = self.app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(dur + Duration::from_millis(120));
|
||||
tx.send(AppEvent::RequestRedraw);
|
||||
});
|
||||
self.request_redraw();
|
||||
}
|
||||
pub(crate) fn set_access_mode_label_ephemeral(&mut self, label: String, dur: Duration) {
|
||||
self.composer.set_access_mode_label_ephemeral(label, dur);
|
||||
// Schedule a redraw after expiry without blocking other scheduled frames.
|
||||
let tx = self.app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(dur + Duration::from_millis(120));
|
||||
tx.send(AppEvent::RequestRedraw);
|
||||
});
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
// Removed restart_live_status_with_text – no longer used by the current streaming UI.
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,13 @@ pub(crate) struct ChatWidget<'a> {
|
||||
|
||||
// One-time hint to teach input history navigation
|
||||
scroll_history_hint_shown: bool,
|
||||
|
||||
// Track and manage the access-mode background status cell so mode changes
|
||||
// replace the existing status instead of stacking multiple entries.
|
||||
access_status_idx: Option<usize>,
|
||||
// Pending one-shot note about the latest access-mode change to inject into
|
||||
// the agent's conversation history on the next user message.
|
||||
pending_access_note: Option<String>,
|
||||
}
|
||||
|
||||
struct PendingJumpBack {
|
||||
@@ -1425,7 +1432,7 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Initialize image protocol for rendering screenshots
|
||||
|
||||
let new_widget = Self {
|
||||
let mut new_widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
codex_op_tx,
|
||||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||||
@@ -1539,7 +1546,11 @@ impl ChatWidget<'_> {
|
||||
internal_seq: 0,
|
||||
show_order_overlay,
|
||||
scroll_history_hint_shown: false,
|
||||
access_status_idx: None,
|
||||
pending_access_note: None,
|
||||
};
|
||||
// Seed footer access indicator based on current config
|
||||
new_widget.apply_access_mode_indicator_from_config();
|
||||
// Insert the welcome cell as top-of-first-request so future model output
|
||||
// appears below it. Also insert the Popular commands immediately so users
|
||||
// don't wait for MCP initialization to finish.
|
||||
@@ -1708,6 +1719,8 @@ impl ChatWidget<'_> {
|
||||
internal_seq: 0,
|
||||
show_order_overlay,
|
||||
scroll_history_hint_shown: false,
|
||||
access_status_idx: None,
|
||||
pending_access_note: None,
|
||||
};
|
||||
// Welcome at top of first request for forked session too
|
||||
w.history_push_top_next_req(history_cell::new_animated_welcome());
|
||||
@@ -2262,9 +2275,15 @@ impl ChatWidget<'_> {
|
||||
let key = self.next_internal_key();
|
||||
let _ = self.history_insert_with_key_global_tagged(Box::new(cell), key, "epilogue");
|
||||
}
|
||||
/// Public helper to push a background event with the standard gutter and styling.
|
||||
pub(crate) fn push_background_event(&mut self, message: String) {
|
||||
self.history_push(history_cell::new_background_event(message));
|
||||
/// Insert a background event near the top of the current request so it appears
|
||||
/// before imminent provider output (e.g. Exec begin).
|
||||
pub(crate) fn insert_background_event_early(&mut self, message: String) {
|
||||
let key = self.near_time_key(None);
|
||||
let _ = self.history_insert_with_key_global_tagged(
|
||||
Box::new(history_cell::new_background_event(message)),
|
||||
key,
|
||||
"background-early",
|
||||
);
|
||||
}
|
||||
/// Push a cell using a synthetic key at the TOP of the NEXT request.
|
||||
fn history_push_top_next_req(&mut self, cell: impl HistoryCell + 'static) {
|
||||
@@ -2693,6 +2712,12 @@ impl ChatWidget<'_> {
|
||||
self.pending_user_prompts_for_next_turn.saturating_add(1);
|
||||
}
|
||||
|
||||
// If an access-mode change was pending, record it in the agent's
|
||||
// conversation history right before the next user turn.
|
||||
if let Some(note) = self.pending_access_note.take() {
|
||||
let _ = self.codex_op_tx.send(Op::AddToHistory { text: note });
|
||||
}
|
||||
|
||||
// Now send to the agent
|
||||
let _ = self.codex_op_tx.send(Op::UserInput { items });
|
||||
if !original_text.is_empty() {
|
||||
@@ -4096,8 +4121,6 @@ impl ChatWidget<'_> {
|
||||
"Keyboard shortcuts",
|
||||
t_fg.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
lines.push(RtLine::from(""));
|
||||
|
||||
let kv = |k: &str, v: &str| -> RtLine<'static> {
|
||||
RtLine::from(vec![
|
||||
// Left-align the key column for improved readability
|
||||
@@ -4106,6 +4129,12 @@ impl ChatWidget<'_> {
|
||||
RtSpan::styled(v.to_string(), t_dim),
|
||||
])
|
||||
};
|
||||
lines.push(RtLine::from(""));
|
||||
// Top quick action
|
||||
lines.push(kv(
|
||||
"Shift+Tab",
|
||||
"Rotate agent between Read Only / Write with Approval / Full Access",
|
||||
));
|
||||
|
||||
// Global
|
||||
lines.push(kv("Ctrl+H", "Help overlay"));
|
||||
@@ -4485,6 +4514,134 @@ impl ChatWidget<'_> {
|
||||
self.history_push(history_cell::new_background_event(message));
|
||||
}
|
||||
|
||||
fn apply_access_mode_indicator_from_config(&mut self) {
|
||||
use codex_core::protocol::{AskForApproval, SandboxPolicy};
|
||||
let label = match (&self.config.sandbox_policy, self.config.approval_policy) {
|
||||
(SandboxPolicy::ReadOnly, _) => Some("Read Only".to_string()),
|
||||
(SandboxPolicy::WorkspaceWrite { network_access: false, .. }, AskForApproval::OnRequest)
|
||||
| (SandboxPolicy::WorkspaceWrite { network_access: false, .. }, AskForApproval::OnFailure)
|
||||
| (SandboxPolicy::WorkspaceWrite { network_access: false, .. }, AskForApproval::UnlessTrusted) => Some("Write with Approval".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
self.bottom_pane.set_access_mode_label(label);
|
||||
}
|
||||
|
||||
/// Rotate the access preset: Read Only (Plan Mode) → Write with Approval → Full Access
|
||||
pub(crate) fn cycle_access_mode(&mut self) {
|
||||
use codex_core::protocol::{AskForApproval, SandboxPolicy};
|
||||
use codex_core::config::set_project_access_mode;
|
||||
|
||||
// Determine current index
|
||||
let idx = match (&self.config.sandbox_policy, self.config.approval_policy) {
|
||||
(SandboxPolicy::ReadOnly, _) => 0,
|
||||
(SandboxPolicy::WorkspaceWrite { network_access: false, .. }, AskForApproval::OnRequest)
|
||||
| (SandboxPolicy::WorkspaceWrite { network_access: false, .. }, AskForApproval::OnFailure)
|
||||
| (SandboxPolicy::WorkspaceWrite { network_access: false, .. }, AskForApproval::UnlessTrusted) => 1,
|
||||
(SandboxPolicy::DangerFullAccess, AskForApproval::Never) => 2,
|
||||
_ => 1,
|
||||
};
|
||||
let next = (idx + 1) % 3;
|
||||
|
||||
// Apply mapping
|
||||
let (label, approval, sandbox) = match next {
|
||||
0 => (
|
||||
"Read Only (Plan Mode)",
|
||||
AskForApproval::OnRequest,
|
||||
SandboxPolicy::ReadOnly,
|
||||
),
|
||||
1 => (
|
||||
"Write with Approval",
|
||||
AskForApproval::OnRequest,
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
),
|
||||
_ => (
|
||||
"Full Access",
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
),
|
||||
};
|
||||
|
||||
// Update local config
|
||||
self.config.approval_policy = approval;
|
||||
self.config.sandbox_policy = sandbox;
|
||||
|
||||
// Send ConfigureSession op to backend
|
||||
let op = Op::ConfigureSession {
|
||||
provider: self.config.model_provider.clone(),
|
||||
model: self.config.model.clone(),
|
||||
model_reasoning_effort: self.config.model_reasoning_effort,
|
||||
model_reasoning_summary: self.config.model_reasoning_summary,
|
||||
model_text_verbosity: self.config.model_text_verbosity,
|
||||
user_instructions: self.config.user_instructions.clone(),
|
||||
base_instructions: self.config.base_instructions.clone(),
|
||||
approval_policy: self.config.approval_policy.clone(),
|
||||
sandbox_policy: self.config.sandbox_policy.clone(),
|
||||
disable_response_storage: self.config.disable_response_storage,
|
||||
notify: self.config.notify.clone(),
|
||||
cwd: self.config.cwd.clone(),
|
||||
resume_path: None,
|
||||
};
|
||||
self.submit_op(op);
|
||||
|
||||
// Persist selection into CODEX_HOME/config.toml for this project directory
|
||||
// so it sticks across restarts.
|
||||
let _ = set_project_access_mode(
|
||||
&self.config.codex_home,
|
||||
&self.config.cwd,
|
||||
self.config.approval_policy,
|
||||
match &self.config.sandbox_policy {
|
||||
SandboxPolicy::ReadOnly => codex_protocol::config_types::SandboxMode::ReadOnly,
|
||||
SandboxPolicy::WorkspaceWrite { .. } => codex_protocol::config_types::SandboxMode::WorkspaceWrite,
|
||||
SandboxPolicy::DangerFullAccess => codex_protocol::config_types::SandboxMode::DangerFullAccess,
|
||||
},
|
||||
);
|
||||
|
||||
// Footer indicator: persistent for RO/Approval; ephemeral for Full Access
|
||||
if next == 2 {
|
||||
self.bottom_pane.set_access_mode_label_ephemeral(
|
||||
"Full Access".to_string(),
|
||||
std::time::Duration::from_secs(4),
|
||||
);
|
||||
} else {
|
||||
let persistent = if next == 0 { "Read Only" } else { "Write with Approval" };
|
||||
self.bottom_pane.set_access_mode_label(Some(persistent.to_string()));
|
||||
}
|
||||
|
||||
// Announce in history: replace the last access-mode status, inserting early
|
||||
// in the current request so it appears above upcoming commands.
|
||||
let msg = format!("✓ Access mode: {}", label);
|
||||
self.set_access_status_message(msg);
|
||||
// No footer notice: indicator covers this; avoid duplicate texts.
|
||||
|
||||
// Prepare a single consolidated note for the agent to see before the
|
||||
// next turn begins. Subsequent cycles will overwrite this note.
|
||||
let agent_note = match next {
|
||||
0 => "System: access mode changed to Read Only. Do not attempt write operations or apply_patch.",
|
||||
1 => "System: access mode changed to Write with Approval. Approval will be automatically requested before writes.",
|
||||
_ => "System: access mode changed to Full Access. Writes and network are allowed.",
|
||||
};
|
||||
self.pending_access_note = Some(agent_note.to_string());
|
||||
}
|
||||
|
||||
/// Insert or replace the access-mode status background event. Uses a near-time
|
||||
/// key so it appears above any imminent Exec/Tool cells in this request.
|
||||
fn set_access_status_message(&mut self, message: String) {
|
||||
let cell = crate::history_cell::new_background_event(message);
|
||||
if let Some(idx) = self.access_status_idx {
|
||||
if idx < self.history_cells.len()
|
||||
&& matches!(self.history_cells[idx].kind(), crate::history_cell::HistoryCellType::BackgroundEvent)
|
||||
{
|
||||
self.history_replace_at(idx, Box::new(cell));
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Insert new status near the top of this request window
|
||||
let key = self.near_time_key(None);
|
||||
let pos = self.history_insert_with_key_global_tagged(Box::new(cell), key, "access-status");
|
||||
self.access_status_idx = Some(pos);
|
||||
}
|
||||
|
||||
fn restyle_history_after_theme_change(&mut self) {
|
||||
let old = self.last_theme.clone();
|
||||
let new = crate::theme::current_theme();
|
||||
@@ -7368,6 +7525,10 @@ impl ChatWidget<'_> {
|
||||
enhanced_keys_supported,
|
||||
using_chatgpt_auth: self.config.using_chatgpt_auth,
|
||||
});
|
||||
self.access_status_idx = None;
|
||||
self.pending_access_note = None;
|
||||
// Re-apply access indicator for new composer
|
||||
self.apply_access_mode_indicator_from_config();
|
||||
|
||||
// Request redraw for the new animation
|
||||
self.mark_needs_redraw();
|
||||
|
||||
+14
-1
@@ -459,6 +459,19 @@ fn determine_repo_trust_state(
|
||||
) -> std::io::Result<bool> {
|
||||
let config_profile = config_toml.get_config_profile(config_profile_override)?;
|
||||
|
||||
// If this project has explicit per-project overrides for approval and/or sandbox,
|
||||
// honor them and skip the trust screen entirely.
|
||||
let proj_key = config.cwd.to_string_lossy().to_string();
|
||||
let has_per_project_overrides = config_toml
|
||||
.projects
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&proj_key))
|
||||
.map(|p| p.approval_policy.is_some() || p.sandbox_mode.is_some())
|
||||
.unwrap_or(false);
|
||||
if has_per_project_overrides {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if approval_policy_overide.is_some() || sandbox_mode_override.is_some() {
|
||||
// if the user has overridden either approval policy or sandbox mode,
|
||||
// skip the trust flow
|
||||
@@ -479,7 +492,7 @@ fn determine_repo_trust_state(
|
||||
config.sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
Ok(false)
|
||||
} else {
|
||||
// if none of the above conditions are met, show the trust screen
|
||||
// if none of the above conditions are met (and no per‑project overrides), show the trust screen
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,9 +120,10 @@ impl UserApprovalWidget<'_> {
|
||||
let mut cmd_span: Span = cmd.clone().into();
|
||||
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
|
||||
let mut contents: Vec<Line> = vec![
|
||||
Line::from(""), // extra spacing above the prompt
|
||||
Line::from(vec![
|
||||
"? ".fg(crate::colors::info()),
|
||||
"Codex wants to run ".bold(),
|
||||
"Code wants to run ".bold(),
|
||||
cmd_span,
|
||||
]),
|
||||
Line::from(""),
|
||||
@@ -255,7 +256,9 @@ impl UserApprovalWidget<'_> {
|
||||
// Append feedback, preserving line breaks
|
||||
format!("{}\nfeedback:\n{}", message, feedback)
|
||||
};
|
||||
self.app_event_tx.send(AppEvent::InsertBackgroundEvent(message));
|
||||
// Insert above the upcoming command begin so the decision reads first.
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertBackgroundEventEarly(message));
|
||||
|
||||
// If the user aborted an exec approval, immediately cancel any running task
|
||||
// so the UI reflects their intent (clear spinner/status) without waiting
|
||||
|
||||
Reference in New Issue
Block a user