Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -83,7 +83,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/fork** — checkpoint long-running sessions and fork saved conversations into sibling paths with parent lineage shown in the picker
- **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git`
Expand Down Expand Up @@ -411,6 +411,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 @@ -659,7 +659,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 @@ -676,6 +676,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 @@ -685,6 +686,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 @@ -1383,6 +1385,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 @@ -354,6 +354,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 @@ -361,7 +361,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 @@ -1983,7 +1983,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 @@ -742,7 +783,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 @@ -758,6 +804,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 @@ -852,6 +919,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 @@ -217,7 +217,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 @@ -920,6 +921,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 @@ -1220,6 +1237,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