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
45 changes: 45 additions & 0 deletions crates/tui/src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
.to_string(),
),
"mode" | "default_mode" => Some(app.mode.as_setting().to_string()),
"pro_plan_profile" | "pro_plan" | "proplan" => {
Some(app.pro_plan_profile_enabled.to_string())
}
"max_history" | "history" => Some(app.max_input_history.to_string()),
"sidebar_width" | "sidebar" => Some(app.sidebar_width_percent.to_string()),
"sidebar_focus" | "focus" => Some(app.sidebar_focus.as_setting().to_string()),
Expand Down Expand Up @@ -768,6 +771,13 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
let mode = AppMode::from_setting(&settings.default_mode);
app.set_mode(mode);
}
"pro_plan_profile" | "pro_plan" | "proplan" => {
app.pro_plan_profile_enabled = settings.pro_plan_profile;
if !app.pro_plan_profile_enabled && app.mode == AppMode::ProPlan {
app.set_mode(AppMode::Plan);
}
app.needs_redraw = true;
}
"max_history" | "history" => {
app.max_input_history = settings.max_input_history;
}
Expand Down Expand Up @@ -813,6 +823,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->

let display_value = match key.as_str() {
"default_mode" | "mode" => settings.default_mode.clone(),
"pro_plan_profile" | "pro_plan" | "proplan" => settings.pro_plan_profile.to_string(),
"cost_currency" | "currency" => settings.cost_currency.clone(),
"theme" | "ui_theme" => settings.theme.clone(),
"synchronized_output" | "sync_output" | "sync" => settings.synchronized_output.clone(),
Expand Down Expand Up @@ -851,6 +862,11 @@ pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult {
};
match parse_mode_arg(arg) {
Some(mode) => {
if mode == AppMode::ProPlan && !app.pro_plan_profile_enabled {
return CommandResult::error(
"Pro Plan profile is disabled. Enable it with `/config pro_plan_profile true --save`, then run `/mode pro-plan`.",
);
}
let (message, changed) = switch_mode_with_status(app, mode);
if changed {
CommandResult::with_message_and_action(message, AppAction::ModeChanged(mode))
Expand All @@ -867,6 +883,12 @@ pub fn switch_mode(app: &mut App, mode: AppMode) -> String {
}

fn switch_mode_with_status(app: &mut App, mode: AppMode) -> (String, bool) {
if mode == AppMode::ProPlan && !app.pro_plan_profile_enabled {
return (
"Pro Plan profile is disabled. Enable it with `/config pro_plan_profile true --save`, then run `/mode pro-plan`.".to_string(),
false,
);
}
if app.set_mode(mode) {
(
format!("Switched to {} mode.", mode_display_name(mode)),
Expand All @@ -885,6 +907,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 @@ -894,6 +917,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 @@ -1251,6 +1275,7 @@ mod tests {
app.auto_model = false;
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model_ids_passthrough = false;
app.pro_plan_profile_enabled = false;
app
}

Expand Down Expand Up @@ -1281,6 +1306,26 @@ 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!(result.is_error);
assert!(
result
.message
.unwrap()
.contains("Pro Plan profile is disabled")
);
assert_eq!(app.mode, AppMode::Yolo);

app.pro_plan_profile_enabled = true;
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)
);
}
}

CommandResult::message(stats)
Expand Down
4 changes: 4 additions & 0 deletions crates/tui/src/config_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ pub struct SettingsSection {
pub status_indicator: StatusIndicatorValue,
pub synchronized_output: SynchronizedOutputValue,
pub default_mode: DefaultModeValue,
pub pro_plan_profile: bool,
#[schemars(range(min = 10, max = 50))]
pub sidebar_width: u16,
pub sidebar_focus: SidebarFocusValue,
Expand Down Expand Up @@ -346,6 +347,7 @@ pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
status_indicator: settings.status_indicator.as_str().into(),
synchronized_output: settings.synchronized_output.as_str().into(),
default_mode: settings.default_mode.as_str().into(),
pro_plan_profile: settings.pro_plan_profile,
sidebar_width: settings.sidebar_width_percent,
sidebar_focus: settings.sidebar_focus.as_str().into(),
context_panel: settings.context_panel,
Expand Down Expand Up @@ -543,6 +545,7 @@ pub fn apply_document(
doc.settings.synchronized_output.as_setting(),
),
("default_mode", doc.settings.default_mode.as_setting()),
("pro_plan_profile", bool_str(doc.settings.pro_plan_profile)),
("sidebar_width", &doc.settings.sidebar_width.to_string()),
("sidebar_focus", doc.settings.sidebar_focus.as_setting()),
("context_panel", bool_str(doc.settings.context_panel)),
Expand Down Expand Up @@ -940,6 +943,7 @@ impl From<&str> for DefaultModeValue {
AppMode::Agent => Self::Agent,
AppMode::Plan => Self::Plan,
AppMode::Yolo => Self::Yolo,
AppMode::ProPlan => Self::Agent,
}
}
}
Expand Down
7 changes: 4 additions & 3 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2648,7 +2648,7 @@ fn approval_mode_for(
) -> crate::tui::approval::ApprovalMode {
match mode {
AppMode::Yolo => crate::tui::approval::ApprovalMode::Auto,
AppMode::Plan => crate::tui::approval::ApprovalMode::Never,
AppMode::Plan | AppMode::ProPlan => crate::tui::approval::ApprovalMode::Never,
AppMode::Agent => session_approval,
}
}
Expand Down Expand Up @@ -2678,7 +2678,7 @@ fn runtime_prompt_text(
) -> String {
let mode_str = match mode {
AppMode::Agent => "agent",
AppMode::Plan => "plan",
AppMode::Plan | AppMode::ProPlan => "plan",
AppMode::Yolo => "yolo",
};
let approval_str = match approval_mode {
Expand Down Expand Up @@ -2822,7 +2822,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 @@ -363,6 +364,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 @@ -371,6 +382,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 @@ -384,13 +399,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 @@ -466,16 +466,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 @@ -1537,11 +1578,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 @@ -1653,6 +1720,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
Loading
Loading