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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,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
Four 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. Pro Plan 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 @@ -480,6 +482,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 @@ -704,7 +704,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 @@ -721,6 +721,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 @@ -730,6 +731,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 @@ -1469,6 +1471,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
9 changes: 9 additions & 0 deletions crates/tui/src/commands/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,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 @@ -374,7 +374,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 @@ -214,6 +214,7 @@ pub enum DefaultModeValue {
Agent,
Plan,
Yolo,
ProPlan,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
Expand Down Expand Up @@ -784,6 +785,7 @@ impl DefaultModeValue {
Self::Agent => "agent",
Self::Plan => "plan",
Self::Yolo => "yolo",
Self::ProPlan => "pro-plan",
}
}
}
Expand Down Expand Up @@ -895,6 +897,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 @@ -2372,7 +2372,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
83 changes: 81 additions & 2 deletions 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 @@ -1055,7 +1096,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 @@ -1071,11 +1117,37 @@ 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 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 @@ -1187,6 +1259,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 @@ -69,19 +73,19 @@ 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());
}

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
Loading