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
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,12 @@ exit codes, type errors from rust-analyzer arriving between turns, sandbox
denials — these are fed back as correction vectors. The model uses its own
drift to self-correct.

Three modes control the action space. Plan is read-only. Agent gates
Three visible modes control the action space. Plan is read-only. Agent gates
destructive operations behind approval. YOLO auto-approves in trusted
workspaces. macOS Seatbelt is the active sandbox; Linux Landlock is
detected but not yet enforced; Windows sandboxing is not yet advertised.
workspaces. The opt-in `/mode pro-plan` profile keeps the Plan confirmation
gate, uses Pro for planning and review, and routes implementation turns through
Flash. macOS Seatbelt is the active sandbox; Linux Landlock is detected but not
yet enforced; Windows sandboxing is not yet advertised.

Fin — a cheap Flash call with thinking off — handles model auto-routing per
turn. `--model auto` is the default.
Expand Down Expand Up @@ -550,6 +552,11 @@ Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md).
| **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 is an explicit routing profile, not part of the default mode picker:
`/mode pro-plan` plans and reviews with `deepseek-v4-pro`, executes with
`deepseek-v4-flash` when the active provider supports it, and keeps the normal
Plan confirmation gate.

---

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

Expand All @@ -1002,6 +1002,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" => Some(AppMode::ProPlan),
_ => None,
}
}
Expand All @@ -1011,6 +1012,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 @@ -1785,6 +1787,15 @@ mod tests {
let result = mode(&mut app, Some("3"));
assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Yolo)));
assert_eq!(app.mode, AppMode::Yolo);
let result = mode(&mut app, Some("4"));
assert!(result.is_error);
assert_eq!(app.mode, AppMode::Yolo);
let result = mode(&mut app, Some("proplan"));
assert_eq!(
result.action,
Some(AppAction::ModeChanged(AppMode::ProPlan))
);
assert_eq!(app.mode, AppMode::ProPlan);
}

#[test]
Expand Down
9 changes: 9 additions & 0 deletions crates/tui/src/commands/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,15 @@ 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, "{}", tr(locale, MessageId::HomeProPlanModeTip));
let _ = writeln!(
stats,
"{}",
tr(locale, MessageId::HomeProPlanModeAutoSwitchTip)
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}

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]",
description_id: MessageId::CmdModeDescription,
},
CommandInfo {
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/config_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,7 @@ impl From<&str> for DefaultModeValue {
AppMode::Agent => Self::Agent,
AppMode::Plan => Self::Plan,
AppMode::Yolo => Self::Yolo,
AppMode::ProPlan => Self::Agent,
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1350,7 +1350,7 @@ impl Engine {
"Any operations you ran automatically under YOLO mode now require \
explicit user approval before executing.",
),
AppMode::Plan => (
AppMode::Plan | AppMode::ProPlan => (
"all writes and patches are blocked; shell and code execution are unavailable",
"Any previously planned operations that require writes or shell access \
must wait until the mode changes back to Agent or YOLO.",
Expand Down Expand Up @@ -2660,7 +2660,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 @@ -387,16 +387,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 @@ -1515,11 +1556,37 @@ fn plan_mode_toggle_preserves_catalog_byte_stability() {
);
}

#[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 parent_turn_registry_includes_goal_tools_for_all_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 Down Expand Up @@ -1631,6 +1698,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 Down Expand Up @@ -68,13 +72,13 @@ impl Engine {

// SlopLedger: plan mode only gets read-only query + export,
// agent/yolo get the full set including append + update.
builder = if mode == AppMode::Plan {
builder = if read_only_mode {
builder.with_slop_ledger_read_only_tools()
} else {
builder.with_slop_ledger_tools()
};

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())
Expand All @@ -84,7 +88,7 @@ impl Engine {
);
}

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) {
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 @@ -244,7 +244,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 @@ -992,6 +993,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 @@ -1344,6 +1361,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