From f4b1212306f0817e21082585ed0775922a0f27bc Mon Sep 17 00:00:00 2001 From: RoyLin Date: Sun, 19 Jul 2026 03:55:02 +0800 Subject: [PATCH 1/2] feat(tui): enforce execution mode workflows --- CHANGELOG.md | 14 + README.md | 40 +- src/tui/app/actions.rs | 15 +- src/tui/app/events.rs | 20 +- src/tui/app/launch.rs | 22 +- src/tui/app/permissions.rs | 397 ++++++++++++- src/tui/app/runtime.rs | 148 ++++- src/tui/app/submit.rs | 82 ++- src/tui/app/types.rs | 3 + src/tui/app/update.rs | 18 +- src/tui/app/update_dispatch.rs | 59 +- src/tui/app/view.rs | 5 +- src/tui/mod.rs | 22 + src/tui/panels/assets/review.rs | 4 +- src/tui/panels/system/help.rs | 11 +- src/tui/panels/system/model.rs | 13 +- src/tui/panels/system/plan.rs | 12 +- src/tui/panels/system/relay.rs | 8 +- src/tui/panels/workspace/goal_engineering.rs | 2 +- src/tui/tests.rs | 117 +++- src/tui/ui/chrome.rs | 2 +- src/tui/ui/editor_state.rs | 18 +- src/tui/ui/plan_review.rs | 590 +++++++++++++++++++ 23 files changed, 1507 insertions(+), 115 deletions(-) create mode 100644 src/tui/ui/plan_review.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a67c110e..65535ea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added strict two-phase Plan turns with an explicit Approve, Revise, or + Abandon review boundary, plus `Ctrl+O` Send now for promoting a prompt ahead + of ordinary queued follow-ups after settling the active turn. + +### Fixed + +- Made TUI Auto mode genuinely non-interactive for every operation that + survives explicit policy and workspace hard denials, including tool-owned + confirmation escalation. Late confirmation events resolve automatically in + both directions, queued turns retain their submission-time mode, and Plan + remains strictly read-only. + ## [0.9.6] - 2026-07-17 ### Added diff --git a/README.md b/README.md index d1412b38..a2628b9e 100644 --- a/README.md +++ b/README.md @@ -665,7 +665,8 @@ input prefixes: | Area | What A3S Code TUI provides | | --- | --- | -| Coding loop | Chat with the coding agent, stream semantic tool cards, approve or deny tools, switch `/auto`, run direct shell turns with `!`, run a durable Ultracode `/goal`, and fork or clear sessions when needed. `/relay` resumes a saved A3S Code session with its per-session settings or hands the latest task from a workspace-scoped Claude Code, Codex, or WorkBuddy transcript to the active session. | +| Coding loop | Chat with the coding agent, stream semantic tool cards, choose Default, Plan, or Auto execution, run direct shell turns with `!`, run a durable Ultracode `/goal`, and fork or clear sessions when needed. `/relay` resumes a saved A3S Code session with its per-session settings or hands the latest task from a workspace-scoped Claude Code, Codex, or WorkBuddy transcript to the active session. | +| Execution modes | Default is the interactive HITL mode. Plan exposes only read-only discovery tools, stages the completed plan behind an explicit Approve, Revise, or Abandon decision, and starts approved implementation as a new Default turn. Auto executes every operation that survives explicit policy and workspace hard denials without entering HITL; hard denials fail directly instead of opening an approval prompt. A queued turn retains the mode captured when it was submitted. | | Workspace UI | `/ide` opens a superfile-style tree and editor with terminal-stable file marks, `/config` edits the active config in the same editor, `Ctrl+T` opens the complete semantic transcript, and file edits render bounded diffs through the shared `DiffView` component. Diff headers use green `+N` and red `-N` counts; Markdown uses Codex-spaced section headings, responsive tables, syntax highlighting, and terminal hyperlinks. | | Models and effort | `/model` switches configured providers, OS gateway models, and signed-in account tabs. Codex account discovery delegates refresh and entitlement checks to the installed Codex CLI, so an expired identity token does not hide models while reusable account access remains. WorkBuddy `hy3` tagged calls are converted into native tool events without exposing protocol markup in streamed messages. `/effort` scales thinking budget, tool-round budget, auto-continuation, and model-agnostic rigor guidance from `low` through `max` and `ultracode`. A3S Code 5.2.4 structured calls use native JSON Schema or forced-tool output only when the active client advertises that capability; unknown custom OpenAI-compatible endpoints retain the bounded prompt fallback instead of receiving an assumed `tool_choice`. | | Dynamic workflows | `ultracode` and `?` DeepResearch can use `DynamicWorkflowRuntime`, a local A3S Flow-backed workflow runner. It records workflow/step history while PTC scripts perform ordinary tool work. This is separate from `/flow`, which is OS Workflow as a Service for persisted workflow assets. | @@ -753,9 +754,12 @@ Pending host turns are scheduled by `a3s_lane::PriorityQueue`: explicit user input has priority over host-generated continuations, while equal-priority messages remain FIFO. A queue item is committed only after Core admits its stream; `SessionBusy` and admission timeouts restore the same priority and FIFO -position. Esc cancels and settles the active worker before consuming exactly -one queued successor. The bottom queue strip contains pending turns only and -removes a message as soon as Lane claims it for execution. +position. Enter appends a follow-up to that immutable FIFO queue. `Ctrl+O` +performs Send now: it cancels and settles the active turn, then promotes the +new prompt ahead of normal follow-ups without cancelling a durable goal. Each +queued row retains its submission-time execution mode even if the composer mode +changes later. The bottom queue strip contains pending turns only and removes a +message as soon as Lane claims it for execution. The transcript uses Codex-style `•` headers with `└` detail and `│` command continuations, groups adjacent reads/lists/searches into one Explore cell, and reflows semantic arguments, output, diffs, and Markdown after a resize. User @@ -775,8 +779,8 @@ instead of rebuilding the full viewport. | Transcript | Assistant text, reasoning, tool cards, diff summaries, task updates, memory recall/store notices, compaction notices, and RemoteUI action links stay in one scrollable history. Drag-select copies transcript text on release. | | Input line | Type a normal prompt, use `Shift+Enter` for multiline input, prefix `!` for a direct shell turn, prefix `?` for DeepResearch, use `@` to attach a workspace file through the clickable picker, or paste an image with `Ctrl+V`. | | Slash menu | Press `/` or type a slash command to open a wheel-browsable, clickable command palette backed by the same command registry used by `/help`. Commands are grouped into model/config, workspace, context, OS, asset, and operations surfaces. | -| Approvals | Mutating tools pause in a confirmation overlay with arguments and result context. Default mode prompts, plan mode auto-approves read-only discovery, and auto mode approves later tool calls in the session. | -| Footer | The footer shows model/provider, effort, mode, context fill, active asset, login/runtime state, and session hints. Context warnings re-arm after compaction, clear, or model switch. | +| Approvals | In Default mode, gated tools pause in a confirmation overlay with arguments and result context. Plan is a strict read-only boundary followed by Approve, Revise, or Abandon review. Auto never enters HITL for an operation that survives explicit hard denials. | +| Footer | The footer shows model/provider, effort, the active turn mode, a distinct `next:` composer mode when it differs, context fill, active asset, login/runtime state, and session hints. Context warnings re-arm after compaction, clear, or model switch. | | Tool calls | Live tool status appears inline while running. Inline `program` calls summarize structured intent, research scope, workflow phase, and completed nested-call results instead of repeating JavaScript wrapper source. | | Semantic transcript | `Ctrl+T` opens the complete live session transcript in a dedicated full-width viewport, preserving user-surface, tool-state, and diff colors while showing reasoning, plans, every tool lifecycle and full output, subagent state, and the current live Markdown tail. | | Workspace editor | `/ide` opens a full-screen file browser/editor. `/config` reuses the editor for the active ACL config. Both surfaces use terminal-safe, type-aware file and folder sigils, semantic icon colors, aligned disclosure rows, icon-bearing breadcrumbs, and a ruled line-number gutter while keeping edits inside the workspace backend and normal permission path. | @@ -789,8 +793,9 @@ Key interactions: | Key or input | Behavior | | --- | --- | | `Enter` | Send the prompt; when a turn is busy, queue the next message. | +| `Ctrl+O` | Send now: cancel the active turn and promote this prompt ahead of normal queued follow-ups. | | `Shift+Enter` | Insert a newline in the input. | -| `Shift+Tab` | Cycle run mode: default, plan, auto. | +| `Shift+Tab` | Cycle the composer mode: Default, strict read-only Plan, non-interactive Auto. Running and queued turns keep their submission-time mode. | | `Up` / `Down` | Recall input history or move through menus/panels. | | `PgUp` / `PgDn` | Scroll the transcript or the active full-screen panel. | | `Shift+End` | Jump to the latest transcript output. | @@ -832,14 +837,17 @@ choice continues the next goal iteration without changing the restored execution mode; the second enters the session with the goal still paused, where `/goal resume` can continue it later. -The TUI owns HITL confirmation for gated tools. In default mode, mutating tools -prompt through a wheel-browsable, clickable approval overlay; `a` or `/auto` approves later tool calls for -the session, while Shift+Tab cycles default, plan, and auto modes. Plan mode -auto-approves read-only discovery tools but still asks before writes. Auto mode -silently approves every operation that reaches HITL; hard permission denials -remain non-bypassable and never enter the confirmation overlay. Tool -timeouts and confirmation timeouts are tracked separately so a human approval -pause does not consume the command runtime budget. +The TUI owns HITL confirmation for gated tools. In Default mode, mutating tools +prompt through a wheel-browsable, clickable approval overlay. Shift+Tab cycles +Default, Plan, and Auto for future submissions. Plan exposes only read-only +discovery tools. When planning completes, the TUI freezes queue draining at an +explicit Approve, Revise, or Abandon boundary; approval starts implementation +as a separate Default turn. Auto resolves allowed tools directly in Core and +bypasses tool-owned confirmation escalation, so it never opens HITL. Explicit +policy denials and workspace guardrails remain authoritative, and hard denials +fail without opening an approval prompt. Tool timeouts and confirmation +timeouts are tracked separately so a human approval pause does not consume the +command runtime budget. All local filesystem work stays under the active workspace services and A3S Code permission policy. OS operations require `/login`; before login the TUI can @@ -1166,7 +1174,7 @@ These commands are available outside the asset-specific flows: | `/clear` | Start a fresh conversation in the current session surface. | | `/fork` | Branch the current transcript into a new session id. | | `/relay` | Open the A3S Code, Claude Code, Codex, and WorkBuddy session picker. Native sessions resume in place with their saved model, effort, execution mode, theme, and paused goal; external sessions relay their latest user task into the current A3S Code session. | -| `/auto` | Switch the session into auto-approve mode. | +| `/auto` | Switch future submissions to non-interactive Auto mode. Operations that survive explicit policy and workspace hard denials execute without HITL. | | `/plugin` / `/reload` | Manage and hot-reload skills/plugins, including wheel browsing and click-to-toggle skill state in the TUI. | | `/update` | Upgrade the CLI and restart back into the saved session. | | `/exit` | Quit `a3s code` after session persistence runs. | diff --git a/src/tui/app/actions.rs b/src/tui/app/actions.rs index 4cc01fb1..dcd9558a 100644 --- a/src/tui/app/actions.rs +++ b/src/tui/app/actions.rs @@ -17,7 +17,20 @@ impl App { } pub(super) fn session_status_chips(&self) -> Vec { - let mut chips = vec![mode_status_chip(self.mode)]; + let effective_mode = self.active_turn_mode.unwrap_or(self.mode); + let mut chips = vec![mode_status_chip(effective_mode)]; + if self + .active_turn_mode + .is_some_and(|active| active != self.mode) + { + chips.push( + SessionStatusChip::new("↪", format!("next:{}", self.mode.name())) + .color(COMPOSER_CHROME.secondary), + ); + } + if self.plan_review.is_some() { + chips.push(SessionStatusChip::new("◆", "plan review").color(COMPOSER_CHROME.active)); + } if self.goal.is_some() { chips.push(goal_status_chip(self.goal_since)); diff --git a/src/tui/app/events.rs b/src/tui/app/events.rs index 7ed0d58b..afd00590 100644 --- a/src/tui/app/events.rs +++ b/src/tui/app/events.rs @@ -440,16 +440,20 @@ impl App { ); } self.update_viewport_with_stream(); - if self.mode.auto_approves_confirmation() { - // Silent: the mode indicator already shows auto-approve is on; - // a line per tool is just noise. Do NOT start another - // spinner_tick here — the turn's tick loop is already running - // (state stays Streaming through auto-approval). Stacking one - // per auto-approved tool made the spinner advance several - // frames per 80ms = the speed-up / slow-down cadence. + if let Some(approved) = self.execution_policy.auto_confirmation_decision( + &tool_name, + &args, + Path::new(&self.cwd), + ) { + // Auto is non-interactive in both directions: approve calls + // that survive the hard guardrail and reject hard denials. + // Do NOT start another spinner tick here — the turn's tick + // loop is already running. let session = self.session.clone(); + let reason = + (!approved).then(|| "Denied by the Auto mode hard guardrail.".to_string()); return Some(cmd::cmd(move || async move { - let _ = session.confirm_tool_use(&tool_id, true, None).await; + let _ = session.confirm_tool_use(&tool_id, approved, reason).await; Msg::Resume })); } diff --git a/src/tui/app/launch.rs b/src/tui/app/launch.rs index 273e2ec2..6b2fd012 100644 --- a/src/tui/app/launch.rs +++ b/src/tui/app/launch.rs @@ -405,6 +405,10 @@ pub(crate) async fn run_in( .and_then(TuiSessionState::effort_index) .or_else(load_tui_effort_preference) .unwrap_or(DEFAULT_TUI_EFFORT_INDEX); + let initial_mode = tui_session_state + .as_ref() + .map(TuiSessionState::mode) + .unwrap_or(Mode::Default); let sidecar_model_preference = tui_session_state .as_ref() .and_then(|state| state.model.clone()); @@ -473,6 +477,7 @@ pub(crate) async fn run_in( let initial_auto_delegation = effort_uses_automatic_delegation(initial_effort); let deep_research_report_tool_gate = DeepResearchReportToolGate::default(); deep_research_report_tool_gate.set_workspace(Path::new(&workspace)); + let execution_policy = TuiExecutionPolicy::new(initial_mode); // Claude Code compatibility: inject CLAUDE.md (AGENTS.md is auto-loaded by // the core) into the system prompt via prompt slots. let instructions = project_instructions(&workspace); @@ -523,9 +528,10 @@ pub(crate) async fn run_in( session_id.as_str(), apply_launch_model_options( with_instr(with_recent_workspace_context( - tui_session_options_with_gate( + tui_session_options_with_gate_and_execution( confirmation.clone(), deep_research_report_tool_gate.clone(), + execution_policy.clone(), ) .with_session_store(store.clone()) .with_workspace_backend(workspace_services.clone()) @@ -564,9 +570,10 @@ pub(crate) async fn run_in( workspace.clone(), Some(apply_launch_model_options( with_instr(with_recent_workspace_context( - tui_session_options_with_gate( + tui_session_options_with_gate_and_execution( confirmation.clone(), deep_research_report_tool_gate.clone(), + execution_policy.clone(), ) .with_session_store(store.clone()) .with_session_id(session_id.as_str()) @@ -731,10 +738,6 @@ pub(crate) async fn run_in( remote_ui::prime_webview_lookup(); - let initial_mode = tui_session_state - .as_ref() - .map(TuiSessionState::mode) - .unwrap_or(Mode::Default); let initial_paused_goal = tui_session_state .as_ref() .and_then(|state| state.paused_goal.clone()); @@ -885,6 +888,7 @@ pub(crate) async fn run_in( host_tool_call_id: None, interrupting: false, pending_tools: VecDeque::new(), + execution_policy, approval_sel: 0, history: history_seed, history_pos: None, @@ -896,12 +900,18 @@ pub(crate) async fn run_in( anim: 0, mode: initial_mode, queue: PriorityQueue::new(), + queued_turn_modes: HashMap::new(), + queued_plan_drafts: HashMap::new(), active_queued_turn: None, active_queued_turn_token: None, + active_turn_mode: None, + active_plan_draft: None, queue_retry_generation: 0, queue_retry_attempt: 0, running_task: None, plan: PlanProjection::default(), + pending_plan_review: None, + plan_review: None, ide: None, memory: None, asset_list: None, diff --git a/src/tui/app/permissions.rs b/src/tui/app/permissions.rs index 58d82010..3352ef5e 100644 --- a/src/tui/app/permissions.rs +++ b/src/tui/app/permissions.rs @@ -14,21 +14,41 @@ pub(super) fn with_recent_workspace_context( pub(super) fn tui_session_options( confirmation: a3s_code_core::hitl::ConfirmationPolicy, ) -> SessionOptions { - tui_session_options_with_gate(confirmation, DeepResearchReportToolGate::default()) + tui_session_options_with_gate_and_execution( + confirmation, + DeepResearchReportToolGate::default(), + TuiExecutionPolicy::default(), + ) } +#[cfg(test)] pub(super) fn tui_session_options_with_gate( confirmation: a3s_code_core::hitl::ConfirmationPolicy, deep_research_report_tool_gate: DeepResearchReportToolGate, +) -> SessionOptions { + tui_session_options_with_gate_and_execution( + confirmation, + deep_research_report_tool_gate, + TuiExecutionPolicy::default(), + ) +} + +pub(super) fn tui_session_options_with_gate_and_execution( + confirmation: a3s_code_core::hitl::ConfirmationPolicy, + deep_research_report_tool_gate: DeepResearchReportToolGate, + execution_policy: TuiExecutionPolicy, ) -> SessionOptions { let permission_policy = tui_permission_policy(); + let confirmation_manager = + TuiModeConfirmationProvider::new(confirmation, execution_policy.clone()); SessionOptions::new() .with_auto_compact(false) - .with_confirmation_policy(confirmation) + .with_confirmation_manager(Arc::new(confirmation_manager)) .with_permission_policy(permission_policy.clone()) - .with_permission_checker(Arc::new(TuiHitlPermissionChecker::new( + .with_permission_checker(Arc::new(TuiHitlPermissionChecker::with_execution( permission_policy, deep_research_report_tool_gate, + execution_policy, ))) .with_tool_timeout(TOOL_EXEC_TIMEOUT_MS) .with_duplicate_tool_call_threshold(TUI_DUPLICATE_TOOL_CALL_THRESHOLD) @@ -78,6 +98,170 @@ pub(super) fn tui_permission_policy() -> a3s_code_core::permissions::PermissionP ]) } +/// Active execution semantics shared by the TUI and every rebuilt Core +/// session. The value describes the running turn, not the mutable composer +/// mode, so a queued Auto turn cannot become interactive if the user changes +/// the footer mode before it starts. +#[derive(Clone)] +pub(super) struct TuiExecutionPolicy { + mode: Arc, +} + +impl Default for TuiExecutionPolicy { + fn default() -> Self { + Self::new(Mode::Default) + } +} + +impl TuiExecutionPolicy { + const DEFAULT: u8 = 0; + const PLAN: u8 = 1; + const AUTO: u8 = 2; + + pub(super) fn new(mode: Mode) -> Self { + let policy = Self { + mode: Arc::new(AtomicU8::new(Self::DEFAULT)), + }; + policy.set_mode(mode); + policy + } + + pub(super) fn set_mode(&self, mode: Mode) { + let encoded = match mode { + Mode::Default => Self::DEFAULT, + Mode::Plan => Self::PLAN, + Mode::Auto => Self::AUTO, + }; + self.mode.store(encoded, Ordering::SeqCst); + } + + pub(super) fn mode(&self) -> Mode { + match self.mode.load(Ordering::SeqCst) { + Self::PLAN => Mode::Plan, + Self::AUTO => Mode::Auto, + _ => Mode::Default, + } + } + + /// Resolve a confirmation already emitted by Core without entering HITL. + /// + /// `None` keeps the interactive Default/Plan flow. Auto always returns a + /// decision: non-denied calls are approved and hard-denied calls are + /// rejected. + pub(super) fn auto_confirmation_decision( + &self, + tool_name: &str, + args: &serde_json::Value, + workspace: &Path, + ) -> Option { + if self.mode() != Mode::Auto { + return None; + } + let checker = a3s_code_core::permissions::InteractiveToolGuardrail::for_mode("auto") + .with_workspace(workspace); + Some( + a3s_code_core::permissions::PermissionChecker::check(&checker, tool_name, args) + != a3s_code_core::permissions::PermissionDecision::Deny, + ) + } +} + +/// Confirmation provider that preserves interactive HITL for Default/Plan +/// turns while disabling the escalation path for an immutable Auto turn. +/// +/// Permission `Allow` normally bypasses HITL, but tool-owned metadata may +/// explicitly request a second confirmation check. Auto remains +/// non-interactive through that path; hard denials are resolved earlier by the +/// permission checker. +struct TuiModeConfirmationProvider { + inner: Arc, + execution_policy: TuiExecutionPolicy, +} + +impl TuiModeConfirmationProvider { + fn new( + policy: a3s_code_core::hitl::ConfirmationPolicy, + execution_policy: TuiExecutionPolicy, + ) -> Self { + let (event_tx, _) = tokio::sync::broadcast::channel(16); + Self { + inner: Arc::new(a3s_code_core::hitl::ConfirmationManager::new( + policy, event_tx, + )), + execution_policy, + } + } + + fn auto_response() -> tokio::sync::oneshot::Receiver + { + let (tx, rx) = tokio::sync::oneshot::channel(); + let _ = tx.send(a3s_code_core::hitl::ConfirmationResponse { + approved: true, + reason: None, + }); + rx + } +} + +#[async_trait::async_trait] +impl a3s_code_core::hitl::ConfirmationProvider for TuiModeConfirmationProvider { + async fn requires_confirmation(&self, tool_name: &str) -> bool { + self.execution_policy.mode() != Mode::Auto + && self.inner.requires_confirmation(tool_name).await + } + + async fn request_confirmation( + &self, + tool_id: &str, + tool_name: &str, + args: &serde_json::Value, + ) -> tokio::sync::oneshot::Receiver { + if self.execution_policy.mode() == Mode::Auto { + Self::auto_response() + } else { + self.inner + .request_confirmation(tool_id, tool_name, args) + .await + } + } + + async fn confirm( + &self, + tool_id: &str, + approved: bool, + reason: Option, + ) -> Result { + self.inner.confirm(tool_id, approved, reason).await + } + + async fn policy(&self) -> a3s_code_core::hitl::ConfirmationPolicy { + self.inner.policy().await + } + + async fn set_policy(&self, policy: a3s_code_core::hitl::ConfirmationPolicy) { + self.inner.set_policy(policy).await; + } + + async fn check_timeouts(&self) -> usize { + self.inner.check_timeouts().await + } + + async fn cancel_all(&self) -> usize { + self.inner.cancel_all().await + } + + async fn pending_confirmations(&self) -> Vec { + self.inner.pending_confirmation_details().await + } +} + +fn plan_tool_is_read_only(tool_name: &str) -> bool { + matches!( + tool_name, + "read" | "grep" | "glob" | "ls" | "web_search" | "web_fetch" + ) +} + #[derive(Clone, Default)] pub(super) struct DeepResearchReportToolGate { phase: Arc, @@ -237,16 +421,31 @@ pub(super) fn should_delay_deep_research_report_tool( pub(super) struct TuiHitlPermissionChecker { base: a3s_code_core::permissions::PermissionPolicy, deep_research_report_tool_gate: DeepResearchReportToolGate, + execution_policy: TuiExecutionPolicy, } impl TuiHitlPermissionChecker { + #[cfg(test)] pub(super) fn new( base: a3s_code_core::permissions::PermissionPolicy, deep_research_report_tool_gate: DeepResearchReportToolGate, + ) -> Self { + Self::with_execution( + base, + deep_research_report_tool_gate, + TuiExecutionPolicy::default(), + ) + } + + pub(super) fn with_execution( + base: a3s_code_core::permissions::PermissionPolicy, + deep_research_report_tool_gate: DeepResearchReportToolGate, + execution_policy: TuiExecutionPolicy, ) -> Self { Self { base, deep_research_report_tool_gate, + execution_policy, } } @@ -298,6 +497,23 @@ impl TuiHitlPermissionChecker { { return a3s_code_core::permissions::PermissionDecision::Deny; } + if !evidence_collection { + match self.execution_policy.mode() { + Mode::Auto => { + return a3s_code_core::permissions::PermissionDecision::Allow; + } + Mode::Plan => { + return if plan_tool_is_read_only(&tool) + && matches!(base, a3s_code_core::permissions::PermissionDecision::Allow) + { + a3s_code_core::permissions::PermissionDecision::Allow + } else { + a3s_code_core::permissions::PermissionDecision::Deny + }; + } + Mode::Default => {} + } + } let decision = a3s_code_core::permissions::InteractiveToolGuardrail::risk_decision(&tool, args); if !evidence_collection { @@ -356,6 +572,9 @@ impl a3s_code_core::permissions::PermissionChecker for TuiHitlPermissionChecker _ => false, }; } + if self.execution_policy.mode() == Mode::Plan { + return plan_tool_is_read_only(&tool); + } true } @@ -395,6 +614,178 @@ pub(super) fn touch_workspace_file_path_for_manifest( } } +#[cfg(test)] +mod execution_policy_tests { + use super::*; + use a3s_code_core::hitl::ConfirmationProvider; + use a3s_code_core::permissions::{PermissionChecker, PermissionDecision}; + + fn checker(workspace: &Path, mode: Mode) -> (TuiHitlPermissionChecker, TuiExecutionPolicy) { + let gate = DeepResearchReportToolGate::default(); + gate.set_workspace(workspace); + let execution = TuiExecutionPolicy::new(mode); + let checker = TuiHitlPermissionChecker::with_execution( + tui_permission_policy(), + gate, + execution.clone(), + ); + (checker, execution) + } + + #[test] + fn auto_mode_resolves_non_denied_tools_without_hitl() { + let workspace = tempfile::tempdir().unwrap(); + let (checker, _) = checker(workspace.path(), Mode::Auto); + + for (tool, args) in [ + ( + "write", + serde_json::json!({"file_path": "README.md", "content": "updated"}), + ), + ("bash", serde_json::json!({"command": "cargo test"})), + ( + "task", + serde_json::json!({"prompt": "inspect and implement the change"}), + ), + ( + "mcp__github__create_issue", + serde_json::json!({"title": "tracked work"}), + ), + ] { + assert_eq!( + checker.check(tool, &args), + PermissionDecision::Allow, + "Auto must not enter HITL for {tool}" + ); + } + + assert_eq!( + checker.check("bash", &serde_json::json!({"command": "rm -rf /"})), + PermissionDecision::Deny, + "hard guardrails remain authoritative in Auto" + ); + } + + #[test] + fn execution_mode_is_shared_across_checker_clones() { + let workspace = tempfile::tempdir().unwrap(); + let (checker, execution) = checker(workspace.path(), Mode::Default); + let clone = checker.clone(); + let args = serde_json::json!({"command": "cargo test"}); + + assert_eq!(checker.check("bash", &args), PermissionDecision::Ask); + execution.set_mode(Mode::Auto); + assert_eq!(checker.check("bash", &args), PermissionDecision::Allow); + assert_eq!(clone.check("bash", &args), PermissionDecision::Allow); + } + + #[test] + fn plan_mode_is_read_only() { + let workspace = tempfile::tempdir().unwrap(); + let (checker, _) = checker(workspace.path(), Mode::Plan); + + assert_eq!( + checker.check("read", &serde_json::json!({"file_path": "README.md"})), + PermissionDecision::Allow + ); + assert_eq!( + checker.check( + "write", + &serde_json::json!({"file_path": "README.md", "content": "new"}) + ), + PermissionDecision::Deny + ); + assert!(!checker.expose_to_model("write")); + } + + #[test] + fn auto_confirmation_fallback_never_routes_to_hitl() { + let workspace = tempfile::tempdir().unwrap(); + let execution = TuiExecutionPolicy::new(Mode::Auto); + + assert_eq!( + execution.auto_confirmation_decision( + "bash", + &serde_json::json!({"command": "cargo test"}), + workspace.path(), + ), + Some(true) + ); + assert_eq!( + execution.auto_confirmation_decision( + "bash", + &serde_json::json!({"command": "rm -rf /"}), + workspace.path(), + ), + Some(false), + "hard denials must be rejected automatically instead of opening HITL" + ); + + execution.set_mode(Mode::Default); + assert_eq!( + execution.auto_confirmation_decision( + "bash", + &serde_json::json!({"command": "cargo test"}), + workspace.path(), + ), + None + ); + } + + #[tokio::test] + async fn auto_mode_bypasses_tool_owned_confirmation_escalation() { + let execution = TuiExecutionPolicy::new(Mode::Default); + let provider = TuiModeConfirmationProvider::new( + a3s_code_core::hitl::ConfirmationPolicy::enabled(), + execution.clone(), + ); + + assert!( + provider + .requires_confirmation("mcp__server__destructive") + .await + ); + + execution.set_mode(Mode::Auto); + assert!( + !provider + .requires_confirmation("mcp__server__destructive") + .await + ); + let response = provider + .request_confirmation("tool-1", "mcp__server__destructive", &serde_json::json!({})) + .await + .await + .expect("Auto confirmation should resolve immediately"); + assert!(response.approved); + assert!(provider.pending_confirmations().await.is_empty()); + } + + #[tokio::test] + async fn session_options_share_one_execution_policy_across_both_hitl_layers() { + let execution = TuiExecutionPolicy::new(Mode::Default); + let options = tui_session_options_with_gate_and_execution( + a3s_code_core::hitl::ConfirmationPolicy::enabled(), + DeepResearchReportToolGate::default(), + execution.clone(), + ); + let checker = options + .permission_checker + .expect("TUI options should install a permission checker"); + let confirmation = options + .confirmation_manager + .expect("TUI options should install a confirmation provider"); + let args = serde_json::json!({"command": "cargo test"}); + + assert_eq!(checker.check("bash", &args), PermissionDecision::Ask); + assert!(confirmation.requires_confirmation("bash").await); + + execution.set_mode(Mode::Auto); + assert_eq!(checker.check("bash", &args), PermissionDecision::Allow); + assert!(!confirmation.requires_confirmation("bash").await); + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum RuntimeEvidenceMode { Any, diff --git a/src/tui/app/runtime.rs b/src/tui/app/runtime.rs index c9424026..2398d361 100644 --- a/src/tui/app/runtime.rs +++ b/src/tui/app/runtime.rs @@ -15,6 +15,65 @@ fn take_matching_queue_claim( } impl App { + /// Change the composer mode without mutating the semantics of an admitted + /// turn. When no turn is active, keep Core's live policy in sync + /// immediately so the footer and authorization boundary cannot diverge. + pub(super) fn set_composer_mode(&mut self, mode: Mode) { + self.mode = mode; + if self.active_turn_mode.is_none() { + self.execution_policy.set_mode(mode); + } + } + + pub(super) fn stream_interrupt_available(&self) -> bool { + self.state == State::Streaming + && !self.interrupting + && !self.stream_join_settling + && !self.deep_research_subagent_settlement_inflight + } + + /// Cancel the admitted turn so the promoted Send-now queue entry can run. + /// Unlike Esc, this does not cancel a durable goal. + pub(super) fn begin_send_now_interrupt(&mut self) -> Option> { + if !self.stream_interrupt_available() { + return None; + } + self.interrupting = true; + if self.stream_join.is_none() && self.rx.is_none() && !self.host_progress_inflight { + self.interrupted_stream_start_token = Some(self.stream_start_token); + } + self.stream_start_token = self.stream_start_token.wrapping_add(1); + self.deep_research_stream_timeout_token = + self.deep_research_stream_timeout_token.wrapping_add(1); + let status_entry = + self.push_tracked_line(&Style::new().fg(TN_YELLOW).render(" ⎋ interrupting…")); + let session = self.session.clone(); + let join = self.stream_join.take(); + let host_abort = self.host_tool_abort.take(); + Some(cmd::cmd(move || async move { + if let Some(host_abort) = host_abort { + host_abort.abort(); + } + let _ = session + .cancel_and_settle( + Duration::from_millis(DEEP_RESEARCH_ABORT_GRACE_MS), + Duration::from_millis(GRACEFUL_QUIT_ABORT_SETTLE_MS), + ) + .await; + if let Some(join) = join { + let _ = settle_stream_join_for_quit( + join, + Duration::from_millis(GRACEFUL_QUIT_ABORT_SETTLE_MS), + ) + .await; + } + Msg::Interrupted { + goal_cancelled: false, + status_entry, + } + })) + } + pub(super) fn start_stream_inner( &mut self, prompt: String, @@ -42,6 +101,7 @@ impl App { synthesis: bool, runtime_expectation: Option, ) -> Option> { + let execution_mode = self.mode; let submitted_images = if include_attachments { std::mem::take(&mut self.pending_images) } else { @@ -54,6 +114,8 @@ impl App { synthesis, runtime_expectation, submitted_images, + execution_mode, + None, ) } @@ -65,6 +127,8 @@ impl App { synthesis: bool, runtime_expectation: Option, submitted_images: Vec, + execution_mode: Mode, + plan_draft: Option, ) -> Option> { let attachments = match submitted_images .iter() @@ -82,6 +146,9 @@ impl App { return None; } }; + self.active_turn_mode = Some(execution_mode); + self.active_plan_draft = plan_draft; + self.execution_policy.set_mode(execution_mode); if clear_turn_artifacts && !synthesis { self.auto_review.on_user_turn(); self.last_activity = Instant::now(); @@ -221,6 +288,30 @@ impl App { Some(cmd::batch(commands)) } + /// Enqueue a turn together with the execution mode visible at submission. + /// a3s-lane owns ordering; this sidecar owns immutable execution semantics. + pub(super) fn enqueue_turn( + &mut self, + priority: a3s_lane::Priority, + queued: Queued, + mode: Mode, + ) -> u64 { + let sequence = self.queue.push(priority, queued); + self.queued_turn_modes.insert(sequence, mode); + sequence + } + + pub(super) fn enqueue_plan_turn( + &mut self, + priority: a3s_lane::Priority, + queued: Queued, + request: PlanDraftRequest, + ) -> u64 { + let sequence = self.enqueue_turn(priority, queued, Mode::Plan); + self.queued_plan_drafts.insert(sequence, request); + sequence + } + /// Pop exactly one queued user turn. Once user input is exhausted, resume /// a DeepResearch synthesis that was deliberately deferred behind it. pub(super) fn drain_queue(&mut self) -> Option> { @@ -232,10 +323,19 @@ impl App { || self.session_rebuild_pending.is_some() || self.interrupted_stream_start_token.is_some() || self.active_queued_turn.is_some() + || self.pending_plan_review.is_some() + || self.plan_review.is_some() { return None; } if let Some(next) = self.queue.pop() { + let sequence = next.sequence(); + let execution_mode = self + .queued_turn_modes + .get(&sequence) + .copied() + .unwrap_or(self.mode); + let plan_draft = self.queued_plan_drafts.get(&sequence).cloned(); let queued = next.value().clone(); if let Some((query, os_runtime, evidence_scope)) = queued.deep_research { let command = self.start_deep_research_workflow( @@ -247,6 +347,9 @@ impl App { if command.is_none() { self.queue.restore(next); self.relayout(); + } else { + self.queued_turn_modes.remove(&sequence); + self.queued_plan_drafts.remove(&sequence); } return command; } @@ -257,6 +360,8 @@ impl App { false, queued.runtime_expectation, queued.images, + execution_mode, + plan_draft, ); if command.is_some() { self.active_queued_turn_token = Some(self.stream_start_token); @@ -280,15 +385,15 @@ impl App { /// point the exact a3s-lane entry remains restorable on `SessionBusy` or an /// admission timeout. pub(super) fn commit_active_queued_turn(&mut self, token: u64) -> bool { - if take_matching_queue_claim( + let Some(item) = take_matching_queue_claim( &mut self.active_queued_turn, &mut self.active_queued_turn_token, token, - ) - .is_none() - { + ) else { return false; - } + }; + self.queued_turn_modes.remove(&item.sequence()); + self.queued_plan_drafts.remove(&item.sequence()); self.queue_retry_generation = self.queue_retry_generation.wrapping_add(1); self.queue_retry_attempt = 0; true @@ -311,11 +416,14 @@ impl App { } pub(super) fn discard_active_queued_turn(&mut self, token: u64) { - let _ = take_matching_queue_claim( + if let Some(item) = take_matching_queue_claim( &mut self.active_queued_turn, &mut self.active_queued_turn_token, token, - ); + ) { + self.queued_turn_modes.remove(&item.sequence()); + self.queued_plan_drafts.remove(&item.sequence()); + } } pub(super) fn retry_queued_turn_after_admission_failure(&mut self) -> Cmd { @@ -439,16 +547,19 @@ impl App { self.completed += 1; } self.warn_missing_runtime_evidence(); + let plan_review = self.take_plan_review_candidate(); // Goal iterations are evaluated by Core before End. A generic hidden // synthesis turn must not replace that event gate or consume the turn // as if it were complete. - let synthesis = if degraded_deep_research || self.goal_run.is_some() { - None - } else { - self.prepare_ultracode_synthesis() - }; + let synthesis = + if plan_review.is_some() || degraded_deep_research || self.goal_run.is_some() { + None + } else { + self.prepare_ultracode_synthesis() + }; let completed_stream_join = self.stream_join.take(); self.finish(); + self.pending_plan_review = plan_review; if let Some(completed_stream_join) = completed_stream_join { // Keep input queue-only until the worker has completed persistence, // cleanup, and release of core's single-flight admission lease. @@ -461,6 +572,9 @@ impl App { &mut self, synthesis: Option<(String, String)>, ) -> Option> { + if self.activate_pending_plan_review() { + return None; + } if self.goal_run.as_ref().is_some_and(|run| run.achieved) { self.pending_goal_failure = None; return self.finish_achieved_goal(); @@ -678,8 +792,8 @@ impl App { } /// An autonomous directive run is starting (/sleep, asset reviews, - /// asset run/deploy, /flow drafts, /loop): switch to auto-approve so tool - /// prompts can't stall it, and arm the loop budget that re-prompts until + /// asset run/deploy, /flow drafts, /loop): switch to non-interactive Auto + /// so tool prompts cannot stall it, and arm the loop budget that re-prompts until /// the deliverable lands. The prior mode is restored when the run ends /// (loop drained, interrupt, error, or /clear). A user already in auto /// mode keeps it — nothing is remembered or restored. @@ -687,7 +801,7 @@ impl App { self.loop_remaining = self.loop_remaining.max(budget); if self.mode != Mode::Auto { self.autonomy_restore = Some(self.mode); - self.mode = Mode::Auto; + self.set_composer_mode(Mode::Auto); self.push_line(&Style::new().fg(TN_GRAY).render( " ⏵⏵ auto mode engaged for this task — restores when it completes (Esc stops)", )); @@ -697,7 +811,7 @@ impl App { pub(super) fn engage_single_turn_autonomy(&mut self) { if self.mode != Mode::Auto { self.autonomy_restore = Some(self.mode); - self.mode = Mode::Auto; + self.set_composer_mode(Mode::Auto); self.push_line(&Style::new().fg(TN_GRAY).render( " ⏵⏵ auto mode engaged for this task — restores when it completes (Esc stops)", )); @@ -754,7 +868,7 @@ impl App { self.deep_research_report_tool_gate.set_report_only(false); self.deep_research_subagent_settlement_inflight = false; if let Some(prev) = self.autonomy_restore.take() { - self.mode = prev; + self.set_composer_mode(prev); self.push_line( &Style::new() .fg(TN_GRAY) diff --git a/src/tui/app/submit.rs b/src/tui/app/submit.rs index ce20c587..374d18c6 100644 --- a/src/tui/app/submit.rs +++ b/src/tui/app/submit.rs @@ -2,8 +2,38 @@ use super::*; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SubmissionIntent { + Queue, + SendNow, +} + impl App { pub(super) fn on_submit(&mut self, text: String) -> Option> { + self.on_submit_with_intent(text, SubmissionIntent::Queue) + } + + pub(super) fn on_submit_now(&mut self, text: String) -> Option> { + let trimmed = text.trim(); + if self.shell_mode + || self.research_mode + || matches!(trimmed.chars().next(), Some('/' | '!' | '?')) + { + self.textarea.set_value(&text); + self.push_notice( + NoticeKind::Warning, + "Send now accepts an agent prompt, not a shell, research, or slash command", + ); + return None; + } + self.on_submit_with_intent(text, SubmissionIntent::SendNow) + } + + fn on_submit_with_intent( + &mut self, + text: String, + intent: SubmissionIntent, + ) -> Option> { let trimmed = text.trim(); if trimmed.is_empty() && self.pending_images.is_empty() { return None; @@ -98,7 +128,8 @@ impl App { // The planner chooses the work; the host only supplies finite hard // caps and one bounded report finalization phase. let runtime_expectation = Some(RuntimeExpectation::required("deep research")); - self.queue.push( + let execution_mode = self.mode; + self.enqueue_turn( USER_TURN_PRIORITY, Queued { text: format!("? {query}"), @@ -107,6 +138,7 @@ impl App { runtime_expectation, deep_research: Some((query, os_runtime, evidence_scope)), }, + execution_mode, ); if self.state == State::Idle { return self.drain_queue(); @@ -780,7 +812,7 @@ impl App { return None; } "/auto" => { - self.mode = Mode::Auto; + self.set_composer_mode(Mode::Auto); self.textarea.clear(); self.rebuild_viewport(); return None; @@ -950,21 +982,45 @@ impl App { }, }, }; - let priority = if loop_cont { + let send_now = intent == SubmissionIntent::SendNow && self.state == State::Streaming; + let priority = if send_now { + PLAN_REVIEW_PRIORITY + } else if loop_cont { SYNTHETIC_TURN_PRIORITY } else { USER_TURN_PRIORITY }; - self.queue.push( - priority, - Queued { - text: prompt, - display, - images: std::mem::take(&mut self.pending_images), - runtime_expectation: None, - deep_research: None, - }, - ); + let execution_mode = self.mode; + let images = std::mem::take(&mut self.pending_images); + if execution_mode == Mode::Plan && !loop_cont { + let request = PlanDraftRequest::initial(prompt, display.clone()); + self.enqueue_plan_turn( + priority, + Queued { + text: request.planning_prompt(), + display, + images, + runtime_expectation: None, + deep_research: None, + }, + request, + ); + } else { + self.enqueue_turn( + priority, + Queued { + text: prompt, + display, + images, + runtime_expectation: None, + deep_research: None, + }, + execution_mode, + ); + } + if send_now { + return self.begin_send_now_interrupt(); + } if self.state == State::Idle { self.drain_queue() } else { diff --git a/src/tui/app/types.rs b/src/tui/app/types.rs index 00a9ab9d..23f47355 100644 --- a/src/tui/app/types.rs +++ b/src/tui/app/types.rs @@ -254,6 +254,9 @@ pub(super) enum Msg { event: Box, }, Submit(String), + /// Cancel the active turn and run this immutable submission before normal + /// queued follow-ups. + SubmitNow(String), StreamStarted { token: u64, session: Arc, diff --git a/src/tui/app/update.rs b/src/tui/app/update.rs index f5b381cf..7281827e 100644 --- a/src/tui/app/update.rs +++ b/src/tui/app/update.rs @@ -38,32 +38,32 @@ impl Model for App { return prompt; } if let Some(transcript) = &self.transcript_view { - return self.overlay_approval(transcript.render()); + return self.overlay_decision_modals(transcript.render()); } if self.help_open { - return self.overlay_approval(self.render_help()); + return self.overlay_decision_modals(self.render_help()); } if let Some(m) = &self.memory { - return self.overlay_approval(self.render_memory(m)); + return self.overlay_decision_modals(self.render_memory(m)); } if let Some(panel) = &self.asset_list { - return self.overlay_approval(self.render_asset_list(panel)); + return self.overlay_decision_modals(self.render_asset_list(panel)); } if let Some(panel) = &self.runtime_activity { - return self.overlay_approval(self.render_runtime_activity(panel)); + return self.overlay_decision_modals(self.render_runtime_activity(panel)); } if let Some(kb) = &self.kb { let page = self.render_kb(kb); - return self.overlay_approval(page); + return self.overlay_decision_modals(page); } if let Some(panel) = &self.loop_panel { - return self.overlay_approval(self.render_loop_panel(panel)); + return self.overlay_decision_modals(self.render_loop_panel(panel)); } if let Some(ide) = &self.ide { // A pending tool approval overlays the full-screen page so it is // never invisible (its keys take priority in the key dispatch). let page = self.render_ide(ide); - return self.overlay_approval(page); + return self.overlay_decision_modals(page); } let width = self.width as usize; let composer_width = self.viewport_content_width(); @@ -231,7 +231,7 @@ impl Model for App { let composed = self.overlay_effort(composed); let composed = self.overlay_theme(composed); let composed = self.overlay_plugins(composed); - self.overlay_approval(composed) + self.overlay_decision_modals(composed) } fn cursor(&self) -> Option<(u16, u16)> { diff --git a/src/tui/app/update_dispatch.rs b/src/tui/app/update_dispatch.rs index 633913bf..afe9a3f2 100644 --- a/src/tui/app/update_dispatch.rs +++ b/src/tui/app/update_dispatch.rs @@ -34,6 +34,10 @@ fn composer_attachment_key_action( None } +fn is_send_now_key(key: &KeyEvent) -> bool { + matches!(key.code, KeyCode::Char('o' | 'O')) && key.modifiers.contains(KeyModifiers::CONTROL) +} + impl App { pub(super) fn update_message(&mut self, msg: Msg) -> Option> { if self.quitting { @@ -91,6 +95,11 @@ impl App { // lines / a3s-lane queue spam — Claude-Code-style paste DX. Msg::Term(Event::Paste(text)) => { self.last_activity = Instant::now(); + if self.plan_review_input_active() { + self.textarea.insert_str(&text); + self.relayout(); + return None; + } if self.composer_input_is_hidden() { return None; } @@ -171,6 +180,12 @@ impl App { if self.state == State::Awaiting { return self.handle_approval_key(&key); } + // A completed plan is a true modal execution boundary. No + // underlying panel, mode shortcut, or composer action may run + // until the user approves, revises, or abandons it. + if self.plan_review.is_some() { + return self.handle_plan_review_key(&key); + } // The semantic transcript is a true modal surface. It keeps // all styles and owns navigation until explicitly closed. if let Some(transcript) = self.transcript_view.as_mut() { @@ -218,9 +233,10 @@ impl App { if self.relay_panel.is_some() { return self.handle_relay_key(&key); } - // Shift+Tab cycles run mode in any state. + // Shift+Tab changes the composer mode. A running or queued + // turn retains the immutable mode captured at submission. if key.code == KeyCode::BackTab { - self.mode = self.mode.next(); + self.set_composer_mode(self.mode.next()); return None; } // /model picker takes keys while open — consume EVERY key so @@ -481,6 +497,25 @@ impl App { self.history_recall(key.code == KeyCode::Up); return None; } + // Ctrl+O is a distinct Send now intent. It cancels a genuinely + // active turn and promotes this submission ahead of ordinary + // FIFO follow-ups; during terminal settlement it safely falls + // back to the normal queue without losing the draft. + if is_send_now_key(&key) { + if self.state != State::Streaming + || (self.textarea.value().trim().is_empty() + && self.pending_images.is_empty()) + { + return None; + } + let text = self.textarea.value(); + self.textarea.clear(); + return Some(cmd::msg(if self.stream_interrupt_available() { + Msg::SubmitNow(text) + } else { + Msg::Submit(text) + })); + } match composer_attachment_key_action( &key, &self.textarea.value(), @@ -532,6 +567,9 @@ impl App { if self.state == State::Awaiting { return self.handle_approval_mouse(&m); } + if self.plan_review.is_some() { + return self.handle_plan_review_mouse(&m); + } if let Some(transcript) = self.transcript_view.as_mut() { transcript.handle_mouse(&m); return None; @@ -689,6 +727,7 @@ impl App { } Msg::Submit(text) => return self.on_submit(text), + Msg::SubmitNow(text) => return self.on_submit_now(text), Msg::GoalContinue { generation, prompt } => { return self.handle_goal_continue(generation, prompt); @@ -1191,4 +1230,20 @@ mod tests { Some(ComposerAttachmentKeyAction::RemoveLastImage) ); } + + #[test] + fn send_now_has_a_distinct_control_chord() { + assert!(is_send_now_key(&key( + KeyCode::Char('o'), + KeyModifiers::CONTROL + ))); + assert!(!is_send_now_key(&key( + KeyCode::Char('o'), + KeyModifiers::NONE + ))); + assert!(!is_send_now_key(&key( + KeyCode::Enter, + KeyModifiers::CONTROL + ))); + } } diff --git a/src/tui/app/view.rs b/src/tui/app/view.rs index c79dd740..d8512ae7 100644 --- a/src/tui/app/view.rs +++ b/src/tui/app/view.rs @@ -128,6 +128,9 @@ impl App { pub(super) fn finish(&mut self) { self.preserve_interrupted_tools(); self.llm_turn_checkpoint = None; + self.active_turn_mode = None; + self.active_plan_draft = None; + self.execution_policy.set_mode(self.mode); self.state = State::Idle; self.running_task = None; self.plan.clear(); @@ -741,7 +744,7 @@ impl App { let (approved, approve_all_pending) = match choice { 0 => (true, false), // yes, once 1 => { - self.mode = Mode::Auto; // yes, and stop asking + self.set_composer_mode(Mode::Auto); // yes, and stop asking (true, true) } _ => (false, false), // no diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e8353257..a868d39c 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -333,6 +333,8 @@ mod file_change_view; mod image; #[path = "ui/message_chrome.rs"] mod message_chrome; +#[path = "ui/plan_review.rs"] +mod plan_review; #[path = "ui/program_preview.rs"] mod program_preview; #[path = "ui/render.rs"] @@ -387,6 +389,7 @@ use message_chrome::*; pub(crate) use panels::ctx::{parse_ctx_search, strip_controls}; pub(crate) use panels::loop_engineering; use panels::transcript::{SemanticTranscriptViewport, TranscriptViewportAction}; +use plan_review::*; use render::*; use runtime_policy::RuntimePolicy; use runtime_projection::{ @@ -753,6 +756,9 @@ struct App { interrupting: bool, /// Manual tool approvals waiting for a decision, in request order. pending_tools: VecDeque<(String, String)>, + /// Mode-aware permission boundary shared with every rebuilt Core session. + /// It always tracks the immutable mode of the running turn. + execution_policy: TuiExecutionPolicy, /// Selected row in the tool-approval options panel (0 yes · 1 always · 2 no). approval_sel: usize, /// Submitted prompts, oldest first, for ↑/↓ recall. @@ -779,10 +785,20 @@ struct App { /// Host turns submitted while the agent is busy. Ordering and FIFO /// semantics come directly from a3s-lane. queue: PriorityQueue, + /// Submission-time execution mode keyed by a3s-lane's stable sequence. + /// Keeping this beside the queue avoids mutable footer state changing the + /// semantics of a turn that was already submitted. + queued_turn_modes: HashMap, + /// Strict planning requests keyed by the same stable queue sequence. + queued_plan_drafts: HashMap, /// A claimed queued turn remains here until Core admits it. Admission /// failure restores this exact item, including its original FIFO sequence. active_queued_turn: Option>, active_queued_turn_token: Option, + /// Immutable mode of the current stream, retained after queue admission. + active_turn_mode: Option, + /// Planning request owned by the current read-only stream. + active_plan_draft: Option, queue_retry_generation: u64, queue_retry_attempt: u8, /// Text of the message currently being processed (the running task). @@ -790,6 +806,11 @@ struct App { /// Typed live plan/TODO projection, pinned above the input and updated from /// PlanningEnd/TaskUpdated or the Codex-compatible `update_plan` tool. plan: PlanProjection, + /// Review staged until the completed stream releases Core's single-flight + /// lease, then promoted to the modal decision boundary below. + pending_plan_review: Option, + /// Explicit Approve / Revise / Abandon boundary for a completed plan. + plan_review: Option, /// `/ide` file-tree + viewer panel (Some when open). ide: Option, /// `/memory` full-screen timeline panel (Some when open). @@ -840,6 +861,7 @@ impl App { fn composer_input_is_hidden(&self) -> bool { self.goal_resume_prompt.is_some() || self.state == State::Awaiting + || (self.plan_review.is_some() && !self.plan_review_input_active()) || self.transcript_view.is_some() || self.model_menu.is_some() || self.relay_panel.is_some() diff --git a/src/tui/panels/assets/review.rs b/src/tui/panels/assets/review.rs index 4e7d64dd..e82d31ea 100644 --- a/src/tui/panels/assets/review.rs +++ b/src/tui/panels/assets/review.rs @@ -311,7 +311,8 @@ impl App { TN_PURPLE, &Style::new().bold().render(&label), ))); - self.queue.push( + let execution_mode = self.mode; + self.enqueue_turn( SYNTHETIC_TURN_PRIORITY, Queued { text: prompt, @@ -320,6 +321,7 @@ impl App { runtime_expectation: None, deep_research: None, }, + execution_mode, ); if self.state == State::Idle { return self.drain_queue(); diff --git a/src/tui/panels/system/help.rs b/src/tui/panels/system/help.rs index 4ffd83b1..9ba26912 100644 --- a/src/tui/panels/system/help.rs +++ b/src/tui/panels/system/help.rs @@ -278,10 +278,17 @@ fn help_panel() -> HelpPanel { HelpSection::new("Keys") .row( "Enter", - "send; while busy, queue the message (Esc interrupts and runs it now)", + "send; while busy, append the message to the FIFO queue", + ) + .row( + "Ctrl+O", + "Send now: cancel the active turn and promote this prompt", ) .row("Shift+Enter", "insert a newline in the input") - .row("Shift+Tab", "cycle run mode: default -> plan -> auto") + .row( + "Shift+Tab", + "cycle default -> read-only plan/review -> non-interactive auto", + ) .row( "Up / Down", "recall input history; inside menus, move selection", diff --git a/src/tui/panels/system/model.rs b/src/tui/panels/system/model.rs index 3b6cfbfb..02f88799 100644 --- a/src/tui/panels/system/model.rs +++ b/src/tui/panels/system/model.rs @@ -736,9 +736,10 @@ impl App { ); let automatic_delegation = effort_uses_automatic_delegation(profile.effort); let mut opts = with_recent_workspace_context( - tui_session_options_with_gate( + tui_session_options_with_gate_and_execution( self.confirmation.clone(), self.deep_research_report_tool_gate.clone(), + self.execution_policy.clone(), ) .with_session_store(self.store.clone()) .with_session_id(profile.session_id.as_str()) @@ -1071,7 +1072,7 @@ impl App { ))); } if selected == ULTRACODE { - self.mode = Mode::Auto; + self.set_composer_mode(Mode::Auto); self.gradient_until = Some(Instant::now()); self.gradient_frame = 0; let native = codex_effort @@ -1082,7 +1083,7 @@ impl App { }) .unwrap_or_default(); self.push_line(&Style::new().fg(ACCENT).bold().render(&format!( - " ◆ ultracode — planning a dynamic workflow + parallel subagents (auto-approve on){native}", + " ◆ ultracode — planning a dynamic workflow + parallel subagents (non-interactive Auto on){native}", ))); } else if let Some(status) = codex_effort { let cap = if status.capped { " (model limit)" } else { "" }; @@ -1186,8 +1187,14 @@ impl App { self.runtime.clear_turn_entities(); self.runtime.clear_subagent_entities(); self.queue.clear(); + self.queued_turn_modes.clear(); + self.queued_plan_drafts.clear(); self.active_queued_turn = None; self.active_queued_turn_token = None; + self.active_turn_mode = None; + self.active_plan_draft = None; + self.pending_plan_review = None; + self.plan_review = None; self.queue_retry_generation = self.queue_retry_generation.wrapping_add(1); self.queue_retry_attempt = 0; self.completed = 0; diff --git a/src/tui/panels/system/plan.rs b/src/tui/panels/system/plan.rs index 279b055e..4553112d 100644 --- a/src/tui/panels/system/plan.rs +++ b/src/tui/panels/system/plan.rs @@ -25,8 +25,18 @@ impl App { .ordered() .into_iter() .map(|item| { + let display = match self + .queued_turn_modes + .get(&item.sequence()) + .copied() + .unwrap_or(Mode::Default) + { + Mode::Plan => format!("✎ {}", item.value().display), + Mode::Auto => format!("⏵⏵ {}", item.value().display), + Mode::Default => item.value().display.clone(), + }; chrome - .queued_task(item.value().display.clone()) + .queued_task(display) .priority(i32::from(item.priority())) .sequence(item.sequence()) }) diff --git a/src/tui/panels/system/relay.rs b/src/tui/panels/system/relay.rs index 7092f3a7..01fe36f2 100644 --- a/src/tui/panels/system/relay.rs +++ b/src/tui/panels/system/relay.rs @@ -393,7 +393,7 @@ impl App { self.model = restore.model; self.model_source = restore.model_source; self.effort = restore.effort; - self.mode = restore.mode; + self.set_composer_mode(restore.mode); self.context_limit = restore.context_limit; self.llm_override = restore.llm_override; if let Some(theme) = restore.theme { @@ -419,8 +419,14 @@ impl App { self.background_subagent_watches.clear(); self.invalidate_subagent_snapshots(); self.queue.clear(); + self.queued_turn_modes.clear(); + self.queued_plan_drafts.clear(); self.active_queued_turn = None; self.active_queued_turn_token = None; + self.active_turn_mode = None; + self.active_plan_draft = None; + self.pending_plan_review = None; + self.plan_review = None; self.queue_retry_generation = self.queue_retry_generation.wrapping_add(1); self.queue_retry_attempt = 0; self.running_task = None; diff --git a/src/tui/panels/workspace/goal_engineering.rs b/src/tui/panels/workspace/goal_engineering.rs index bf4d648f..ab89ce26 100644 --- a/src/tui/panels/workspace/goal_engineering.rs +++ b/src/tui/panels/workspace/goal_engineering.rs @@ -487,7 +487,7 @@ impl App { pub(crate) fn finish_goal_start(&mut self) -> Option> { self.effort = ULTRACODE; - self.mode = Mode::Auto; + self.set_composer_mode(Mode::Auto); self.autonomy_restore = None; self.loop_remaining = 0; self.gradient_until = Some(Instant::now()); diff --git a/src/tui/tests.rs b/src/tui/tests.rs index c6fdfb59..5cdf8994 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -6331,13 +6331,8 @@ fn tui_guardrail_keeps_confirmable_and_non_bypassable_risk_separate() { } #[test] -fn auto_mode_approves_every_confirmation_required_tool() { - use a3s_code_core::permissions::{PermissionChecker, PermissionDecision}; - - let checker = TuiHitlPermissionChecker::new( - tui_permission_policy(), - DeepResearchReportToolGate::default(), - ); +fn auto_mode_resolves_every_confirmation_without_hitl() { + let execution = TuiExecutionPolicy::new(Mode::Auto); for (tool, args) in [ ("write", serde_json::json!({"file_path": "README.md"})), ("bash", serde_json::json!({"command": "cargo test"})), @@ -6357,10 +6352,6 @@ fn auto_mode_approves_every_confirmation_required_tool() { "mcp__github__create_issue", serde_json::json!({"title": "side effect"}), ), - ( - "git", - serde_json::json!({"command": "checkout", "ref": "feature", "force": true}), - ), ( "batch", serde_json::json!({"invocations": [ @@ -6369,15 +6360,31 @@ fn auto_mode_approves_every_confirmation_required_tool() { ), ] { assert_eq!( - checker.check(tool, &args), - PermissionDecision::Ask, - "{tool} must reach HITL instead of bypassing a hard denial" + execution.auto_confirmation_decision(tool, &args, Path::new(".")), + Some(true), + "{tool} must be approved without entering HITL in Auto mode" ); - assert!(Mode::Auto.auto_approves_confirmation()); } - assert!(!Mode::Default.auto_approves_confirmation()); - assert!(!Mode::Plan.auto_approves_confirmation()); + assert_eq!( + execution.auto_confirmation_decision( + "bash", + &serde_json::json!({"command": "rm -rf /"}), + Path::new("."), + ), + Some(false), + "a hard denial must be rejected without entering HITL" + ); + + execution.set_mode(Mode::Default); + assert_eq!( + execution.auto_confirmation_decision( + "bash", + &serde_json::json!({"command": "cargo test"}), + Path::new("."), + ), + None + ); } #[test] @@ -7003,6 +7010,82 @@ async fn tui_session_policy_does_not_block_web_fetch() { ); } +#[tokio::test] +async fn auto_mode_executes_shell_side_effect_without_confirmation_event() { + let dir = std::env::temp_dir().join(format!( + "a3s-auto-no-hitl-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = dir.join("config.acl"); + test_config(&cfg); + + let agent = a3s_code_core::Agent::new(cfg.to_string_lossy().to_string()) + .await + .unwrap(); + let llm = Arc::new(CaptureLlmClient::new(vec![ + tool_call_response( + "bash", + serde_json::json!({"command": "printf auto-mode-ok > auto-mode-probe.txt"}), + ), + done_response(), + ])); + let gate = DeepResearchReportToolGate::default(); + gate.set_workspace(&dir); + let execution = TuiExecutionPolicy::new(Mode::Auto); + let opts = tui_session_options_with_gate_and_execution( + a3s_code_core::hitl::ConfirmationPolicy::enabled().with_timeout(300, TimeoutAction::Reject), + gate, + execution, + ) + .with_llm_client(llm) + .with_planning_mode(a3s_code_core::PlanningMode::Disabled); + let session = agent + .session_async(dir.to_string_lossy().to_string(), Some(opts)) + .await + .unwrap(); + + let (mut rx, join) = session + .stream("Create the requested probe file.", None) + .await + .unwrap(); + let mut shell_completed = false; + while let Some(event) = rx.recv().await { + match event { + a3s_code_core::AgentEvent::ConfirmationRequired { tool_name, .. } => { + panic!("Auto emitted an interactive confirmation for {tool_name}") + } + a3s_code_core::AgentEvent::PermissionDenied { + tool_name, reason, .. + } => panic!("{tool_name} was denied in Auto: {reason}"), + a3s_code_core::AgentEvent::ToolEnd { + name, + output, + exit_code, + .. + } if name == "bash" => { + assert_eq!(exit_code, 0, "{output}"); + shell_completed = true; + } + a3s_code_core::AgentEvent::End { .. } => break, + a3s_code_core::AgentEvent::Error { message } => panic!("{message}"), + _ => {} + } + } + join.await.unwrap(); + + assert!(shell_completed, "the shell tool did not finish"); + assert_eq!( + std::fs::read_to_string(dir.join("auto-mode-probe.txt")).unwrap(), + "auto-mode-ok" + ); + let _ = std::fs::remove_dir_all(&dir); +} + #[tokio::test] async fn hitl_wait_does_not_consume_tool_timeout_budget() { let dir = std::env::temp_dir().join(format!( diff --git a/src/tui/ui/chrome.rs b/src/tui/ui/chrome.rs index 8e3e8c52..85a16a92 100644 --- a/src/tui/ui/chrome.rs +++ b/src/tui/ui/chrome.rs @@ -197,7 +197,7 @@ pub(super) const SLASH_COMMANDS: &[(&str, &str)] = &[ "branch a new session from this point (original kept)", ), ("/clear", "reset the conversation"), - ("/auto", "switch to auto-approve mode"), + ("/auto", "make future turns non-interactive"), ("/exit", "quit a3s code"), ]; diff --git a/src/tui/ui/editor_state.rs b/src/tui/ui/editor_state.rs index 4ef282e3..834cfe97 100644 --- a/src/tui/ui/editor_state.rs +++ b/src/tui/ui/editor_state.rs @@ -561,9 +561,9 @@ pub(super) fn textarea_width_for(width: u16) -> u16 { pub(super) enum Mode { /// Standard risk-aware mode: safe reads run quietly and side effects prompt. Default, - /// Exploration/planning mode: safe reads run quietly and side effects prompt. + /// Strict read-only planning mode. Implementation starts only after review. Plan, - /// Auto-approve every confirmable operation; hard permission denials remain blocked. + /// Non-interactive execution. Hard policy and workspace denials still win. Auto, } @@ -600,22 +600,16 @@ impl Mode { Mode::Auto => COMPOSER_CHROME.warning, } } - - /// Whether an operation that already reached Core's confirmable `Ask` - /// branch should be approved without opening the HITL overlay. Critical - /// operations never call this method because Core emits `PermissionDenied` - /// for them before a confirmation can be requested. - pub(super) const fn auto_approves_confirmation(self) -> bool { - matches!(self, Mode::Auto) - } } /// Highest runnable host-turn priority. Safety decisions and stream lifecycle /// barriers are control-plane operations and therefore remain outside the /// pending-turn queue. -pub(super) const USER_TURN_PRIORITY: a3s_lane::Priority = 0; +pub(super) const PLAN_REVIEW_PRIORITY: a3s_lane::Priority = 0; +/// Explicit user submissions run before autonomous continuations. +pub(super) const USER_TURN_PRIORITY: a3s_lane::Priority = 1; /// Host-generated work that must never overtake an explicit user turn. -pub(super) const SYNTHETIC_TURN_PRIORITY: a3s_lane::Priority = 1; +pub(super) const SYNTHETIC_TURN_PRIORITY: a3s_lane::Priority = 2; /// A host-owned turn queued while the agent is busy. Priority and FIFO /// metadata are owned by `a3s_lane::PriorityQueue`, not duplicated in the UI diff --git a/src/tui/ui/plan_review.rs b/src/tui/ui/plan_review.rs new file mode 100644 index 00000000..0c0718e6 --- /dev/null +++ b/src/tui/ui/plan_review.rs @@ -0,0 +1,590 @@ +//! Strict two-phase planning state and its compact review surface. +//! +//! A planning turn is read-only and ends at this host-owned boundary. The +//! implementation turn is created only after an explicit approval. + +use a3s_code_core::planning::Task; +use a3s_tui::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use a3s_tui::style::{fit_visible, wrap_words, Style}; +use a3s_tui::KeyCode; + +use super::{ + App, Cmd, Mode, Msg, NoticeKind, PendingImage, Queued, State, TextareaMsg, TranscriptEntry, + COMPOSER_CHROME, PLAN_REVIEW_PRIORITY, SURFACE_SELECTED, TN_GREEN, TN_RED, TN_SUBTLE, +}; + +const OPTION_COUNT: usize = 3; +const MAX_PLAN_TEXT_CHARS: usize = 16 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PlanDraftRequest { + pub(super) original_prompt: String, + pub(super) display: String, + pub(super) revision: u32, + previous_plan: Option, + feedback: Option, +} + +impl PlanDraftRequest { + pub(super) fn initial(original_prompt: String, display: String) -> Self { + Self { + original_prompt, + display, + revision: 0, + previous_plan: None, + feedback: None, + } + } + + pub(super) fn revised(&self, previous_plan: String, feedback: String) -> Self { + Self { + original_prompt: self.original_prompt.clone(), + display: self.display.clone(), + revision: self.revision.saturating_add(1), + previous_plan: Some(bounded_text(&previous_plan)), + feedback: Some(feedback), + } + } + + pub(super) fn planning_prompt(&self) -> String { + let mut prompt = format!( + "[strict-plan]\n\ + This is a read-only planning turn. Inspect the workspace as needed, \ + but do not modify files, run mutating commands, create external \ + resources, or begin implementation. Produce a concrete, ordered, \ + verifiable implementation plan and then stop for user review.\n\n\ + Original request:\n{}", + self.original_prompt + ); + if let Some(previous_plan) = self.previous_plan.as_deref() { + prompt.push_str("\n\nPrevious plan:\n"); + prompt.push_str(previous_plan); + } + if let Some(feedback) = self.feedback.as_deref() { + prompt.push_str("\n\nRequired revision feedback:\n"); + prompt.push_str(feedback); + } + prompt + } +} + +#[derive(Clone, Debug)] +pub(super) struct PlanReviewState { + pub(super) request: PlanDraftRequest, + pub(super) tasks: Vec, + narrative: String, + pub(super) selected: usize, + stashed_composer: Option, +} + +impl PlanReviewState { + pub(super) fn new(request: PlanDraftRequest, tasks: Vec, narrative: String) -> Self { + Self { + request, + tasks, + narrative: bounded_text(&narrative), + selected: 0, + stashed_composer: None, + } + } + + pub(super) fn is_revising(&self) -> bool { + self.stashed_composer.is_some() + } + + pub(super) fn begin_revision(&mut self, composer: String) { + if self.stashed_composer.is_none() { + self.stashed_composer = Some(composer); + } + self.selected = 1; + } + + pub(super) fn cancel_revision(&mut self) -> Option { + self.stashed_composer.take() + } + + pub(super) fn take_stashed_composer(&mut self) -> Option { + self.stashed_composer.take() + } + + pub(super) fn plan_text(&self) -> String { + if !self.tasks.is_empty() { + return self + .tasks + .iter() + .enumerate() + .map(|(index, task)| format!("{}. {}", index + 1, task.content.trim())) + .collect::>() + .join("\n"); + } + if self.narrative.trim().is_empty() { + "No structured plan was returned. Revisions should request concrete steps.".to_string() + } else { + self.narrative.trim().to_string() + } + } + + pub(super) fn implementation_prompt(&self) -> String { + format!( + "[approved-plan]\n\ + The user explicitly approved the plan below. Implement it now, \ + verify the result, and report the outcome. Treat the original \ + request as authoritative if a plan step is ambiguous.\n\n\ + Original request:\n{}\n\n\ + Approved plan:\n{}", + self.request.original_prompt, + self.plan_text() + ) + } +} + +fn bounded_text(text: &str) -> String { + let mut chars = text.chars(); + let bounded = chars.by_ref().take(MAX_PLAN_TEXT_CHARS).collect::(); + if chars.next().is_some() { + format!("{bounded}\n…") + } else { + bounded + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PlanReviewPromptMsg { + Selected(usize), +} + +#[derive(Clone, Debug)] +pub(super) struct PlanReviewPrompt { + selected: usize, + steps: usize, + revision: u32, + revising: bool, + y_offset: u16, +} + +impl PlanReviewPrompt { + pub(super) fn new(state: &PlanReviewState) -> Self { + Self { + selected: state.selected.min(OPTION_COUNT - 1), + steps: state.tasks.len(), + revision: state.request.revision, + revising: state.is_revising(), + y_offset: 0, + } + } + + pub(super) fn lines(&self, width: usize) -> Vec { + if width == 0 { + return Vec::new(); + } + if self.revising { + return self.revision_lines(width); + } + + let step_label = if self.steps == 1 { + "1 step".to_string() + } else { + format!("{} steps", self.steps) + }; + let mut lines = vec![fit_visible( + &Style::new() + .fg(COMPOSER_CHROME.active) + .bold() + .render(&format!( + "◆ Plan ready · {step_label} · revision {}", + self.revision + 1 + )), + width, + )]; + lines.extend( + wrap_words( + "Implementation is paused until you choose an action.", + width.saturating_sub(2).max(1), + ) + .into_iter() + .map(|line| { + fit_visible( + &Style::new() + .fg(COMPOSER_CHROME.secondary) + .render(&format!(" {line}")), + width, + ) + }), + ); + lines.extend((0..OPTION_COUNT).map(|index| self.option_line(index, width))); + lines.push(fit_visible( + &Style::new() + .fg(TN_SUBTLE) + .render(" Enter select · ↑↓ move · a approve · r revise · x abandon"), + width, + )); + lines + } + + pub(super) fn set_y_offset(&mut self, y_offset: u16) { + self.y_offset = y_offset; + } + + pub(super) fn selected_index(&self) -> usize { + self.selected.min(OPTION_COUNT - 1) + } + + pub(super) fn handle_mouse( + &mut self, + mouse: &MouseEvent, + width: usize, + ) -> Option { + if width == 0 || self.revising { + return None; + } + let local_row = mouse.row.checked_sub(self.y_offset)? as usize; + let choice_start = self.choice_start_row(width); + match mouse.kind { + MouseEventKind::ScrollUp => { + self.selected = self.selected_index().saturating_sub(1); + None + } + MouseEventKind::ScrollDown => { + self.selected = (self.selected_index() + 1).min(OPTION_COUNT - 1); + None + } + MouseEventKind::Down(MouseButton::Left) => { + let index = local_row.checked_sub(choice_start)?; + if index < OPTION_COUNT { + self.selected = index; + Some(PlanReviewPromptMsg::Selected(index)) + } else { + None + } + } + _ => None, + } + } + + fn choice_start_row(&self, width: usize) -> usize { + 1 + wrap_words( + "Implementation is paused until you choose an action.", + width.saturating_sub(2).max(1), + ) + .len() + } + + fn option_line(&self, index: usize, width: usize) -> String { + let (label, color) = match index { + 0 => ("Approve plan and implement", TN_GREEN), + 1 => ("Revise plan with feedback", COMPOSER_CHROME.primary), + _ => ("Abandon plan", TN_RED), + }; + let marker = if index == self.selected_index() { + "›" + } else { + " " + }; + let line = format!(" {marker} {}. {label}", index + 1); + let styled = if index == self.selected_index() { + Style::new().fg(color).bg(SURFACE_SELECTED).bold() + } else { + Style::new().fg(color) + }; + fit_visible(&styled.render(&line), width) + } + + fn revision_lines(&self, width: usize) -> Vec { + vec![ + fit_visible( + &Style::new() + .fg(COMPOSER_CHROME.active) + .bold() + .render("↺ Revise plan"), + width, + ), + fit_visible( + &Style::new() + .fg(COMPOSER_CHROME.secondary) + .render(" Type concrete feedback below, then press Enter. Esc cancels."), + width, + ), + ] + } +} + +impl App { + /// Capture a completed read-only planning turn before normal turn cleanup + /// clears its typed plan projection. + pub(super) fn take_plan_review_candidate(&mut self) -> Option { + let request = self.active_plan_draft.take()?; + Some(PlanReviewState::new( + request, + self.plan.tasks().to_vec(), + self.turn_text.clone(), + )) + } + + /// Promote the staged review only after the stream worker has released the + /// session lease. Queue draining remains blocked until a decision is made. + pub(super) fn activate_pending_plan_review(&mut self) -> bool { + let Some(review) = self.pending_plan_review.take() else { + return false; + }; + self.set_plan(&review.tasks); + self.plan_review = Some(review); + self.state = State::Idle; + self.relayout(); + true + } + + pub(super) fn plan_review_input_active(&self) -> bool { + self.plan_review + .as_ref() + .is_some_and(PlanReviewState::is_revising) + } + + pub(super) fn handle_plan_review_key(&mut self, key: &KeyEvent) -> Option> { + if self.plan_review_input_active() { + if key.code == KeyCode::Esc { + let restored = self + .plan_review + .as_mut() + .and_then(PlanReviewState::cancel_revision); + if let Some(restored) = restored { + self.textarea.set_value(&restored); + } + self.relayout(); + return None; + } + match self.textarea.handle_key(key) { + Some(TextareaMsg::Submit(feedback)) if !feedback.trim().is_empty() => { + return self.submit_plan_revision(feedback); + } + Some(TextareaMsg::Submit(_)) => return None, + Some(TextareaMsg::Changed(_)) => { + self.relayout(); + return None; + } + None => return None, + } + } + + let selected = self.plan_review.as_ref()?.selected; + match key.code { + KeyCode::Up => { + if let Some(review) = self.plan_review.as_mut() { + review.selected = review.selected.saturating_sub(1); + } + None + } + KeyCode::Down => { + if let Some(review) = self.plan_review.as_mut() { + review.selected = (review.selected + 1).min(OPTION_COUNT - 1); + } + None + } + KeyCode::Enter => self.apply_plan_review_choice(selected), + KeyCode::Char('a' | 'A') | KeyCode::Char('1') => self.approve_plan_review(), + KeyCode::Char('r' | 'R') | KeyCode::Char('2') => { + self.begin_plan_revision(); + None + } + KeyCode::Char('x' | 'X') | KeyCode::Char('3') | KeyCode::Esc => { + self.abandon_plan_review() + } + _ => None, + } + } + + pub(super) fn handle_plan_review_mouse(&mut self, mouse: &MouseEvent) -> Option> { + let review = self.plan_review.as_ref()?; + if review.is_revising() { + return None; + } + let width = self.width as usize; + if width == 0 { + return None; + } + let mut prompt = PlanReviewPrompt::new(review); + let rows = prompt.lines(width).len(); + let y_offset = self + .height + .saturating_sub(self.approval_rows_below().min(u16::MAX as usize) as u16) + .saturating_sub(rows.min(u16::MAX as usize) as u16); + prompt.set_y_offset(y_offset); + let before = prompt.selected_index(); + match prompt.handle_mouse(mouse, width) { + Some(PlanReviewPromptMsg::Selected(index)) => self.apply_plan_review_choice(index), + None => { + let after = prompt.selected_index(); + if after != before { + if let Some(review) = self.plan_review.as_mut() { + review.selected = after; + } + } + None + } + } + } + + pub(super) fn overlay_plan_review(&self, composed: String) -> String { + let Some(review) = self.plan_review.as_ref() else { + return composed; + }; + let lines = PlanReviewPrompt::new(review).lines(self.width as usize); + self.overlay_list_with_rows_below(composed, &lines, self.approval_rows_below()) + } + + pub(super) fn overlay_decision_modals(&self, composed: String) -> String { + self.overlay_approval(self.overlay_plan_review(composed)) + } + + fn apply_plan_review_choice(&mut self, choice: usize) -> Option> { + match choice { + 0 => self.approve_plan_review(), + 1 => { + self.begin_plan_revision(); + None + } + _ => self.abandon_plan_review(), + } + } + + fn begin_plan_revision(&mut self) { + let draft = self.textarea.value(); + if let Some(review) = self.plan_review.as_mut() { + review.begin_revision(draft); + self.textarea.clear(); + self.relayout(); + } + } + + fn approve_plan_review(&mut self) -> Option> { + let mut review = self.plan_review.take()?; + if let Some(stashed) = review.take_stashed_composer() { + self.textarea.set_value(&stashed); + } + let prompt = review.implementation_prompt(); + let display = format!("Implement approved plan: {}", review.request.display); + self.mode = Mode::Default; + self.execution_policy.set_mode(Mode::Default); + self.plan.clear(); + self.push_notice(NoticeKind::Info, "Plan approved · implementation started"); + self.enqueue_turn( + PLAN_REVIEW_PRIORITY, + Queued { + text: prompt, + display, + images: Vec::::new(), + runtime_expectation: None, + deep_research: None, + }, + Mode::Default, + ); + self.drain_queue() + } + + fn submit_plan_revision(&mut self, feedback: String) -> Option> { + let feedback = feedback.trim().to_string(); + let mut review = self.plan_review.take()?; + let stashed = review.take_stashed_composer().unwrap_or_default(); + let request = review.request.revised(review.plan_text(), feedback.clone()); + self.textarea.set_value(&stashed); + self.messages + .push(TranscriptEntry::user(format!("Plan feedback: {feedback}"))); + self.history.push(feedback); + self.history_pos = None; + self.history_draft = None; + self.mode = Mode::Plan; + self.execution_policy.set_mode(Mode::Plan); + self.plan.clear(); + let display = format!("Revise plan: {}", request.display); + self.enqueue_plan_turn( + PLAN_REVIEW_PRIORITY, + Queued { + text: request.planning_prompt(), + display, + images: Vec::::new(), + runtime_expectation: None, + deep_research: None, + }, + request, + ); + self.rebuild_viewport(); + self.drain_queue() + } + + fn abandon_plan_review(&mut self) -> Option> { + let mut review = self.plan_review.take()?; + if let Some(stashed) = review.take_stashed_composer() { + self.textarea.set_value(&stashed); + } + self.mode = Mode::Default; + self.execution_policy.set_mode(Mode::Default); + self.plan.clear(); + self.push_notice( + NoticeKind::Info, + "Plan abandoned · no implementation was started", + ); + self.relayout(); + self.drain_queue() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use a3s_code_core::planning::TaskStatus; + use a3s_tui::style::strip_ansi; + + fn task(id: &str, content: &str) -> Task { + let mut task = Task::new(id, content); + task.status = TaskStatus::Pending; + task + } + + #[test] + fn revision_prompt_keeps_original_request_and_feedback() { + let original = PlanDraftRequest::initial("Implement the feature".into(), "feature".into()); + let revised = original.revised("1. Inspect\n2. Implement".into(), "Add tests".into()); + let prompt = revised.planning_prompt(); + + assert!(prompt.contains("[strict-plan]")); + assert!(prompt.contains("Implement the feature")); + assert!(prompt.contains("1. Inspect")); + assert!(prompt.contains("Add tests")); + assert_eq!(revised.revision, 1); + } + + #[test] + fn approved_implementation_uses_typed_plan() { + let review = PlanReviewState::new( + PlanDraftRequest::initial("Fix Auto mode".into(), "Auto mode".into()), + vec![ + task("one", "Bind mode to the queued turn"), + task("two", "Verify permissions"), + ], + "ignored narrative".into(), + ); + let prompt = review.implementation_prompt(); + + assert!(prompt.contains("[approved-plan]")); + assert!(prompt.contains("1. Bind mode to the queued turn")); + assert!(prompt.contains("2. Verify permissions")); + } + + #[test] + fn review_surface_has_three_explicit_actions() { + let state = PlanReviewState::new( + PlanDraftRequest::initial("Plan it".into(), "Plan it".into()), + vec![task("one", "Inspect")], + String::new(), + ); + let plain = PlanReviewPrompt::new(&state) + .lines(80) + .into_iter() + .map(|line| strip_ansi(&line)) + .collect::>() + .join("\n"); + + assert!(plain.contains("Approve plan and implement")); + assert!(plain.contains("Revise plan with feedback")); + assert!(plain.contains("Abandon plan")); + } +} From ec385aa0a575846a52624bee3a63f6ad00f8eb60 Mon Sep 17 00:00:00 2001 From: RoyLin Date: Sun, 19 Jul 2026 04:30:54 +0800 Subject: [PATCH 2/2] refactor(tui): bundle stream start context --- src/tui/app/runtime.rs | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/tui/app/runtime.rs b/src/tui/app/runtime.rs index 2398d361..1a203a62 100644 --- a/src/tui/app/runtime.rs +++ b/src/tui/app/runtime.rs @@ -14,6 +14,13 @@ fn take_matching_queue_claim( active.take() } +struct StreamStartEnvelope { + runtime_expectation: Option, + submitted_images: Vec, + execution_mode: Mode, + plan_draft: Option, +} + impl App { /// Change the composer mode without mutating the semantics of an admitted /// turn. When no turn is active, keep Core's live policy in sync @@ -112,10 +119,12 @@ impl App { display_task, clear_turn_artifacts, synthesis, - runtime_expectation, - submitted_images, - execution_mode, - None, + StreamStartEnvelope { + runtime_expectation, + submitted_images, + execution_mode, + plan_draft: None, + }, ) } @@ -125,11 +134,14 @@ impl App { display_task: String, clear_turn_artifacts: bool, synthesis: bool, - runtime_expectation: Option, - submitted_images: Vec, - execution_mode: Mode, - plan_draft: Option, + envelope: StreamStartEnvelope, ) -> Option> { + let StreamStartEnvelope { + runtime_expectation, + submitted_images, + execution_mode, + plan_draft, + } = envelope; let attachments = match submitted_images .iter() .map(PendingImage::attachment) @@ -358,10 +370,12 @@ impl App { queued.display, true, false, - queued.runtime_expectation, - queued.images, - execution_mode, - plan_draft, + StreamStartEnvelope { + runtime_expectation: queued.runtime_expectation, + submitted_images: queued.images, + execution_mode, + plan_draft, + }, ); if command.is_some() { self.active_queued_turn_token = Some(self.stream_start_token);