Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ It is built around DeepSeek V4 (`deepseek-v4-pro` / `deepseek-v4-flash`), includ
- **Full tool suite** — file ops, shell execution, git, web search/browse, apply-patch, sub-agents, MCP servers
- **1M-token context** — context tracking, manual or configured compaction, and prefix-cache telemetry
- **Prefix-cache stability tracking** — an optional `/statusline` footer chip surfaces how stable the cached prefix has been across recent turns so cost-busting edits are visible before they land
- **Three modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved)
- **Four modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved), Pro Plan (Pro plan/review + Flash execute)
- **Reasoning-effort tiers** — cycle through `off → high → max` with `Shift + Tab`
- **Session save/resume** — checkpoint and resume long-running sessions
- **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git`
Expand Down Expand Up @@ -393,6 +393,7 @@ Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md).
| **Plan** 🔍 | Read-only investigation — model explores and proposes a plan before making changes; multi-step investigations use `checklist_write` |
| **Agent** 🤖 | Default interactive mode — multi-step tool use with approval gates; substantial work is tracked with `checklist_write` |
| **YOLO** ⚡ | Auto-approve all tools in a trusted workspace; multi-step work still keeps a visible checklist |
| **Pro Plan** 🔄 | Plan and review with `deepseek-v4-pro`, execute with `deepseek-v4-flash`, and keep the normal plan confirmation gate |

---

Expand Down
8 changes: 7 additions & 1 deletion crates/tui/src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult {
};
match parse_mode_arg(arg) {
Some(mode) => CommandResult::message(switch_mode(app, mode)),
None => CommandResult::error("Usage: /mode [agent|plan|yolo|1|2|3]"),
None => CommandResult::error("Usage: /mode [agent|plan|yolo|pro-plan|1|2|3|4]"),
}
}

Expand All @@ -664,6 +664,7 @@ fn parse_mode_arg(arg: &str) -> Option<AppMode> {
"agent" | "1" => Some(AppMode::Agent),
"plan" | "2" => Some(AppMode::Plan),
"yolo" | "3" => Some(AppMode::Yolo),
"pro-plan" | "proplan" | "4" => Some(AppMode::ProPlan),
_ => None,
}
}
Expand All @@ -673,6 +674,7 @@ fn mode_display_name(mode: AppMode) -> &'static str {
AppMode::Agent => "Agent",
AppMode::Plan => "Plan",
AppMode::Yolo => "YOLO",
AppMode::ProPlan => "Pro Plan",
}
}

Expand Down Expand Up @@ -1371,6 +1373,10 @@ mod tests {
assert_eq!(app.mode, AppMode::Plan);
let _ = mode(&mut app, Some("3"));
assert_eq!(app.mode, AppMode::Yolo);
let _ = mode(&mut app, Some("4"));
assert_eq!(app.mode, AppMode::ProPlan);
let _ = mode(&mut app, Some("proplan"));
assert_eq!(app.mode, AppMode::ProPlan);
}

#[test]
Expand Down
11 changes: 11 additions & 0 deletions crates/tui/src/commands/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,17 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeTip));
let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeChecklistTip));
}
AppMode::ProPlan => {
let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeTip));
let _ = writeln!(
stats,
"Pro Plan mode: plan with Pro, execute with Flash, review with Pro"
);
let _ = writeln!(
stats,
"The model switches automatically based on the current phase."
);
}
}

CommandResult::message(stats)
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "mode",
aliases: &["jihua", "zidong"],
usage: "/mode [agent|plan|yolo|1|2|3]",
usage: "/mode [agent|plan|yolo|pro-plan|1|2|3|4]",
description_id: MessageId::CmdModeDescription,
},
CommandInfo {
Expand Down
3 changes: 3 additions & 0 deletions crates/tui/src/config_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ pub enum DefaultModeValue {
Agent,
Plan,
Yolo,
ProPlan,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
Expand Down Expand Up @@ -806,6 +807,7 @@ impl DefaultModeValue {
Self::Agent => "agent",
Self::Plan => "plan",
Self::Yolo => "yolo",
Self::ProPlan => "pro-plan",
}
}
}
Expand Down Expand Up @@ -917,6 +919,7 @@ impl From<&str> for DefaultModeValue {
AppMode::Agent => Self::Agent,
AppMode::Plan => Self::Plan,
AppMode::Yolo => Self::Yolo,
AppMode::ProPlan => Self::ProPlan,
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1950,7 +1950,8 @@ use self::dispatch::{
ToolExecutionBatch, ToolExecutionPlan, caller_allowed_for_tool, caller_type_for_tool_use,
final_tool_input, format_tool_error, mcp_tool_approval_description, mcp_tool_is_parallel_safe,
mcp_tool_is_read_only, parse_parallel_tool_calls, parse_tool_input,
plan_tool_execution_batches, should_force_update_plan_first, should_stop_after_plan_tool,
plan_tool_execution_batches, should_force_update_plan_first, should_force_update_plan_step,
should_stop_after_plan_tool,
};
use self::loop_guard::{AttemptDecision, LoopGuard, OutcomeDecision};
#[cfg(test)]
Expand Down
35 changes: 35 additions & 0 deletions crates/tui/src/core/engine/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

use serde_json::json;

use crate::core::turn::TurnToolCall;
use crate::models::{Tool, ToolCaller};
use crate::tools::spec::{ToolError, ToolResult};
use crate::tui::app::AppMode;
Expand Down Expand Up @@ -334,6 +335,16 @@ pub(super) fn should_force_update_plan_first(mode: AppMode, content: &str) -> bo
"make a plan",
"outline a plan",
"draft a plan",
"call update_plan",
"call `update_plan`",
"use update_plan",
"use `update_plan`",
"制定计划",
"只制定计划",
"做个计划",
"写个计划",
"给我计划",
"规划一下",
]
.iter()
.any(|needle| lower.contains(needle));
Expand All @@ -342,6 +353,10 @@ pub(super) fn should_force_update_plan_first(mode: AppMode, content: &str) -> bo
return false;
}

if lower.contains("<pro_plan_planning>") {
return true;
}

let asks_for_repo_exploration = [
"inspect the repo",
"inspect the code",
Expand All @@ -355,13 +370,33 @@ pub(super) fn should_force_update_plan_first(mode: AppMode, content: &str) -> bo
"understand the current",
"ground it in the codebase",
"based on the codebase",
"先看",
"看看代码",
"查看代码",
"阅读代码",
"检查代码",
"检查仓库",
"调研",
"分析代码",
"基于代码",
"根据代码",
]
.iter()
.any(|needle| lower.contains(needle));

!asks_for_repo_exploration
}

pub(super) fn should_force_update_plan_step(
force_update_plan_first: bool,
tool_calls: &[TurnToolCall],
) -> bool {
force_update_plan_first
&& !tool_calls
.iter()
.any(|call| call.name == "update_plan" && call.error.is_none())
}

pub(super) fn mcp_tool_is_parallel_safe(name: &str) -> bool {
matches!(
name,
Expand Down
76 changes: 75 additions & 1 deletion crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,16 +309,57 @@ fn quick_plan_requests_force_update_plan_on_first_step() {
AppMode::Plan,
"Make a high-level plan for the footer work."
));
assert!(should_force_update_plan_first(
AppMode::Plan,
"Use the existing Plan mode behavior and call update_plan with the proposed implementation plan."
));
assert!(should_force_update_plan_first(
AppMode::Plan,
"请只制定计划,不要改文件。"
));
assert!(should_force_update_plan_first(
AppMode::Plan,
"先看代码再制定计划。\n\n<pro_plan_planning>\ncall update_plan\n</pro_plan_planning>"
));
assert!(!should_force_update_plan_first(
AppMode::Plan,
"Inspect the repo and then give me a quick plan."
));
assert!(!should_force_update_plan_first(
AppMode::Plan,
"先看代码再制定计划。"
));
assert!(!should_force_update_plan_first(
AppMode::Agent,
"Give me a quick 3-step plan."
));
}

#[test]
fn forced_plan_step_stays_active_until_update_plan_succeeds() {
assert!(should_force_update_plan_step(true, &[]));

let mut read_call = TurnToolCall::new(
"read-1".to_string(),
"read_file".to_string(),
json!({"path": "README.md"}),
);
read_call.set_error(
"blocked until update_plan".to_string(),
std::time::Duration::from_millis(1),
);
assert!(should_force_update_plan_step(true, &[read_call]));

let mut plan_call = TurnToolCall::new(
"plan-1".to_string(),
"update_plan".to_string(),
json!({"plan": []}),
);
plan_call.set_result("planned".to_string(), std::time::Duration::from_millis(1));
assert!(!should_force_update_plan_step(true, &[plan_call]));
assert!(!should_force_update_plan_step(false, &[]));
}

#[test]
fn quick_plan_turn_can_narrow_first_step_tools_to_update_plan() {
let catalog = vec![
Expand Down Expand Up @@ -739,7 +780,12 @@ fn turn_tool_registry_builder_keeps_plan_mode_read_only_for_files() {
fn parent_turn_registry_includes_recall_archive_for_investigative_modes() {
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());

for mode in [AppMode::Plan, AppMode::Agent, AppMode::Yolo] {
for mode in [
AppMode::Plan,
AppMode::ProPlan,
AppMode::Agent,
AppMode::Yolo,
] {
let registry = engine
.build_turn_tool_registry_builder(
mode,
Expand All @@ -755,6 +801,27 @@ fn parent_turn_registry_includes_recall_archive_for_investigative_modes() {
}
}

#[test]
fn raw_pro_plan_registry_fails_closed_to_plan_tools() {
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
let registry = engine
.build_turn_tool_registry_builder(
AppMode::ProPlan,
engine.config.todos.clone(),
engine.config.plan_state.clone(),
)
.build(engine.build_tool_context(AppMode::ProPlan, false));

assert!(registry.contains("read_file"));
assert!(registry.contains("list_dir"));
assert!(registry.contains("update_plan"));
assert!(!registry.contains("write_file"));
assert!(!registry.contains("edit_file"));
assert!(!registry.contains("apply_patch"));
assert!(!registry.contains("exec_shell"));
assert!(!registry.contains("task_shell_start"));
}

#[test]
fn agent_mode_can_build_auto_approved_tool_context() {
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
Expand Down Expand Up @@ -849,6 +916,13 @@ fn sandbox_policy_for_mode_returns_correct_policy_per_mode() {
SandboxPolicy::ReadOnly
));

// Raw ProPlan should fail closed. Normal ProPlan execution is resolved to
// Plan or Agent before this point.
assert!(matches!(
sandbox_policy_for_mode(AppMode::ProPlan, &workspace),
SandboxPolicy::ReadOnly
));

// Agent: WorkspaceWrite with workspace as writable root, network on.
match sandbox_policy_for_mode(AppMode::Agent, &workspace) {
SandboxPolicy::WorkspaceWrite {
Expand Down
14 changes: 9 additions & 5 deletions crates/tui/src/core/engine/tool_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ use crate::sandbox::SandboxPolicy;
/// on. Approval flow gates risky individual commands; the sandbox handles
/// the rest. Network is allowed because cargo / npm / curl-style commands
/// are normal during agent work and DNS-deny breaks them silently.
/// - **ProPlan**: `ReadOnly` as a defense-in-depth fallback. Normal ProPlan
/// turns are resolved to `Plan` or `Agent` before reaching the engine; if a
/// future path passes raw `ProPlan`, fail closed.
/// - **YOLO**: `DangerFullAccess` — explicit no-guardrails contract.
pub(crate) fn sandbox_policy_for_mode(mode: AppMode, workspace: &Path) -> SandboxPolicy {
match mode {
AppMode::Plan => SandboxPolicy::ReadOnly,
AppMode::Plan | AppMode::ProPlan => SandboxPolicy::ReadOnly,
AppMode::Agent => SandboxPolicy::WorkspaceWrite {
writable_roots: vec![workspace.to_path_buf()],
network_access: true,
Expand All @@ -39,7 +42,8 @@ impl Engine {
todo_list: SharedTodoList,
plan_state: SharedPlanState,
) -> ToolRegistryBuilder {
let mut builder = if mode == AppMode::Plan {
let read_only_mode = matches!(mode, AppMode::Plan | AppMode::ProPlan);
let mut builder = if read_only_mode {
ToolRegistryBuilder::new()
.with_read_only_file_tools()
.with_search_tools()
Expand All @@ -65,21 +69,21 @@ impl Engine {
.with_parallel_tool()
.with_recall_archive_tool();

if mode != AppMode::Plan {
if !read_only_mode {
builder = builder
.with_rlm_tool(self.deepseek_client.clone(), self.session.model.clone())
.with_fim_tool(self.deepseek_client.clone(), self.session.model.clone());
}

if self.config.features.enabled(Feature::ApplyPatch) && mode != AppMode::Plan {
if self.config.features.enabled(Feature::ApplyPatch) && !read_only_mode {
builder = builder.with_patch_tools();
}
if self.config.features.enabled(Feature::WebSearch) {
builder = builder.with_web_tools();
}
// Plan mode is strictly read-only: do not expose shell execution at
// all, even if the session would otherwise allow it.
if mode != AppMode::Plan
if !read_only_mode
&& self.config.features.enabled(Feature::ShellTool)
&& self.session.allow_shell
{
Expand Down
25 changes: 24 additions & 1 deletion crates/tui/src/core/engine/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ impl Engine {
self.layered_context_checkpoint().await;

// Build the request
let force_update_plan_this_step = force_update_plan_first && turn.tool_calls.is_empty();
let force_update_plan_this_step =
should_force_update_plan_step(force_update_plan_first, &turn.tool_calls);
let mut active_tools = if tool_catalog.is_empty() {
None
} else {
Expand Down Expand Up @@ -871,6 +872,22 @@ impl Engine {
continue;
}

if force_update_plan_this_step {
let reminder = "Plan confirmation is required before any other response. Call update_plan with the proposed plan now; do not call other tools.";
self.add_session_message(
self.user_text_message_with_turn_metadata(reminder.to_string()),
)
.await;
let _ = self
.tx_event
.send(Event::status(
"Waiting for update_plan before continuing plan flow",
))
.await;
turn.next_step();
continue;
}

// Sub-agent completion handoff (issue #756). The model finished
// streaming with no tool calls — but if it has direct children
// still running (or completions queued from children that
Expand Down Expand Up @@ -1138,6 +1155,12 @@ impl Engine {
)));
}

if force_update_plan_this_step && tool_name != "update_plan" {
blocked_error = Some(ToolError::permission_denied(format!(
"Tool '{tool_name}' is unavailable until update_plan records the plan"
)));
}

if blocked_error.is_none()
&& tool_def.is_none()
&& !McpPool::is_mcp_tool(&tool_name)
Expand Down
Loading