diff --git a/README.md b/README.md index 9b7a60ace..62f50459c 100644 --- a/README.md +++ b/README.md @@ -130,10 +130,12 @@ exit codes, type errors from rust-analyzer arriving between turns, sandbox denials — these are fed back as correction vectors. The model uses its own drift to self-correct. -Three modes control the action space. Plan is read-only. Agent gates +Three visible modes control the action space. Plan is read-only. Agent gates destructive operations behind approval. YOLO auto-approves in trusted -workspaces. macOS Seatbelt is the active sandbox; Linux Landlock is -detected but not yet enforced; Windows sandboxing is not yet advertised. +workspaces. The opt-in `/mode pro-plan` profile keeps the Plan confirmation +gate, uses Pro for planning and review, and routes implementation turns through +Flash. macOS Seatbelt is the active sandbox; Linux Landlock is detected but not +yet enforced; Windows sandboxing is not yet advertised. Fin — a cheap Flash call with thinking off — handles model auto-routing per turn. `--model auto` is the default. @@ -550,6 +552,11 @@ Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md). | **Agent** 🤖 | Default interactive mode — multi-step tool use with approval gates; substantial work is tracked with `checklist_write` | | **YOLO** ⚡ | Auto-approve all tools in a trusted workspace; multi-step work still keeps a visible checklist | +Pro Plan is an explicit routing profile, not part of the default mode picker: +`/mode pro-plan` plans and reviews with `deepseek-v4-pro`, executes with +`deepseek-v4-flash` when the active provider supports it, and keeps the normal +Plan confirmation gate. + --- ## Configuration diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 36d5e2fd0..abcac9b9a 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -975,7 +975,7 @@ pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult { CommandResult::message(message) } } - None => CommandResult::error("Usage: /mode [agent|plan|yolo|1|2|3]"), + None => CommandResult::error("Usage: /mode [agent|plan|yolo|pro-plan|1|2|3]"), } } @@ -1002,6 +1002,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" => Some(AppMode::ProPlan), _ => None, } } @@ -1011,6 +1012,7 @@ fn mode_display_name(mode: AppMode) -> &'static str { AppMode::Agent => "Agent", AppMode::Plan => "Plan", AppMode::Yolo => "YOLO", + AppMode::ProPlan => "Pro Plan", } } @@ -1785,6 +1787,15 @@ mod tests { let result = mode(&mut app, Some("3")); assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Yolo))); assert_eq!(app.mode, AppMode::Yolo); + let result = mode(&mut app, Some("4")); + assert!(result.is_error); + assert_eq!(app.mode, AppMode::Yolo); + let result = mode(&mut app, Some("proplan")); + assert_eq!( + result.action, + Some(AppAction::ModeChanged(AppMode::ProPlan)) + ); + assert_eq!(app.mode, AppMode::ProPlan); } #[test] diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index c8f32bddc..18b17d418 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -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) diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 9a953be62..e8b605ab1 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -355,7 +355,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "mode", aliases: &["jihua", "zidong"], - usage: "/mode [agent|plan|yolo|1|2|3]", + usage: "/mode [agent|plan|yolo|pro-plan|1|2|3]", description_id: MessageId::CmdModeDescription, }, CommandInfo { diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index d5632befe..c2c75dadc 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -940,6 +940,7 @@ impl From<&str> for DefaultModeValue { AppMode::Agent => Self::Agent, AppMode::Plan => Self::Plan, AppMode::Yolo => Self::Yolo, + AppMode::ProPlan => Self::Agent, } } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index fa2146171..9c383f22d 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1350,7 +1350,7 @@ impl Engine { "Any operations you ran automatically under YOLO mode now require \ explicit user approval before executing.", ), - AppMode::Plan => ( + AppMode::Plan | AppMode::ProPlan => ( "all writes and patches are blocked; shell and code execution are unavailable", "Any previously planned operations that require writes or shell access \ must wait until the mode changes back to Agent or YOLO.", @@ -2660,7 +2660,8 @@ use self::dispatch::{ ToolExecutionBatch, ToolExecutionPlan, caller_allowed_for_tool, caller_type_for_tool_use, final_tool_input, format_tool_error, mcp_tool_approval_description, mcp_tool_is_parallel_safe, mcp_tool_is_read_only, parse_parallel_tool_calls, parse_tool_input, - plan_tool_execution_batches, should_force_update_plan_first, should_stop_after_plan_tool, + plan_tool_execution_batches, should_force_update_plan_first, should_force_update_plan_step, + should_stop_after_plan_tool, }; use self::loop_guard::{AttemptDecision, LoopGuard, OutcomeDecision}; #[cfg(test)] 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 bed3276a0..8ec562f86 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -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\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![ @@ -1515,11 +1556,37 @@ fn plan_mode_toggle_preserves_catalog_byte_stability() { ); } +#[test] +fn raw_pro_plan_registry_fails_closed_to_plan_tools() { + let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + let registry = engine + .build_turn_tool_registry_builder( + AppMode::ProPlan, + engine.config.todos.clone(), + engine.config.plan_state.clone(), + ) + .build(engine.build_tool_context(AppMode::ProPlan, false)); + + assert!(registry.contains("read_file")); + assert!(registry.contains("list_dir")); + assert!(registry.contains("update_plan")); + assert!(!registry.contains("write_file")); + assert!(!registry.contains("edit_file")); + assert!(!registry.contains("apply_patch")); + assert!(!registry.contains("exec_shell")); + assert!(!registry.contains("task_shell_start")); +} + #[test] fn parent_turn_registry_includes_goal_tools_for_all_modes() { let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); - for mode in [AppMode::Plan, AppMode::Agent, AppMode::Yolo] { + for mode in [ + AppMode::Plan, + AppMode::ProPlan, + AppMode::Agent, + AppMode::Yolo, + ] { let registry = engine .build_turn_tool_registry_builder( mode, @@ -1631,6 +1698,13 @@ fn sandbox_policy_for_mode_returns_correct_policy_per_mode() { SandboxPolicy::ReadOnly )); + // Raw ProPlan should fail closed. Normal ProPlan execution is resolved to + // Plan or Agent before this point. + assert!(matches!( + sandbox_policy_for_mode(AppMode::ProPlan, &workspace), + SandboxPolicy::ReadOnly + )); + // Agent: WorkspaceWrite with workspace as writable root, network on. match sandbox_policy_for_mode(AppMode::Agent, &workspace) { SandboxPolicy::WorkspaceWrite { diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index ec99fab4b..a293661ec 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() @@ -68,13 +72,13 @@ impl Engine { // SlopLedger: plan mode only gets read-only query + export, // agent/yolo get the full set including append + update. - builder = if mode == AppMode::Plan { + builder = if read_only_mode { builder.with_slop_ledger_read_only_tools() } else { builder.with_slop_ledger_tools() }; - if mode != AppMode::Plan { + if !read_only_mode { builder = builder .with_rlm_tool(self.deepseek_client.clone(), self.session.model.clone()) .with_fim_tool(self.deepseek_client.clone(), self.session.model.clone()) @@ -84,7 +88,7 @@ impl Engine { ); } - if self.config.features.enabled(Feature::ApplyPatch) && mode != AppMode::Plan { + if self.config.features.enabled(Feature::ApplyPatch) && !read_only_mode { builder = builder.with_patch_tools(); } if self.config.features.enabled(Feature::WebSearch) { diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index de71c5fa0..452dabc53 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -244,7 +244,8 @@ impl Engine { self.layered_context_checkpoint().await; // Build the request - let force_update_plan_this_step = force_update_plan_first && turn.tool_calls.is_empty(); + let force_update_plan_this_step = + should_force_update_plan_step(force_update_plan_first, &turn.tool_calls); let mut active_tools = if tool_catalog.is_empty() { None } else { @@ -992,6 +993,22 @@ impl Engine { continue; } + if force_update_plan_this_step { + let reminder = "Plan confirmation is required before any other response. Call update_plan with the proposed plan now; do not call other tools."; + self.add_session_message( + self.user_text_message_with_turn_metadata(reminder.to_string()), + ) + .await; + let _ = self + .tx_event + .send(Event::status( + "Waiting for update_plan before continuing plan flow", + )) + .await; + turn.next_step(); + continue; + } + // Sub-agent completion handoff (issue #756). The model finished // streaming with no tool calls — but if it has direct children // still running (or completions queued from children that @@ -1344,6 +1361,12 @@ impl Engine { ))); } + if force_update_plan_this_step && tool_name != "update_plan" { + blocked_error = Some(ToolError::permission_denied(format!( + "Tool '{tool_name}' is unavailable until update_plan records the plan" + ))); + } + if blocked_error.is_none() && tool_def.is_none() && !McpPool::is_mcp_tool(&tool_name) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index e85990de0..0a67e2ab3 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -406,6 +406,32 @@ pub enum MessageId { KbToggleHelpSlash, HelpUsageLabel, HelpAliasesLabel, + PlanPromptTitle, + PlanPromptActionRequired, + PlanPromptChooseNextStep, + PlanPromptAcceptPlan, + PlanPromptAcceptPlanDescription, + PlanPromptAcceptPlanYolo, + PlanPromptAcceptPlanYoloDescription, + PlanPromptRevisePlan, + PlanPromptRevisePlanDescription, + PlanPromptExitToAgent, + PlanPromptExitToAgentDescription, + PlanPromptPlanSteps, + PlanPromptQuickPick, + PlanPromptMove, + PlanPromptConfirm, + PlanPromptClose, + ProPlanStatusPlan, + ProPlanStatusExecute, + ProPlanStatusReview, + ProPlanStatusDone, + ToolBlockedReadOnly, + ProPlanAcceptedExecution, + ProPlanAcceptedAutoExecution, + ProPlanExecutionQueued, + ProPlanAutoExecutionQueued, + ProPlanExitedToAgent, SettingsTitle, SettingsConfigFile, ClearConversation, @@ -443,6 +469,8 @@ pub enum MessageId { HomeYoloModeCaution, HomePlanModeTip, HomePlanModeChecklistTip, + HomeProPlanModeTip, + HomeProPlanModeAutoSwitchTip, HomeGoalModeTip, // Onboarding screens — language picker. OnboardLanguageTitle, @@ -734,6 +762,32 @@ 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::PlanPromptPlanSteps, + MessageId::PlanPromptQuickPick, + MessageId::PlanPromptMove, + MessageId::PlanPromptConfirm, + MessageId::PlanPromptClose, + MessageId::ProPlanStatusPlan, + MessageId::ProPlanStatusExecute, + MessageId::ProPlanStatusReview, + MessageId::ProPlanStatusDone, + MessageId::ToolBlockedReadOnly, + MessageId::ProPlanAcceptedExecution, + MessageId::ProPlanAcceptedAutoExecution, + MessageId::ProPlanExecutionQueued, + MessageId::ProPlanAutoExecutionQueued, + MessageId::ProPlanExitedToAgent, MessageId::SettingsTitle, MessageId::SettingsConfigFile, MessageId::ClearConversation, @@ -771,6 +825,8 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::HomeYoloModeCaution, MessageId::HomePlanModeTip, MessageId::HomePlanModeChecklistTip, + MessageId::HomeProPlanModeTip, + MessageId::HomeProPlanModeAutoSwitchTip, MessageId::HomeGoalModeTip, MessageId::OnboardLanguageTitle, MessageId::OnboardLanguageBlurb, @@ -1138,7 +1194,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]" } MessageId::CmdModelDescription => "Switch or view current model", MessageId::CmdModelsDescription => "List available models from API", @@ -1333,6 +1389,38 @@ 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::PlanPromptPlanSteps => "Plan steps:", + 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::ToolBlockedReadOnly => "Blocked tool in read-only mode", + MessageId::ProPlanAcceptedExecution => { + "Plan accepted. Starting Pro Plan execution with the Flash model." + } + MessageId::ProPlanAcceptedAutoExecution => { + "Plan accepted. Starting Pro Plan execution with auto-approval." + } + MessageId::ProPlanExecutionQueued => "Queued accepted plan execution (pro-plan).", + MessageId::ProPlanAutoExecutionQueued => "Queued accepted plan execution (pro-plan auto).", + MessageId::ProPlanExitedToAgent => "Exited Pro Plan mode. Switched to Agent mode.", MessageId::SettingsTitle => "Settings:", MessageId::SettingsConfigFile => "Config file:", MessageId::ClearConversation => "Conversation cleared", @@ -1372,6 +1460,12 @@ fn english(id: MessageId) -> &'static str { MessageId::HomeYoloModeCaution => " Be careful with destructive operations!", MessageId::HomePlanModeTip => "Plan mode - Design before implementing", MessageId::HomePlanModeChecklistTip => " Use /mode plan to create structured checklists", + MessageId::HomeProPlanModeTip => { + "Pro Plan mode - Plan with Pro, execute with Flash, review with Pro" + } + MessageId::HomeProPlanModeAutoSwitchTip => { + " The model switches automatically based on the current phase" + } MessageId::HomeGoalModeTip => "Goal tracking - Set /goal to pursue objectives", // Onboarding — language picker. MessageId::OnboardLanguageTitle => "Choose your language", @@ -1608,7 +1702,7 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CmdMcpDescription => "Mở hoặc quản lý các máy chủ MCP", MessageId::CmdMemoryDescription => "Kiểm tra hoặc quản lý tệp bộ nhớ người dùng liên tục", MessageId::CmdModeDescription => { - "Chuyển đổi chế độ hoặc mở bảng chọn: /mode [agent|plan|yolo|1|2|3]" + "Chuyển đổi chế độ hoặc mở bảng chọn: /mode [agent|plan|yolo|pro-plan|1|2|3]" } MessageId::CmdModelDescription => "Chuyển đổi hoặc xem mô hình AI hiện tại", MessageId::CmdModelsDescription => "Liệt kê các mô hình khả dụng từ API", @@ -1869,6 +1963,10 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::HomePlanModeChecklistTip => { " Sử dụng /mode plan để tạo danh sách kiểm tra có cấu trúc" } + MessageId::HomeProPlanModeTip => { + "Chế độ Pro Plan - Lập kế hoạch bằng Pro, thực thi bằng Flash, rà soát bằng Pro" + } + MessageId::HomeProPlanModeAutoSwitchTip => " Mô hình tự chuyển theo giai đoạn hiện tại", MessageId::HomeGoalModeTip => { "Theo dõi mục tiêu - Dùng /goal để đặt mục tiêu làm việc" } @@ -1950,6 +2048,40 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "ngữ cảnh đang hoạt động và gợi ý bộ nhớ đệm", MessageId::CtxMenuHelp => "Trợ giúp", MessageId::CtxMenuHelpDesc => "phím tắt và lệnh", + MessageId::PlanPromptTitle => " Xác nhận kế hoạch ", + MessageId::PlanPromptActionRequired => "Cần thao tác", + MessageId::PlanPromptChooseNextStep => "Chọn điều cần làm sau kế hoạch này.", + MessageId::PlanPromptAcceptPlan => "Chấp nhận kế hoạch", + MessageId::PlanPromptAcceptPlanDescription => "Bắt đầu triển khai có phê duyệt", + MessageId::PlanPromptAcceptPlanYolo => "Chấp nhận kế hoạch (YOLO)", + MessageId::PlanPromptAcceptPlanYoloDescription => { + "Bắt đầu triển khai với tự động phê duyệt" + } + MessageId::PlanPromptRevisePlan => "Sửa kế hoạch", + MessageId::PlanPromptRevisePlanDescription => "Hỏi thêm hoặc yêu cầu chỉnh kế hoạch", + MessageId::PlanPromptExitToAgent => "Thoát về Agent", + MessageId::PlanPromptExitToAgentDescription => "Quay về Agent mà không triển khai", + MessageId::PlanPromptPlanSteps => "Các bước kế hoạch:", + MessageId::PlanPromptQuickPick => " chọn nhanh", + MessageId::PlanPromptMove => " di chuyển", + MessageId::PlanPromptConfirm => " xác nhận", + MessageId::PlanPromptClose => " đóng", + MessageId::ProPlanStatusPlan => "PRO-PLAN: Lập kế hoạch", + MessageId::ProPlanStatusExecute => "PRO-PLAN: Thực thi", + MessageId::ProPlanStatusReview => "PRO-PLAN: Rà soát", + MessageId::ProPlanStatusDone => "PRO-PLAN: Hoàn tất", + MessageId::ToolBlockedReadOnly => "Đã chặn công cụ trong chế độ chỉ đọc", + MessageId::ProPlanAcceptedExecution => { + "Đã chấp nhận kế hoạch. Bắt đầu thực thi Pro Plan bằng mô hình Flash." + } + MessageId::ProPlanAcceptedAutoExecution => { + "Đã chấp nhận kế hoạch. Bắt đầu thực thi Pro Plan với tự động phê duyệt." + } + MessageId::ProPlanExecutionQueued => "Đã xếp hàng thực thi kế hoạch (pro-plan).", + MessageId::ProPlanAutoExecutionQueued => { + "Đã xếp hàng thực thi kế hoạch (pro-plan tự động)." + } + MessageId::ProPlanExitedToAgent => "Đã thoát Pro Plan. Chuyển sang chế độ Agent.", MessageId::FanoutCounts => { "{done} hoàn thành · {running} đang chạy · {failed} thất bại · {pending} chờ" } @@ -2157,7 +2289,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]" } MessageId::CmdModelDescription => "現在のモデルを切り替え・確認", MessageId::CmdModelsDescription => "API から利用可能なモデルを一覧表示", @@ -2355,6 +2487,40 @@ 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::PlanPromptPlanSteps => "プランの手順:", + 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::ToolBlockedReadOnly => "読み取り専用モードでツールをブロックしました", + MessageId::ProPlanAcceptedExecution => { + "プランを承認しました。Flash モデルで Pro Plan 実行を開始します。" + } + MessageId::ProPlanAcceptedAutoExecution => { + "プランを承認しました。自動承認で Pro Plan 実行を開始します。" + } + MessageId::ProPlanExecutionQueued => "承認済みプランの実行をキューしました (pro-plan)。", + MessageId::ProPlanAutoExecutionQueued => { + "承認済みプランの実行をキューしました (pro-plan auto)。" + } + MessageId::ProPlanExitedToAgent => { + "Pro Plan モードを終了し、Agent モードに切り替えました。" + } MessageId::SettingsTitle => "設定:", MessageId::SettingsConfigFile => "設定ファイル:", MessageId::ClearConversation => "会話履歴をクリアしました", @@ -2396,6 +2562,10 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::HomePlanModeChecklistTip => { " /mode plan を使って構造化されたチェックリストを作成" } + MessageId::HomeProPlanModeTip => { + "Pro Plan モード - Pro で計画、Flash で実行、Pro でレビュー" + } + MessageId::HomeProPlanModeAutoSwitchTip => " 現在のフェーズに応じてモデルを自動切り替え", MessageId::HomeGoalModeTip => "Goal 追跡 - /goal <目標> で持続的な目標を追跡", // Onboarding — language picker. MessageId::OnboardLanguageTitle => "言語を選択", @@ -2602,7 +2772,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]" + } MessageId::CmdModelDescription => "切换或查看当前模型", MessageId::CmdModelsDescription => "列出 API 中可用的模型", MessageId::CmdNetworkDescription => "管理网络允许和拒绝规则", @@ -2771,6 +2943,34 @@ 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::PlanPromptPlanSteps => "计划步骤:", + 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::ToolBlockedReadOnly => "只读模式已阻止工具", + MessageId::ProPlanAcceptedExecution => { + "计划已接受。正在使用 Flash 模型开始 Pro Plan 执行。" + } + MessageId::ProPlanAcceptedAutoExecution => "计划已接受。正在以自动审批开始 Pro Plan 执行。", + MessageId::ProPlanExecutionQueued => "已排队执行接受的计划 (pro-plan)。", + MessageId::ProPlanAutoExecutionQueued => "已排队执行接受的计划 (pro-plan auto)。", + MessageId::ProPlanExitedToAgent => "已退出 Pro Plan 模式,并切换到 Agent 模式。", MessageId::SettingsTitle => "设置:", MessageId::SettingsConfigFile => "配置文件:", MessageId::ClearConversation => "对话已清空", @@ -2810,6 +3010,8 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::HomeYoloModeCaution => " 请小心破坏性操作!", MessageId::HomePlanModeTip => "Plan 模式 - 先设计再实现", MessageId::HomePlanModeChecklistTip => " 使用 /mode plan 创建结构化检查清单", + MessageId::HomeProPlanModeTip => "Pro Plan 模式 - 用 Pro 计划、用 Flash 执行、用 Pro 审查", + MessageId::HomeProPlanModeAutoSwitchTip => " 模型会根据当前阶段自动切换", MessageId::HomeGoalModeTip => "Goal 跟踪 - 设置 /goal <目标> 以跟踪持久目标", // Onboarding — language picker. MessageId::OnboardLanguageTitle => "选择语言", @@ -3023,7 +3225,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]" } MessageId::CmdModelDescription => "Trocar ou exibir o modelo atual", MessageId::CmdModelsDescription => "Listar os modelos disponíveis pela API", @@ -3231,6 +3433,38 @@ 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::PlanPromptPlanSteps => "Etapas do plano:", + 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::ToolBlockedReadOnly => "Ferramenta bloqueada no modo somente leitura", + MessageId::ProPlanAcceptedExecution => { + "Plano aceito. Iniciando execução do Pro Plan com o modelo Flash." + } + MessageId::ProPlanAcceptedAutoExecution => { + "Plano aceito. Iniciando execução do Pro Plan com autoaprovação." + } + MessageId::ProPlanExecutionQueued => "Execução do plano aceita enfileirada (pro-plan).", + MessageId::ProPlanAutoExecutionQueued => { + "Execução do plano aceita enfileirada (pro-plan auto)." + } + MessageId::ProPlanExitedToAgent => "Saiu do modo Pro Plan. Alternado para Agent.", MessageId::SettingsTitle => "Configurações:", MessageId::SettingsConfigFile => "Arquivo de configuração:", MessageId::ClearConversation => "Conversa limpa", @@ -3276,6 +3510,12 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::HomePlanModeChecklistTip => { " Use /mode plan para criar checklists estruturados" } + MessageId::HomeProPlanModeTip => { + "Modo Pro Plan - Planeje com Pro, execute com Flash, revise com Pro" + } + MessageId::HomeProPlanModeAutoSwitchTip => { + " O modelo troca automaticamente conforme a fase atual" + } MessageId::HomeGoalModeTip => { "Rastreamento de Goal - Use /goal para rastrear um objetivo persistente" } @@ -3511,7 +3751,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]" } MessageId::CmdModelDescription => "Cambiar o mostrar el modelo actual", MessageId::CmdModelsDescription => "Listar los modelos disponibles por la API", @@ -3729,6 +3969,40 @@ 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::PlanPromptPlanSteps => "Pasos del plan:", + 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::ToolBlockedReadOnly => "Herramienta bloqueada en modo solo lectura", + MessageId::ProPlanAcceptedExecution => { + "Plan aceptado. Iniciando ejecución de Pro Plan con el modelo Flash." + } + MessageId::ProPlanAcceptedAutoExecution => { + "Plan aceptado. Iniciando ejecución de Pro Plan con autoaprobación." + } + MessageId::ProPlanExecutionQueued => "Ejecución del plan aceptado en cola (pro-plan).", + MessageId::ProPlanAutoExecutionQueued => { + "Ejecución del plan aceptado en cola (pro-plan auto)." + } + MessageId::ProPlanExitedToAgent => "Saliste del modo Pro Plan. Cambiado a Agent.", MessageId::SettingsTitle => "Configuraciones:", MessageId::SettingsConfigFile => "Archivo de configuración:", MessageId::ClearConversation => "Conversación limpia", @@ -3774,6 +4048,12 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::HomePlanModeChecklistTip => { " Usa /mode plan para crear checklists estructurados" } + MessageId::HomeProPlanModeTip => { + "Modo Pro Plan - Planear con Pro, ejecutar con Flash, revisar con Pro" + } + MessageId::HomeProPlanModeAutoSwitchTip => { + " El modelo cambia automáticamente según la fase actual" + } MessageId::HomeGoalModeTip => { "Seguimiento de Goal - Usa /goal para seguir un objetivo persistente" } diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 00584cd38..73d39a355 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -659,7 +659,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, } } @@ -667,14 +667,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, @@ -2177,6 +2177,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 3eb494f16..0129e7489 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -38,6 +38,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::sidebar::SidebarWorkSummary; @@ -145,6 +146,7 @@ pub enum AppMode { Agent, Yolo, Plan, + ProPlan, } /// One row in the per-turn cache-telemetry ring (`/cache` debug surface, #263). @@ -770,6 +772,7 @@ impl AppMode { match value.trim().to_ascii_lowercase().as_str() { "plan" => Self::Plan, "yolo" => Self::Yolo, + "pro-plan" | "proplan" => Self::ProPlan, _ => Self::Agent, } } @@ -780,6 +783,7 @@ impl AppMode { Self::Agent => "agent", Self::Yolo => "yolo", Self::Plan => "plan", + Self::ProPlan => "pro-plan", } } @@ -789,6 +793,7 @@ impl AppMode { AppMode::Agent => "AGENT", AppMode::Yolo => "YOLO", AppMode::Plan => "PLAN", + AppMode::ProPlan => "PRO-PLAN", } } @@ -799,6 +804,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" + } } } } @@ -1208,6 +1216,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. @@ -1367,6 +1377,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 @@ -1999,7 +2010,11 @@ impl App { last_status_message_seen: None, model, provider_models, - auto_model, + auto_model: if initial_mode == AppMode::ProPlan { + false + } else { + auto_model + }, last_effective_model: None, api_provider: provider, model_ids_passthrough, @@ -2071,6 +2086,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::for_provider(provider))) + } else { + None + }, clipboard: ClipboardHandler::new(), approval_session_approved: HashSet::new(), approval_session_denied: HashSet::new(), @@ -2291,6 +2316,20 @@ 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::for_provider( + self.api_provider, + ))); + 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()) @@ -2302,12 +2341,14 @@ impl App { true } - /// Cycle through modes: Plan → Agent → YOLO → Plan. + /// Cycle through the visible modes: Plan -> Agent -> YOLO -> Plan. + /// Pro Plan remains an explicit opt-in via `/mode pro-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::ProPlan => AppMode::Agent, }; let _ = self.set_mode(next); } @@ -2319,6 +2360,7 @@ impl App { AppMode::Agent => AppMode::Plan, AppMode::Yolo => AppMode::Agent, AppMode::Plan => AppMode::Yolo, + AppMode::ProPlan => AppMode::Plan, }; let _ = self.set_mode(next); } @@ -4798,6 +4840,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 @@ -4809,6 +4854,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" @@ -5948,6 +5997,12 @@ mod tests { app.cycle_mode_reverse(); assert_eq!(app.mode, AppMode::Yolo); + app.cycle_mode_reverse(); + assert_eq!(app.mode, AppMode::Agent); + + app.cycle_mode_reverse(); + assert_eq!(app.mode, AppMode::Plan); + app.mode = AppMode::Agent; app.cycle_mode_reverse(); assert_eq!(app.mode, AppMode::Plan); @@ -5955,6 +6010,10 @@ mod tests { app.mode = AppMode::Yolo; app.cycle_mode_reverse(); assert_eq!(app.mode, AppMode::Agent); + + app.mode = AppMode::ProPlan; + app.cycle_mode_reverse(); + assert_eq!(app.mode, AppMode::Plan); } #[test] @@ -5964,16 +6023,19 @@ mod tests { AppMode::Plan => AppMode::Agent, AppMode::Agent => AppMode::Yolo, AppMode::Yolo => AppMode::Plan, + AppMode::ProPlan => AppMode::Agent, }; let second_mode = match first_mode { AppMode::Plan => AppMode::Agent, AppMode::Agent => AppMode::Yolo, AppMode::Yolo => AppMode::Plan, + AppMode::ProPlan => AppMode::Agent, }; let third_mode = match second_mode { AppMode::Plan => AppMode::Agent, AppMode::Agent => AppMode::Yolo, AppMode::Yolo => AppMode::Plan, + AppMode::ProPlan => AppMode::Agent, }; app.set_mode(first_mode); @@ -6112,6 +6174,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 02dc8ce55..23b9480ea 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -895,6 +895,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 af2d8996d..4704caecc 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -51,6 +51,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 5ac2da1c3..7ed2e1dfd 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -5,30 +5,34 @@ 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::tools::plan::PlanSnapshot; 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) @@ -93,16 +97,30 @@ fn push_option_lines( ))); } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct PlanPromptView { selected: usize, + locale: Locale, /// The plan snapshot to display (if update_plan was called). plan: Option, } impl PlanPromptView { + #[cfg(test)] pub fn new(plan: Option) -> Self { - Self { selected: 0, plan } + Self { + selected: 0, + locale: Locale::En, + plan, + } + } + + pub fn new_for_locale_with_plan(locale: Locale, plan: Option) -> Self { + Self { + selected: 0, + locale, + plan, + } } fn max_index(&self) -> usize { @@ -190,11 +208,11 @@ 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("")); @@ -212,7 +230,7 @@ impl ModalView for PlanPromptView { } if !plan.items.is_empty() { lines.push(Line::from(Span::styled( - "Plan steps:", + tr(self.locale, MessageId::PlanPromptPlanSteps), Style::default().fg(palette::DEEPSEEK_SKY).bold(), ))); for item in &plan.items { @@ -230,9 +248,15 @@ impl ModalView for PlanPromptView { } } - 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("")); @@ -241,22 +265,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); @@ -363,6 +399,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..cd8c78f3d --- /dev/null +++ b/crates/tui/src/tui/pro_plan.rs @@ -0,0 +1,392 @@ +use serde::{Deserialize, Serialize}; + +use crate::config::{ + ApiProvider, model_completion_names_for_provider, normalize_model_name_for_provider, +}; + +const PRO_PLAN_PLAN_MODEL: &str = "deepseek-v4-pro"; +const PRO_PLAN_EXECUTE_MODEL: &str = "deepseek-v4-flash"; +const PRO_PLAN_REVIEW_MODEL: &str = "deepseek-v4-pro"; + +#[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: String, + pub execute_model: String, + pub review_model: String, +} + +impl ProPlanConfig { + pub fn for_provider(provider: ApiProvider) -> Self { + let plan_model = resolve_route_model(provider, PRO_PLAN_PLAN_MODEL) + .unwrap_or_else(|| PRO_PLAN_PLAN_MODEL.to_string()); + let review_model = resolve_route_model(provider, PRO_PLAN_REVIEW_MODEL) + .unwrap_or_else(|| plan_model.clone()); + let execute_model = resolve_route_model(provider, PRO_PLAN_EXECUTE_MODEL) + .filter(|model| provider_offers_model(provider, model)) + .unwrap_or_else(|| plan_model.clone()); + + Self { + plan_model, + execute_model, + review_model, + } + } +} + +impl Default for ProPlanConfig { + fn default() -> Self { + Self { + plan_model: PRO_PLAN_PLAN_MODEL.to_string(), + execute_model: PRO_PLAN_EXECUTE_MODEL.to_string(), + review_model: PRO_PLAN_REVIEW_MODEL.to_string(), + } + } +} + +fn resolve_route_model(provider: ApiProvider, model: &str) -> Option { + normalize_model_name_for_provider(provider, model) + .filter(|resolved| !resolved.trim().is_empty()) +} + +fn provider_offers_model(provider: ApiProvider, model: &str) -> bool { + model_completion_names_for_provider(provider) + .into_iter() + .any(|available| available.eq_ignore_ascii_case(model)) +} + +#[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) -> &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; + self.state.execute_auto_approve = false; + 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::*; + use crate::config::{ApiProvider, DEFAULT_FIREWORKS_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL}; + + #[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 provider_config_uses_flash_when_provider_advertises_route() { + let config = ProPlanConfig::for_provider(ApiProvider::Openrouter); + + assert_eq!(config.execute_model, DEFAULT_OPENROUTER_FLASH_MODEL); + } + + #[test] + fn provider_config_falls_back_to_pro_when_flash_route_is_unavailable() { + let config = ProPlanConfig::for_provider(ApiProvider::Fireworks); + let mut router = ProPlanRouter::new(config); + + router.start_execution(false); + + assert_eq!(router.current_model(), DEFAULT_FIREWORKS_MODEL); + } + + #[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_returns_to_execute() { + let config = ProPlanConfig::default(); + let mut router = ProPlanRouter::new(config); + + router.state.phase = ProPlanPhase::Review; + router.state.has_generated_plan = true; + router.state.execute_auto_approve = 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); + assert!(!router.execute_auto_approve()); + } + + #[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 b23f4fadf..0ad5735c7 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -47,7 +47,7 @@ use crate::config::{ }; use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent}; use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; -use crate::core::events::Event as EngineEvent; +use crate::core::events::{Event as EngineEvent, TurnOutcomeStatus}; use crate::core::ops::{Op, USER_SHELL_TOOL_ID_PREFIX}; use crate::hooks::{HookEvent, HookExecutor}; use crate::llm_client::LlmClient; @@ -90,6 +90,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; @@ -818,7 +819,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, @@ -888,6 +889,176 @@ 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" + | "build" + ) + }); + + let asks_for_action_phrase = [ + "implement", + "write code", + "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 apply_pro_plan_turn_completion( + router: &mut ProPlanRouter, + status: TurnOutcomeStatus, + last_assistant_text: &str, + plan_tool_used: bool, +) -> (ProPlanPhase, bool) { + let phase_before = router.phase(); + if status == TurnOutcomeStatus::Completed { + if !last_assistant_text.is_empty() { + router.transition(last_assistant_text); + } + if plan_tool_used && router.phase() == ProPlanPhase::Plan { + router.mark_plan_ready(); + } + } + (phase_before, router.phase() != phase_before) +} + +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 +} + /// How long after a task finishes it should still appear in the Work /// sidebar even if its `ended_at` predates the current TUI session. /// @@ -1779,7 +1950,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 @@ -1912,7 +2085,116 @@ async fn run_event_loop( }); if app.view_stack.top_kind() != Some(ModalKind::PlanPrompt) { let plan = Some(app.plan_state.lock().await.snapshot()); - app.view_stack.push(PlanPromptView::new(plan)); + app.view_stack + .push(PlanPromptView::new_for_locale_with_plan( + app.ui_locale, + plan, + )); + } + } + + 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 { + 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, transition_changed) = + apply_pro_plan_turn_completion( + router, + status, + &last_assistant_text, + app.plan_tool_used_in_turn, + ); + + if status == 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 { + 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, + )); + } + } + } + + 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) { + let plan = Some(app.plan_state.lock().await.snapshot()); + app.view_stack + .push(PlanPromptView::new_for_locale_with_plan( + app.ui_locale, + plan, + )); } } app.plan_tool_used_in_turn = false; @@ -1961,7 +2243,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); @@ -2204,6 +2488,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 / @@ -2219,6 +2504,21 @@ 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!( + "{}: {tool_name}", + tr(app.ui_locale, MessageId::ToolBlockedReadOnly) + )); } else if session_approved || app.approval_mode == ApprovalMode::Auto { log_sensitive_event( "tool.approval.auto_approve", @@ -4071,6 +4371,7 @@ async fn run_event_loop( AppMode::Plan => AppMode::Agent, AppMode::Agent => AppMode::Yolo, AppMode::Yolo => AppMode::Plan, + AppMode::ProPlan => AppMode::Agent, }; apply_mode_update(app, &engine_handle, new_mode).await; } @@ -4843,11 +5144,24 @@ 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); + let prompt_model_id = if app.mode == AppMode::ProPlan { + app.effective_model_for_budget() + } else { + &app.model + }; app.system_prompt = Some( prompts::system_prompt_for_mode_with_context_skills_and_session( - app.mode, + turn_mode, &app.workspace, None, None, @@ -4858,7 +5172,7 @@ async fn dispatch_user_message( project_context_pack_enabled: config.project_context_pack_enabled(), locale_tag: app.ui_locale.tag(), translation_enabled: app.translation_enabled, - model_id: &app.model, + model_id: prompt_model_id, show_thinking: app.show_thinking, allow_shell: app.allow_shell, }, @@ -4901,7 +5215,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()) @@ -4940,6 +5256,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; } @@ -4947,15 +5265,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.hunt.quarry.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, show_thinking: app.show_thinking, @@ -6365,7 +6683,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", @@ -6405,45 +6723,92 @@ async fn apply_plan_choice( ) -> Result<()> { match choice { PlanChoice::AcceptAgent => { - apply_mode_update(app, engine_handle, AppMode::Agent).await; + 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 { + apply_mode_update(app, engine_handle, AppMode::Agent).await; + } app.add_message(HistoryCell::System { - content: "Plan accepted. Switching to Agent mode and starting implementation." - .to_string(), + content: if pro_plan { + tr(app.ui_locale, MessageId::ProPlanAcceptedExecution).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 { + tr(app.ui_locale, MessageId::ProPlanExecutionQueued).to_string() + } else { + "Queued accepted plan execution (agent mode).".to_string() + }); } else { dispatch_user_message(app, config, engine_handle, followup).await?; } } PlanChoice::AcceptYolo => { - apply_mode_update(app, engine_handle, AppMode::Yolo).await; + 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 { + apply_mode_update(app, engine_handle, AppMode::Yolo).await; + } app.add_message(HistoryCell::System { - content: "Plan accepted. Switching to YOLO mode and starting implementation." - .to_string(), + content: if pro_plan { + tr(app.ui_locale, MessageId::ProPlanAcceptedAutoExecution).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 { + tr(app.ui_locale, MessageId::ProPlanAutoExecutionQueued).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 { + tr(app.ui_locale, MessageId::ProPlanExitedToAgent) + } else { + "Exited Plan mode. Switched to Agent mode." + }; apply_mode_update(app, engine_handle, AppMode::Agent).await; 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 5c916fdd4..8f44ae9c8 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5,6 +5,7 @@ use crate::config::{ }; use crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent}; use crate::core::engine::mock_engine_handle; +use crate::core::events::TurnOutcomeStatus; use crate::tui::active_cell::ActiveCell; use crate::tui::app::ToolDetailRecord; use crate::tui::file_mention::{ @@ -1322,6 +1323,122 @@ 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); + + let router = app.pro_plan_router.as_mut().expect("pro plan router"); + router.transition(""); + assert_eq!(router.phase(), crate::tui::pro_plan::ProPlanPhase::Execute); + assert!(!router.execute_auto_approve()); + assert_eq!(effective_mode_for_turn(&app), AppMode::Agent); +} + +#[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_phase_does_not_advance_on_aborted_turns() { + let config = crate::tui::pro_plan::ProPlanConfig::default(); + let mut router = ProPlanRouter::new(config); + router.start_execution(false); + + let (_, interrupted_changed) = apply_pro_plan_turn_completion( + &mut router, + TurnOutcomeStatus::Interrupted, + "", + false, + ); + assert!(!interrupted_changed); + assert_eq!(router.phase(), crate::tui::pro_plan::ProPlanPhase::Execute); + + let (_, failed_changed) = apply_pro_plan_turn_completion( + &mut router, + TurnOutcomeStatus::Failed, + "", + false, + ); + assert!(!failed_changed); + assert_eq!(router.phase(), crate::tui::pro_plan::ProPlanPhase::Execute); + + let (_, completed_changed) = apply_pro_plan_turn_completion( + &mut router, + TurnOutcomeStatus::Completed, + "", + false, + ); + assert!(completed_changed); + assert_eq!(router.phase(), crate::tui::pro_plan::ProPlanPhase::Review); +} + +#[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?" + )); + assert!(!should_add_pro_plan_planning_instruction( + &app, + "优化一下整体体验,先聊聊方向" + )); + + 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)); @@ -4595,6 +4712,39 @@ async fn numeric_plan_choice_still_queues_follow_up_when_busy() { ); } +#[tokio::test] +async fn pro_plan_accept_yolo_uses_auto_approval_for_one_execution_pass() { + 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 e84cd043f..75567dd75 100644 --- a/crates/tui/src/tui/views/mode_picker.rs +++ b/crates/tui/src/tui/views/mode_picker.rs @@ -196,6 +196,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::Agent); + } + #[test] fn enter_emits_selected_mode() { let mut view = ModePickerView::new(AppMode::Agent); @@ -219,5 +225,8 @@ mod tests { } other => panic!("expected ModeSelected, got {other:?}"), } + + let action = view.handle_key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); } } diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 91c16d956..e5af05861 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -310,11 +310,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 3c6804125..57fafa417 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 92deb9bed..aba06d01e 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -509,6 +509,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/MODES.md b/docs/MODES.md index 3064084c5..feb7e32c1 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -3,6 +3,8 @@ codewhale has two related concepts: - **TUI mode**: what kind of visible interaction you're in (Plan/Agent/YOLO). +- **Opt-in routing profile**: an explicit workflow that can route models across + phases after the user chooses it. - **Approval mode**: how aggressively the UI asks before executing tools. Model selection is separate. `--model auto` and `/model auto` route each turn to @@ -44,6 +46,24 @@ mode catalog does not list them. All action-capable 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. +## Opt-In Pro Plan Profile + +`/mode pro-plan` enters an explicit Pro Plan routing profile. It is intentionally +not part of the default `Tab` cycle or `/mode` picker. + +In Pro Plan, the user chooses the profile, then CodeWhale chooses the phase +model: + +- **Plan** and **Review** use the resolved Pro route and run with Plan-mode + read-only tool policy. +- **Execute** uses the resolved Flash route. If Flash is not advertised by the + active provider, Execute falls back to the Pro route rather than sending an + invalid model id. +- The existing Plan Confirmation gate decides whether execution starts. Accept + uses Agent-style approvals; Accept (YOLO) enables auto-approval only for that + execution pass. If review asks for follow-up changes, Pro Plan returns to + Agent-style approvals unless the user explicitly chooses YOLO again. + The fast `deepseek-v4-flash` / thinking-off path is called Fin in the product language. Fin is a seam for routing, summaries, cheap child calls, and coordination work; it does not change approval behavior. diff --git a/docs/PRO_PLAN_MODE.md b/docs/PRO_PLAN_MODE.md new file mode 100644 index 000000000..76d0ff2ec --- /dev/null +++ b/docs/PRO_PLAN_MODE.md @@ -0,0 +1,74 @@ +# Pro Plan Profile + +Pro Plan is an explicit `/mode pro-plan` routing profile, not part of the +default `Tab` mode cycle or `/mode` picker. The user chooses the profile; then +CodeWhale chooses the model route for each phase. Planning and review stay on +the stronger model while implementation uses the faster model when available: + +- 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 pass, but the visible mode stays Pro Plan so review still runs. If + review requests follow-up changes, the next Execute pass returns to + Agent-style approvals unless the user explicitly accepts with YOLO again. + +After `Done`, the next user turn resets the router to a fresh Plan phase. + +Model routes are provider-aware. If the active provider does not advertise a +usable `deepseek-v4-flash` route, the Execute phase falls back to the resolved +Pro model instead of sending an unavailable model id. + +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.