feat(plan): preserve rich PlanArtifact context

Harvested from PR #2733 by @idling11.

Adds richer update_plan artifact fields for grounded Plan-mode review, renders them in the transcript and Plan confirmation prompt, and carries them through /relay, fork-state, and saved-session replay.

Verification: cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture

Verification: cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture

Verification: cargo clippy -p codewhale-tui --locked -- -D warnings

Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com>
This commit is contained in:
Hunter B
2026-06-03 21:31:09 -07:00
parent 66c88ddfae
commit 7ac8063b6b
15 changed files with 1170 additions and 141 deletions
+7 -2
View File
@@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested
commits use GitHub-mappable numeric noreply identities instead of `.local`,
placeholder, bot/tool, or raw third-party emails.
- Added rich PlanArtifact support to `update_plan`: Plan mode can now carry
grounded objectives, context, sources, critical files, constraints,
verification, risks, and handoff notes through the transcript card, Plan
confirmation prompt, `/relay`, fork-state, and saved-session replay.
### Changed
@@ -58,8 +62,9 @@ Thanks to **@cyq1017** for the restore-listing implementation (#2513) and
**@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata
prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale
settings-path migration work (#2730), **@gaord** for the runtime thread
workspace update API (#2640), and **@shenjackyuanjie** for the
HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634).
workspace update API (#2640), **@shenjackyuanjie** for the
HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), and
**@idling11** for the PlanArtifact direction in Plan mode (#2733).
## [0.8.53] - 2026-06-03
+56 -4
View File
@@ -854,11 +854,35 @@ fn build_relay_instruction(app: &App, focus: Option<&str>) -> String {
if let Ok(plan) = app.plan_state.try_lock() {
let snapshot = plan.snapshot();
if snapshot.explanation.is_some() || !snapshot.items.is_empty() {
if !snapshot.is_empty() {
let _ = writeln!(out, "\nOptional strategy metadata from update_plan:");
if let Some(explanation) = snapshot.explanation.as_deref() {
let _ = writeln!(out, "- Explanation: {explanation}");
}
write_plan_field(&mut out, "Title", snapshot.title.as_deref());
write_plan_field(&mut out, "Objective", snapshot.objective.as_deref());
write_plan_field(&mut out, "Context", snapshot.context_summary.as_deref());
write_plan_field(&mut out, "Explanation", snapshot.explanation.as_deref());
write_plan_list(&mut out, "Source", &snapshot.sources_used);
write_plan_list(&mut out, "Critical file", &snapshot.critical_files);
write_plan_list(&mut out, "Constraint", &snapshot.constraints);
write_plan_field(
&mut out,
"Recommended approach",
snapshot.recommended_approach.as_deref(),
);
write_plan_field(
&mut out,
"Verification plan",
snapshot.verification_plan.as_deref(),
);
write_plan_field(
&mut out,
"Risks and unknowns",
snapshot.risks_and_unknowns.as_deref(),
);
write_plan_field(
&mut out,
"Handoff packet",
snapshot.handoff_packet.as_deref(),
);
for item in snapshot.items {
let _ = writeln!(out, "- [{}] {}", plan_status_label(&item.status), item.step);
}
@@ -904,6 +928,21 @@ fn build_relay_instruction(app: &App, focus: Option<&str>) -> String {
out
}
fn write_plan_field(out: &mut String, label: &str, value: Option<&str>) {
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
let _ = writeln!(out, "- {label}: {value}");
}
}
fn write_plan_list(out: &mut String, label: &str, values: &[String]) {
for value in values {
let value = value.trim();
if !value.is_empty() {
let _ = writeln!(out, "- {label}: {value}");
}
}
}
fn plan_status_label(status: &crate::tools::plan::StepStatus) -> &'static str {
match status {
crate::tools::plan::StepStatus::Pending => "pending",
@@ -1166,11 +1205,18 @@ mod tests {
{
let mut plan = app.plan_state.try_lock().expect("plan lock");
plan.update(UpdatePlanArgs {
objective: Some("Keep relays grounded".to_string()),
explanation: Some("RLM-style strategy".to_string()),
sources_used: vec!["transcript context".to_string()],
critical_files: vec!["crates/tui/src/commands/mod.rs".to_string()],
constraints: vec!["Do not invent verification".to_string()],
verification_plan: Some("Check relay prompt assertions".to_string()),
handoff_packet: Some("Next thread should read the Work checklist".to_string()),
plan: vec![PlanItemArg {
step: "keep checklist primary".to_string(),
status: StepStatus::InProgress,
}],
..UpdatePlanArgs::default()
});
}
@@ -1197,7 +1243,13 @@ mod tests {
assert!(message.contains("#1 [completed] inspect workspace"));
assert!(message.contains("#2 [in_progress] patch relay command"));
assert!(message.contains("Optional strategy metadata from update_plan"));
assert!(message.contains("Objective: Keep relays grounded"));
assert!(message.contains("Explanation: RLM-style strategy"));
assert!(message.contains("Source: transcript context"));
assert!(message.contains("Critical file: crates/tui/src/commands/mod.rs"));
assert!(message.contains("Constraint: Do not invent verification"));
assert!(message.contains("Verification plan: Check relay prompt assertions"));
assert!(message.contains("Handoff packet: Next thread should read the Work checklist"));
assert!(message.contains("[in_progress] keep checklist primary"));
}
+38 -3
View File
@@ -168,9 +168,29 @@ impl StructuredState {
if let Some(plan) = self.plan_snapshot.as_ref() {
out.push_str("\nStrategy metadata\n");
if let Some(explanation) = plan.explanation.as_ref() {
out.push_str(&format!("{explanation}\n\n"));
}
append_plan_field(&mut out, "Title", plan.title.as_deref());
append_plan_field(&mut out, "Objective", plan.objective.as_deref());
append_plan_field(&mut out, "Context", plan.context_summary.as_deref());
append_plan_field(&mut out, "Explanation", plan.explanation.as_deref());
append_plan_list(&mut out, "Source", &plan.sources_used);
append_plan_list(&mut out, "Critical file", &plan.critical_files);
append_plan_list(&mut out, "Constraint", &plan.constraints);
append_plan_field(
&mut out,
"Recommended approach",
plan.recommended_approach.as_deref(),
);
append_plan_field(
&mut out,
"Verification plan",
plan.verification_plan.as_deref(),
);
append_plan_field(
&mut out,
"Risks and unknowns",
plan.risks_and_unknowns.as_deref(),
);
append_plan_field(&mut out, "Handoff packet", plan.handoff_packet.as_deref());
for item in &plan.items {
let marker = match item.status {
crate::tools::plan::StepStatus::Pending => "[ ]",
@@ -204,6 +224,21 @@ impl StructuredState {
}
}
fn append_plan_field(out: &mut String, label: &str, value: Option<&str>) {
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
out.push_str(&format!("- {label}: {value}\n"));
}
}
fn append_plan_list(out: &mut String, label: &str, values: &[String]) {
for value in values {
let value = value.trim();
if !value.is_empty() {
out.push_str(&format!("- {label}: {value}\n"));
}
}
}
// === Types ===
/// Configuration for the engine
+40
View File
@@ -3,6 +3,7 @@ use super::*;
use super::context::TURN_MAX_OUTPUT_TOKENS;
use crate::models::SystemBlock;
use crate::test_support::lock_test_env;
use crate::tools::plan::{PlanItemArg, PlanSnapshot, StepStatus};
use crate::tools::spec::ToolCapability;
use serde_json::json;
use std::collections::{HashMap, HashSet};
@@ -84,6 +85,45 @@ fn build_engine_with_capacity(capacity: CapacityControllerConfig) -> Engine {
engine
}
#[test]
fn structured_state_block_includes_rich_plan_artifact() {
let state = StructuredState {
mode_label: "Plan".to_string(),
workspace: PathBuf::from("/workspace/codewhale"),
cwd: None,
working_set_summary: None,
todo_snapshot: None,
plan_snapshot: Some(PlanSnapshot {
objective: Some("Make Plan mode reviewable".to_string()),
context_summary: Some("Grounded in issue #2691".to_string()),
sources_used: vec!["gh issue view 2691".to_string()],
critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()],
constraints: vec!["Preserve legacy payloads".to_string()],
recommended_approach: Some("Enrich update_plan".to_string()),
verification_plan: Some("Run focused tests".to_string()),
risks_and_unknowns: Some("Replay may drift".to_string()),
handoff_packet: Some("Next agent should inspect replay".to_string()),
items: vec![PlanItemArg {
step: "Render rich artifact".to_string(),
status: StepStatus::InProgress,
}],
..PlanSnapshot::default()
}),
subagent_snapshots: Vec::new(),
};
let block = state.to_system_block().expect("fork state block");
assert!(block.contains("Objective: Make Plan mode reviewable"));
assert!(block.contains("Context: Grounded in issue #2691"));
assert!(block.contains("Source: gh issue view 2691"));
assert!(block.contains("Critical file: crates/tui/src/tools/plan.rs"));
assert!(block.contains("Constraint: Preserve legacy payloads"));
assert!(block.contains("Verification plan: Run focused tests"));
assert!(block.contains("Handoff packet: Next agent should inspect replay"));
assert!(block.contains("- [~] Render rich artifact"));
}
#[test]
fn env_only_auth_error_gets_recovery_hint() {
let _guard = lock_test_env();
+4
View File
@@ -5,6 +5,10 @@ You are running in Plan mode — design before implementing.
Investigate first, act later. Use `checklist_write` for visible, granular progress on multi-step
investigations. When you are ready to present the implementation plan, call `update_plan` with
the final plan; that is the handoff signal that lets the UI show the accept / revise / exit prompt.
For non-trivial work, make the plan artifact grounded: include the objective, a short context
summary, sources used, critical files, constraints, recommended approach, verification plan,
risks or unknowns, and any concise handoff packet another agent would need. Do not include
secrets in sources, file lists, or handoff text.
All writes and patches are blocked — you can read the world but you
can't change it. Shell and code execution are unavailable.
+59
View File
@@ -1003,6 +1003,8 @@ fn format_age(dt: &DateTime<Utc>) -> String {
mod tests {
use super::*;
use crate::models::ContentBlock;
use crate::tools::plan::StepStatus;
use crate::tui::history::{HistoryCell, ToolCell, history_cells_from_message};
use std::fs;
use tempfile::tempdir;
@@ -1106,6 +1108,63 @@ mod tests {
assert_eq!(loaded.messages.len(), 2);
}
#[test]
fn save_and_load_session_preserves_rich_update_plan_tool_payload() {
let tmp = tempdir().expect("tempdir");
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
let messages = vec![
make_test_message("user", "plan this carefully"),
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "plan-1".to_string(),
name: "update_plan".to_string(),
input: serde_json::json!({
"objective": "Make Plan mode reviewable",
"sources_used": ["gh issue view 2691"],
"critical_files": ["crates/tui/src/tools/plan.rs"],
"constraints": ["Preserve legacy update_plan payloads"],
"verification_plan": "Run focused plan tests",
"handoff_packet": "Next agent should inspect replay",
"plan": [
{ "step": "render replay card", "status": "completed" }
]
}),
caller: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "plan-1".to_string(),
content: "Plan updated".to_string(),
is_error: None,
content_blocks: None,
}],
},
];
let session = create_saved_session(&messages, "deepseek-v4-flash", tmp.path(), 42, None);
let session_id = session.metadata.id.clone();
manager.save_session(&session).expect("save");
let loaded = manager.load_session(&session_id).expect("load");
assert_eq!(loaded.messages.len(), 3);
let cells = history_cells_from_message(&loaded.messages[1]);
let Some(HistoryCell::Tool(ToolCell::PlanUpdate(cell))) = cells.first() else {
panic!("expected loaded update_plan to replay as a PlanUpdate cell");
};
assert_eq!(
cell.snapshot.objective.as_deref(),
Some("Make Plan mode reviewable")
);
assert_eq!(
cell.snapshot.critical_files,
vec!["crates/tui/src/tools/plan.rs"]
);
assert_eq!(cell.snapshot.items[0].status, StepStatus::Completed);
}
#[test]
fn save_session_preserves_large_tool_outputs_for_cache_fidelity() {
let tmp = tempdir().expect("tempdir");
+438 -20
View File
@@ -54,10 +54,31 @@ pub struct PlanItemArg {
}
/// Update payload used by the plan tool.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdatePlanArgs {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub objective: Option<String>,
#[serde(default)]
pub context_summary: Option<String>,
#[serde(default)]
pub explanation: Option<String>,
#[serde(default)]
pub sources_used: Vec<String>,
#[serde(default)]
pub critical_files: Vec<String>,
#[serde(default)]
pub constraints: Vec<String>,
#[serde(default)]
pub recommended_approach: Option<String>,
#[serde(default)]
pub verification_plan: Option<String>,
#[serde(default)]
pub risks_and_unknowns: Option<String>,
#[serde(default)]
pub handoff_packet: Option<String>,
#[serde(default)]
pub plan: Vec<PlanItemArg>,
}
@@ -115,16 +136,110 @@ impl PlanStep {
}
/// Serializable snapshot for display
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlanSnapshot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub objective: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explanation: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources_used: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub critical_files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constraints: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recommended_approach: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub risks_and_unknowns: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handoff_packet: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub items: Vec<PlanItemArg>,
}
impl PlanSnapshot {
#[must_use]
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.objective.is_none()
&& self.context_summary.is_none()
&& self.explanation.is_none()
&& self.sources_used.is_empty()
&& self.critical_files.is_empty()
&& self.constraints.is_empty()
&& self.recommended_approach.is_none()
&& self.verification_plan.is_none()
&& self.risks_and_unknowns.is_none()
&& self.handoff_packet.is_none()
&& self.items.is_empty()
}
/// Parse the user/model-facing `update_plan` payload into a displayable
/// snapshot. This is intentionally tolerant so saved transcript replay can
/// keep legacy and partially streamed payloads visible.
#[must_use]
pub fn from_tool_input(input: &serde_json::Value) -> Self {
let mut items = Vec::new();
if let Some(plan_items) = input.get("plan").and_then(|v| v.as_array()) {
for item in plan_items {
let step = item
.get("step")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if step.is_empty() {
continue;
}
let status = item
.get("status")
.and_then(|v| v.as_str())
.and_then(StepStatus::from_str)
.unwrap_or(StepStatus::Pending);
items.push(PlanItemArg {
step: step.to_string(),
status,
});
}
}
Self {
title: clean_optional(string_field(input, "title")),
objective: clean_optional(string_field(input, "objective")),
context_summary: clean_optional(string_field(input, "context_summary")),
explanation: clean_optional(string_field(input, "explanation")),
sources_used: clean_list(string_vec_field(input, "sources_used")),
critical_files: clean_list(string_vec_field(input, "critical_files")),
constraints: clean_list(string_vec_field(input, "constraints")),
recommended_approach: clean_optional(string_field(input, "recommended_approach")),
verification_plan: clean_optional(string_field(input, "verification_plan")),
risks_and_unknowns: clean_optional(string_field(input, "risks_and_unknowns")),
handoff_packet: clean_optional(string_field(input, "handoff_packet")),
items,
}
}
}
/// State tracking for the current plan
#[derive(Debug, Clone, Default)]
pub struct PlanState {
title: Option<String>,
objective: Option<String>,
context_summary: Option<String>,
explanation: Option<String>,
sources_used: Vec<String>,
critical_files: Vec<String>,
constraints: Vec<String>,
recommended_approach: Option<String>,
verification_plan: Option<String>,
risks_and_unknowns: Option<String>,
handoff_packet: Option<String>,
steps: Vec<PlanStep>,
}
@@ -132,19 +247,44 @@ impl PlanState {
/// Check whether the plan is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.steps.is_empty() && self.explanation.as_deref().unwrap_or("").is_empty()
self.steps.is_empty()
&& self.title.is_none()
&& self.objective.is_none()
&& self.context_summary.is_none()
&& self.explanation.is_none()
&& self.sources_used.is_empty()
&& self.critical_files.is_empty()
&& self.constraints.is_empty()
&& self.recommended_approach.is_none()
&& self.verification_plan.is_none()
&& self.risks_and_unknowns.is_none()
&& self.handoff_packet.is_none()
}
pub fn update(&mut self, args: UpdatePlanArgs) {
self.explanation = args.explanation.filter(|s| !s.trim().is_empty());
self.title = clean_optional(args.title);
self.objective = clean_optional(args.objective);
self.context_summary = clean_optional(args.context_summary);
self.explanation = clean_optional(args.explanation);
self.sources_used = clean_list(args.sources_used);
self.critical_files = clean_list(args.critical_files);
self.constraints = clean_list(args.constraints);
self.recommended_approach = clean_optional(args.recommended_approach);
self.verification_plan = clean_optional(args.verification_plan);
self.risks_and_unknowns = clean_optional(args.risks_and_unknowns);
self.handoff_packet = clean_optional(args.handoff_packet);
let now = Instant::now();
let mut new_steps = Vec::new();
let mut in_progress_seen = false;
for item in args.plan {
let step_text = item.step.trim();
if step_text.is_empty() {
continue;
}
// Try to find existing step to preserve timing
let existing = self.steps.iter().find(|s| s.text == item.step);
let existing = self.steps.iter().find(|s| s.text == step_text);
let mut status = item.status;
// Enforce single in_progress
@@ -171,7 +311,7 @@ impl PlanState {
s
} else {
let mut s = PlanStep::new(item.step, status.clone());
let mut s = PlanStep::new(step_text.to_string(), status.clone());
if status == StepStatus::InProgress {
s.started_at = Some(now);
}
@@ -186,7 +326,17 @@ impl PlanState {
pub fn snapshot(&self) -> PlanSnapshot {
PlanSnapshot {
title: self.title.clone(),
objective: self.objective.clone(),
context_summary: self.context_summary.clone(),
explanation: self.explanation.clone(),
sources_used: self.sources_used.clone(),
critical_files: self.critical_files.clone(),
constraints: self.constraints.clone(),
recommended_approach: self.recommended_approach.clone(),
verification_plan: self.verification_plan.clone(),
risks_and_unknowns: self.risks_and_unknowns.clone(),
handoff_packet: self.handoff_packet.clone(),
items: self
.steps
.iter()
@@ -236,6 +386,20 @@ impl PlanState {
}
}
fn clean_optional(value: Option<String>) -> Option<String> {
value
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn clean_list(values: Vec<String>) -> Vec<String> {
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect()
}
/// Validation result for plan transitions
#[derive(Debug)]
#[allow(dead_code)]
@@ -306,16 +470,59 @@ impl ToolSpec for UpdatePlanTool {
}
fn description(&self) -> &'static str {
"Update optional high-level strategy metadata for complex initiatives. Use checklist_write for primary Work progress; update_plan should capture phase-level approach changes, not duplicate checklist items. Each strategy step has a description and status (pending, in_progress, completed). Optionally include an explanation of the overall approach."
"Update optional high-level strategy metadata for complex initiatives. Use checklist_write for primary Work progress; update_plan should capture phase-level approach changes, not duplicate checklist items. Include sources, critical files, constraints, verification, risks, and handoff context when they help the user review or continue the plan. Each strategy step has a description and status (pending, in_progress, completed)."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Optional short title for the plan artifact"
},
"objective": {
"type": "string",
"description": "What the plan is trying to accomplish"
},
"context_summary": {
"type": "string",
"description": "Brief summary of the evidence and current state behind the plan"
},
"explanation": {
"type": "string",
"description": "Optional high-level explanation of the plan or approach"
"description": "Legacy-compatible high-level explanation of the plan or approach"
},
"sources_used": {
"type": "array",
"description": "Files, issues, PRs, commands, or other evidence used to ground the plan. Do not include secrets.",
"items": { "type": "string" }
},
"critical_files": {
"type": "array",
"description": "Repo paths or surfaces likely to be edited or verified. Do not include secrets.",
"items": { "type": "string" }
},
"constraints": {
"type": "array",
"description": "Hard requirements, user preferences, or boundaries the implementation must respect",
"items": { "type": "string" }
},
"recommended_approach": {
"type": "string",
"description": "Recommended implementation strategy and important trade-offs"
},
"verification_plan": {
"type": "string",
"description": "Tests, checks, or manual verification expected before the work is considered done"
},
"risks_and_unknowns": {
"type": "string",
"description": "Known risks, blockers, or unresolved questions"
},
"handoff_packet": {
"type": "string",
"description": "Concise continuation notes for another agent or a later session"
},
"plan": {
"type": "array",
@@ -336,8 +543,7 @@ impl ToolSpec for UpdatePlanTool {
"required": ["step", "status"]
}
}
},
"required": ["plan"]
}
})
}
@@ -354,15 +560,13 @@ impl ToolSpec for UpdatePlanTool {
input: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let explanation = input
.get("explanation")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string);
let plan_items = input
.get("plan")
.and_then(|v| v.as_array())
.ok_or_else(|| ToolError::invalid_input("Missing or invalid 'plan' array"))?;
let empty_plan = Vec::new();
let plan_items = match input.get("plan") {
Some(value) => value
.as_array()
.ok_or_else(|| ToolError::invalid_input("Invalid 'plan' array"))?,
None => &empty_plan,
};
let mut plan_args = Vec::new();
for item in plan_items {
@@ -385,7 +589,17 @@ impl ToolSpec for UpdatePlanTool {
}
let args = UpdatePlanArgs {
explanation,
title: string_field(&input, "title"),
objective: string_field(&input, "objective"),
context_summary: string_field(&input, "context_summary"),
explanation: string_field(&input, "explanation"),
sources_used: string_vec_field(&input, "sources_used"),
critical_files: string_vec_field(&input, "critical_files"),
constraints: string_vec_field(&input, "constraints"),
recommended_approach: string_field(&input, "recommended_approach"),
verification_plan: string_field(&input, "verification_plan"),
risks_and_unknowns: string_field(&input, "risks_and_unknowns"),
handoff_packet: string_field(&input, "handoff_packet"),
plan: plan_args,
};
@@ -404,3 +618,207 @@ impl ToolSpec for UpdatePlanTool {
)))
}
}
fn string_field(input: &serde_json::Value, field: &str) -> Option<String> {
input
.get(field)
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
}
fn string_vec_field(input: &serde_json::Value, field: &str) -> Vec<String> {
input
.get(field)
.and_then(|v| v.as_array())
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str().map(std::string::ToString::to_string))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::spec::{ToolContext, ToolSpec};
use serde_json::json;
#[test]
fn plan_state_treats_every_artifact_field_as_non_empty() {
let cases = vec![
UpdatePlanArgs {
title: Some("Title".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
objective: Some("Objective".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
context_summary: Some("Context".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
explanation: Some("Explanation".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
sources_used: vec!["gh issue view 2691".to_string()],
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()],
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
constraints: vec!["Preserve legacy payloads".to_string()],
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
recommended_approach: Some("Do the narrow slice".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
verification_plan: Some("Run focused tests".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
risks_and_unknowns: Some("Replay may drift".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
handoff_packet: Some("Next agent should inspect rendering".to_string()),
..UpdatePlanArgs::default()
},
];
for args in cases {
let mut state = PlanState::default();
state.update(args);
assert!(
!state.is_empty(),
"artifact metadata must keep plan state visible"
);
}
}
#[test]
fn plan_state_snapshot_trims_blank_artifact_values() {
let mut state = PlanState::default();
state.update(UpdatePlanArgs {
title: Some(" Rich plan ".to_string()),
sources_used: vec![" ".to_string(), " gh issue view 2691 ".to_string()],
critical_files: vec![" crates/tui/src/tools/plan.rs ".to_string()],
constraints: vec!["".to_string(), " no secrets ".to_string()],
plan: vec![
PlanItemArg {
step: " ".to_string(),
status: StepStatus::Pending,
},
PlanItemArg {
step: " render sections ".to_string(),
status: StepStatus::InProgress,
},
],
..UpdatePlanArgs::default()
});
let snapshot = state.snapshot();
assert_eq!(snapshot.title.as_deref(), Some("Rich plan"));
assert_eq!(snapshot.sources_used, vec!["gh issue view 2691"]);
assert_eq!(
snapshot.critical_files,
vec!["crates/tui/src/tools/plan.rs"]
);
assert_eq!(snapshot.constraints, vec!["no secrets"]);
assert_eq!(snapshot.items.len(), 1);
assert_eq!(snapshot.items[0].step, "render sections");
assert_eq!(snapshot.items[0].status, StepStatus::InProgress);
}
#[test]
fn snapshot_serde_skips_empty_fields_and_deserializes_legacy() {
let snapshot = PlanSnapshot {
objective: Some("Ship PlanArtifact".to_string()),
items: vec![PlanItemArg {
step: "keep legacy replay working".to_string(),
status: StepStatus::Completed,
}],
..PlanSnapshot::default()
};
let value = serde_json::to_value(&snapshot).expect("serialize snapshot");
assert!(value.get("objective").is_some());
assert!(value.get("title").is_none());
assert!(value.get("sources_used").is_none());
assert!(value.get("constraints").is_none());
let legacy: PlanSnapshot = serde_json::from_value(json!({
"explanation": "Legacy explanation",
"items": [
{ "step": "legacy step", "status": "pending" }
]
}))
.expect("legacy snapshot should deserialize");
assert_eq!(legacy.explanation.as_deref(), Some("Legacy explanation"));
assert_eq!(legacy.items.len(), 1);
assert!(legacy.sources_used.is_empty());
}
#[tokio::test]
async fn legacy_update_plan_still_works() {
let state = new_shared_plan_state();
let tool = UpdatePlanTool::new(state.clone());
let context = ToolContext::new(std::env::temp_dir());
tool.execute(
json!({
"explanation": "Legacy shape",
"plan": [
{ "step": "inspect", "status": "completed" },
{ "step": "patch", "status": "in_progress" }
]
}),
&context,
)
.await
.expect("legacy update_plan should succeed");
let snapshot = state.lock().await.snapshot();
assert_eq!(snapshot.explanation.as_deref(), Some("Legacy shape"));
assert_eq!(snapshot.items.len(), 2);
assert_eq!(snapshot.items[0].status, StepStatus::Completed);
assert_eq!(snapshot.items[1].status, StepStatus::InProgress);
}
#[tokio::test]
async fn update_plan_tool_accepts_metadata_only_payload() {
let state = new_shared_plan_state();
let tool = UpdatePlanTool::new(state.clone());
let context = ToolContext::new(std::env::temp_dir());
let result = tool
.execute(
json!({
"objective": "Make Plan mode reviewable",
"sources_used": ["gh issue view 2691"],
"critical_files": ["crates/tui/src/tools/plan.rs"],
"verification_plan": "Run focused plan tests"
}),
&context,
)
.await
.expect("metadata-only update_plan should succeed");
assert!(result.content.contains("Make Plan mode reviewable"));
let snapshot = state.lock().await.snapshot();
assert!(!snapshot.is_empty());
assert!(snapshot.items.is_empty());
assert_eq!(
snapshot.critical_files,
vec!["crates/tui/src/tools/plan.rs"]
);
}
}
+1
View File
@@ -5918,6 +5918,7 @@ mod tests {
step: "step 1".to_string(),
status: StepStatus::InProgress,
}],
..UpdatePlanArgs::default()
});
assert!(!plan.is_empty());
}
+165 -47
View File
@@ -11,6 +11,7 @@ use unicode_width::UnicodeWidthStr;
use crate::deepseek_theme::active_theme;
use crate::models::{ContentBlock, Message};
use crate::palette;
use crate::tools::plan::{PlanSnapshot, StepStatus};
use crate::tools::review::ReviewOutput;
use crate::tui::app::TranscriptSpacing;
use crate::tui::diff_render;
@@ -651,6 +652,12 @@ pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
});
}
}
ContentBlock::ToolUse { name, input, .. } if name == "update_plan" => {
cells.push(HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
snapshot: PlanSnapshot::from_tool_input(input),
status: ToolStatus::Success,
})));
}
_ => {}
}
}
@@ -883,8 +890,7 @@ pub struct ExploringEntry {
/// Cell for plan updates emitted by the plan tool.
#[derive(Debug, Clone)]
pub struct PlanUpdateCell {
pub explanation: Option<String>,
pub steps: Vec<PlanStep>,
pub snapshot: PlanSnapshot,
pub status: ToolStatus,
}
@@ -900,39 +906,68 @@ impl PlanUpdateCell {
low_motion,
));
if let Some(explanation) = self.explanation.as_ref() {
lines.extend(render_message(
"",
system_label_style(),
system_body_style(),
explanation,
width,
));
}
for step in &self.steps {
let marker = match step.status.as_str() {
"completed" => "done",
"in_progress" => "live",
_ => "next",
};
lines.extend(render_compact_kv(
marker,
&step.step,
tool_value_style(),
width,
));
}
render_plan_snapshot_lines(&self.snapshot, &mut lines, width);
lines
}
}
/// Single plan step rendered in the UI.
#[derive(Debug, Clone)]
pub struct PlanStep {
pub step: String,
pub status: String,
fn render_plan_snapshot_lines(snapshot: &PlanSnapshot, lines: &mut Vec<Line<'static>>, width: u16) {
render_plan_optional(lines, "title", snapshot.title.as_deref(), width);
render_plan_optional(lines, "objective", snapshot.objective.as_deref(), width);
render_plan_optional(lines, "context", snapshot.context_summary.as_deref(), width);
render_plan_optional(lines, "explain", snapshot.explanation.as_deref(), width);
render_plan_list(lines, "source", &snapshot.sources_used, width);
render_plan_list(lines, "file", &snapshot.critical_files, width);
render_plan_list(lines, "constraint", &snapshot.constraints, width);
render_plan_optional(
lines,
"approach",
snapshot.recommended_approach.as_deref(),
width,
);
render_plan_optional(
lines,
"verify",
snapshot.verification_plan.as_deref(),
width,
);
render_plan_optional(lines, "risk", snapshot.risks_and_unknowns.as_deref(), width);
render_plan_optional(lines, "handoff", snapshot.handoff_packet.as_deref(), width);
for step in &snapshot.items {
let marker = match step.status {
StepStatus::Completed => "done",
StepStatus::InProgress => "live",
StepStatus::Pending => "next",
};
lines.extend(render_compact_kv(
marker,
&step.step,
tool_value_style(),
width,
));
}
}
fn render_plan_optional(
lines: &mut Vec<Line<'static>>,
label: &str,
value: Option<&str>,
width: u16,
) {
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
lines.extend(render_compact_kv(label, value, tool_value_style(), width));
}
}
fn render_plan_list(lines: &mut Vec<Line<'static>>, label: &str, values: &[String], width: u16) {
for value in values {
let value = value.trim();
if !value.is_empty() {
lines.extend(render_compact_kv(label, value, tool_value_style(), width));
}
}
}
/// Cell for patch summaries emitted by the patch tool.
@@ -3434,8 +3469,8 @@ fn looks_like_file_path(s: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{
ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, PlanStep,
PlanUpdateCell, REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, TOOL_RUNNING_SYMBOLS,
ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, PlanUpdateCell,
REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, TOOL_RUNNING_SYMBOLS,
TOOL_STATUS_SYMBOL_MS, ToolCell, ToolStatus, TranscriptRenderOptions, USER_GLYPH,
assistant_label_style_for, extract_reasoning_summary, render_thinking,
running_status_label_with_elapsed,
@@ -3443,6 +3478,7 @@ mod tests {
use crate::deepseek_theme::Theme;
use crate::models::{ContentBlock, Message};
use crate::palette;
use crate::tools::plan::{PlanSnapshot, StepStatus};
use ratatui::style::Modifier;
use std::time::{Duration, Instant};
@@ -3923,6 +3959,40 @@ mod tests {
assert_eq!(summary, "Summary body");
}
#[test]
fn history_replays_update_plan_tool_use_as_plan_card() {
let msg = Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "plan-1".to_string(),
name: "update_plan".to_string(),
input: serde_json::json!({
"objective": "Make Plan mode reviewable",
"sources_used": ["gh issue view 2691"],
"critical_files": ["crates/tui/src/tools/plan.rs"],
"plan": [
{ "step": "render replay card", "status": "completed" }
]
}),
caller: None,
}],
};
let cells = super::history_cells_from_message(&msg);
assert_eq!(cells.len(), 1);
let HistoryCell::Tool(ToolCell::PlanUpdate(cell)) = &cells[0] else {
panic!("expected update_plan replay cell");
};
assert_eq!(cell.status, ToolStatus::Success);
assert_eq!(
cell.snapshot.objective.as_deref(),
Some("Make Plan mode reviewable")
);
assert_eq!(cell.snapshot.sources_used, vec!["gh issue view 2691"]);
assert_eq!(cell.snapshot.items[0].status, StepStatus::Completed);
}
#[test]
fn render_thinking_collapsed_shows_details_affordance() {
let lines = render_thinking(
@@ -4602,21 +4672,23 @@ mod tests {
fn plan_update_cell_renders_with_dark_theme_tokens() {
let theme = Theme::dark();
let cell = PlanUpdateCell {
explanation: None,
steps: vec![
PlanStep {
step: "scan repo".to_string(),
status: "completed".to_string(),
},
PlanStep {
step: "extract theme".to_string(),
status: "in_progress".to_string(),
},
PlanStep {
step: "land tests".to_string(),
status: "pending".to_string(),
},
],
snapshot: PlanSnapshot {
items: vec![
crate::tools::plan::PlanItemArg {
step: "scan repo".to_string(),
status: StepStatus::Completed,
},
crate::tools::plan::PlanItemArg {
step: "extract theme".to_string(),
status: StepStatus::InProgress,
},
crate::tools::plan::PlanItemArg {
step: "land tests".to_string(),
status: StepStatus::Pending,
},
],
..PlanSnapshot::default()
},
status: ToolStatus::Running,
};
@@ -4691,6 +4763,52 @@ mod tests {
assert_eq!(visible[3].trim_end(), "▏ next: land tests");
}
#[test]
fn plan_update_cell_renders_rich_artifact_metadata() {
let cell = PlanUpdateCell {
snapshot: PlanSnapshot {
objective: Some("Make Plan mode reviewable".to_string()),
context_summary: Some("Grounded in issue #2691".to_string()),
sources_used: vec!["gh issue view 2691".to_string()],
critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()],
constraints: vec!["Keep checklist primary".to_string()],
recommended_approach: Some(
"Enrich update_plan without breaking legacy calls".to_string(),
),
verification_plan: Some("Run focused renderer tests".to_string()),
risks_and_unknowns: Some("Metadata-only plans can disappear".to_string()),
handoff_packet: Some("Next agent should inspect relay output".to_string()),
items: vec![crate::tools::plan::PlanItemArg {
step: "Render artifact sections".to_string(),
status: StepStatus::InProgress,
}],
..PlanSnapshot::default()
},
status: ToolStatus::Success,
};
let visible = cell
.lines_with_motion(120, true)
.into_iter()
.map(|line| {
line.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(visible.contains("objective:"));
assert!(visible.contains("Make Plan mode reviewable"));
assert!(visible.contains("source:"));
assert!(visible.contains("gh issue view 2691"));
assert!(visible.contains("file:"));
assert!(visible.contains("verify:"));
assert!(visible.contains("handoff:"));
assert!(visible.contains("Render artifact sections"));
}
#[test]
fn exec_cell_failed_status_renders_with_dark_theme_tokens() {
let theme = Theme::dark();
+267 -35
View File
@@ -9,7 +9,7 @@ use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap};
use unicode_width::UnicodeWidthStr;
use crate::palette;
use crate::tools::plan::PlanSnapshot;
use crate::tools::plan::{PlanSnapshot, StepStatus};
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
struct PlanOption {
@@ -371,36 +371,7 @@ impl ModalView for PlanPromptView {
// v0.8.44: render plan details when update_plan was called (#834)
if let Some(ref plan) = self.plan {
if let Some(ref explanation) = plan.explanation {
for line in wrap_text(explanation, content_width) {
lines.push(Line::from(Span::styled(
line,
Style::default().fg(palette::TEXT_MUTED),
)));
}
lines.push(Line::from(""));
}
if !plan.items.is_empty() {
lines.push(Line::from(Span::styled(
"Plan steps:",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)));
for (i, item) in plan.items.iter().enumerate() {
let status_mark = match item.status {
crate::tools::plan::StepStatus::Pending => "\u{b7}",
crate::tools::plan::StepStatus::InProgress => "\u{25b6}",
crate::tools::plan::StepStatus::Completed => "\u{2713}",
};
let step_text = format!(" {status_mark} {}. {}", i + 1, &item.step);
for line in wrap_text(&step_text, content_width) {
lines.push(Line::from(Span::styled(
line,
Style::default().fg(palette::TEXT_PRIMARY),
)));
}
}
lines.push(Line::from(""));
}
push_plan_snapshot_lines(&mut lines, plan, content_width);
}
for (idx, option) in PLAN_OPTIONS.iter().enumerate() {
@@ -418,10 +389,15 @@ impl ModalView for PlanPromptView {
// Since plan steps are now pre-wrapped via wrap_text(), each Line is
// already width-bounded — use the raw line count directly.
let total_lines = lines.len();
// Borders and padding consume rows inside the modal. Slice the visible
// lines ourselves instead of relying on Paragraph's internal clamp so
// bottom-jump scrolling reliably reaches the action rows.
let visible_lines = usize::from(popup_area.height).saturating_sub(4).max(1);
let max_scroll = total_lines.saturating_sub(visible_lines);
self.last_max_scroll.set(max_scroll);
let scroll = self.scroll.min(max_scroll);
let rendered_lines: Vec<Line<'static>> =
lines.into_iter().skip(scroll).take(visible_lines).collect();
// Build footer: scroll indicator (left) + data-driven option shortcuts +
// description of the currently selected option (right).
@@ -468,16 +444,213 @@ impl ModalView for PlanPromptView {
// which breaks only on display-width overflow, not on script boundaries
// (Latin ↔ CJK). This avoids forced line-breaks between English and
// Chinese characters when there is still room on the current line.
let paragraph = Paragraph::new(lines)
let paragraph = Paragraph::new(rendered_lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: false })
.block(modal_block().title_bottom(Line::from(footer_spans)))
.scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0));
.block(modal_block().title_bottom(Line::from(footer_spans)));
paragraph.render(popup_area, buf);
}
}
fn push_plan_snapshot_lines(
lines: &mut Vec<Line<'static>>,
plan: &PlanSnapshot,
content_width: usize,
) {
let show_empty = plan_uses_rich_artifact_shape(plan);
push_plan_text(
lines,
"Title",
plan.title.as_deref(),
content_width,
show_empty,
);
push_plan_text(
lines,
"Objective",
plan.objective.as_deref(),
content_width,
show_empty,
);
push_plan_text(
lines,
"Context",
plan.context_summary.as_deref(),
content_width,
show_empty,
);
push_plan_text(
lines,
"Explanation",
plan.explanation.as_deref(),
content_width,
show_empty,
);
push_plan_list(
lines,
"Sources used",
&plan.sources_used,
content_width,
show_empty,
);
push_plan_list(
lines,
"Critical files",
&plan.critical_files,
content_width,
show_empty,
);
push_plan_list(
lines,
"Constraints",
&plan.constraints,
content_width,
show_empty,
);
push_plan_text(
lines,
"Recommended approach",
plan.recommended_approach.as_deref(),
content_width,
show_empty,
);
push_plan_text(
lines,
"Verification plan",
plan.verification_plan.as_deref(),
content_width,
show_empty,
);
push_plan_text(
lines,
"Risks and unknowns",
plan.risks_and_unknowns.as_deref(),
content_width,
show_empty,
);
push_plan_text(
lines,
"Handoff packet",
plan.handoff_packet.as_deref(),
content_width,
show_empty,
);
if !plan.items.is_empty() {
lines.push(Line::from(Span::styled(
"Plan steps:",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)));
for (i, item) in plan.items.iter().enumerate() {
let status_mark = match item.status {
StepStatus::Pending => "\u{b7}",
StepStatus::InProgress => "\u{25b6}",
StepStatus::Completed => "\u{2713}",
};
let step_text = format!(" {status_mark} {}. {}", i + 1, &item.step);
for line in wrap_text(&step_text, content_width) {
lines.push(Line::from(Span::styled(
line,
Style::default().fg(palette::TEXT_PRIMARY),
)));
}
}
lines.push(Line::from(""));
} else if show_empty {
lines.push(Line::from(Span::styled(
"Plan steps:",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)));
lines.push(Line::from(Span::styled(
" Not provided",
Style::default().fg(palette::TEXT_MUTED).italic(),
)));
lines.push(Line::from(""));
}
}
fn plan_uses_rich_artifact_shape(plan: &PlanSnapshot) -> bool {
plan.title.is_some()
|| plan.objective.is_some()
|| plan.context_summary.is_some()
|| !plan.sources_used.is_empty()
|| !plan.critical_files.is_empty()
|| !plan.constraints.is_empty()
|| plan.recommended_approach.is_some()
|| plan.verification_plan.is_some()
|| plan.risks_and_unknowns.is_some()
|| plan.handoff_packet.is_some()
}
fn push_plan_text(
lines: &mut Vec<Line<'static>>,
label: &'static str,
value: Option<&str>,
content_width: usize,
show_empty: bool,
) {
let value = value.map(str::trim).filter(|value| !value.is_empty());
if value.is_none() && !show_empty {
return;
};
lines.push(Line::from(Span::styled(
format!("{label}:"),
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)));
let (value, style) = value.map_or_else(
|| {
(
"Not provided",
Style::default().fg(palette::TEXT_MUTED).italic(),
)
},
|value| (value, Style::default().fg(palette::TEXT_MUTED)),
);
for line in wrap_text(value, content_width) {
lines.push(Line::from(Span::styled(format!(" {line}"), style)));
}
lines.push(Line::from(""));
}
fn push_plan_list(
lines: &mut Vec<Line<'static>>,
label: &'static str,
values: &[String],
content_width: usize,
show_empty: bool,
) {
let values: Vec<&str> = values
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.collect();
if values.is_empty() && !show_empty {
return;
}
lines.push(Line::from(Span::styled(
format!("{label}:"),
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)));
if values.is_empty() {
lines.push(Line::from(Span::styled(
" Not provided",
Style::default().fg(palette::TEXT_MUTED).italic(),
)));
lines.push(Line::from(""));
return;
}
for value in values {
for line in wrap_text(&format!("- {value}"), content_width) {
lines.push(Line::from(Span::styled(
format!(" {line}"),
Style::default().fg(palette::TEXT_MUTED),
)));
}
}
lines.push(Line::from(""));
}
/// Wrap text into lines no wider than `width` characters.
fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
@@ -595,6 +768,63 @@ mod tests {
assert!(rendered.contains("Start implementation in YOLO mode (auto-approve)"));
}
#[test]
fn plan_prompt_renders_rich_plan_artifact_sections() {
use crate::tools::plan::{PlanItemArg, PlanSnapshot, StepStatus};
let plan = PlanSnapshot {
title: Some("PlanArtifact rollout".to_string()),
objective: Some("Make Plan mode reviewable".to_string()),
context_summary: Some("Issue #2691 asks for grounded plan artifacts.".to_string()),
sources_used: vec!["gh issue view 2691".to_string()],
critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()],
constraints: vec!["Preserve legacy update_plan payloads".to_string()],
recommended_approach: Some(
"Keep checklist primary and enrich update_plan.".to_string(),
),
verification_plan: Some("Run focused plan prompt tests.".to_string()),
risks_and_unknowns: Some("Avoid dropping metadata-only plans.".to_string()),
handoff_packet: Some("Continue with transcript replay checks.".to_string()),
items: vec![PlanItemArg {
step: "Render rich sections".to_string(),
status: StepStatus::InProgress,
}],
..PlanSnapshot::default()
};
let view = PlanPromptView::new(Some(plan));
let rendered = render_view(&view, 160, 120);
assert!(rendered.contains("Objective:"));
assert!(rendered.contains("Make Plan mode reviewable"));
assert!(rendered.contains("Sources used:"));
assert!(rendered.contains("gh issue view 2691"));
assert!(rendered.contains("Critical files:"));
assert!(rendered.contains("Verification plan:"));
assert!(rendered.contains("Handoff packet:"));
assert!(rendered.contains("Render rich sections"));
}
#[test]
fn plan_prompt_renders_empty_artifact_sections_for_rich_plans() {
use crate::tools::plan::PlanSnapshot;
let plan = PlanSnapshot {
objective: Some("Review grounded plan".to_string()),
..PlanSnapshot::default()
};
let view = PlanPromptView::new(Some(plan));
let rendered = render_view(&view, 160, 120);
assert!(rendered.contains("Objective:"));
assert!(rendered.contains("Review grounded plan"));
assert!(rendered.contains("Sources used:"));
assert!(rendered.contains("Critical files:"));
assert!(rendered.contains("Verification plan:"));
assert!(rendered.contains("Risks and unknowns:"));
assert!(rendered.contains("Plan steps:"));
assert!(rendered.contains("Not provided"));
}
#[test]
fn plan_prompt_shows_scroll_indicator_when_content_overflows() {
use crate::tools::plan::{PlanItemArg, PlanSnapshot, StepStatus};
@@ -608,6 +838,7 @@ mod tests {
};
20
],
..PlanSnapshot::default()
};
let view = PlanPromptView::new(Some(plan));
// Render into a small area so content overflows.
@@ -672,10 +903,11 @@ mod tests {
};
30
],
..PlanSnapshot::default()
};
let mut view = PlanPromptView::new(Some(plan));
// Set scroll far beyond content.
view.scroll = 999;
view.scroll = usize::MAX;
let rendered = render_view(&view, 80, 20);
// The rendered view should still contain the last option.
+6 -2
View File
@@ -949,9 +949,13 @@ fn sidebar_tool_row_from_cell(cell: &HistoryCell) -> Option<SidebarToolRow> {
name: "update_plan".to_string(),
status: plan.status,
summary: plan
.explanation
.snapshot
.objective
.as_deref()
.or_else(|| plan.steps.first().map(|step| step.step.as_str()))
.or(plan.snapshot.title.as_deref())
.or(plan.snapshot.explanation.as_deref())
.or(plan.snapshot.recommended_approach.as_deref())
.or_else(|| plan.snapshot.items.first().map(|step| step.step.as_str()))
.unwrap_or("")
.to_string(),
duration_ms: None,
+66 -28
View File
@@ -5,14 +5,15 @@ use std::time::Instant;
use crate::hooks::HookEvent;
use crate::tools::ReviewOutput;
use crate::tools::plan::PlanSnapshot;
use crate::tools::spec::{ToolError, ToolResult};
use crate::tui::active_cell::ActiveCell;
use crate::tui::app::{App, ToolDetailRecord, ToolEvidence};
use crate::tui::history::{
DiffPreviewCell, ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell,
McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus,
ViewImageCell, WebSearchCell, output_looks_like_diff, summarize_mcp_output,
summarize_tool_args, summarize_tool_output,
McpToolCell, PatchSummaryCell, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus, ViewImageCell,
WebSearchCell, output_looks_like_diff, summarize_mcp_output, summarize_tool_args,
summarize_tool_output,
};
#[allow(clippy::too_many_lines)]
@@ -142,15 +143,14 @@ pub(super) fn handle_tool_call_started(
}
if name == "update_plan" {
let (explanation, steps) = parse_plan_input(input);
let snapshot = parse_plan_input(input);
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
explanation,
steps,
snapshot,
status: ToolStatus::Running,
})),
);
@@ -936,28 +936,8 @@ fn review_target_label(input: &serde_json::Value) -> String {
target.to_string()
}
fn parse_plan_input(input: &serde_json::Value) -> (Option<String>, Vec<PlanStep>) {
let explanation = input
.get("explanation")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string);
let mut steps = Vec::new();
if let Some(items) = input.get("plan").and_then(|v| v.as_array()) {
for item in items {
let step = item.get("step").and_then(|v| v.as_str()).unwrap_or("");
let status = item
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("pending");
if !step.is_empty() {
steps.push(PlanStep {
step: step.to_string(),
status: status.to_string(),
});
}
}
}
(explanation, steps)
fn parse_plan_input(input: &serde_json::Value) -> PlanSnapshot {
PlanSnapshot::from_tool_input(input)
}
fn parse_patch_summary(input: &serde_json::Value) -> (String, String) {
@@ -1186,3 +1166,61 @@ fn exec_is_background(input: &serde_json::Value) -> bool {
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::plan::StepStatus;
use serde_json::json;
#[test]
fn parse_plan_input_accepts_legacy_payload() {
let snapshot = parse_plan_input(&json!({
"explanation": "Legacy explanation",
"plan": [
{ "step": "inspect", "status": "completed" },
{ "step": "patch", "status": "in_progress" }
]
}));
assert_eq!(snapshot.explanation.as_deref(), Some("Legacy explanation"));
assert_eq!(snapshot.items.len(), 2);
assert_eq!(snapshot.items[0].status, StepStatus::Completed);
assert_eq!(snapshot.items[1].status, StepStatus::InProgress);
}
#[test]
fn parse_plan_input_extracts_rich_artifact_fields() {
let snapshot = parse_plan_input(&json!({
"title": " PlanArtifact ",
"objective": "Make Plan mode reviewable",
"context_summary": "Grounded in issue #2691",
"sources_used": [" gh issue view 2691 ", ""],
"critical_files": ["crates/tui/src/tools/plan.rs"],
"constraints": ["No secrets"],
"recommended_approach": "Enrich update_plan",
"verification_plan": "Run focused tests",
"risks_and_unknowns": "Replay may drift",
"handoff_packet": "Continue with session replay",
"plan": [
{ "step": " ", "status": "completed" },
{ "step": "render all fields", "status": "weird" }
]
}));
assert_eq!(snapshot.title.as_deref(), Some("PlanArtifact"));
assert_eq!(snapshot.sources_used, vec!["gh issue view 2691"]);
assert_eq!(
snapshot.critical_files,
vec!["crates/tui/src/tools/plan.rs"]
);
assert_eq!(snapshot.constraints, vec!["No secrets"]);
assert_eq!(
snapshot.verification_plan.as_deref(),
Some("Run focused tests")
);
assert_eq!(snapshot.items.len(), 1);
assert_eq!(snapshot.items[0].step, "render all fields");
assert_eq!(snapshot.items[0].status, StepStatus::Pending);
}
}
+5
View File
@@ -232,6 +232,11 @@ Or switch directly:
Plan mode is the safest place to start in an unfamiliar repository. It is for
inspection and decision-making, not file edits.
For non-trivial work, Plan mode's confirmation prompt can show a grounded
PlanArtifact: objective, context, sources used, critical files, constraints,
approach, verification plan, risks, and handoff notes. Empty sections are
visible when the agent uses the rich artifact shape, so you can ask for a
revision instead of accepting an under-specified plan.
Agent mode is the default for most contribution work. It lets CodeWhale read,
run checks, and edit files while keeping risky actions behind approval gates.
+16
View File
@@ -118,6 +118,16 @@ by exact name, but they are not part of the model-visible catalog; compatibility
results include `_deprecation.use_instead = checklist_*` and
`_deprecation.removed_in = 0.9.0`.
`update_plan` accepts both the legacy shape (`explanation` plus `plan` steps)
and a richer PlanArtifact shape for Plan mode review. The richer fields are
optional and should be filled only when grounded in evidence: `title`,
`objective`, `context_summary`, `sources_used`, `critical_files`,
`constraints`, `recommended_approach`, `verification_plan`,
`risks_and_unknowns`, and `handoff_packet`. The transcript card, Plan-mode
confirmation prompt, `/relay`, and fork-state handoff all render the same
artifact so a plan can be reviewed, accepted, revised, replayed, or delegated
without losing its source context.
### Verification gates and artifacts
| Tool | Niche |
@@ -233,6 +243,12 @@ Aliases: `/batonpass`, `/接力`.
Use it before a long break, compaction, or moving work to a fresh session. The
relay should preserve the goal, current Work checklist item, changed files,
decisions, verification state, and one concrete next action.
Treat it as the deliberate counterpart to automatic compaction: both exist to
preserve continuity for the next session or sub-agent, but `/relay` lets the
current agent inspect live evidence and choose the durable handoff facts
explicitly. When `update_plan` has a rich PlanArtifact, `/relay` includes that
strategy metadata so manual relay, fork-state, and compacted continuity do not
drift into separate stories.
### Parallel fan-out: cost-class caps
+2
View File
@@ -45,6 +45,7 @@ harvest/stewardship commits:
| #2730 canonical codewhale settings path | Already harvested as `9e15805f6`; follow-up reviewer assertion added on this branch. | Fixes #2664 by reading legacy DeepSeek settings fallbacks, migrating them into `~/.codewhale/settings.toml`, and ensuring `/config` displays the canonical CodeWhale path. `cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture` passed. |
| Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. |
| #2640 workspace field on UpdateThreadRequest | Harvested with the stale-engine fix restored. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. |
| #2733 PlanArtifact for Plan mode | Locally harvested as a broader continuity-artifact slice. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. |
| #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. |
| #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. |
| #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. |
@@ -113,6 +114,7 @@ harvest/stewardship commits:
| #2708 Windows width fix | Mergeable | Cherry-picked and patched locally. |
| #2730 canonical codewhale settings path | Mergeable | Already harvested as `9e15805f6`; follow-up reviewer assertion added locally. Comment/close original after integration branch is public, crediting @xyuai and issue #2664. |
| #2732 pausable command lifecycle | Draft/mergeable | Defer; review flagged behavior changes. |
| #2733 PlanArtifact UI | Mergeable | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Comment/close original after integration branch is public, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. |
## Issue Reduction Strategy