diff --git a/README.md b/README.md index ef6a2d29d..488b2e6a1 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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 | --- diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index c84afec16..a038a855a 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -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]"), } } @@ -676,6 +676,7 @@ fn parse_mode_arg(arg: &str) -> Option { "agent" | "1" => Some(AppMode::Agent), "plan" | "2" => Some(AppMode::Plan), "yolo" | "3" => Some(AppMode::Yolo), + "pro-plan" | "proplan" | "4" => Some(AppMode::ProPlan), _ => None, } } @@ -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", } } @@ -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] diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 4820b1bdc..59405569c 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -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) diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 188b4d1f9..76406bcdb 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -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 { diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index a0915dd82..a0b7f4e38 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -215,6 +215,7 @@ pub enum DefaultModeValue { Agent, Plan, Yolo, + ProPlan, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -806,6 +807,7 @@ impl DefaultModeValue { Self::Agent => "agent", Self::Plan => "plan", Self::Yolo => "yolo", + Self::ProPlan => "pro-plan", } } } @@ -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, } } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 4332df6ee..1b1dd3107 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -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)] diff --git a/crates/tui/src/core/engine/dispatch.rs b/crates/tui/src/core/engine/dispatch.rs index 335639c4e..47eb700b0 100644 --- a/crates/tui/src/core/engine/dispatch.rs +++ b/crates/tui/src/core/engine/dispatch.rs @@ -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; @@ -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)); @@ -342,6 +353,10 @@ pub(super) fn should_force_update_plan_first(mode: AppMode, content: &str) -> bo return false; } + if lower.contains("") { + return true; + } + let asks_for_repo_exploration = [ "inspect the repo", "inspect the code", @@ -355,6 +370,16 @@ 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)); @@ -362,6 +387,16 @@ pub(super) fn should_force_update_plan_first(mode: AppMode, content: &str) -> bo !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, diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 78d504351..698f85a86 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -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\ncall update_plan\n" + )); 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![ @@ -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, @@ -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()); @@ -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 { diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index 2354d6a8c..be5e77df7 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -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, @@ -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() @@ -65,13 +69,13 @@ 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) { @@ -79,7 +83,7 @@ impl Engine { } // 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 { diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 1cfe8f097..ac79a9832 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -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 { @@ -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 @@ -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) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index d3c177741..4d32aa285 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -385,6 +385,25 @@ pub enum MessageId { KbToggleHelpSlash, HelpUsageLabel, HelpAliasesLabel, + PlanPromptTitle, + PlanPromptActionRequired, + PlanPromptChooseNextStep, + PlanPromptAcceptPlan, + PlanPromptAcceptPlanDescription, + PlanPromptAcceptPlanYolo, + PlanPromptAcceptPlanYoloDescription, + PlanPromptRevisePlan, + PlanPromptRevisePlanDescription, + PlanPromptExitToAgent, + PlanPromptExitToAgentDescription, + PlanPromptQuickPick, + PlanPromptMove, + PlanPromptConfirm, + PlanPromptClose, + ProPlanStatusPlan, + ProPlanStatusExecute, + ProPlanStatusReview, + ProPlanStatusDone, SettingsTitle, SettingsConfigFile, ClearConversation, @@ -621,6 +640,25 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::KbToggleHelpSlash, MessageId::HelpUsageLabel, MessageId::HelpAliasesLabel, + MessageId::PlanPromptTitle, + MessageId::PlanPromptActionRequired, + MessageId::PlanPromptChooseNextStep, + MessageId::PlanPromptAcceptPlan, + MessageId::PlanPromptAcceptPlanDescription, + MessageId::PlanPromptAcceptPlanYolo, + MessageId::PlanPromptAcceptPlanYoloDescription, + MessageId::PlanPromptRevisePlan, + MessageId::PlanPromptRevisePlanDescription, + MessageId::PlanPromptExitToAgent, + MessageId::PlanPromptExitToAgentDescription, + MessageId::PlanPromptQuickPick, + MessageId::PlanPromptMove, + MessageId::PlanPromptConfirm, + MessageId::PlanPromptClose, + MessageId::ProPlanStatusPlan, + MessageId::ProPlanStatusExecute, + MessageId::ProPlanStatusReview, + MessageId::ProPlanStatusDone, MessageId::SettingsTitle, MessageId::SettingsConfigFile, MessageId::ClearConversation, @@ -944,7 +982,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdMcpDescription => "Open or manage MCP servers", MessageId::CmdMemoryDescription => "Inspect or manage the persistent user-memory file", MessageId::CmdModeDescription => { - "Switch mode or open picker: /mode [agent|plan|yolo|1|2|3]" + "Switch mode or open picker: /mode [agent|plan|yolo|pro-plan|1|2|3|4]" } MessageId::CmdModelDescription => "Switch or view current model", MessageId::CmdModelsDescription => "List available models from API", @@ -1118,6 +1156,27 @@ fn english(id: MessageId) -> &'static str { MessageId::KbToggleHelpSlash => "Toggle help overlay", MessageId::HelpUsageLabel => "Usage:", MessageId::HelpAliasesLabel => "Aliases:", + MessageId::PlanPromptTitle => " Plan Confirmation ", + MessageId::PlanPromptActionRequired => "Action required", + MessageId::PlanPromptChooseNextStep => "Choose what should happen after this plan.", + MessageId::PlanPromptAcceptPlan => "Accept plan", + MessageId::PlanPromptAcceptPlanDescription => "Start implementation with approvals", + MessageId::PlanPromptAcceptPlanYolo => "Accept plan (YOLO)", + MessageId::PlanPromptAcceptPlanYoloDescription => "Start implementation with auto-approval", + MessageId::PlanPromptRevisePlan => "Revise plan", + MessageId::PlanPromptRevisePlanDescription => "Ask follow-ups or request plan changes", + MessageId::PlanPromptExitToAgent => "Exit to Agent", + MessageId::PlanPromptExitToAgentDescription => { + "Return to Agent mode without implementation" + } + MessageId::PlanPromptQuickPick => " quick pick", + MessageId::PlanPromptMove => " move", + MessageId::PlanPromptConfirm => " confirm", + MessageId::PlanPromptClose => " close", + MessageId::ProPlanStatusPlan => "PRO-PLAN: Plan", + MessageId::ProPlanStatusExecute => "PRO-PLAN: Execute", + MessageId::ProPlanStatusReview => "PRO-PLAN: Review", + MessageId::ProPlanStatusDone => "PRO-PLAN: Done", MessageId::SettingsTitle => "Settings:", MessageId::SettingsConfigFile => "Config file:", MessageId::ClearConversation => "Conversation cleared", @@ -1326,7 +1385,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdMcpDescription => "MCP サーバを開く・管理する", MessageId::CmdMemoryDescription => "永続ユーザーメモリファイルを確認・管理", MessageId::CmdModeDescription => { - "動作モードを切り替え、または選択画面を開く: /mode [agent|plan|yolo|1|2|3]" + "動作モードを切り替え、または選択画面を開く: /mode [agent|plan|yolo|pro-plan|1|2|3|4]" } MessageId::CmdModelDescription => "現在のモデルを切り替え・確認", MessageId::CmdModelsDescription => "API から利用可能なモデルを一覧表示", @@ -1503,6 +1562,25 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::KbToggleHelpSlash => "ヘルプオーバーレイを切り替え", MessageId::HelpUsageLabel => "使い方:", MessageId::HelpAliasesLabel => "エイリアス:", + MessageId::PlanPromptTitle => " Plan 確認 ", + MessageId::PlanPromptActionRequired => "操作が必要です", + MessageId::PlanPromptChooseNextStep => "このプランの後に行うことを選んでください。", + MessageId::PlanPromptAcceptPlan => "プランを承認", + MessageId::PlanPromptAcceptPlanDescription => "承認付きで実装を開始", + MessageId::PlanPromptAcceptPlanYolo => "プランを承認 (YOLO)", + MessageId::PlanPromptAcceptPlanYoloDescription => "自動承認で実装を開始", + MessageId::PlanPromptRevisePlan => "プランを修正", + MessageId::PlanPromptRevisePlanDescription => "質問やプラン変更を依頼", + MessageId::PlanPromptExitToAgent => "Agent に戻る", + MessageId::PlanPromptExitToAgentDescription => "実装せず Agent モードへ戻る", + MessageId::PlanPromptQuickPick => " クイック選択", + MessageId::PlanPromptMove => " 移動", + MessageId::PlanPromptConfirm => " 確定", + MessageId::PlanPromptClose => " 閉じる", + MessageId::ProPlanStatusPlan => "PRO-PLAN: 計画", + MessageId::ProPlanStatusExecute => "PRO-PLAN: 実行", + MessageId::ProPlanStatusReview => "PRO-PLAN: レビュー", + MessageId::ProPlanStatusDone => "PRO-PLAN: 完了", MessageId::SettingsTitle => "設定:", MessageId::SettingsConfigFile => "設定ファイル:", MessageId::ClearConversation => "会話履歴をクリアしました", @@ -1672,7 +1750,9 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdLogoutDescription => "清除 API 密钥并返回设置", MessageId::CmdMcpDescription => "打开或管理 MCP 服务器", MessageId::CmdMemoryDescription => "查看或管理持久用户记忆文件", - MessageId::CmdModeDescription => "切换运行模式或打开选择器:/mode [agent|plan|yolo|1|2|3]", + MessageId::CmdModeDescription => { + "切换运行模式或打开选择器:/mode [agent|plan|yolo|pro-plan|1|2|3|4]" + } MessageId::CmdModelDescription => "切换或查看当前模型", MessageId::CmdModelsDescription => "列出 API 中可用的模型", MessageId::CmdNetworkDescription => "管理网络允许和拒绝规则", @@ -1820,6 +1900,25 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::KbToggleHelpSlash => "切换帮助覆盖层", MessageId::HelpUsageLabel => "用法:", MessageId::HelpAliasesLabel => "别名:", + MessageId::PlanPromptTitle => " 计划确认 ", + MessageId::PlanPromptActionRequired => "需要操作", + MessageId::PlanPromptChooseNextStep => "选择计划完成后要执行的操作。", + MessageId::PlanPromptAcceptPlan => "接受计划", + MessageId::PlanPromptAcceptPlanDescription => "带审批开始执行", + MessageId::PlanPromptAcceptPlanYolo => "接受计划 (YOLO)", + MessageId::PlanPromptAcceptPlanYoloDescription => "自动审批并开始执行", + MessageId::PlanPromptRevisePlan => "修改计划", + MessageId::PlanPromptRevisePlanDescription => "继续提问或要求调整计划", + MessageId::PlanPromptExitToAgent => "退出到 Agent", + MessageId::PlanPromptExitToAgentDescription => "不执行,返回 Agent 模式", + MessageId::PlanPromptQuickPick => " 快速选择", + MessageId::PlanPromptMove => " 移动", + MessageId::PlanPromptConfirm => " 确认", + MessageId::PlanPromptClose => " 关闭", + MessageId::ProPlanStatusPlan => "PRO-PLAN: 计划", + MessageId::ProPlanStatusExecute => "PRO-PLAN: 执行", + MessageId::ProPlanStatusReview => "PRO-PLAN: 审查", + MessageId::ProPlanStatusDone => "PRO-PLAN: 完成", MessageId::SettingsTitle => "设置:", MessageId::SettingsConfigFile => "配置文件:", MessageId::ClearConversation => "对话已清空", @@ -1998,7 +2097,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Inspecionar ou gerenciar o arquivo persistente de memória do usuário" } MessageId::CmdModeDescription => { - "Alternar modo ou abrir seletor: /mode [agent|plan|yolo|1|2|3]" + "Alternar modo ou abrir seletor: /mode [agent|plan|yolo|pro-plan|1|2|3|4]" } MessageId::CmdModelDescription => "Trocar ou exibir o modelo atual", MessageId::CmdModelsDescription => "Listar os modelos disponíveis pela API", @@ -2185,6 +2284,25 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::KbToggleHelpSlash => "Alternar sobreposição de ajuda", MessageId::HelpUsageLabel => "Uso:", MessageId::HelpAliasesLabel => "Apelidos:", + MessageId::PlanPromptTitle => " Confirmação do Plano ", + MessageId::PlanPromptActionRequired => "Ação necessária", + MessageId::PlanPromptChooseNextStep => "Escolha o que deve acontecer depois deste plano.", + MessageId::PlanPromptAcceptPlan => "Aceitar plano", + MessageId::PlanPromptAcceptPlanDescription => "Iniciar implementação com aprovações", + MessageId::PlanPromptAcceptPlanYolo => "Aceitar plano (YOLO)", + MessageId::PlanPromptAcceptPlanYoloDescription => "Iniciar implementação com autoaprovação", + MessageId::PlanPromptRevisePlan => "Revisar plano", + MessageId::PlanPromptRevisePlanDescription => "Fazer perguntas ou pedir mudanças no plano", + MessageId::PlanPromptExitToAgent => "Sair para Agent", + MessageId::PlanPromptExitToAgentDescription => "Voltar ao modo Agent sem implementar", + MessageId::PlanPromptQuickPick => " escolha rápida", + MessageId::PlanPromptMove => " mover", + MessageId::PlanPromptConfirm => " confirmar", + MessageId::PlanPromptClose => " fechar", + MessageId::ProPlanStatusPlan => "PRO-PLAN: Plano", + MessageId::ProPlanStatusExecute => "PRO-PLAN: Execução", + MessageId::ProPlanStatusReview => "PRO-PLAN: Revisão", + MessageId::ProPlanStatusDone => "PRO-PLAN: Concluído", MessageId::SettingsTitle => "Configurações:", MessageId::SettingsConfigFile => "Arquivo de configuração:", MessageId::ClearConversation => "Conversa limpa", @@ -2385,7 +2503,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { "Inspeccionar o gestionar el archivo persistente de memoria del usuario" } MessageId::CmdModeDescription => { - "Alternar modo o abrir selector: /mode [agent|plan|yolo|1|2|3]" + "Alternar modo o abrir selector: /mode [agent|plan|yolo|pro-plan|1|2|3|4]" } MessageId::CmdModelDescription => "Cambiar o mostrar el modelo actual", MessageId::CmdModelsDescription => "Listar los modelos disponibles por la API", @@ -2578,6 +2696,27 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::KbToggleHelpSlash => "Alternar superposición de ayuda", MessageId::HelpUsageLabel => "Uso:", MessageId::HelpAliasesLabel => "Alias:", + MessageId::PlanPromptTitle => " Confirmación del Plan ", + MessageId::PlanPromptActionRequired => "Acción requerida", + MessageId::PlanPromptChooseNextStep => "Elige qué debe pasar después de este plan.", + MessageId::PlanPromptAcceptPlan => "Aceptar plan", + MessageId::PlanPromptAcceptPlanDescription => "Iniciar implementación con aprobaciones", + MessageId::PlanPromptAcceptPlanYolo => "Aceptar plan (YOLO)", + MessageId::PlanPromptAcceptPlanYoloDescription => { + "Iniciar implementación con autoaprobación" + } + MessageId::PlanPromptRevisePlan => "Revisar plan", + MessageId::PlanPromptRevisePlanDescription => "Hacer preguntas o pedir cambios al plan", + MessageId::PlanPromptExitToAgent => "Salir a Agent", + MessageId::PlanPromptExitToAgentDescription => "Volver al modo Agent sin implementar", + MessageId::PlanPromptQuickPick => " elección rápida", + MessageId::PlanPromptMove => " mover", + MessageId::PlanPromptConfirm => " confirmar", + MessageId::PlanPromptClose => " cerrar", + MessageId::ProPlanStatusPlan => "PRO-PLAN: Plan", + MessageId::ProPlanStatusExecute => "PRO-PLAN: Ejecutar", + MessageId::ProPlanStatusReview => "PRO-PLAN: Revisión", + MessageId::ProPlanStatusDone => "PRO-PLAN: Listo", MessageId::SettingsTitle => "Configuraciones:", MessageId::SettingsConfigFile => "Archivo de configuración:", MessageId::ClearConversation => "Conversación limpia", diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index f801ed596..dcf020ec6 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -412,7 +412,7 @@ fn mode_prompt(mode: AppMode) -> &'static str { match mode { AppMode::Agent => AGENT_MODE, AppMode::Yolo => YOLO_MODE, - AppMode::Plan => PLAN_MODE, + AppMode::Plan | AppMode::ProPlan => PLAN_MODE, } } @@ -420,14 +420,14 @@ fn default_approval_mode_for_mode(mode: AppMode) -> ApprovalMode { match mode { AppMode::Agent => ApprovalMode::Suggest, AppMode::Yolo => ApprovalMode::Auto, - AppMode::Plan => ApprovalMode::Never, + AppMode::Plan | AppMode::ProPlan => ApprovalMode::Never, } } fn approval_prompt_for_mode(mode: AppMode, approval_mode: ApprovalMode) -> &'static str { match mode { AppMode::Yolo => AUTO_APPROVAL, - AppMode::Plan => NEVER_APPROVAL, + AppMode::Plan | AppMode::ProPlan => NEVER_APPROVAL, AppMode::Agent => match approval_mode { ApprovalMode::Auto => AUTO_APPROVAL, ApprovalMode::Suggest => SUGGEST_APPROVAL, @@ -1340,6 +1340,18 @@ mod tests { assert!( compose_prompt(AppMode::Plan, Personality::Calm).contains("Approval Policy: Never") ); + assert!( + compose_prompt(AppMode::ProPlan, Personality::Calm).contains("Approval Policy: Never") + ); + } + + #[test] + fn pro_plan_prompt_reuses_plan_mode_contract() { + let prompt = compose_prompt(AppMode::ProPlan, Personality::Calm); + + assert!(prompt.contains("Mode: Plan")); + assert!(prompt.contains("design before implementing")); + assert!(prompt.contains("All writes and patches are blocked")); } #[test] diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 9977d6d1d..69030e441 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -35,6 +35,7 @@ use crate::tui::clipboard::{ClipboardContent, ClipboardHandler}; use crate::tui::file_mention::ContextReference; use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; use crate::tui::paste_burst::{FlushResult, PasteBurst}; +use crate::tui::pro_plan::{ProPlanConfig, ProPlanRouter}; use crate::tui::scrolling::{MouseScrollState, TranscriptLineMeta, TranscriptScroll}; use crate::tui::selection::{SelectionAutoscroll, TranscriptSelection}; use crate::tui::streaming::StreamingState; @@ -127,6 +128,7 @@ pub enum AppMode { Agent, Yolo, Plan, + ProPlan, } /// One row in the per-turn cache-telemetry ring (`/cache` debug surface, #263). @@ -575,6 +577,7 @@ impl AppMode { match value.trim().to_ascii_lowercase().as_str() { "plan" => Self::Plan, "yolo" => Self::Yolo, + "pro-plan" | "proplan" => Self::ProPlan, _ => Self::Agent, } } @@ -585,6 +588,7 @@ impl AppMode { Self::Agent => "agent", Self::Yolo => "yolo", Self::Plan => "plan", + Self::ProPlan => "pro-plan", } } @@ -594,6 +598,7 @@ impl AppMode { AppMode::Agent => "AGENT", AppMode::Yolo => "YOLO", AppMode::Plan => "PLAN", + AppMode::ProPlan => "PRO-PLAN", } } @@ -604,6 +609,9 @@ impl AppMode { AppMode::Agent => "Agent mode - autonomous task execution with tools", AppMode::Yolo => "YOLO mode - full tool access without approvals", AppMode::Plan => "Plan mode - design before implementing", + AppMode::ProPlan => { + "Pro Plan mode - plan with Pro, execute with Flash, review with Pro" + } } } } @@ -898,6 +906,8 @@ pub struct App { pub auto_model: bool, /// Last concrete model chosen while `auto_model` is active. pub last_effective_model: Option, + /// ProPlan phase router (Some only when mode is ProPlan). + pub pro_plan_router: Option, /// Current API provider (mirrors `Config::api_provider`). /// Updated by `/provider` switches so the UI/commands can read the /// active backend without re-deriving it from the live config. @@ -1016,6 +1026,7 @@ pub struct App { #[allow(dead_code)] pub yolo: bool, yolo_restore: Option, + pro_plan_restore_auto_model: Option, // Clipboard handler pub clipboard: ClipboardHandler, // Tool approval session allowlist @@ -1591,7 +1602,11 @@ impl App { sticky_status: None, last_status_message_seen: None, model, - auto_model, + auto_model: if initial_mode == AppMode::ProPlan { + false + } else { + auto_model + }, last_effective_model: None, api_provider: provider, reasoning_effort, @@ -1648,6 +1663,16 @@ impl App { hooks, yolo: initial_mode == AppMode::Yolo, yolo_restore, + pro_plan_restore_auto_model: if initial_mode == AppMode::ProPlan { + Some(auto_model) + } else { + None + }, + pro_plan_router: if initial_mode == AppMode::ProPlan { + Some(ProPlanRouter::new(ProPlanConfig::default())) + } else { + None + }, clipboard: ClipboardHandler::new(), approval_session_approved: HashSet::new(), approval_session_denied: HashSet::new(), @@ -1850,6 +1875,18 @@ impl App { self.plan_tool_used_in_turn = false; } + // ProPlan mode: create / drop the phase router + if mode == AppMode::ProPlan { + self.pro_plan_router = Some(ProPlanRouter::new(ProPlanConfig::default())); + self.pro_plan_restore_auto_model = Some(self.auto_model); + self.auto_model = false; + } else if previous_mode == AppMode::ProPlan { + self.pro_plan_router = None; + if let Some(auto_model) = self.pro_plan_restore_auto_model.take() { + self.auto_model = auto_model; + } + } + // Execute mode change hooks let context = HookContext::new() .with_mode(mode.label()) @@ -1861,12 +1898,13 @@ impl App { true } - /// Cycle through modes: Plan → Agent → YOLO → Plan. + /// Cycle through modes: Plan → Agent → YOLO → ProPlan → Plan. pub fn cycle_mode(&mut self) { let next = match self.mode { AppMode::Plan => AppMode::Agent, AppMode::Agent => AppMode::Yolo, - AppMode::Yolo => AppMode::Plan, + AppMode::Yolo => AppMode::ProPlan, + AppMode::ProPlan => AppMode::Plan, }; let _ = self.set_mode(next); } @@ -1877,7 +1915,8 @@ impl App { let next = match self.mode { AppMode::Agent => AppMode::Plan, AppMode::Yolo => AppMode::Agent, - AppMode::Plan => AppMode::Yolo, + AppMode::Plan => AppMode::ProPlan, + AppMode::ProPlan => AppMode::Yolo, }; let _ = self.set_mode(next); } @@ -4172,6 +4211,9 @@ impl App { } pub fn effective_model_for_budget(&self) -> &str { + if let Some(ref router) = self.pro_plan_router { + return router.current_model(); + } if self.auto_model { return self .last_effective_model @@ -4183,6 +4225,10 @@ impl App { } pub fn model_display_label(&self) -> String { + if self.mode == AppMode::ProPlan { + return format!("pro-plan: {}", self.effective_model_for_budget()); + } + if self.auto_model { if let Some(effective) = self.last_effective_model.as_deref() && effective != "auto" @@ -4257,7 +4303,7 @@ pub enum AppAction { /// Open the `/provider` picker modal — DeepSeek / NVIDIA NIM / OpenRouter /// / Novita with inline API-key prompt for un-configured providers (#52). OpenProviderPicker, - /// Open the `/mode` picker modal for Agent / Plan / YOLO. + /// Open the `/mode` picker modal for Agent / Plan / YOLO / Pro Plan. OpenModePicker, /// Open the `/statusline` multi-select picker for footer items. OpenStatusPicker, @@ -5009,9 +5055,15 @@ mod tests { let mut app = App::new(test_options(false), &Config::default()); app.mode = AppMode::Plan; + app.cycle_mode_reverse(); + assert_eq!(app.mode, AppMode::ProPlan); + app.cycle_mode_reverse(); assert_eq!(app.mode, AppMode::Yolo); + app.cycle_mode_reverse(); + assert_eq!(app.mode, AppMode::Agent); + app.mode = AppMode::Agent; app.cycle_mode_reverse(); assert_eq!(app.mode, AppMode::Plan); @@ -5104,6 +5156,33 @@ mod tests { assert_eq!(app.approval_mode, ApprovalMode::Never); } + #[test] + fn set_mode_pro_plan_temporarily_disables_auto_model_and_restores_on_exit() { + let mut options = test_options(false); + options.start_in_agent_mode = true; // avoid coupling to settings.default_mode + let mut app = App::new(options, &Config::default()); + app.auto_model = true; + app.last_effective_model = Some("deepseek-v4-flash".to_string()); + + app.set_mode(AppMode::ProPlan); + assert_eq!(app.mode, AppMode::ProPlan); + assert!(!app.auto_model); + assert!(app.pro_plan_router.is_some()); + assert_eq!(app.model_display_label(), "pro-plan: deepseek-v4-pro"); + + { + let router = app.pro_plan_router.as_mut().expect("pro plan router"); + router.start_execution(false); + } + assert_eq!(app.model_display_label(), "pro-plan: deepseek-v4-flash"); + + app.set_mode(AppMode::Agent); + assert_eq!(app.mode, AppMode::Agent); + assert!(app.auto_model); + assert!(app.pro_plan_router.is_none()); + assert_eq!(app.model_display_label(), "auto: deepseek-v4-flash"); + } + #[test] fn leaving_yolo_after_startup_restores_baseline_policies() { let config = Config { diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 958e965db..1e8d14d77 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -725,6 +725,7 @@ pub(crate) fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Col crate::tui::app::AppMode::Agent => app.ui_theme.mode_agent, crate::tui::app::AppMode::Yolo => app.ui_theme.mode_yolo, crate::tui::app::AppMode::Plan => app.ui_theme.mode_plan, + crate::tui::app::AppMode::ProPlan => app.ui_theme.mode_plan, }; (label, color) } diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 34b70ee27..59951e004 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -50,6 +50,7 @@ pub mod paste; pub mod paste_burst; pub mod persistence_actor; pub mod plan_prompt; +pub mod pro_plan; pub mod provider_picker; pub mod scrolling; pub mod selection; diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index e75c203f6..feaef21f4 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -5,29 +5,33 @@ use ratatui::layout::{Alignment, Rect}; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}; +use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; -const PLAN_OPTIONS: [(&str, &str); 4] = [ +const PLAN_OPTIONS: [(MessageId, MessageId); 4] = [ ( - "Accept plan (Agent)", - "Start implementation in Agent mode with approvals", + MessageId::PlanPromptAcceptPlan, + MessageId::PlanPromptAcceptPlanDescription, ), ( - "Accept plan (YOLO)", - "Start implementation in YOLO mode (auto-approve)", + MessageId::PlanPromptAcceptPlanYolo, + MessageId::PlanPromptAcceptPlanYoloDescription, ), - ("Revise plan", "Ask follow-ups or request plan changes"), ( - "Exit Plan mode", - "Return to Agent mode without implementation", + MessageId::PlanPromptRevisePlan, + MessageId::PlanPromptRevisePlanDescription, + ), + ( + MessageId::PlanPromptExitToAgent, + MessageId::PlanPromptExitToAgentDescription, ), ]; -fn modal_block() -> Block<'static> { +fn modal_block(locale: Locale) -> Block<'static> { Block::default() .title(Line::from(vec![Span::styled( - " Plan Confirmation ", + tr(locale, MessageId::PlanPromptTitle), Style::default().fg(palette::DEEPSEEK_BLUE).bold(), )])) .borders(Borders::ALL) @@ -92,16 +96,34 @@ fn push_option_lines( ))); } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct PlanPromptView { selected: usize, + locale: Locale, +} + +impl Default for PlanPromptView { + fn default() -> Self { + Self { + selected: 0, + locale: Locale::En, + } + } } impl PlanPromptView { + #[cfg(test)] pub fn new() -> Self { Self::default() } + pub fn new_for_locale(locale: Locale) -> Self { + Self { + selected: 0, + locale, + } + } + fn max_index(&self) -> usize { PLAN_OPTIONS.len().saturating_sub(1) } @@ -187,18 +209,24 @@ impl ModalView for PlanPromptView { fn render(&self, area: Rect, buf: &mut Buffer) { let mut lines: Vec = Vec::new(); lines.push(Line::from(vec![Span::styled( - "Action required", + tr(self.locale, MessageId::PlanPromptActionRequired), Style::default().fg(palette::DEEPSEEK_SKY).bold(), )])); lines.push(Line::from(vec![Span::styled( - "Choose what should happen after this plan.", + tr(self.locale, MessageId::PlanPromptChooseNextStep), Style::default().fg(palette::TEXT_PRIMARY).bold(), )])); lines.push(Line::from("")); - for (idx, (label, description)) in PLAN_OPTIONS.iter().enumerate() { + for (idx, (label_id, description_id)) in PLAN_OPTIONS.iter().enumerate() { let number = idx + 1; - push_option_lines(&mut lines, self.selected == idx, number, label, description); + push_option_lines( + &mut lines, + self.selected == idx, + number, + tr(self.locale, *label_id), + tr(self.locale, *description_id), + ); } lines.push(Line::from("")); @@ -207,22 +235,34 @@ impl ModalView for PlanPromptView { "1-4 / a / y / r / q", Style::default().fg(palette::DEEPSEEK_SKY).bold(), ), - Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)), + Span::styled( + tr(self.locale, MessageId::PlanPromptQuickPick), + Style::default().fg(palette::TEXT_MUTED), + ), Span::raw(" "), Span::styled("Up/Down", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)), + Span::styled( + tr(self.locale, MessageId::PlanPromptMove), + Style::default().fg(palette::TEXT_MUTED), + ), Span::raw(" "), Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)), + Span::styled( + tr(self.locale, MessageId::PlanPromptConfirm), + Style::default().fg(palette::TEXT_MUTED), + ), Span::raw(" "), Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled(" close", Style::default().fg(palette::TEXT_MUTED)), + Span::styled( + tr(self.locale, MessageId::PlanPromptClose), + Style::default().fg(palette::TEXT_MUTED), + ), ])); let paragraph = Paragraph::new(lines) .alignment(Alignment::Left) .wrap(Wrap { trim: true }) - .block(modal_block()); + .block(modal_block(self.locale)); let popup_area = centered_rect(72, 52, area); render_modal_chrome(area, popup_area, buf); @@ -283,6 +323,6 @@ mod tests { let rendered = render_view(&view, 110, 36); assert!(rendered.contains("> 2) Accept plan (YOLO)")); - assert!(rendered.contains("Start implementation in YOLO mode (auto-approve)")); + assert!(rendered.contains("Start implementation with auto-approval")); } } diff --git a/crates/tui/src/tui/pro_plan.rs b/crates/tui/src/tui/pro_plan.rs new file mode 100644 index 000000000..96d35d980 --- /dev/null +++ b/crates/tui/src/tui/pro_plan.rs @@ -0,0 +1,334 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProPlanPhase { + Plan, + Execute, + Review, + Done, +} + +impl Default for ProPlanPhase { + fn default() -> Self { + ProPlanPhase::Plan + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProPlanFollowUp { + ReviewImplementation, + AddressReviewFeedback, +} + +#[derive(Debug, Clone)] +pub struct ProPlanConfig { + pub plan_model: &'static str, + pub execute_model: &'static str, + pub review_model: &'static str, +} + +impl Default for ProPlanConfig { + fn default() -> Self { + Self { + plan_model: "deepseek-v4-pro", + execute_model: "deepseek-v4-flash", + review_model: "deepseek-v4-pro", + } + } +} + +#[derive(Debug, Clone)] +pub struct ProPlanState { + pub phase: ProPlanPhase, + pub has_generated_plan: bool, + pub execute_auto_approve: bool, + pub plan_turns: u32, + pub execute_turns: u32, +} + +impl Default for ProPlanState { + fn default() -> Self { + Self { + phase: ProPlanPhase::default(), + has_generated_plan: false, + execute_auto_approve: false, + plan_turns: 0, + execute_turns: 0, + } + } +} + +pub struct ProPlanRouter { + config: ProPlanConfig, + state: ProPlanState, +} + +impl ProPlanRouter { + pub fn new(config: ProPlanConfig) -> Self { + Self { + config, + state: ProPlanState::default(), + } + } + + pub fn current_model(&self) -> &'static str { + match self.state.phase { + ProPlanPhase::Plan => self.config.plan_model, + ProPlanPhase::Execute => self.config.execute_model, + ProPlanPhase::Review => self.config.review_model, + ProPlanPhase::Done => self.config.review_model, + } + } + + pub fn phase(&self) -> ProPlanPhase { + self.state.phase + } + + pub fn state(&self) -> &ProPlanState { + &self.state + } + + pub fn transition(&mut self, msg: &str) -> ProPlanPhase { + let msg_lower = msg.to_ascii_lowercase(); + + match self.state.phase { + ProPlanPhase::Plan => { + self.state.plan_turns += 1; + if ProPlanRouter::contains_plan_marker(&msg_lower) { + self.state.has_generated_plan = true; + } + } + ProPlanPhase::Execute => { + self.state.execute_turns += 1; + if Self::execute_complete(&msg_lower) { + self.state.phase = ProPlanPhase::Review; + return ProPlanPhase::Review; + } + if Self::should_replan(&msg_lower) { + self.state.phase = ProPlanPhase::Plan; + self.state.has_generated_plan = false; + self.state.plan_turns = 0; + self.state.execute_turns = 0; + return ProPlanPhase::Plan; + } + } + ProPlanPhase::Review => { + if Self::review_rejected(&msg_lower) { + self.state.phase = ProPlanPhase::Execute; + return ProPlanPhase::Execute; + } + if Self::review_approved(&msg_lower) { + self.state.phase = ProPlanPhase::Done; + return ProPlanPhase::Done; + } + } + ProPlanPhase::Done => {} + } + + self.state.phase + } + + pub fn mark_plan_ready(&mut self) { + self.state.has_generated_plan = true; + } + + pub fn start_execution(&mut self, auto_approve: bool) { + self.state.phase = ProPlanPhase::Execute; + self.state.execute_auto_approve = auto_approve; + } + + pub fn execute_auto_approve(&self) -> bool { + self.state.execute_auto_approve + } + + pub fn reset(&mut self) { + self.state = ProPlanState::default(); + } + + pub fn follow_up_after_transition( + before: ProPlanPhase, + after: ProPlanPhase, + ) -> Option { + match (before, after) { + (ProPlanPhase::Execute, ProPlanPhase::Review) => { + Some(ProPlanFollowUp::ReviewImplementation) + } + (ProPlanPhase::Review, ProPlanPhase::Execute) => { + Some(ProPlanFollowUp::AddressReviewFeedback) + } + _ => None, + } + } + + fn contains_plan_marker(msg: &str) -> bool { + let markers = [""]; + markers.iter().any(|m| msg.contains(m)) + } + + fn execute_complete(msg: &str) -> bool { + let keywords = [ + "", + ]; + keywords.iter().any(|k| msg.contains(k)) + } + + fn should_replan(msg: &str) -> bool { + let keywords = [ + "", + " bool { + let keywords = [ + "", + ]; + keywords.iter().any(|k| msg.contains(k)) + } + + fn review_rejected(msg: &str) -> bool { + let keywords = [ + "", + ]; + keywords.iter().any(|k| msg.contains(k)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_initial_phase_is_plan() { + let config = ProPlanConfig::default(); + let router = ProPlanRouter::new(config); + assert_eq!(router.phase(), ProPlanPhase::Plan); + assert_eq!(router.current_model(), "deepseek-v4-pro"); + } + + #[test] + fn test_plan_to_execute_transition() { + let config = ProPlanConfig::default(); + let mut router = ProPlanRouter::new(config); + + assert_eq!(router.phase(), ProPlanPhase::Plan); + router.transition("Here is my plan:\n"); + assert_eq!(router.phase(), ProPlanPhase::Plan); + assert!(router.state.has_generated_plan); + router.start_execution(false); + assert_eq!(router.current_model(), "deepseek-v4-flash"); + assert!(!router.execute_auto_approve()); + } + + #[test] + fn test_plan_to_auto_approved_execution() { + let config = ProPlanConfig::default(); + let mut router = ProPlanRouter::new(config); + + router.mark_plan_ready(); + router.start_execution(true); + + assert_eq!(router.phase(), ProPlanPhase::Execute); + assert!(router.execute_auto_approve()); + } + + #[test] + fn ordinary_numbered_answer_does_not_mark_plan_ready() { + let config = ProPlanConfig::default(); + let mut router = ProPlanRouter::new(config); + + router.transition("1. ProPlan exists\n2. /mode pro-plan works\n3. No changes needed"); + + assert_eq!(router.phase(), ProPlanPhase::Plan); + assert!(!router.state.has_generated_plan); + } + + #[test] + fn test_execute_to_review_transition_requires_completion_marker() { + let config = ProPlanConfig::default(); + let mut router = ProPlanRouter::new(config); + + router.state.phase = ProPlanPhase::Execute; + router.state.has_generated_plan = true; + + router.transition("please review this"); + assert_eq!(router.phase(), ProPlanPhase::Execute); + + router.transition(""); + assert_eq!(router.phase(), ProPlanPhase::Review); + assert_eq!(router.current_model(), "deepseek-v4-pro"); + } + + #[test] + fn test_review_approved_to_done_requires_marker() { + let config = ProPlanConfig::default(); + let mut router = ProPlanRouter::new(config); + + router.state.phase = ProPlanPhase::Review; + router.transition("lgtm"); + assert_eq!(router.phase(), ProPlanPhase::Review); + + router.transition(""); + assert_eq!(router.phase(), ProPlanPhase::Done); + assert_eq!(router.current_model(), "deepseek-v4-pro"); + } + + #[test] + fn test_review_rejected_to_plan() { + let config = ProPlanConfig::default(); + let mut router = ProPlanRouter::new(config); + + router.state.phase = ProPlanPhase::Review; + router.state.has_generated_plan = true; + router.state.execute_turns = 5; + + router.transition("not good, please replan"); + assert_eq!(router.phase(), ProPlanPhase::Review); + + router.transition(""); + assert_eq!(router.phase(), ProPlanPhase::Execute); + assert!(router.state.has_generated_plan); + } + + #[test] + fn test_replan_during_execute() { + let config = ProPlanConfig::default(); + let mut router = ProPlanRouter::new(config); + + router.state.phase = ProPlanPhase::Execute; + router.state.has_generated_plan = true; + router.state.execute_turns = 3; + + router.transition("replan this"); + assert_eq!(router.phase(), ProPlanPhase::Execute); + + router.transition(""); + assert_eq!(router.phase(), ProPlanPhase::Plan); + assert!(!router.state.has_generated_plan); + } + + #[test] + fn follow_up_actions_only_emit_on_real_phase_edges() { + assert_eq!( + ProPlanRouter::follow_up_after_transition(ProPlanPhase::Execute, ProPlanPhase::Review), + Some(ProPlanFollowUp::ReviewImplementation) + ); + assert_eq!( + ProPlanRouter::follow_up_after_transition(ProPlanPhase::Review, ProPlanPhase::Execute), + Some(ProPlanFollowUp::AddressReviewFeedback) + ); + assert_eq!( + ProPlanRouter::follow_up_after_transition(ProPlanPhase::Review, ProPlanPhase::Review), + None + ); + assert_eq!( + ProPlanRouter::follow_up_after_transition(ProPlanPhase::Execute, ProPlanPhase::Execute), + None + ); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3cf028a40..8189cca03 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -44,6 +44,7 @@ use crate::core::events::Event as EngineEvent; use crate::core::ops::Op; use crate::hooks::{HookEvent, HookExecutor}; use crate::llm_client::LlmClient; +use crate::localization::{MessageId, tr}; use crate::models::{ ContentBlock, Message, MessageRequest, SystemPrompt, Usage, context_window_for_model, }; @@ -80,6 +81,7 @@ use crate::tui::onboarding; use crate::tui::pager::PagerView; use crate::tui::persistence_actor::{self, PersistRequest}; use crate::tui::plan_prompt::PlanPromptView; +use crate::tui::pro_plan::{ProPlanFollowUp, ProPlanPhase, ProPlanRouter}; use crate::tui::scrolling::TranscriptScroll; // SelectionAutoscroll unused use crate::tui::session_picker::SessionPickerView; @@ -666,7 +668,7 @@ fn handle_memory_quick_add(app: &mut App, input: &str, config: &Config) { fn build_engine_config(app: &App, config: &Config) -> EngineConfig { EngineConfig { - model: app.model.clone(), + model: app.effective_model_for_budget().to_string(), workspace: app.workspace.clone(), allow_shell: app.allow_shell, trust_mode: app.trust_mode, @@ -724,6 +726,163 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { } } +fn effective_mode_for_turn(app: &App) -> AppMode { + if app.mode != AppMode::ProPlan { + return app.mode; + } + + match app.pro_plan_router.as_ref() { + Some(router) if router.phase() == ProPlanPhase::Execute => { + if router.execute_auto_approve() { + AppMode::Yolo + } else { + AppMode::Agent + } + } + // Planning and review are intentionally read-only, matching the + // Pro Plan shape: think with the stronger model before/after writes. + Some(_) | None => AppMode::Plan, + } +} + +fn prepare_pro_plan_for_user_turn(app: &mut App) { + if app.mode != AppMode::ProPlan { + return; + } + + if let Some(router) = app.pro_plan_router.as_mut() { + if router.phase() == ProPlanPhase::Done { + router.reset(); + } + } +} + +fn should_add_pro_plan_planning_instruction(app: &App, input: &str) -> bool { + if app.mode != AppMode::ProPlan { + return false; + } + + let Some(router) = app.pro_plan_router.as_ref() else { + return false; + }; + if router.phase() != ProPlanPhase::Plan { + return false; + } + + let trimmed = input.trim(); + if trimmed.is_empty() { + return false; + } + + let lower = trimmed.to_ascii_lowercase(); + let explicit_no_plan = [ + "don't plan", + "do not plan", + "no plan", + "without a plan", + "don't use update_plan", + "do not use update_plan", + "不要计划", + "不用计划", + "别计划", + "不要用 update_plan", + "不要使用 update_plan", + ] + .iter() + .any(|needle| lower.contains(needle)); + if explicit_no_plan { + return false; + } + + let words = lower + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|word| !word.is_empty()); + let asks_for_action_word = words.into_iter().any(|word| { + matches!( + word, + "implement" + | "add" + | "fix" + | "modify" + | "change" + | "update" + | "refactor" + | "create" + | "delete" + | "remove" + | "wire" + | "integrate" + | "improve" + | "polish" + | "repair" + | "build" + ) + }); + + let asks_for_action_phrase = [ + "implement", + "write test", + "open a pr", + "create a pr", + "submit a pr", + ] + .iter() + .any(|needle| lower.contains(needle)); + + let asks_for_cn_action = [ + "帮我改", + "改一下", + "修改", + "修复", + "新增", + "添加", + "实现", + "接入", + "优化", + "重构", + "删除", + "移除", + "完善", + "跑测试", + "提pr", + "提 pr", + "开pr", + "开 pr", + ] + .iter() + .any(|needle| trimmed.contains(needle)); + + asks_for_action_word || asks_for_action_phrase || asks_for_cn_action +} + +fn pro_plan_planning_instruction() -> &'static str { + "\n\n\nYou are in Pro Plan's planning phase. Use the existing Plan mode behavior and call update_plan with the proposed implementation plan as the next tool call, then stop. Do not edit files in this phase. If the user only asked a question, answer normally without update_plan.\n" +} + +fn turn_auto_approve(app: &App, turn_mode: AppMode) -> bool { + if turn_mode == AppMode::Yolo { + return true; + } + + app.mode == AppMode::Yolo +} + +fn turn_allows_shell(app: &App, turn_mode: AppMode) -> bool { + if turn_mode == AppMode::Yolo { + return true; + } + + app.allow_shell +} + +fn turn_trust_mode(app: &App, turn_mode: AppMode) -> bool { + if turn_mode == AppMode::Yolo { + return true; + } + + app.trust_mode +} + async fn refresh_active_task_panel(app: &mut App, task_manager: &SharedTaskManager) { let tasks = task_manager.list_tasks(None).await; let mut entries: Vec = tasks @@ -1389,7 +1548,9 @@ async fn run_event_loop( } // Update session cost - let pricing_model = if app.auto_model { + let pricing_model = if app.mode == AppMode::ProPlan { + app.last_effective_model.as_deref().unwrap_or(&app.model) + } else if app.auto_model { app.last_effective_model.as_deref().unwrap_or(&app.model) } else { &app.model @@ -1445,7 +1606,117 @@ async fn run_event_loop( content: plan_next_step_prompt(), }); if app.view_stack.top_kind() != Some(ModalKind::PlanPrompt) { - app.view_stack.push(PlanPromptView::new()); + app.view_stack + .push(PlanPromptView::new_for_locale(app.ui_locale)); + } + } + // ProPlan mode: automatic phase advancement + let queued_count = app.queued_message_count(); + let has_queued_draft = app.queued_draft.is_some(); + let mut show_pro_plan_prompt = false; + if app.mode == AppMode::ProPlan { + if let Some(ref mut router) = app.pro_plan_router { + // Collect the last assistant message for explicit + // ProPlan marker detection. + let last_assistant = app + .api_messages + .iter() + .rev() + .find(|m| m.role == "assistant"); + + let last_assistant_text = last_assistant + .map(|m| { + m.content + .iter() + .filter_map(|block| match block { + crate::models::ContentBlock::Text { + text, .. + } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + + let phase_before = router.phase(); + if !last_assistant_text.is_empty() { + router.transition(&last_assistant_text); + } + if app.plan_tool_used_in_turn + && router.phase() == ProPlanPhase::Plan + { + router.mark_plan_ready(); + } + let transition_changed = router.phase() != phase_before; + + if status == crate::core::events::TurnOutcomeStatus::Completed { + let no_pending_user_work = + queued_count == 0 && !has_queued_draft; + let phase_after = router.phase(); + + if phase_after == ProPlanPhase::Plan + && router.state().has_generated_plan + && !app.plan_prompt_pending + && no_pending_user_work + { + show_pro_plan_prompt = true; + } + + if transition_changed + && no_pending_user_work + && queued_to_send.is_none() + { + let follow_up = ProPlanRouter::follow_up_after_transition( + phase_before, + phase_after, + ); + if let Some(follow_up) = follow_up { + // These are model steering messages, not UI chrome. + // Keep the control tags exact so phase detection stays stable. + let follow_up_text = match follow_up { + ProPlanFollowUp::ReviewImplementation => { + "Review the implementation against the accepted plan. Do not edit files during review. If it is correct, include ``; if changes are needed, include `` and list the fixes." + } + ProPlanFollowUp::AddressReviewFeedback => { + "Address the review feedback using the accepted plan, then summarize the changes and include ``." + } + }; + queued_to_send = Some(QueuedMessage::new( + follow_up_text.to_string(), + None, + )); + } + } + } + + // Reflect phase in status message. + let phase_label = match router.phase() { + ProPlanPhase::Plan => { + tr(app.ui_locale, MessageId::ProPlanStatusPlan) + } + ProPlanPhase::Execute => { + tr(app.ui_locale, MessageId::ProPlanStatusExecute) + } + ProPlanPhase::Review => { + tr(app.ui_locale, MessageId::ProPlanStatusReview) + } + ProPlanPhase::Done => { + tr(app.ui_locale, MessageId::ProPlanStatusDone) + } + }; + let model = router.current_model(); + app.status_message = Some(format!("{phase_label} ({model})")); + } + } + if show_pro_plan_prompt { + app.plan_prompt_pending = true; + app.add_message(HistoryCell::System { + content: plan_next_step_prompt(), + }); + if app.view_stack.top_kind() != Some(ModalKind::PlanPrompt) { + app.view_stack + .push(PlanPromptView::new_for_locale(app.ui_locale)); } } app.plan_tool_used_in_turn = false; @@ -1494,7 +1765,9 @@ async fn run_event_loop( app.current_session_id = Some(session_id); app.api_messages = messages; app.system_prompt = system_prompt; - if app.auto_model { + if app.mode == AppMode::ProPlan { + app.last_effective_model = Some(model); + } else if app.auto_model { app.last_effective_model = Some(model); } else { app.set_model_selection(model); @@ -1721,6 +1994,7 @@ async fn run_event_loop( } => { let session_approved = is_session_approved_for_tool(app, &tool_name, &approval_grouping_key); + let read_only_turn = effective_mode_for_turn(app) == AppMode::Plan; let session_denied = is_session_denied_for_key(app, &approval_key); if session_denied { // The user already said no to this exact tool / @@ -1736,6 +2010,19 @@ async fn run_event_loop( }), ); let _ = engine_handle.deny_tool_call(id.clone()).await; + } else if read_only_turn { + log_sensitive_event( + "tool.approval.auto_deny_read_only", + serde_json::json!({ + "tool_name": tool_name, + "approval_key": approval_key, + "session_id": app.current_session_id, + "mode": app.mode.label(), + }), + ); + let _ = engine_handle.deny_tool_call(id.clone()).await; + app.status_message = + Some(format!("Blocked tool '{tool_name}' (read-only mode)")); } else if session_approved || app.approval_mode == ApprovalMode::Auto { log_sensitive_event( "tool.approval.auto_approve", @@ -3876,11 +4163,19 @@ async fn dispatch_user_message( &app.workspace, cwd.clone(), ); - let content = queued_message_content_for_app(app, &message, cwd); + prepare_pro_plan_for_user_turn(app); + let mut content = queued_message_content_for_app(app, &message, cwd); + if should_add_pro_plan_planning_instruction(app, &message.display) { + content.push_str(pro_plan_planning_instruction()); + } let message_index = app.api_messages.len(); + let turn_mode = effective_mode_for_turn(app); + let auto_approve = turn_auto_approve(app, turn_mode); + let allow_shell = turn_allows_shell(app, turn_mode); + let trust_mode = turn_trust_mode(app, turn_mode); app.system_prompt = Some( prompts::system_prompt_for_mode_with_context_skills_and_session( - app.mode, + turn_mode, &app.workspace, None, None, @@ -3930,7 +4225,9 @@ async fn dispatch_user_message( None }; - let effective_model = if app.auto_model { + let effective_model = if app.mode == AppMode::ProPlan { + app.effective_model_for_budget().to_string() + } else if app.auto_model { auto_selection .as_ref() .map(|selection| selection.model.clone()) @@ -3969,6 +4266,8 @@ async fn dispatch_user_message( } app.status_message = Some(status); } + } else if app.mode == AppMode::ProPlan { + app.last_effective_model = Some(effective_model.clone()); } else { app.last_effective_model = None; } @@ -3976,15 +4275,15 @@ async fn dispatch_user_message( if let Err(err) = engine_handle .send(Op::SendMessage { content, - mode: app.mode, + mode: turn_mode, model: effective_model, goal_objective: app.goal.goal_objective.clone(), reasoning_effort: effective_reasoning_effort, reasoning_effort_auto: auto_controls_reasoning, auto_model: app.auto_model, - allow_shell: app.allow_shell, - trust_mode: app.trust_mode, - auto_approve: app.mode == AppMode::Yolo, + allow_shell, + trust_mode, + auto_approve, approval_mode: app.approval_mode, translation_enabled: app.translation_enabled, }) @@ -5195,7 +5494,7 @@ enum PlanChoice { fn plan_next_step_prompt() -> String { [ "Action required: choose the next step for this plan.", - " 1) Accept + implement in Agent mode", + " 1) Accept + implement with approvals", " 2) Accept + implement in YOLO mode", " 3) Revise the plan / ask follow-ups", " 4) Return to Agent mode without implementing", @@ -5235,45 +5534,92 @@ async fn apply_plan_choice( ) -> Result<()> { match choice { PlanChoice::AcceptAgent => { - app.set_mode(AppMode::Agent); + let pro_plan = app.mode == AppMode::ProPlan; + if pro_plan { + if let Some(router) = app.pro_plan_router.as_mut() { + router.start_execution(false); + } + } else { + app.set_mode(AppMode::Agent); + } app.add_message(HistoryCell::System { - content: "Plan accepted. Switching to Agent mode and starting implementation." - .to_string(), + content: if pro_plan { + "Plan accepted. Starting Pro Plan execution with the Flash model.".to_string() + } else { + "Plan accepted. Switching to Agent mode and starting implementation." + .to_string() + }, }); - let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None); + let followup_text = if pro_plan { + "Proceed with the accepted plan. Implement it now. When implementation is complete, summarize the changes and include ``." + } else { + "Proceed with the accepted plan." + }; + let followup = QueuedMessage::new(followup_text.to_string(), None); if app.is_loading { app.queue_message(followup); - app.status_message = - Some("Queued accepted plan execution (agent mode).".to_string()); + app.status_message = Some(if pro_plan { + "Queued accepted plan execution (pro-plan).".to_string() + } else { + "Queued accepted plan execution (agent mode).".to_string() + }); } else { dispatch_user_message(app, config, engine_handle, followup).await?; } } PlanChoice::AcceptYolo => { - app.set_mode(AppMode::Yolo); + let pro_plan = app.mode == AppMode::ProPlan; + if pro_plan { + if let Some(router) = app.pro_plan_router.as_mut() { + router.start_execution(true); + } + } else { + app.set_mode(AppMode::Yolo); + } app.add_message(HistoryCell::System { - content: "Plan accepted. Switching to YOLO mode and starting implementation." - .to_string(), + content: if pro_plan { + "Plan accepted. Starting Pro Plan execution with auto-approval.".to_string() + } else { + "Plan accepted. Switching to YOLO mode and starting implementation.".to_string() + }, }); - let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None); + let followup_text = if pro_plan { + "Proceed with the accepted plan using auto-approval. Implement it now. When implementation is complete, summarize the changes and include ``." + } else { + "Proceed with the accepted plan." + }; + let followup = QueuedMessage::new(followup_text.to_string(), None); if app.is_loading { app.queue_message(followup); - app.status_message = - Some("Queued accepted plan execution (YOLO mode).".to_string()); + app.status_message = Some(if pro_plan { + "Queued accepted plan execution (pro-plan auto).".to_string() + } else { + "Queued accepted plan execution (YOLO mode).".to_string() + }); } else { dispatch_user_message(app, config, engine_handle, followup).await?; } } PlanChoice::RevisePlan => { + if app.mode == AppMode::ProPlan { + if let Some(router) = app.pro_plan_router.as_mut() { + router.reset(); + } + } let prompt = "Revise the plan: "; app.input = prompt.to_string(); app.cursor_position = prompt.chars().count(); app.status_message = Some("Revise the plan and press Enter.".to_string()); } PlanChoice::ExitPlan => { + let content = if app.mode == AppMode::ProPlan { + "Exited Pro Plan mode. Switched to Agent mode." + } else { + "Exited Plan mode. Switched to Agent mode." + }; app.set_mode(AppMode::Agent); app.add_message(HistoryCell::System { - content: "Exited Plan mode. Switched to Agent mode.".to_string(), + content: content.to_string(), }); } } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 5ec4f7883..69757f58c 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1112,6 +1112,78 @@ fn plan_prompt_view_escape_emits_dismiss_event() { )); } +#[test] +fn pro_plan_effective_mode_tracks_phase_and_auto_approval() { + let mut app = create_test_app(); + app.set_mode(AppMode::ProPlan); + + assert_eq!(effective_mode_for_turn(&app), AppMode::Plan); + + let router = app.pro_plan_router.as_mut().expect("pro plan router"); + router.start_execution(false); + assert_eq!(effective_mode_for_turn(&app), AppMode::Agent); + + let router = app.pro_plan_router.as_mut().expect("pro plan router"); + router.start_execution(true); + assert_eq!(effective_mode_for_turn(&app), AppMode::Yolo); + + let router = app.pro_plan_router.as_mut().expect("pro plan router"); + router.transition(""); + assert_eq!(effective_mode_for_turn(&app), AppMode::Plan); +} + +#[test] +fn pro_plan_done_resets_before_next_user_turn() { + let mut app = create_test_app(); + app.set_mode(AppMode::ProPlan); + + let router = app.pro_plan_router.as_mut().expect("pro plan router"); + router.start_execution(false); + router.transition(""); + router.transition(""); + assert_eq!(router.phase(), crate::tui::pro_plan::ProPlanPhase::Done); + + prepare_pro_plan_for_user_turn(&mut app); + + assert_eq!( + app.pro_plan_router + .as_ref() + .expect("pro plan router") + .phase(), + crate::tui::pro_plan::ProPlanPhase::Plan + ); +} + +#[test] +fn pro_plan_planning_instruction_only_wraps_actionable_plan_turns() { + let mut app = create_test_app(); + app.set_mode(AppMode::ProPlan); + + assert!(should_add_pro_plan_planning_instruction( + &app, + "帮我修复 ProPlan 的循环问题" + )); + assert!(should_add_pro_plan_planning_instruction( + &app, + "Add a README note for Pro Plan" + )); + assert!(!should_add_pro_plan_planning_instruction( + &app, + "为什么不能直接用 deepseek-tui?只回答一句话" + )); + assert!(!should_add_pro_plan_planning_instruction( + &app, + "What is prefix cache?" + )); + + let router = app.pro_plan_router.as_mut().expect("pro plan router"); + router.start_execution(false); + assert!(!should_add_pro_plan_planning_instruction( + &app, + "继续实现刚才的计划" + )); +} + #[test] fn transcript_scroll_percent_is_clamped_and_relative() { assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0)); @@ -3095,6 +3167,39 @@ async fn numeric_plan_choice_still_queues_follow_up_when_busy() { ); } +#[tokio::test] +async fn pro_plan_accept_yolo_keeps_review_pipeline() { + let mut app = create_test_app(); + app.set_mode(AppMode::ProPlan); + app.plan_prompt_pending = true; + app.is_loading = true; + + let engine = crate::core::engine::mock_engine_handle(); + let config = Config::default(); + + let handled = handle_plan_choice(&mut app, &config, &engine.handle, "2") + .await + .expect("plan choice"); + + assert!(handled); + assert!(!app.plan_prompt_pending); + assert_eq!(app.mode, AppMode::ProPlan); + let router = app.pro_plan_router.as_ref().expect("pro plan router"); + assert_eq!(router.phase(), crate::tui::pro_plan::ProPlanPhase::Execute); + assert!(router.execute_auto_approve()); + assert_eq!(effective_mode_for_turn(&app), AppMode::Yolo); + assert_eq!(app.queued_message_count(), 1); + assert_eq!( + app.queued_messages + .front() + .map(crate::tui::app::QueuedMessage::content), + Some( + "Proceed with the accepted plan using auto-approval. Implement it now. When implementation is complete, summarize the changes and include ``." + .to_string() + ) + ); +} + #[test] fn api_key_validation_warns_without_blocking_unusual_formats() { assert!(matches!( diff --git a/crates/tui/src/tui/views/mode_picker.rs b/crates/tui/src/tui/views/mode_picker.rs index ea6fe1eb3..f2b815832 100644 --- a/crates/tui/src/tui/views/mode_picker.rs +++ b/crates/tui/src/tui/views/mode_picker.rs @@ -1,4 +1,4 @@ -//! `/mode` picker for Agent / Plan / YOLO. +//! `/mode` picker for Agent / Plan / YOLO / Pro Plan. use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ @@ -40,6 +40,12 @@ const MODE_ROWS: &[ModeRow] = &[ name: "YOLO", hint: "Shell + trust + auto-approve", }, + ModeRow { + mode: AppMode::ProPlan, + number: '4', + name: "Pro Plan", + hint: "Pro plan/review + Flash execution", + }, ]; pub struct ModePickerView { @@ -196,6 +202,12 @@ mod tests { assert_eq!(view.selected_mode(), AppMode::Plan); } + #[test] + fn opens_on_current_pro_plan_mode() { + let view = ModePickerView::new(AppMode::ProPlan); + assert_eq!(view.selected_mode(), AppMode::ProPlan); + } + #[test] fn enter_emits_selected_mode() { let mut view = ModePickerView::new(AppMode::Agent); @@ -219,5 +231,13 @@ mod tests { } other => panic!("expected ModeSelected, got {other:?}"), } + + let action = view.handle_key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE)); + match action { + ViewAction::EmitAndClose(ViewEvent::ModeSelected { mode }) => { + assert_eq!(mode, AppMode::ProPlan); + } + other => panic!("expected ModeSelected, got {other:?}"), + } } } diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 01ac69f8e..564da71dd 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -292,11 +292,13 @@ fn mode_style(app: &App) -> (&'static str, Color) { AppMode::Agent => "agent", AppMode::Yolo => "yolo", AppMode::Plan => "plan", + AppMode::ProPlan => "pro-plan", }; let color = match app.mode { AppMode::Agent => app.ui_theme.mode_agent, AppMode::Yolo => app.ui_theme.mode_yolo, AppMode::Plan => app.ui_theme.mode_plan, + AppMode::ProPlan => app.ui_theme.mode_plan, // Reuse plan color }; (label, color) } diff --git a/crates/tui/src/tui/widgets/header.rs b/crates/tui/src/tui/widgets/header.rs index afcacd502..dea3dccb8 100644 --- a/crates/tui/src/tui/widgets/header.rs +++ b/crates/tui/src/tui/widgets/header.rs @@ -181,6 +181,7 @@ impl<'a> HeaderWidget<'a> { AppMode::Agent => palette::MODE_AGENT, AppMode::Yolo => palette::MODE_YOLO, AppMode::Plan => palette::MODE_PLAN, + AppMode::ProPlan => palette::MODE_PLAN, // Reuse plan color } } @@ -189,6 +190,7 @@ impl<'a> HeaderWidget<'a> { AppMode::Agent => "Agent", AppMode::Yolo => "Yolo", AppMode::Plan => "Plan", + AppMode::ProPlan => "Pro Plan", } } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 2eb2c63a1..b5a376d65 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -503,6 +503,7 @@ impl<'a> ComposerWidget<'a> { AppMode::Agent => palette::MODE_AGENT, AppMode::Yolo => palette::MODE_YOLO, AppMode::Plan => palette::MODE_PLAN, + AppMode::ProPlan => palette::MODE_PLAN, // Reuse plan color } } diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 0fd9ddf03..5b6fc8a3b 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -12,7 +12,7 @@ Bindings are not (yet) user-configurable — tracked for a future release (#436, | `Ctrl-K` | Open the command palette (slash-command finder) | | `Ctrl-C` | Cancel current turn / dismiss modal / arm-then-confirm quit | | `Ctrl-D` | Quit (only when the composer is empty) | -| `Tab` | Cycle TUI mode: Plan → Agent → YOLO → Plan | +| `Tab` | Cycle TUI mode: Plan → Agent → YOLO → Pro Plan → Plan | | `Shift-Tab` | Cycle reasoning effort: off → high → max → off | | `Ctrl-R` | Open the resume-session picker | | `Ctrl-L` | Refresh / clear the screen | diff --git a/docs/MODES.md b/docs/MODES.md index 1e2d98e41..69df0744b 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -2,23 +2,25 @@ DeepSeek TUI has two related concepts: -- **TUI mode**: what kind of visible interaction you're in (Plan/Agent/YOLO). +- **TUI mode**: what kind of visible interaction you're in (Plan/Agent/YOLO/Pro Plan). - **Approval mode**: how aggressively the UI asks before executing tools. ## TUI Modes Press `Tab` to complete composer menus, queue a draft as a next-turn follow-up while a turn is running, or cycle through the visible modes when the composer is -otherwise idle: **Plan → Agent → YOLO → Plan**. +otherwise idle: **Plan → Agent → YOLO → Pro Plan → Plan**. Press `Shift+Tab` to cycle reasoning effort. Run `/mode` to open the mode picker, or switch directly with `/mode agent`, -`/mode plan`, `/mode yolo`, `/mode 1`, `/mode 2`, or `/mode 3`. +`/mode plan`, `/mode yolo`, `/mode pro-plan`, `/mode 1`, `/mode 2`, +`/mode 3`, or `/mode 4`. - **Plan**: design-first prompting. Read-only investigation tools stay available; shell and patch execution stay off. Use this when you want to think out loud and produce a plan to hand to a human (yourself later, or a reviewer). - **Agent**: multi-step tool use. Approvals for shell and paid tools (file writes are allowed without a prompt). - **YOLO**: enables shell + trust mode and auto-approves all tools. Use only in trusted repos. +- **Pro Plan**: plan and review with `deepseek-v4-pro`, execute with `deepseek-v4-flash`, and keep the plan confirmation gate between planning and implementation. The YOLO accept option auto-approves only the execution pipeline; Pro Plan still returns to read-only Pro review afterwards. -All three modes have access to persistent RLM sessions through `rlm_open`, `rlm_eval`, `rlm_configure`, and `rlm_close`. Inside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child calls pinned to `deepseek-v4-flash`. The model reaches for it when work is too large or repetitive for the parent transcript. +All four modes have access to persistent RLM sessions through `rlm_open`, `rlm_eval`, `rlm_configure`, and `rlm_close`. Inside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child calls pinned to `deepseek-v4-flash`. The model reaches for it when work is too large or repetitive for the parent transcript. ## Compatibility Notes diff --git a/docs/PRO_PLAN_MODE.md b/docs/PRO_PLAN_MODE.md new file mode 100644 index 000000000..16c5c88b9 --- /dev/null +++ b/docs/PRO_PLAN_MODE.md @@ -0,0 +1,66 @@ +# Pro Plan Mode + +Pro Plan is a model-routing mode that keeps planning and review on the +stronger model while using the faster model for the implementation pass: + +- Plan phase: use `deepseek-v4-pro` with the existing Plan mode prompt and + read-only tool policy. +- Execute phase: use `deepseek-v4-flash` with Agent-mode tools and normal + approvals, or temporary YOLO semantics when the user accepts the plan with + auto-approval. +- Review phase: use `deepseek-v4-pro` with Plan-mode read-only tools. + +The mode intentionally reuses the existing Plan and Agent contracts instead of +inventing a separate prompt or permission system. + +## State Flow + +```text +Plan --user accepts plan--> Execute --explicit completion marker--> Review +Review --approved marker--> Done +Review --changes requested marker--> Execute +Execute --explicit replan marker--> Plan +``` + +Plan confirmation is shown only when the Plan phase actually creates plan +state, either through the existing `update_plan` tool path or an explicit +`` marker. Ordinary numbered answers are not enough +to trigger implementation. + +For implementation-like requests in Pro Plan's Plan phase, the TUI adds a +small turn-local instruction to use the existing Plan behavior and call +`update_plan` as the next step. The engine keeps that requirement active until +`update_plan` succeeds, so even text-parsed tool calls such as `read_file` are +blocked before the plan confirmation gate. Pure questions are not wrapped this +way, so normal Q&A does not pop a confirmation dialog. + +The Review follow-up is queued only on the real `Execute -> Review` transition. +Remaining in Review does not enqueue another review request, which prevents +empty review loops after non-implementation conversations. + +## Markers + +Markers are control protocol, not user-facing prose: + +- `` +- `` +- `` +- `` +- `` + +Natural-language words like "review", "lgtm", "可以", or numbered lists are +not used as state-transition triggers. + +## Fail-Closed Rules + +Normal Pro Plan turns are resolved before dispatch: + +- `Plan`, `Review`, and `Done` use `AppMode::Plan`. +- `Execute` uses `AppMode::Agent`. +- `Execute` after "Accept plan (YOLO)" uses `AppMode::Yolo` for that Pro Plan + execution pipeline, but the visible mode stays Pro Plan so review still runs. + +After `Done`, the next user turn resets the router to a fresh Plan phase. + +If a raw `AppMode::ProPlan` reaches the engine unexpectedly, it fails closed to +Plan-mode behavior: read-only registry, read-only sandbox, and Never approval.