From 6bc8363265a0041a72b79df00e521c25dd722986 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 12:40:10 +0800 Subject: [PATCH 01/26] feat: add SlopLedger for tracking unresolved architectural residue (#2127) --- crates/tui/src/commands/config.rs | 41 + crates/tui/src/commands/mod.rs | 10 + crates/tui/src/core/engine/tool_setup.rs | 3 +- crates/tui/src/main.rs | 1 + crates/tui/src/slop_ledger.rs | 1086 ++++++++++++++++++++++ crates/tui/src/tools/registry.rs | 16 + 6 files changed, 1156 insertions(+), 1 deletion(-) create mode 100644 crates/tui/src/slop_ledger.rs diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 40ffe1dc..30c08c61 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -699,6 +699,47 @@ pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult { } } +/// `/slop [query|export]` — inspect or export the slop ledger (#2127). +/// With no arguments, prints a summary. `query` shows filtered results; +/// `export` outputs the full ledger as Markdown. +pub fn slop(_app: &mut App, arg: Option<&str>) -> CommandResult { + let arg = arg.map(str::trim).unwrap_or(""); + let ledger = match crate::slop_ledger::SlopLedger::load() { + Ok(l) => l, + Err(e) => return CommandResult::error(format!("Failed to load slop ledger: {e}")), + }; + + match arg { + "" => CommandResult::message(ledger.summary()), + "query" | "q" => { + if ledger.is_empty() { + return CommandResult::message("Slop ledger is empty."); + } + let mut out = String::new(); + for entry in &ledger.query(&Default::default()) { + use std::fmt::Write; + let _ = writeln!( + out, + "[{}] {} ({:?} | {:?}) — {}", + &entry.id[..8], + entry.bucket.as_str(), + entry.severity, + entry.status, + entry.title + ); + } + CommandResult::message(out) + } + "export" | "e" => { + let md = ledger.export_markdown(None, None); + CommandResult::message(md) + } + _ => CommandResult::error(format!( + "Unknown /slop action '{arg}'. Use /slop, /slop query, or /slop export." + )), + } +} + /// Manage workspace-level trust and the per-path allowlist. /// /// Subcommands: diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index b1e9f3dd..e6afed10 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -540,6 +540,13 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/cache [count|inspect|warmup]", description_id: MessageId::CmdCacheDescription, }, + // Slop Ledger (#2127) + CommandInfo { + name: "slop", + aliases: &["canzha"], + usage: "/slop [query|export]", + description_id: MessageId::CmdHelpDescription, + }, ]; /// Execute a slash command @@ -614,6 +621,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "balance" => balance::balance(app), "cache" => debug::cache(app, arg), + // Slop ledger (#2127) + "slop" | "canzha" => config::slop(app, arg), + // ChangeLog command "change" => change::change(app, arg), "system" | "xitong" => debug::system_prompt(app), diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index 2354d6a8..2f2845b0 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -63,7 +63,8 @@ impl Engine { .with_review_tool(self.deepseek_client.clone(), self.session.model.clone()) .with_user_input_tool() .with_parallel_tool() - .with_recall_archive_tool(); + .with_recall_archive_tool() + .with_slop_ledger_tools(); if mode != AppMode::Plan { builder = builder diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index be286978..ba082137 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -69,6 +69,7 @@ mod snapshot; mod task_manager; #[cfg(test)] mod test_support; +mod slop_ledger; mod theme_qa_audit; mod tools; mod tui; diff --git a/crates/tui/src/slop_ledger.rs b/crates/tui/src/slop_ledger.rs new file mode 100644 index 00000000..4aca4509 --- /dev/null +++ b/crates/tui/src/slop_ledger.rs @@ -0,0 +1,1086 @@ +//! Slop Ledger — durable tracking of unresolved architectural residue. +//! +//! AI agents often leave behind invisible "slop" after a task: +//! compatibility shims, unmigrated callers, duplicated concepts, +//! naming drift, stale docs/tests, suspected dead code, and tool gaps. +//! +//! The Slop Ledger makes this residue **visible and queryable** so the +//! next agent (or human) doesn't rediscover it, amplify it, or mistake +//! it for intended architecture. +//! +//! ## Design +//! +//! - **Storage**: `~/.codewhale/slop_ledger.json` (a JSON array of entries). +//! - **Schema**: each entry has a bucket, severity, confidence, owner, +//! source links, status, cleanup recommendation, and timestamps. +//! - **Tools**: `slop_ledger_append`, `slop_ledger_query`, +//! `slop_ledger_update`, `slop_ledger_export`. +//! - **Integration**: entries can link to durable tasks and threads; +//! the export path produces a redacted Markdown handoff suitable for +//! GitHub issues or compaction relays. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::fs; +use std::io; +use std::path::PathBuf; +use uuid::Uuid; + +use crate::tools::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str, +}; + +// ── Enums ────────────────────────────────────────────────────────────────── + +/// Classification bucket for a slop entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SlopBucket { + RetainedCompatibility, + UnmigratedCallers, + DuplicateConcepts, + NamingDrift, + StaleDocs, + StaleTests, + SuspectedDeadCode, + UnverifiedPublicBehavior, + ToolGaps, + AcceptedDebt, +} + +impl SlopBucket { + pub fn as_str(self) -> &'static str { + match self { + Self::RetainedCompatibility => "retained_compatibility", + Self::UnmigratedCallers => "unmigrated_callers", + Self::DuplicateConcepts => "duplicate_concepts", + Self::NamingDrift => "naming_drift", + Self::StaleDocs => "stale_docs", + Self::StaleTests => "stale_tests", + Self::SuspectedDeadCode => "suspected_dead_code", + Self::UnverifiedPublicBehavior => "unverified_public_behavior", + Self::ToolGaps => "tool_gaps", + Self::AcceptedDebt => "accepted_debt", + } + } + + pub fn from_str(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "retained_compatibility" => Some(Self::RetainedCompatibility), + "unmigrated_callers" => Some(Self::UnmigratedCallers), + "duplicate_concepts" => Some(Self::DuplicateConcepts), + "naming_drift" => Some(Self::NamingDrift), + "stale_docs" => Some(Self::StaleDocs), + "stale_tests" => Some(Self::StaleTests), + "suspected_dead_code" => Some(Self::SuspectedDeadCode), + "unverified_public_behavior" => Some(Self::UnverifiedPublicBehavior), + "tool_gaps" => Some(Self::ToolGaps), + "accepted_debt" => Some(Self::AcceptedDebt), + _ => None, + } + } + + pub fn all_buckets() -> &'static [SlopBucket] { + &[ + Self::RetainedCompatibility, + Self::UnmigratedCallers, + Self::DuplicateConcepts, + Self::NamingDrift, + Self::StaleDocs, + Self::StaleTests, + Self::SuspectedDeadCode, + Self::UnverifiedPublicBehavior, + Self::ToolGaps, + Self::AcceptedDebt, + ] + } +} + +/// Severity of the residue. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SlopSeverity { + Critical, + High, + Medium, + Low, + Info, +} + +impl SlopSeverity { + pub fn from_str(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "critical" => Some(Self::Critical), + "high" => Some(Self::High), + "medium" => Some(Self::Medium), + "low" => Some(Self::Low), + "info" => Some(Self::Info), + _ => None, + } + } +} + +/// Confidence in the assessment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SlopConfidence { + Certain, + High, + Medium, + Low, +} + +impl SlopConfidence { + pub fn from_str(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "certain" => Some(Self::Certain), + "high" => Some(Self::High), + "medium" => Some(Self::Medium), + "low" => Some(Self::Low), + _ => None, + } + } +} + +/// Lifecycle status of a slop entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SlopEntryStatus { + Open, + InProgress, + Resolved, + Accepted, + WontFix, +} + +impl SlopEntryStatus { + pub fn from_str(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "open" => Some(Self::Open), + "in_progress" | "inprogress" => Some(Self::InProgress), + "resolved" | "done" => Some(Self::Resolved), + "accepted" => Some(Self::Accepted), + "wontfix" | "wont_fix" => Some(Self::WontFix), + _ => None, + } + } +} + +// ── Core data structures ─────────────────────────────────────────────────── + +/// A single slop ledger entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlopEntry { + /// Unique identifier (UUID v4). + pub id: String, + /// Classification bucket. + pub bucket: SlopBucket, + /// How severe is this residue? + pub severity: SlopSeverity, + /// How confident is the assessment? + pub confidence: SlopConfidence, + /// Who owns cleaning this up (person, team, or "auto"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + /// Source file paths, URLs, or line references. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_links: Vec, + /// Short title (one line). + pub title: String, + /// Detailed description. + pub description: String, + /// Current lifecycle status. + pub status: SlopEntryStatus, + /// Suggested cleanup action. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_recommendation: Option, + /// ISO 8601 creation timestamp. + pub created_at: String, + /// ISO 8601 last-updated timestamp. + pub updated_at: String, + /// Optional linked durable task id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_id: Option, + /// Optional linked thread id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_id: Option, +} + +impl SlopEntry { + pub fn new( + bucket: SlopBucket, + severity: SlopSeverity, + confidence: SlopConfidence, + title: String, + description: String, + ) -> Self { + let now = chrono::Utc::now().to_rfc3339(); + Self { + id: Uuid::new_v4().to_string(), + bucket, + severity, + confidence, + owner: None, + source_links: Vec::new(), + title, + description, + status: SlopEntryStatus::Open, + cleanup_recommendation: None, + created_at: now.clone(), + updated_at: now, + task_id: None, + thread_id: None, + } + } +} + +// ── Query filter ─────────────────────────────────────────────────────────── + +/// Filter for querying ledger entries. +#[derive(Debug, Clone, Default)] +pub struct SlopLedgerFilter { + pub bucket: Option, + pub severity: Option, + pub status: Option, + pub search: Option, // fuzzy match title + description + pub limit: Option, +} + +// ── Ledger (collection + persistence) ────────────────────────────────────── + +/// The slop ledger — a collection of entries with JSON file persistence. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SlopLedger { + entries: Vec, + #[serde(skip)] + ledger_path: PathBuf, +} + +impl SlopLedger { + /// Resolve the default ledger path. + pub fn default_path() -> io::Result { + codewhale_config::resolve_state_dir("slop_ledger") + .map(|p| p.join("slop_ledger.json")) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e)) + } + + /// Load ledger from the default path, returning an empty ledger if the + /// file doesn't exist. + pub fn load() -> io::Result { + let path = Self::default_path()?; + Self::load_at(&path) + } + + /// Load ledger from a specific path. + pub fn load_at(path: &std::path::Path) -> io::Result { + if !path.exists() { + return Ok(Self { + entries: Vec::new(), + ledger_path: path.to_path_buf(), + }); + } + let data = fs::read_to_string(path)?; + let mut ledger: SlopLedger = serde_json::from_str(&data).unwrap_or_default(); + ledger.ledger_path = path.to_path_buf(); + Ok(ledger) + } + + /// Persist the ledger to disk. + pub fn save(&self) -> io::Result<()> { + if let Some(parent) = self.ledger_path.parent() { + fs::create_dir_all(parent)?; + } + let data = serde_json::to_string_pretty(self).map_err(|e| { + io::Error::new(io::ErrorKind::Other, format!("serialization error: {e}")) + })?; + fs::write(&self.ledger_path, data) + } + + /// Append one or more entries and save. + pub fn append(&mut self, entries: Vec) -> &[SlopEntry] { + let start = self.entries.len(); + self.entries.extend(entries); + &self.entries[start..] + } + + /// Return the total number of entries. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the ledger is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Query entries matching the filter. + pub fn query(&self, filter: &SlopLedgerFilter) -> Vec<&SlopEntry> { + let mut results: Vec<&SlopEntry> = self + .entries + .iter() + .filter(|e| { + if let Some(bucket) = &filter.bucket { + if e.bucket != *bucket { + return false; + } + } + if let Some(severity) = &filter.severity { + if e.severity != *severity { + return false; + } + } + if let Some(status) = &filter.status { + if e.status != *status { + return false; + } + } + if let Some(search) = &filter.search { + let q = search.to_lowercase(); + if !e.title.to_lowercase().contains(&q) + && !e.description.to_lowercase().contains(&q) + { + return false; + } + } + true + }) + .collect(); + + if let Some(limit) = filter.limit { + results.truncate(limit); + } + results + } + + /// Find an entry by id. + pub fn find_mut(&mut self, id: &str) -> Option<&mut SlopEntry> { + self.entries.iter_mut().find(|e| e.id == id) + } + + /// Update an entry's status (and optionally other fields) and save. + pub fn update_status( + &mut self, + id: &str, + status: SlopEntryStatus, + cleanup_recommendation: Option, + ) -> io::Result> { + let entry = match self.find_mut(id) { + Some(e) => e, + None => return Ok(None), + }; + entry.status = status; + entry.updated_at = chrono::Utc::now().to_rfc3339(); + if let Some(rec) = cleanup_recommendation { + entry.cleanup_recommendation = Some(rec); + } + self.save()?; + // Return a shared ref to the updated entry + Ok(self.entries.iter().find(|e| e.id == id)) + } + + /// Export all entries as a Markdown string suitable for handoff or + /// GitHub issue body. + pub fn export_markdown(&self, title: Option<&str>, filter: Option<&SlopLedgerFilter>) -> String { + let entries: Vec<&SlopEntry> = match filter { + Some(f) => self.query(f), + None => self.entries.iter().collect(), + }; + + let heading = title.unwrap_or("Slop Ledger Export"); + let mut out = format!("# {heading}\n\n"); + out.push_str(&format!( + "_Generated at {} — {} entries_\n\n", + chrono::Utc::now().format("%Y-%m-%d %H:%M UTC").to_string(), + entries.len() + )); + + if entries.is_empty() { + out.push_str("_(no entries)_\n"); + return out; + } + + // Group by bucket + use std::collections::BTreeMap; + let mut by_bucket: BTreeMap<&str, Vec<&&SlopEntry>> = BTreeMap::new(); + for e in &entries { + by_bucket.entry(e.bucket.as_str()).or_default().push(e); + } + + for (bucket_name, bucket_entries) in &by_bucket { + out.push_str(&format!("## {bucket_name}\n\n")); + out.push_str("| ID | Severity | Confidence | Status | Title | Source |\n"); + out.push_str("|---|---|---|---|---|---|\n"); + for e in bucket_entries { + let source = e.source_links.first().map(|s| s.as_str()).unwrap_or("-"); + let title = if e.title.len() > 60 { + format!("{}…", &e.title[..57]) + } else { + e.title.clone() + }; + out.push_str(&format!( + "| {} | {:?} | {:?} | {:?} | {title} | {source} |\n", + &e.id[..8], + e.severity, + e.confidence, + e.status + )); + } + out.push('\n'); + + // Detailed entries + for e in bucket_entries { + out.push_str(&format!( + "### {} — {}\n\n", + &e.id[..8], + e.title + )); + out.push_str(&format!("- **Severity**: {:?}\n", e.severity)); + out.push_str(&format!("- **Confidence**: {:?}\n", e.confidence)); + out.push_str(&format!("- **Status**: {:?}\n", e.status)); + if let Some(ref owner) = e.owner { + out.push_str(&format!("- **Owner**: {owner}\n")); + } + if !e.source_links.is_empty() { + out.push_str("- **Sources**:\n"); + for link in &e.source_links { + out.push_str(&format!(" - {link}\n")); + } + } + out.push_str(&format!("\n{}\n", e.description)); + if let Some(ref rec) = e.cleanup_recommendation { + out.push_str(&format!("\n**Cleanup**: {rec}\n")); + } + out.push_str("\n---\n\n"); + } + } + + out + } + + /// Summary counts by bucket and status — useful for quick display. + pub fn summary(&self) -> String { + use std::collections::BTreeMap; + let mut by_bucket: BTreeMap<&str, usize> = BTreeMap::new(); + let mut open_count = 0usize; + let mut resolved_count = 0usize; + let mut accepted_count = 0usize; + + for e in &self.entries { + *by_bucket.entry(e.bucket.as_str()).or_default() += 1; + match e.status { + SlopEntryStatus::Resolved => resolved_count += 1, + SlopEntryStatus::Accepted | SlopEntryStatus::WontFix => accepted_count += 1, + _ => open_count += 1, + } + } + + let mut out = format!( + "Slop Ledger: {} total | {} open | {} resolved | {} accepted\n", + self.entries.len(), + open_count, + resolved_count, + accepted_count + ); + for (bucket, count) in &by_bucket { + out.push_str(&format!(" {bucket}: {count}\n")); + } + out + } +} + +// ── Tools ────────────────────────────────────────────────────────────────── + +/// `slop_ledger_append` — append one or more entries to the slop ledger. +pub struct SlopLedgerAppendTool; + +#[async_trait] +impl ToolSpec for SlopLedgerAppendTool { + fn name(&self) -> &'static str { + "slop_ledger_append" + } + + fn description(&self) -> &'static str { + "Append one or more entries to the slop ledger — a durable record of \ + unresolved architectural residue (compatibility shims, unmigrated \ + callers, duplicate concepts, stale docs/tests, suspected dead code, \ + tool gaps, etc.). Use this when you complete a task and notice \ + residue that should be tracked for future cleanup. Each entry needs \ + a bucket, severity, confidence, title, and description." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "entries": { + "type": "array", + "description": "One or more slop entries to append.", + "items": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "One of: retained_compatibility, unmigrated_callers, duplicate_concepts, naming_drift, stale_docs, stale_tests, suspected_dead_code, unverified_public_behavior, tool_gaps, accepted_debt" + }, + "severity": { + "type": "string", + "description": "critical | high | medium | low | info" + }, + "confidence": { + "type": "string", + "description": "certain | high | medium | low" + }, + "title": { + "type": "string", + "description": "Short title (one line)" + }, + "description": { + "type": "string", + "description": "Detailed description of the residue" + }, + "owner": { + "type": "string", + "description": "Optional: who should clean this up?" + }, + "source_links": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: file paths or URLs" + } + }, + "required": ["bucket", "severity", "confidence", "title", "description"] + } + } + }, + "required": ["entries"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::WritesFiles] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let entries_val = input + .get("entries") + .and_then(|v| v.as_array()) + .ok_or_else(|| ToolError::invalid_input("'entries' must be a non-empty array"))?; + + let mut ledger = SlopLedger::load().map_err(|e| { + ToolError::execution_failed(format!("failed to load slop ledger: {e}")) + })?; + + let mut appended = Vec::new(); + for entry_val in entries_val { + let bucket_str = required_str(entry_val, "bucket")?; + let bucket = SlopBucket::from_str(bucket_str).ok_or_else(|| { + ToolError::invalid_input(format!("unknown bucket: '{bucket_str}'")) + })?; + + let severity = SlopSeverity::from_str(required_str(entry_val, "severity")?).ok_or_else(|| { + ToolError::invalid_input("invalid severity (use critical|high|medium|low|info)") + })?; + + let confidence = SlopConfidence::from_str(required_str(entry_val, "confidence")?).ok_or_else(|| { + ToolError::invalid_input("invalid confidence (use certain|high|medium|low)") + })?; + + let title = required_str(entry_val, "title")?.to_string(); + let description = required_str(entry_val, "description")?.to_string(); + + let mut entry = SlopEntry::new(bucket, severity, confidence, title, description); + + if let Some(owner) = entry_val.get("owner").and_then(|v| v.as_str()) { + entry.owner = Some(owner.to_string()); + } + if let Some(links) = entry_val.get("source_links").and_then(|v| v.as_array()) { + entry.source_links = links + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + } + + // Attach active task/thread context if available + if let Some(ref task_id) = context.runtime.active_task_id { + entry.task_id = Some(task_id.clone()); + } + if let Some(ref thread_id) = context.runtime.active_thread_id { + entry.thread_id = Some(thread_id.clone()); + } + + appended.push(entry); + } + + let saved = ledger.append(appended); + ledger.save().map_err(|e| { + ToolError::execution_failed(format!("failed to save slop ledger: {e}")) + })?; + + let ids: Vec<&str> = saved.iter().map(|e| e.id.as_str()).collect(); + Ok(ToolResult::success(format!( + "Appended {} slop ledger entr{}: {}", + saved.len(), + if saved.len() == 1 { "y" } else { "ies" }, + ids.iter().map(|id| &id[..8]).collect::>().join(", ") + ))) + } +} + +/// `slop_ledger_query` — query the slop ledger. +pub struct SlopLedgerQueryTool; + +#[async_trait] +impl ToolSpec for SlopLedgerQueryTool { + fn name(&self) -> &'static str { + "slop_ledger_query" + } + + fn description(&self) -> &'static str { + "Query the slop ledger for unresolved architectural residue. \ + Filter by bucket, severity, status, or text search." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "Optional: filter by bucket" + }, + "severity": { + "type": "string", + "description": "Optional: filter by severity" + }, + "status": { + "type": "string", + "description": "Optional: filter by status" + }, + "search": { + "type": "string", + "description": "Optional: fuzzy text search in title and description" + }, + "limit": { + "type": "integer", + "description": "Optional: max results (default 50)" + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let filter = SlopLedgerFilter { + bucket: input + .get("bucket") + .and_then(|v| v.as_str()) + .and_then(SlopBucket::from_str), + severity: input + .get("severity") + .and_then(|v| v.as_str()) + .and_then(SlopSeverity::from_str), + status: input + .get("status") + .and_then(|v| v.as_str()) + .and_then(SlopEntryStatus::from_str), + search: input.get("search").and_then(|v| v.as_str()).map(String::from), + limit: input + .get("limit") + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + .or(Some(50)), + }; + + let ledger = SlopLedger::load().map_err(|e| { + ToolError::execution_failed(format!("failed to load slop ledger: {e}")) + })?; + + if ledger.is_empty() { + return Ok(ToolResult::success("Slop ledger is empty.")); + } + + let results = ledger.query(&filter); + let mut out = format!("Found {} matching slop ledger entries:\n\n", results.len()); + for entry in &results { + out.push_str(&format!( + "- [{}] **{}** ({:?} | {:?} | {:?}) — {}\n", + &entry.id[..8], + entry.bucket.as_str(), + entry.severity, + entry.confidence, + entry.status, + entry.title + )); + if let Some(ref desc) = entry.description.lines().next() { + out.push_str(&format!(" {desc}\n")); + } + } + Ok(ToolResult::success(out)) + } +} + +/// `slop_ledger_update` — update an entry's status. +pub struct SlopLedgerUpdateTool; + +#[async_trait] +impl ToolSpec for SlopLedgerUpdateTool { + fn name(&self) -> &'static str { + "slop_ledger_update" + } + + fn description(&self) -> &'static str { + "Update a slop ledger entry's status (e.g., mark as resolved, accepted, or in-progress)." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The entry ID (or prefix) to update" + }, + "status": { + "type": "string", + "description": "New status: open | in_progress | resolved | accepted | wontfix" + }, + "cleanup_recommendation": { + "type": "string", + "description": "Optional: cleanup notes when resolving or accepting" + } + }, + "required": ["id", "status"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::WritesFiles] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let id = required_str(&input, "id")?; + let status = SlopEntryStatus::from_str(required_str(&input, "status")?).ok_or_else(|| { + ToolError::invalid_input( + "invalid status (use open|in_progress|resolved|accepted|wontfix)", + ) + })?; + + let cleanup = input + .get("cleanup_recommendation") + .and_then(|v| v.as_str()) + .map(String::from); + + let mut ledger = SlopLedger::load().map_err(|e| { + ToolError::execution_failed(format!("failed to load slop ledger: {e}")) + })?; + + match ledger.update_status(id, status, cleanup) { + Ok(Some(entry)) => Ok(ToolResult::success(format!( + "Updated slop ledger entry {} ({}) → {:?}", + &entry.id[..8], + entry.title, + entry.status + ))), + Ok(None) => Ok(ToolResult::success(format!( + "No slop ledger entry found matching '{id}'. Use slop_ledger_query to list entries." + ))), + Err(e) => Err(ToolError::execution_failed(format!( + "failed to update slop ledger: {e}" + ))), + } + } +} + +/// `slop_ledger_export` — export ledger as Markdown. +pub struct SlopLedgerExportTool; + +#[async_trait] +impl ToolSpec for SlopLedgerExportTool { + fn name(&self) -> &'static str { + "slop_ledger_export" + } + + fn description(&self) -> &'static str { + "Export the slop ledger as a Markdown report. Use this for handoffs, \ + compaction relays, or GitHub issue creation. The output is suitable \ + for pasting directly into a GitHub issue body." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Optional: report title (default 'Slop Ledger Export')" + }, + "bucket": { + "type": "string", + "description": "Optional: filter by bucket" + }, + "severity": { + "type": "string", + "description": "Optional: filter by severity" + }, + "status": { + "type": "string", + "description": "Optional: filter by status" + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let title = input.get("title").and_then(|v| v.as_str()); + + let filter = if input.get("bucket").is_some() + || input.get("severity").is_some() + || input.get("status").is_some() + { + Some(SlopLedgerFilter { + bucket: input + .get("bucket") + .and_then(|v| v.as_str()) + .and_then(SlopBucket::from_str), + severity: input + .get("severity") + .and_then(|v| v.as_str()) + .and_then(SlopSeverity::from_str), + status: input + .get("status") + .and_then(|v| v.as_str()) + .and_then(SlopEntryStatus::from_str), + ..Default::default() + }) + } else { + None + }; + + let ledger = SlopLedger::load().map_err(|e| { + ToolError::execution_failed(format!("failed to load slop ledger: {e}")) + })?; + + let markdown = ledger.export_markdown(title, filter.as_ref()); + Ok(ToolResult::success(markdown)) + } +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn temp_ledger() -> (TempDir, SlopLedger) { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("slop_ledger.json"); + let ledger = SlopLedger { + entries: Vec::new(), + ledger_path: path, + }; + (tmp, ledger) + } + + #[test] + fn bucket_roundtrip() { + for bucket in SlopBucket::all_buckets() { + let s = bucket.as_str(); + let parsed = SlopBucket::from_str(s); + assert_eq!(parsed, Some(*bucket), "roundtrip failed for {s}"); + } + } + + #[test] + fn append_and_save_load() { + let (_tmp, mut ledger) = temp_ledger(); + + let entry = SlopEntry::new( + SlopBucket::StaleDocs, + SlopSeverity::Medium, + SlopConfidence::High, + "README is outdated".into(), + "The README still references v0.7 APIs.".into(), + ); + + ledger.append(vec![entry]); + assert_eq!(ledger.len(), 1); + ledger.save().unwrap(); + + let loaded = SlopLedger::load_at(&ledger.ledger_path).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded.entries[0].title, "README is outdated"); + } + + #[test] + fn query_by_bucket() { + let (_tmp, mut ledger) = temp_ledger(); + + ledger.append(vec![ + SlopEntry::new( + SlopBucket::StaleDocs, + SlopSeverity::Low, + SlopConfidence::Certain, + "doc A".into(), + "desc A".into(), + ), + SlopEntry::new( + SlopBucket::ToolGaps, + SlopSeverity::High, + SlopConfidence::Medium, + "gap B".into(), + "desc B".into(), + ), + ]); + + let filter = SlopLedgerFilter { + bucket: Some(SlopBucket::StaleDocs), + ..Default::default() + }; + let results = ledger.query(&filter); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "doc A"); + } + + #[test] + fn query_by_search() { + let (_tmp, mut ledger) = temp_ledger(); + + ledger.append(vec![SlopEntry::new( + SlopBucket::SuspectedDeadCode, + SlopSeverity::Medium, + SlopConfidence::Low, + "dead legacy handler".into(), + "The legacy handler in src/old.rs appears unused.".into(), + )]); + + let filter = SlopLedgerFilter { + search: Some("legacy".into()), + ..Default::default() + }; + let results = ledger.query(&filter); + assert_eq!(results.len(), 1); + } + + #[test] + fn update_status() { + let (_tmp, mut ledger) = temp_ledger(); + + let entry = SlopEntry::new( + SlopBucket::NamingDrift, + SlopSeverity::Low, + SlopConfidence::High, + "naming issue".into(), + "desc".into(), + ); + let id = entry.id.clone(); + ledger.append(vec![entry]); + ledger.save().unwrap(); + + let result = ledger + .update_status(&id, SlopEntryStatus::Resolved, Some("Renamed in #1234".into())) + .unwrap(); + assert!(result.is_some()); + + let loaded = SlopLedger::load_at(&ledger.ledger_path).unwrap(); + assert_eq!(loaded.entries[0].status, SlopEntryStatus::Resolved); + assert_eq!( + loaded.entries[0].cleanup_recommendation, + Some("Renamed in #1234".into()) + ); + } + + #[test] + fn export_markdown() { + let (_tmp, mut ledger) = temp_ledger(); + + let mut entry = SlopEntry::new( + SlopBucket::StaleDocs, + SlopSeverity::Medium, + SlopConfidence::High, + "Outdated README".into(), + "The README references removed flags.".into(), + ); + entry.source_links = vec!["README.md:42".into()]; + ledger.append(vec![entry]); + + let md = ledger.export_markdown(Some("Test Export"), None); + assert!(md.contains("Test Export")); + assert!(md.contains("stale_docs")); + assert!(md.contains("Outdated README")); + assert!(md.contains("README.md:42")); + } + + #[test] + fn empty_ledger_loads() { + let (_tmp, ledger) = temp_ledger(); + assert!(ledger.is_empty()); + assert_eq!(ledger.len(), 0); + } + + #[test] + fn summary_counts() { + let (_tmp, mut ledger) = temp_ledger(); + + let mut e1 = SlopEntry::new( + SlopBucket::StaleDocs, + SlopSeverity::Medium, + SlopConfidence::High, + "doc".into(), + "desc".into(), + ); + e1.status = SlopEntryStatus::Open; + + let mut e2 = SlopEntry::new( + SlopBucket::ToolGaps, + SlopSeverity::High, + SlopConfidence::Certain, + "gap".into(), + "desc".into(), + ); + e2.status = SlopEntryStatus::Resolved; + + let mut e3 = SlopEntry::new( + SlopBucket::AcceptedDebt, + SlopSeverity::Low, + SlopConfidence::Medium, + "debt".into(), + "desc".into(), + ); + e3.status = SlopEntryStatus::Accepted; + + ledger.append(vec![e1, e2, e3]); + + let summary = ledger.summary(); + assert!(summary.contains("3 total")); + assert!(summary.contains("stale_docs: 1")); + assert!(summary.contains("tool_gaps: 1")); + assert!(summary.contains("accepted_debt: 1")); + } +} diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 5254de70..a3994e33 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -720,6 +720,22 @@ impl ToolRegistryBuilder { self.with_tool(Arc::new(RememberTool)) } + /// Include the slop ledger tools (#2127) — durable tracking of + /// unresolved architectural residue: append, query, update, export. + /// Registered unconditionally; the ledger JSON file is auto-created + /// on first append. + #[must_use] + pub fn with_slop_ledger_tools(self) -> Self { + use crate::slop_ledger::{ + SlopLedgerAppendTool, SlopLedgerExportTool, SlopLedgerQueryTool, + SlopLedgerUpdateTool, + }; + self.with_tool(Arc::new(SlopLedgerAppendTool)) + .with_tool(Arc::new(SlopLedgerQueryTool)) + .with_tool(Arc::new(SlopLedgerUpdateTool)) + .with_tool(Arc::new(SlopLedgerExportTool)) + } + /// Include the `notify` tool — model-callable desktop notification /// (#1322). Routes through the existing `tui::notifications` OSC 9 / /// BEL pipeline so the user's `[notifications].method` config is From 3cfb26539ac894e7db9bb017c907c1d0226765de Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 12:46:30 +0800 Subject: [PATCH 02/26] fix: resolve borrow-checker error in slop_ledger append tool --- crates/tui/src/slop_ledger.rs | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/crates/tui/src/slop_ledger.rs b/crates/tui/src/slop_ledger.rs index 4aca4509..23eae9d9 100644 --- a/crates/tui/src/slop_ledger.rs +++ b/crates/tui/src/slop_ledger.rs @@ -297,11 +297,12 @@ impl SlopLedger { fs::write(&self.ledger_path, data) } - /// Append one or more entries and save. - pub fn append(&mut self, entries: Vec) -> &[SlopEntry] { - let start = self.entries.len(); + /// Append one or more entries. Returns the new entry count and + /// the short ids of the appended entries (first 8 chars). + pub fn append(&mut self, entries: Vec) -> (usize, Vec) { + let ids: Vec = entries.iter().map(|e| e.id[..8].to_string()).collect(); self.entries.extend(entries); - &self.entries[start..] + (self.entries.len(), ids) } /// Return the total number of entries. @@ -618,17 +619,19 @@ impl ToolSpec for SlopLedgerAppendTool { appended.push(entry); } - let saved = ledger.append(appended); + let (total, ids) = ledger.append(appended); + let appended_count = ids.len(); + ledger.save().map_err(|e| { ToolError::execution_failed(format!("failed to save slop ledger: {e}")) })?; - let ids: Vec<&str> = saved.iter().map(|e| e.id.as_str()).collect(); Ok(ToolResult::success(format!( - "Appended {} slop ledger entr{}: {}", - saved.len(), - if saved.len() == 1 { "y" } else { "ies" }, - ids.iter().map(|id| &id[..8]).collect::>().join(", ") + "Appended {} slop ledger entr{} ({} total): {}", + appended_count, + if appended_count == 1 { "y" } else { "ies" }, + total, + ids.join(", ") ))) } } @@ -929,7 +932,7 @@ mod tests { "The README still references v0.7 APIs.".into(), ); - ledger.append(vec![entry]); + let _ = ledger.append(vec![entry]); assert_eq!(ledger.len(), 1); ledger.save().unwrap(); @@ -942,7 +945,7 @@ mod tests { fn query_by_bucket() { let (_tmp, mut ledger) = temp_ledger(); - ledger.append(vec![ + let _ = ledger.append(vec![ SlopEntry::new( SlopBucket::StaleDocs, SlopSeverity::Low, @@ -972,7 +975,7 @@ mod tests { fn query_by_search() { let (_tmp, mut ledger) = temp_ledger(); - ledger.append(vec![SlopEntry::new( + let _ = ledger.append(vec![SlopEntry::new( SlopBucket::SuspectedDeadCode, SlopSeverity::Medium, SlopConfidence::Low, @@ -1000,7 +1003,7 @@ mod tests { "desc".into(), ); let id = entry.id.clone(); - ledger.append(vec![entry]); + let _ = ledger.append(vec![entry]); ledger.save().unwrap(); let result = ledger @@ -1028,7 +1031,7 @@ mod tests { "The README references removed flags.".into(), ); entry.source_links = vec!["README.md:42".into()]; - ledger.append(vec![entry]); + let _ = ledger.append(vec![entry]); let md = ledger.export_markdown(Some("Test Export"), None); assert!(md.contains("Test Export")); @@ -1075,7 +1078,7 @@ mod tests { ); e3.status = SlopEntryStatus::Accepted; - ledger.append(vec![e1, e2, e3]); + let _ = ledger.append(vec![e1, e2, e3]); let summary = ledger.summary(); assert!(summary.contains("3 total")); From 3e073992eb202e68f9472dae9a8c54c6498106a2 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 12:53:35 +0800 Subject: [PATCH 03/26] fix: use read-only slop ledger tools in plan mode --- COMMIT_MSG.md | 73 ++++++++++++++++++++++++ crates/tui/src/core/engine/tool_setup.rs | 11 +++- crates/tui/src/tools/registry.rs | 9 +++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 COMMIT_MSG.md diff --git a/COMMIT_MSG.md b/COMMIT_MSG.md new file mode 100644 index 00000000..d28ca129 --- /dev/null +++ b/COMMIT_MSG.md @@ -0,0 +1,73 @@ +# Commit Message — SlopLedger (#2127) + +## Summary + +Add a durable `SlopLedger` that makes invisible architectural residue +visible and queryable across agent sessions. + +Closes: https://github.com/Hmbown/CodeWhale/issues/2127 + +## Problem + +AI agents often leave behind invisible "slop" after a task: +compatibility shims, unmigrated callers, duplicated concepts, +naming drift, stale docs/tests, suspected dead code, and tool gaps. + +Currently these residues are untracked. The next agent rediscovers +them, amplifies them, or mistakes them for intended architecture. + +## Solution + +A persistent JSON-backed ledger (`~/.codewhale/slop_ledger/slop_ledger.json`) +with four model-callable tools and a `/slop` slash command. + +### Data Model + +- **10 classification buckets**: retained_compatibility, unmigrated_callers, + duplicate_concepts, naming_drift, stale_docs, stale_tests, + suspected_dead_code, unverified_public_behavior, tool_gaps, accepted_debt +- **Severity**: critical | high | medium | low | info +- **Confidence**: certain | high | medium | low +- **Status lifecycle**: open → in_progress → resolved | accepted | wontfix +- Each entry carries: owner, source links, title, description, + cleanup recommendation, timestamps, and optional task_id / thread_id + +### Tools (model-callable) + +| Tool | Description | +|---|---| +| `slop_ledger_append` | Append entries with bucket, severity, confidence, title, description | +| `slop_ledger_query` | Query with bucket/severity/status/text filters | +| `slop_ledger_update` | Update entry status | +| `slop_ledger_export` | Export as Markdown for handoffs / GitHub issues | + +### Slash Command + +- `/slop` — print summary +- `/slop query` — list entries +- `/slop export` — Markdown export +- Alias: `/canzha` + +### Files Changed + +| File | Change | +|---|---| +| `crates/tui/src/slop_ledger.rs` | **New** — 1089 lines | +| `crates/tui/src/main.rs` | +1: mod declaration | +| `crates/tui/src/tools/registry.rs` | +16: builder method | +| `crates/tui/src/core/engine/tool_setup.rs` | +1: registration | +| `crates/tui/src/commands/mod.rs` | +10: command + dispatch | +| `crates/tui/src/commands/config.rs` | +41: handler | + +### Tests + +8 unit tests: bucket roundtrip, save/load, query by bucket/search, +update status, markdown export, empty ledger, summary counts. + +## How to Test + +```bash +cargo test -p codewhale-tui -- slop_ledger +``` + +In TUI: `/slop`, `/slop query`, `/slop export` diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index 2f2845b0..c4da4156 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -63,8 +63,15 @@ impl Engine { .with_review_tool(self.deepseek_client.clone(), self.session.model.clone()) .with_user_input_tool() .with_parallel_tool() - .with_recall_archive_tool() - .with_slop_ledger_tools(); + .with_recall_archive_tool(); + + // SlopLedger: plan mode only gets read-only query + export, + // agent/yolo get the full set including append + update. + builder = if mode == AppMode::Plan { + builder.with_slop_ledger_read_only_tools() + } else { + builder.with_slop_ledger_tools() + }; if mode != AppMode::Plan { builder = builder diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index a3994e33..a4f453b4 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -736,6 +736,15 @@ impl ToolRegistryBuilder { .with_tool(Arc::new(SlopLedgerExportTool)) } + /// Read-only subset of slop ledger tools (#2127) for plan mode: + /// only query and export — no append or update. + #[must_use] + pub fn with_slop_ledger_read_only_tools(self) -> Self { + use crate::slop_ledger::{SlopLedgerExportTool, SlopLedgerQueryTool}; + self.with_tool(Arc::new(SlopLedgerQueryTool)) + .with_tool(Arc::new(SlopLedgerExportTool)) + } + /// Include the `notify` tool — model-callable desktop notification /// (#1322). Routes through the existing `tui::notifications` OSC 9 / /// BEL pipeline so the user's `[notifications].method` config is From d10743362e1059899ea7abde4797797286522262 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 12:55:29 +0800 Subject: [PATCH 04/26] chore: remove COMMIT_MSG.md from tracking --- COMMIT_MSG.md | 73 --------------------------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 COMMIT_MSG.md diff --git a/COMMIT_MSG.md b/COMMIT_MSG.md deleted file mode 100644 index d28ca129..00000000 --- a/COMMIT_MSG.md +++ /dev/null @@ -1,73 +0,0 @@ -# Commit Message — SlopLedger (#2127) - -## Summary - -Add a durable `SlopLedger` that makes invisible architectural residue -visible and queryable across agent sessions. - -Closes: https://github.com/Hmbown/CodeWhale/issues/2127 - -## Problem - -AI agents often leave behind invisible "slop" after a task: -compatibility shims, unmigrated callers, duplicated concepts, -naming drift, stale docs/tests, suspected dead code, and tool gaps. - -Currently these residues are untracked. The next agent rediscovers -them, amplifies them, or mistakes them for intended architecture. - -## Solution - -A persistent JSON-backed ledger (`~/.codewhale/slop_ledger/slop_ledger.json`) -with four model-callable tools and a `/slop` slash command. - -### Data Model - -- **10 classification buckets**: retained_compatibility, unmigrated_callers, - duplicate_concepts, naming_drift, stale_docs, stale_tests, - suspected_dead_code, unverified_public_behavior, tool_gaps, accepted_debt -- **Severity**: critical | high | medium | low | info -- **Confidence**: certain | high | medium | low -- **Status lifecycle**: open → in_progress → resolved | accepted | wontfix -- Each entry carries: owner, source links, title, description, - cleanup recommendation, timestamps, and optional task_id / thread_id - -### Tools (model-callable) - -| Tool | Description | -|---|---| -| `slop_ledger_append` | Append entries with bucket, severity, confidence, title, description | -| `slop_ledger_query` | Query with bucket/severity/status/text filters | -| `slop_ledger_update` | Update entry status | -| `slop_ledger_export` | Export as Markdown for handoffs / GitHub issues | - -### Slash Command - -- `/slop` — print summary -- `/slop query` — list entries -- `/slop export` — Markdown export -- Alias: `/canzha` - -### Files Changed - -| File | Change | -|---|---| -| `crates/tui/src/slop_ledger.rs` | **New** — 1089 lines | -| `crates/tui/src/main.rs` | +1: mod declaration | -| `crates/tui/src/tools/registry.rs` | +16: builder method | -| `crates/tui/src/core/engine/tool_setup.rs` | +1: registration | -| `crates/tui/src/commands/mod.rs` | +10: command + dispatch | -| `crates/tui/src/commands/config.rs` | +41: handler | - -### Tests - -8 unit tests: bucket roundtrip, save/load, query by bucket/search, -update status, markdown export, empty ledger, summary counts. - -## How to Test - -```bash -cargo test -p codewhale-tui -- slop_ledger -``` - -In TUI: `/slop`, `/slop query`, `/slop export` From f70007663f58e9ab9d7f9a6724258cc3337e6d27 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 13:06:50 +0800 Subject: [PATCH 05/26] fix: suppress dead_code warnings for CI -Dwarnings --- crates/tui/src/slop_ledger.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tui/src/slop_ledger.rs b/crates/tui/src/slop_ledger.rs index 23eae9d9..9e0b1ed1 100644 --- a/crates/tui/src/slop_ledger.rs +++ b/crates/tui/src/slop_ledger.rs @@ -81,6 +81,7 @@ impl SlopBucket { } } + #[allow(dead_code)] pub fn all_buckets() -> &'static [SlopBucket] { &[ Self::RetainedCompatibility, @@ -307,6 +308,7 @@ impl SlopLedger { /// Return the total number of entries. #[must_use] + #[allow(dead_code)] pub fn len(&self) -> usize { self.entries.len() } From 8928e1dde44b4947a5e0f07ebff619812049fdb0 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 13:52:59 +0800 Subject: [PATCH 06/26] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20error=20propagation,=20prefix=20match,=20atomic=20w?= =?UTF-8?q?rite,=20UTF-8=20safe=20truncation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COMMIT_MSG.md | 73 ++++++++++++++++++++++ crates/tui/src/main.rs | 2 +- crates/tui/src/slop_ledger.rs | 102 ++++++++++++++++++------------- crates/tui/src/tools/registry.rs | 3 +- 4 files changed, 135 insertions(+), 45 deletions(-) create mode 100644 COMMIT_MSG.md diff --git a/COMMIT_MSG.md b/COMMIT_MSG.md new file mode 100644 index 00000000..d28ca129 --- /dev/null +++ b/COMMIT_MSG.md @@ -0,0 +1,73 @@ +# Commit Message — SlopLedger (#2127) + +## Summary + +Add a durable `SlopLedger` that makes invisible architectural residue +visible and queryable across agent sessions. + +Closes: https://github.com/Hmbown/CodeWhale/issues/2127 + +## Problem + +AI agents often leave behind invisible "slop" after a task: +compatibility shims, unmigrated callers, duplicated concepts, +naming drift, stale docs/tests, suspected dead code, and tool gaps. + +Currently these residues are untracked. The next agent rediscovers +them, amplifies them, or mistakes them for intended architecture. + +## Solution + +A persistent JSON-backed ledger (`~/.codewhale/slop_ledger/slop_ledger.json`) +with four model-callable tools and a `/slop` slash command. + +### Data Model + +- **10 classification buckets**: retained_compatibility, unmigrated_callers, + duplicate_concepts, naming_drift, stale_docs, stale_tests, + suspected_dead_code, unverified_public_behavior, tool_gaps, accepted_debt +- **Severity**: critical | high | medium | low | info +- **Confidence**: certain | high | medium | low +- **Status lifecycle**: open → in_progress → resolved | accepted | wontfix +- Each entry carries: owner, source links, title, description, + cleanup recommendation, timestamps, and optional task_id / thread_id + +### Tools (model-callable) + +| Tool | Description | +|---|---| +| `slop_ledger_append` | Append entries with bucket, severity, confidence, title, description | +| `slop_ledger_query` | Query with bucket/severity/status/text filters | +| `slop_ledger_update` | Update entry status | +| `slop_ledger_export` | Export as Markdown for handoffs / GitHub issues | + +### Slash Command + +- `/slop` — print summary +- `/slop query` — list entries +- `/slop export` — Markdown export +- Alias: `/canzha` + +### Files Changed + +| File | Change | +|---|---| +| `crates/tui/src/slop_ledger.rs` | **New** — 1089 lines | +| `crates/tui/src/main.rs` | +1: mod declaration | +| `crates/tui/src/tools/registry.rs` | +16: builder method | +| `crates/tui/src/core/engine/tool_setup.rs` | +1: registration | +| `crates/tui/src/commands/mod.rs` | +10: command + dispatch | +| `crates/tui/src/commands/config.rs` | +41: handler | + +### Tests + +8 unit tests: bucket roundtrip, save/load, query by bucket/search, +update status, markdown export, empty ledger, summary counts. + +## How to Test + +```bash +cargo test -p codewhale-tui -- slop_ledger +``` + +In TUI: `/slop`, `/slop query`, `/slop export` diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index ba082137..8136d744 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -65,11 +65,11 @@ mod session_manager; mod settings; mod skill_state; mod skills; +mod slop_ledger; mod snapshot; mod task_manager; #[cfg(test)] mod test_support; -mod slop_ledger; mod theme_qa_audit; mod tools; mod tui; diff --git a/crates/tui/src/slop_ledger.rs b/crates/tui/src/slop_ledger.rs index 9e0b1ed1..bc3a4f3a 100644 --- a/crates/tui/src/slop_ledger.rs +++ b/crates/tui/src/slop_ledger.rs @@ -282,7 +282,12 @@ impl SlopLedger { }); } let data = fs::read_to_string(path)?; - let mut ledger: SlopLedger = serde_json::from_str(&data).unwrap_or_default(); + let mut ledger: SlopLedger = serde_json::from_str(&data).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to parse slop ledger JSON: {e}"), + ) + })?; ledger.ledger_path = path.to_path_buf(); Ok(ledger) } @@ -295,7 +300,7 @@ impl SlopLedger { let data = serde_json::to_string_pretty(self).map_err(|e| { io::Error::new(io::ErrorKind::Other, format!("serialization error: {e}")) })?; - fs::write(&self.ledger_path, data) + crate::utils::write_atomic(&self.ledger_path, data.as_bytes()) } /// Append one or more entries. Returns the new entry count and @@ -360,7 +365,7 @@ impl SlopLedger { /// Find an entry by id. pub fn find_mut(&mut self, id: &str) -> Option<&mut SlopEntry> { - self.entries.iter_mut().find(|e| e.id == id) + self.entries.iter_mut().find(|e| e.id.starts_with(id)) } /// Update an entry's status (and optionally other fields) and save. @@ -386,7 +391,11 @@ impl SlopLedger { /// Export all entries as a Markdown string suitable for handoff or /// GitHub issue body. - pub fn export_markdown(&self, title: Option<&str>, filter: Option<&SlopLedgerFilter>) -> String { + pub fn export_markdown( + &self, + title: Option<&str>, + filter: Option<&SlopLedgerFilter>, + ) -> String { let entries: Vec<&SlopEntry> = match filter { Some(f) => self.query(f), None => self.entries.iter().collect(), @@ -418,11 +427,7 @@ impl SlopLedger { out.push_str("|---|---|---|---|---|---|\n"); for e in bucket_entries { let source = e.source_links.first().map(|s| s.as_str()).unwrap_or("-"); - let title = if e.title.len() > 60 { - format!("{}…", &e.title[..57]) - } else { - e.title.clone() - }; + let title = truncate_str(&e.title, 60); out.push_str(&format!( "| {} | {:?} | {:?} | {:?} | {title} | {source} |\n", &e.id[..8], @@ -435,11 +440,7 @@ impl SlopLedger { // Detailed entries for e in bucket_entries { - out.push_str(&format!( - "### {} — {}\n\n", - &e.id[..8], - e.title - )); + out.push_str(&format!("### {} — {}\n\n", &e.id[..8], e.title)); out.push_str(&format!("- **Severity**: {:?}\n", e.severity)); out.push_str(&format!("- **Confidence**: {:?}\n", e.confidence)); out.push_str(&format!("- **Status**: {:?}\n", e.status)); @@ -576,9 +577,8 @@ impl ToolSpec for SlopLedgerAppendTool { .and_then(|v| v.as_array()) .ok_or_else(|| ToolError::invalid_input("'entries' must be a non-empty array"))?; - let mut ledger = SlopLedger::load().map_err(|e| { - ToolError::execution_failed(format!("failed to load slop ledger: {e}")) - })?; + let mut ledger = SlopLedger::load() + .map_err(|e| ToolError::execution_failed(format!("failed to load slop ledger: {e}")))?; let mut appended = Vec::new(); for entry_val in entries_val { @@ -587,13 +587,15 @@ impl ToolSpec for SlopLedgerAppendTool { ToolError::invalid_input(format!("unknown bucket: '{bucket_str}'")) })?; - let severity = SlopSeverity::from_str(required_str(entry_val, "severity")?).ok_or_else(|| { - ToolError::invalid_input("invalid severity (use critical|high|medium|low|info)") - })?; + let severity = SlopSeverity::from_str(required_str(entry_val, "severity")?) + .ok_or_else(|| { + ToolError::invalid_input("invalid severity (use critical|high|medium|low|info)") + })?; - let confidence = SlopConfidence::from_str(required_str(entry_val, "confidence")?).ok_or_else(|| { - ToolError::invalid_input("invalid confidence (use certain|high|medium|low)") - })?; + let confidence = SlopConfidence::from_str(required_str(entry_val, "confidence")?) + .ok_or_else(|| { + ToolError::invalid_input("invalid confidence (use certain|high|medium|low)") + })?; let title = required_str(entry_val, "title")?.to_string(); let description = required_str(entry_val, "description")?.to_string(); @@ -624,9 +626,9 @@ impl ToolSpec for SlopLedgerAppendTool { let (total, ids) = ledger.append(appended); let appended_count = ids.len(); - ledger.save().map_err(|e| { - ToolError::execution_failed(format!("failed to save slop ledger: {e}")) - })?; + ledger + .save() + .map_err(|e| ToolError::execution_failed(format!("failed to save slop ledger: {e}")))?; Ok(ToolResult::success(format!( "Appended {} slop ledger entr{} ({} total): {}", @@ -702,7 +704,10 @@ impl ToolSpec for SlopLedgerQueryTool { .get("status") .and_then(|v| v.as_str()) .and_then(SlopEntryStatus::from_str), - search: input.get("search").and_then(|v| v.as_str()).map(String::from), + search: input + .get("search") + .and_then(|v| v.as_str()) + .map(String::from), limit: input .get("limit") .and_then(|v| v.as_u64()) @@ -710,9 +715,8 @@ impl ToolSpec for SlopLedgerQueryTool { .or(Some(50)), }; - let ledger = SlopLedger::load().map_err(|e| { - ToolError::execution_failed(format!("failed to load slop ledger: {e}")) - })?; + let ledger = SlopLedger::load() + .map_err(|e| ToolError::execution_failed(format!("failed to load slop ledger: {e}")))?; if ledger.is_empty() { return Ok(ToolResult::success("Slop ledger is empty.")); @@ -782,20 +786,20 @@ impl ToolSpec for SlopLedgerUpdateTool { async fn execute(&self, input: Value, _context: &ToolContext) -> Result { let id = required_str(&input, "id")?; - let status = SlopEntryStatus::from_str(required_str(&input, "status")?).ok_or_else(|| { - ToolError::invalid_input( - "invalid status (use open|in_progress|resolved|accepted|wontfix)", - ) - })?; + let status = + SlopEntryStatus::from_str(required_str(&input, "status")?).ok_or_else(|| { + ToolError::invalid_input( + "invalid status (use open|in_progress|resolved|accepted|wontfix)", + ) + })?; let cleanup = input .get("cleanup_recommendation") .and_then(|v| v.as_str()) .map(String::from); - let mut ledger = SlopLedger::load().map_err(|e| { - ToolError::execution_failed(format!("failed to load slop ledger: {e}")) - })?; + let mut ledger = SlopLedger::load() + .map_err(|e| ToolError::execution_failed(format!("failed to load slop ledger: {e}")))?; match ledger.update_status(id, status, cleanup) { Ok(Some(entry)) => Ok(ToolResult::success(format!( @@ -887,15 +891,25 @@ impl ToolSpec for SlopLedgerExportTool { None }; - let ledger = SlopLedger::load().map_err(|e| { - ToolError::execution_failed(format!("failed to load slop ledger: {e}")) - })?; + let ledger = SlopLedger::load() + .map_err(|e| ToolError::execution_failed(format!("failed to load slop ledger: {e}")))?; let markdown = ledger.export_markdown(title, filter.as_ref()); Ok(ToolResult::success(markdown)) } } +/// Truncate a UTF-8 string to at most `max_chars` characters, appending '…' +/// when truncation occurs. Operates on char boundaries — never panics on +/// multi-byte characters. +fn truncate_str(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + return s.to_string(); + } + let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect(); + format!("{truncated}…") +} + // ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -1009,7 +1023,11 @@ mod tests { ledger.save().unwrap(); let result = ledger - .update_status(&id, SlopEntryStatus::Resolved, Some("Renamed in #1234".into())) + .update_status( + &id, + SlopEntryStatus::Resolved, + Some("Renamed in #1234".into()), + ) .unwrap(); assert!(result.is_some()); diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index a4f453b4..04f62003 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -727,8 +727,7 @@ impl ToolRegistryBuilder { #[must_use] pub fn with_slop_ledger_tools(self) -> Self { use crate::slop_ledger::{ - SlopLedgerAppendTool, SlopLedgerExportTool, SlopLedgerQueryTool, - SlopLedgerUpdateTool, + SlopLedgerAppendTool, SlopLedgerExportTool, SlopLedgerQueryTool, SlopLedgerUpdateTool, }; self.with_tool(Arc::new(SlopLedgerAppendTool)) .with_tool(Arc::new(SlopLedgerQueryTool)) From 9c5bf7dadf51d68613b54ded2e865e1fc3ce6a0a Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 13:57:43 +0800 Subject: [PATCH 07/26] chore: remove COMMIT_MSG.md --- COMMIT_MSG.md | 73 --------------------------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 COMMIT_MSG.md diff --git a/COMMIT_MSG.md b/COMMIT_MSG.md deleted file mode 100644 index d28ca129..00000000 --- a/COMMIT_MSG.md +++ /dev/null @@ -1,73 +0,0 @@ -# Commit Message — SlopLedger (#2127) - -## Summary - -Add a durable `SlopLedger` that makes invisible architectural residue -visible and queryable across agent sessions. - -Closes: https://github.com/Hmbown/CodeWhale/issues/2127 - -## Problem - -AI agents often leave behind invisible "slop" after a task: -compatibility shims, unmigrated callers, duplicated concepts, -naming drift, stale docs/tests, suspected dead code, and tool gaps. - -Currently these residues are untracked. The next agent rediscovers -them, amplifies them, or mistakes them for intended architecture. - -## Solution - -A persistent JSON-backed ledger (`~/.codewhale/slop_ledger/slop_ledger.json`) -with four model-callable tools and a `/slop` slash command. - -### Data Model - -- **10 classification buckets**: retained_compatibility, unmigrated_callers, - duplicate_concepts, naming_drift, stale_docs, stale_tests, - suspected_dead_code, unverified_public_behavior, tool_gaps, accepted_debt -- **Severity**: critical | high | medium | low | info -- **Confidence**: certain | high | medium | low -- **Status lifecycle**: open → in_progress → resolved | accepted | wontfix -- Each entry carries: owner, source links, title, description, - cleanup recommendation, timestamps, and optional task_id / thread_id - -### Tools (model-callable) - -| Tool | Description | -|---|---| -| `slop_ledger_append` | Append entries with bucket, severity, confidence, title, description | -| `slop_ledger_query` | Query with bucket/severity/status/text filters | -| `slop_ledger_update` | Update entry status | -| `slop_ledger_export` | Export as Markdown for handoffs / GitHub issues | - -### Slash Command - -- `/slop` — print summary -- `/slop query` — list entries -- `/slop export` — Markdown export -- Alias: `/canzha` - -### Files Changed - -| File | Change | -|---|---| -| `crates/tui/src/slop_ledger.rs` | **New** — 1089 lines | -| `crates/tui/src/main.rs` | +1: mod declaration | -| `crates/tui/src/tools/registry.rs` | +16: builder method | -| `crates/tui/src/core/engine/tool_setup.rs` | +1: registration | -| `crates/tui/src/commands/mod.rs` | +10: command + dispatch | -| `crates/tui/src/commands/config.rs` | +41: handler | - -### Tests - -8 unit tests: bucket roundtrip, save/load, query by bucket/search, -update status, markdown export, empty ledger, summary counts. - -## How to Test - -```bash -cargo test -p codewhale-tui -- slop_ledger -``` - -In TUI: `/slop`, `/slop query`, `/slop export` From 95ba01eba8e2cb8b16fe65263ceafef8f09cf4ed Mon Sep 17 00:00:00 2001 From: Ben Gao Date: Wed, 27 May 2026 21:43:45 +0800 Subject: [PATCH 08/26] fix(skills): align skills API with TUI command behavior - Use discover_for_workspace_and_dir() instead of SkillRegistry::discover() to search all skill directories (workspace + global), matching TUI /skills - Add is_bundled field to SkillEntry for built-in skill identification - Add directories field to SkillsResponse showing all search paths - Use skill.path instead of constructing path from skills_dir + name - Update set_skill_enabled to use the same discovery logic --- crates/tui/src/runtime_api.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 20110cc4..90c1156e 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -40,7 +40,6 @@ use crate::runtime_threads::{ }; use crate::session_manager::{SavedSession, SessionManager, SessionMetadata, default_sessions_dir}; use crate::skill_state::SkillStateStore; -use crate::skills::SkillRegistry; use crate::task_manager::{ NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskRecord, TaskSummary, }; @@ -261,11 +260,13 @@ struct SkillEntry { description: String, path: PathBuf, enabled: bool, + is_bundled: bool, } #[derive(Debug, Serialize)] struct SkillsResponse { directory: PathBuf, + directories: Vec, warnings: Vec, skills: Vec, } @@ -906,20 +907,24 @@ async fn list_skills( State(state): State, ) -> Result, ApiError> { let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let registry = SkillRegistry::discover(&skills_dir); + let registry = + crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); let skill_state = state.skill_state.lock().await; + let directories = crate::skills::skills_directories(&state.workspace); let skills = registry .list() .iter() .map(|skill| SkillEntry { name: skill.name.clone(), description: skill.description.clone(), - path: skills_dir.join(&skill.name).join("SKILL.md"), + path: skill.path.clone(), enabled: skill_state.is_enabled(&skill.name), + is_bundled: crate::skills::is_bundled_skill_name(&skill.name), }) .collect(); Ok(Json(SkillsResponse { directory: skills_dir, + directories, warnings: registry.warnings().to_vec(), skills, })) @@ -931,12 +936,12 @@ async fn set_skill_enabled( Json(req): Json, ) -> Result, ApiError> { let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let registry = SkillRegistry::discover(&skills_dir); + let registry = + crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); let exists = registry.list().iter().any(|skill| skill.name == name); if !exists { return Err(ApiError::not_found(format!( - "skill '{name}' not found under {}", - skills_dir.display() + "skill '{name}' not found" ))); } From 9943fe537daf62f921fd9f37a5be26c6c91c2a24 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 11:46:15 +0800 Subject: [PATCH 09/26] feat: add export redaction + completion-gate verifier hook for SlopLedger (#2127) --- crates/tui/src/slop_ledger.rs | 105 ++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/tui/src/slop_ledger.rs b/crates/tui/src/slop_ledger.rs index bc3a4f3a..7a0f6048 100644 --- a/crates/tui/src/slop_ledger.rs +++ b/crates/tui/src/slop_ledger.rs @@ -461,6 +461,7 @@ impl SlopLedger { } } + redact_exported_text(&mut out); out } @@ -491,6 +492,7 @@ impl SlopLedger { for (bucket, count) in &by_bucket { out.push_str(&format!(" {bucket}: {count}\n")); } + redact_exported_text(&mut out); out } } @@ -910,6 +912,109 @@ fn truncate_str(s: &str, max_chars: usize) -> String { format!("{truncated}…") } +/// Redact sensitive patterns from exported text: API keys and secrets +/// paths. Scan the output for known key prefixes (`sk-`, `Bearer `, `dsk-`) +/// and replace the token until a whitespace / punctuation boundary with +/// `[REDACTED]`. Also normalises fully-qualified secrets directory paths +/// to the portable `~/.codewhale/secrets` form. +fn redact_exported_text(text: &mut String) { + let prefixes: &[&[u8]] = &[b"sk-", b"Bearer ", b"dsk-", b"deepseek-"]; + let mut result = String::with_capacity(text.len()); + let bytes = text.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + let mut matched = false; + for prefix in prefixes { + if bytes[i..].len() >= prefix.len() + && bytes[i..i + prefix.len()].eq_ignore_ascii_case(prefix) + { + // Scan forward to first whitespace or delimiter. + let end = bytes[i + prefix.len()..] + .iter() + .position(|b| b.is_ascii_whitespace() || *b == b',' || *b == b';') + .map(|p| i + prefix.len() + p) + .unwrap_or(bytes.len()); + result.push_str("[REDACTED]"); + i = end; + matched = true; + break; + } + } + if !matched { + // Advance by one char (preserving multi-byte UTF-8 safety). + let ch = text[i..].chars().next().unwrap(); + result.push(ch); + i += ch.len_utf8(); + } + } + + // Normalise secrets directory paths. + if let Some(home) = dirs::home_dir() { + for leaf in [".codewhale/secrets", ".deepseek/secrets"] { + let dir = home.join(leaf); + let prefix = dir.to_string_lossy().to_string(); + result = result.replace(&prefix, "~/.codewhale/secrets"); + } + } + *text = result; +} + +impl SlopLedger { + /// Completion-gate / verifier hook: returns `true` when there are + /// unresolved slop entries (status `Open` or `Investigate`) that the + /// agent should review before claiming the task is done. + /// + /// Tools and engine hooks can call this on claim-of-done to surface + /// architectural residue the agent may have overlooked. + #[allow(dead_code)] + #[must_use] + pub fn has_open_entries(&self) -> bool { + self.entries.iter().any(|e| { + matches!( + e.status, + SlopEntryStatus::Open | SlopEntryStatus::InProgress + ) + }) + } + + /// Return a concise completion-gate summary suitable for a verifier + /// sub-agent or the claim-of-done prompt. Returns `None` when all + /// entries are resolved — the caller can then treat the gate as "pass". + #[allow(dead_code)] + #[must_use] + pub fn completion_gate_summary(&self) -> Option { + let open: Vec<&SlopEntry> = self + .entries + .iter() + .filter(|e| { + matches!( + e.status, + SlopEntryStatus::Open | SlopEntryStatus::InProgress + ) + }) + .collect(); + if open.is_empty() { + return None; + } + let mut out = format!( + "## ⚠️ SlopLedger gate — {} open slop entries\n\n", + open.len() + ); + out.push_str("Review these before claiming completion:\n\n"); + for e in open { + out.push_str(&format!( + "- **{}** `{}` ({:?}/{:?}): {}\n", + e.bucket.as_str(), + &e.id[..8], + e.severity, + e.confidence, + truncate_str(&e.title, 80), + )); + } + Some(out) + } +} + // ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] From e9026814be51a95a5236f35e0601dbe7c9e54599 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 11:53:25 +0800 Subject: [PATCH 10/26] =?UTF-8?q?feat:=20integrate=20SlopLedger=20completi?= =?UTF-8?q?on-gate=20into=20turn=20loop=20=E2=80=94=20auto-check=20on=20ev?= =?UTF-8?q?ery=20completed=20turn=20(#2127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/tui/ui.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 61cb195c..1512c5bb 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1491,6 +1491,27 @@ async fn run_event_loop( // Generate post-turn receipt for completed turns. if status == crate::core::events::TurnOutcomeStatus::Completed { + // SlopLedger completion-gate: after every completed + // turn, check whether there are unresolved slop entries + // the agent should address before claiming the task is + // done (#2127). This runs autonomously — no tool call + // required — so the agent can't forget to check. + if let Ok(ledger) = crate::slop_ledger::SlopLedger::load() + && ledger.has_open_entries() + { + if let Some(gate_msg) = ledger.completion_gate_summary() { + let short = gate_msg + .lines() + .nth(4) + .unwrap_or("review before done"); + app.push_status_toast( + format!("⚠️ SlopLedger: {short}"), + crate::tui::app::StatusToastLevel::Warning, + Some(12_000), + ); + } + } + let tool_count = app.tool_evidence.len(); let mut receipt = "✓ turn completed".to_string(); if tool_count > 0 { From ff1a8cd44b5666355785ccf99fdf8390406b4bda Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 11:55:36 +0800 Subject: [PATCH 11/26] style: cargo fmt fix --- crates/tui/src/tui/ui.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 1512c5bb..311861c3 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1500,10 +1500,8 @@ async fn run_event_loop( && ledger.has_open_entries() { if let Some(gate_msg) = ledger.completion_gate_summary() { - let short = gate_msg - .lines() - .nth(4) - .unwrap_or("review before done"); + let short = + gate_msg.lines().nth(4).unwrap_or("review before done"); app.push_status_toast( format!("⚠️ SlopLedger: {short}"), crate::tui::app::StatusToastLevel::Warning, From a73da589519e2b88c5e5fc26f43b0304eae73e78 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 12:02:19 +0800 Subject: [PATCH 12/26] =?UTF-8?q?feat:=20inject=20SlopLedger=20gate=20into?= =?UTF-8?q?=20system=20prompt=20=E2=80=94=20agent=20sees=20open=20entries?= =?UTF-8?q?=20every=20turn=20(#2127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/core/engine.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index f98f523c..6d3bfd78 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1840,8 +1840,23 @@ impl Engine { }, self.session.approval_mode, ); - let stable_prompt = + let mut stable_prompt = merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone()); + + // SlopLedger completion-gate: inject unresolved slop entries into the + // system prompt so the agent can autonomously review them before claiming + // the task is done (#2127). Only active when entries actually exist. + if let Ok(ledger) = crate::slop_ledger::SlopLedger::load() { + if ledger.has_open_entries() { + if let Some(gate_block) = ledger.completion_gate_summary() { + if let Some(SystemPrompt::Text(prompt_text)) = &mut stable_prompt { + prompt_text.push_str("\n\n"); + prompt_text.push_str(&gate_block); + } + } + } + } + let stable_hash = system_prompt_hash(stable_prompt.as_ref()); if self.session.system_prompt_override { self.session.last_system_prompt_hash = Some(stable_hash); From 721a9797555bfaf7a27c7cc629bd479f693ce82b Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 12:21:51 +0800 Subject: [PATCH 13/26] perf: cache SlopLedger gate in engine to avoid disk I/O on every turn (#2127) --- crates/tui/src/core/engine.rs | 40 ++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 6d3bfd78..b8070b25 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -331,6 +331,11 @@ pub struct Engine { /// Diagnostics collected during the current step's tool calls. Drained /// and forwarded as a synthetic user message before the next API call. pending_lsp_blocks: Vec, + /// Cached SlopLedger gate block so `refresh_system_prompt` doesn't hit + /// the filesystem on every turn (#2127). `None` = not yet loaded; + /// `Some(None)` = loaded, no open entries; `Some(Some(...))` = loaded, + /// gate block ready. + slop_ledger_gate_cache: Option>, } // === Internal tool helpers === @@ -564,6 +569,7 @@ impl Engine { turn_counter: 0, lsp_manager, pending_lsp_blocks: Vec::new(), + slop_ledger_gate_cache: None, workshop_vars, sandbox_backend, }; @@ -1844,16 +1850,30 @@ impl Engine { merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone()); // SlopLedger completion-gate: inject unresolved slop entries into the - // system prompt so the agent can autonomously review them before claiming - // the task is done (#2127). Only active when entries actually exist. - if let Ok(ledger) = crate::slop_ledger::SlopLedger::load() { - if ledger.has_open_entries() { - if let Some(gate_block) = ledger.completion_gate_summary() { - if let Some(SystemPrompt::Text(prompt_text)) = &mut stable_prompt { - prompt_text.push_str("\n\n"); - prompt_text.push_str(&gate_block); - } - } + // system prompt so the agent can autonomously review them before + // claiming the task is done (#2127). Cached to avoid filesystem I/O on + // every turn — only re-loaded when the cache is empty (first call or + // after invalidation). + let gate_block = match &self.slop_ledger_gate_cache { + Some(cached) => cached.clone(), + None => { + let loaded = crate::slop_ledger::SlopLedger::load() + .ok() + .and_then(|ledger| { + if ledger.has_open_entries() { + ledger.completion_gate_summary() + } else { + None + } + }); + self.slop_ledger_gate_cache = Some(loaded.clone()); + loaded + } + }; + if let Some(ref block) = gate_block { + if let Some(SystemPrompt::Text(prompt_text)) = &mut stable_prompt { + prompt_text.push_str("\n\n"); + prompt_text.push_str(block); } } From 5ddac40909fcf146b8b1e2e3589274fc0d8c21bf Mon Sep 17 00:00:00 2001 From: Justin Gao Date: Fri, 29 May 2026 10:00:11 +0800 Subject: [PATCH 14/26] feat: whale-size route taxonomy for model + thinking-effort picker (#2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a central whale-route taxonomy that maps each (model, reasoning_effort) pair to a friendly whale-species label sorted from largest/deepest to smallest/fastest: Blue Whale — Pro + max thinking Fin Whale — Pro + high thinking Sperm Whale — Pro + no thinking Humpback — Flash + max thinking Minke Whale — Flash + high thinking Porpoise — Flash + no thinking For DeepSeek providers the /model picker now shows a single-column whale-route list instead of the two-column Model|Thinking layout. Each route sets both model and effort at once. Pass-through providers retain the classic layout. New whale_routes module: - WhaleRoute struct with label, model, effort, sort_order, hint, description - WHALE_ROUTES const array (6 routes) - for_model_effort() and by_sort_order() lookups - 8 unit tests Model picker changes: - show_whale_routes flag activates on DeepSeek providers - Selected route maps to model + effort simultaneously - Fallback row for "auto" and custom models - Updated test suite for whale-route behavior (7 new/updated tests) Refs: #1676, #2026 --- crates/tui/src/tui/mod.rs | 1 + crates/tui/src/tui/model_picker.rs | 297 ++++++++++++++++++++++++----- crates/tui/src/tui/whale_routes.rs | 186 ++++++++++++++++++ 3 files changed, 436 insertions(+), 48 deletions(-) create mode 100644 crates/tui/src/tui/whale_routes.rs diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index bfb2e477..ba614e5b 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -71,6 +71,7 @@ mod ui_text; pub mod user_input; pub mod views; pub mod vim_mode; +pub mod whale_routes; pub mod widgets; pub mod workspace_context; diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 8220dc6f..b39a98cc 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -1,22 +1,16 @@ -//! `/model` picker modal: pick a DeepSeek model and a thinking-effort tier -//! and apply both at once (#39). +//! `/model` picker modal: pick a model and thinking-effort tier (#39, #2026). //! -//! Two side-by-side panes — Models on the left, Thinking effort on the -//! right. Tab swaps focus, ↑/↓ moves within the focused pane, Enter applies -//! both and closes the modal. Esc applies the last-highlighted choice and -//! closes. +//! For DeepSeek providers the picker shows whale-sized routes — model + effort +//! combinations sorted largest → fastest with friendly whale-species labels +//! (Blue Whale, Fin Whale, …, Porpoise). A single ↑/↓ selection sets both +//! model and effort at once. The "auto" option is always available; custom +//! (unrecognised) model ids appear as a separate row. //! -//! The effort pane intentionally only exposes `Off / High / Max`. Per -//! DeepSeek's [Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model), -//! `low`/`medium` are silently mapped to `high` server-side and `xhigh` is -//! mapped to `max`, so surfacing them as separate choices would be misleading. -//! The legacy variants remain valid in `~/.deepseek/settings.toml` for -//! back-compat — the picker just doesn't offer them. +//! For pass-through providers the picker falls back to the classic two-column +//! layout (Models | Thinking), with no whale labelling. //! //! On apply we emit a [`ViewEvent::ModelPickerApplied`] with the resolved -//! model id and effort tier; the UI handler updates `App` state, persists -//! the choice via `Settings`, and forwards `Op::SetModel` so the running -//! engine picks up the change without a restart. +//! model id and effort tier. use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ @@ -30,6 +24,7 @@ use ratatui::{ use crate::palette; use crate::tui::app::{App, ReasoningEffort}; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; +use crate::tui::whale_routes::{WHALE_ROUTES, WhaleRoute}; /// Models the picker exposes by default. Kept short on purpose — power /// users can still type `/model ` for anything else. @@ -69,12 +64,17 @@ pub struct ModelPickerView { /// When true, hide DeepSeek-specific model rows (pass-through providers /// like openai don't support them). hide_deepseek_models: bool, + /// When true, show whale-sized routes instead of two-column model/effort. + show_whale_routes: bool, + /// Selected whale-route index (when show_whale_routes is true). + selected_route_idx: usize, } impl ModelPickerView { #[must_use] pub fn new(app: &App) -> Self { let hide_deepseek_models = crate::config::provider_passes_model_through(app.api_provider); + let show_whale_routes = !hide_deepseek_models; let initial_model = if app.auto_model { "auto".to_string() } else { @@ -104,6 +104,15 @@ impl ModelPickerView { .position(|e| *e == normalized) .unwrap_or(2); // default to High if somehow unknown + // When showing whale routes, find the matching route index. + let selected_route_idx = if show_whale_routes { + WhaleRoute::for_model_effort(&initial_model, normalized) + .map(|r| r.sort_order) + .unwrap_or(WHALE_ROUTES.len()) // "auto" or custom falls after routes + } else { + 0 + }; + Self { initial_model, initial_effort, @@ -113,6 +122,8 @@ impl ModelPickerView { selection_touched: false, show_custom_model_row, hide_deepseek_models, + show_whale_routes, + selected_route_idx, } } @@ -128,10 +139,11 @@ impl ModelPickerView { self.visible_model_ids().len() + if self.show_custom_model_row { 1 } else { 0 } } - /// Resolve the currently highlighted model row to a model id. If the - /// custom row is selected we return the original model from the App so - /// "Apply" doesn't blow away an unrecognised id. + /// Resolve the currently highlighted row to a model id. fn resolved_model(&self) -> String { + if self.show_whale_routes { + return self.resolved_whale_model(); + } let visible = self.visible_model_ids(); if self.show_custom_model_row && self.selected_model_idx == visible.len() { self.initial_model.clone() @@ -143,13 +155,55 @@ impl ModelPickerView { } fn resolved_effort(&self) -> ReasoningEffort { + if self.show_whale_routes { + return self.resolved_whale_effort(); + } if self.resolved_model().trim().eq_ignore_ascii_case("auto") { return ReasoningEffort::Auto; } PICKER_EFFORTS[self.selected_effort_idx] } + /// Resolve model from the whale-route list. + fn resolved_whale_model(&self) -> String { + if self.selected_route_idx < WHALE_ROUTES.len() { + WHALE_ROUTES[self.selected_route_idx].model.to_string() + } else { + // Past the last whale route: "auto" or custom. + self.initial_model.clone() + } + } + + /// Resolve effort from the whale-route list. + fn resolved_whale_effort(&self) -> ReasoningEffort { + if self.selected_route_idx < WHALE_ROUTES.len() { + WHALE_ROUTES[self.selected_route_idx].effort + } else if self + .resolved_whale_model() + .trim() + .eq_ignore_ascii_case("auto") + { + ReasoningEffort::Auto + } else { + // Custom model — keep the initial effort. + self.initial_effort + } + } + + /// Number of rows in the whale-route list: routes + (auto or custom). + fn whale_route_row_count(&self) -> usize { + // All whale routes + 1 for the fallback row (auto or custom). + WHALE_ROUTES.len() + 1 + } + fn move_up(&mut self) -> bool { + if self.show_whale_routes { + if self.selected_route_idx > 0 { + self.selected_route_idx -= 1; + return true; + } + return false; + } match self.focus { Pane::Model => { if self.selected_model_idx > 0 { @@ -168,6 +222,14 @@ impl ModelPickerView { } fn move_down(&mut self) -> bool { + if self.show_whale_routes { + let max = self.whale_route_row_count().saturating_sub(1); + if self.selected_route_idx < max { + self.selected_route_idx += 1; + return true; + } + return false; + } match self.focus { Pane::Model => { let max = self.model_row_count().saturating_sub(1); @@ -285,7 +347,9 @@ impl ModalView for ModelPickerView { ViewAction::None } KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => { - self.toggle_focus(); + if !self.show_whale_routes { + self.toggle_focus(); + } ViewAction::None } _ => ViewAction::None, @@ -293,6 +357,87 @@ impl ModalView for ModelPickerView { } fn render(&self, area: Rect, buf: &mut Buffer) { + if self.show_whale_routes { + self.render_whale_routes(area, buf); + } else { + self.render_classic(area, buf); + } + } + + /// Single-column whale-route list for DeepSeek providers. + fn render_whale_routes(&self, area: Rect, buf: &mut Buffer) { + let popup_width = 62.min(area.width.saturating_sub(4)).max(44); + let row_count = self.whale_route_row_count(); + let popup_height = (row_count + 4).min(area.height.saturating_sub(4)).max(8) as u16; + let popup_area = Rect { + x: area.x + (area.width.saturating_sub(popup_width)) / 2, + y: area.y + (area.height.saturating_sub(popup_height)) / 2, + width: popup_width, + height: popup_height, + }; + + Clear.render(popup_area, buf); + + let outer = Block::default() + .title(Line::from(Span::styled( + " Whale Routes ", + Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::BOLD), + ))) + .title_bottom(Line::from(vec![ + Span::styled(" ↑↓ ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("choose "), + Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("apply "), + Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("apply "), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default()); + let inner = outer.inner(popup_area); + outer.render(popup_area, buf); + + let mut rows: Vec<(String, String)> = WHALE_ROUTES + .iter() + .map(|r| { + ( + format!("{} — {}", r.label, r.hint), + r.description.to_string(), + ) + }) + .collect(); + + // Fallback row: "auto" if not set, otherwise the current custom model. + let fallback_label = if self.initial_model == "auto" { + "auto — select per turn".to_string() + } else if self.show_custom_model_row { + format!("{} — custom", self.initial_model) + } else { + "auto — select per turn".to_string() + }; + let fallback_hint = if self.initial_model == "auto" { + "Let CodeWhale pick the best model each turn".to_string() + } else if self.show_custom_model_row { + "Current model (not a standard route)".to_string() + } else { + "Let CodeWhale pick the best model each turn".to_string() + }; + rows.push((fallback_label, fallback_hint)); + + self.render_pane( + inner, + buf, + "Model & thinking", + rows, + self.selected_route_idx, + true, + ); + } + + /// Classic two-column layout for pass-through providers. + fn render_classic(&self, area: Rect, buf: &mut Buffer) { let popup_width = 64.min(area.width.saturating_sub(4)).max(40); let popup_height = 14.min(area.height.saturating_sub(4)).max(10); let popup_area = Rect { @@ -505,53 +650,46 @@ mod tests { } #[test] - fn arrow_keys_move_within_focused_pane() { + fn arrow_keys_move_within_whale_routes() { let (app, _lock) = create_test_app(); let mut view = ModelPickerView::new(&app); - // Default focus is Model; move down then up. - let initial = view.selected_model_idx; + assert!(view.show_whale_routes); + let initial = view.selected_route_idx; view.handle_key(KeyEvent::new( KeyCode::Down, crossterm::event::KeyModifiers::NONE, )); - assert_eq!(view.selected_model_idx, initial + 1); + assert_eq!(view.selected_route_idx, initial + 1); view.handle_key(KeyEvent::new( KeyCode::Up, crossterm::event::KeyModifiers::NONE, )); - assert_eq!(view.selected_model_idx, initial); + assert_eq!(view.selected_route_idx, initial); } #[test] - fn tab_switches_focus_and_arrow_now_moves_effort() { - let (mut app, _lock) = create_test_app(); - // Default is Max; pin to Off so the Down arrow has - // somewhere to go. - app.reasoning_effort = ReasoningEffort::Off; + fn tab_is_noop_in_whale_route_mode() { + let (app, _lock) = create_test_app(); let mut view = ModelPickerView::new(&app); - let initial_effort_idx = view.selected_effort_idx; + assert!(view.show_whale_routes); + let before = view.selected_route_idx; view.handle_key(KeyEvent::new( KeyCode::Tab, crossterm::event::KeyModifiers::NONE, )); - assert_eq!(view.focus, Pane::Effort); - view.handle_key(KeyEvent::new( - KeyCode::Down, - crossterm::event::KeyModifiers::NONE, - )); - assert!(view.selected_effort_idx > initial_effort_idx); + assert_eq!(view.selected_route_idx, before); } #[test] - fn enter_emits_apply_event_with_selection() { + fn enter_with_whale_routes_emits_apply_event() { let (mut app, _lock) = create_test_app(); app.reasoning_effort = ReasoningEffort::High; + app.model = "deepseek-v4-pro".to_string(); app.auto_model = false; let mut view = ModelPickerView::new(&app); - view.handle_key(KeyEvent::new( - KeyCode::Tab, - crossterm::event::KeyModifiers::NONE, - )); + // Initial route: Fin Whale (Pro + High, sort_order=1) + assert_eq!(view.selected_route_idx, 1); + // Move down to Sperm Whale (Pro + Off, sort_order=2) view.handle_key(KeyEvent::new( KeyCode::Down, crossterm::event::KeyModifiers::NONE, @@ -568,13 +706,71 @@ mod tests { .. }) => { assert_eq!(model, "deepseek-v4-pro"); - assert_eq!(effort, ReasoningEffort::Max); + assert_eq!(effort, ReasoningEffort::Off); assert_eq!(previous_effort, ReasoningEffort::High); } other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"), } } + #[test] + fn whale_routes_initial_selection_matches_app_state() { + let (mut app, _lock) = create_test_app(); + app.model = "deepseek-v4-flash".to_string(); + app.auto_model = false; + app.reasoning_effort = ReasoningEffort::Max; + let view = ModelPickerView::new(&app); + // Humpback = Flash + Max, sort_order = 3 + assert_eq!(view.selected_route_idx, 3); + assert_eq!(view.resolved_model(), "deepseek-v4-flash"); + assert_eq!(view.resolved_effort(), ReasoningEffort::Max); + } + + #[test] + fn whale_routes_auto_effort_maps_to_fallback_row() { + let (mut app, _lock) = create_test_app(); + app.model = "auto".to_string(); + app.auto_model = true; + app.reasoning_effort = ReasoningEffort::Auto; + let view = ModelPickerView::new(&app); + // "auto" doesn't match any whale route, falls to fallback row + assert_eq!(view.selected_route_idx, WHALE_ROUTES.len()); + assert_eq!(view.resolved_model(), "auto"); + assert_eq!(view.resolved_effort(), ReasoningEffort::Auto); + } + + #[test] + fn whale_routes_custom_model_falls_back() { + let (mut app, _lock) = create_test_app(); + app.model = "deepseek-v4-pro-2026-04-XX".to_string(); + app.auto_model = false; + app.reasoning_effort = ReasoningEffort::High; + let view = ModelPickerView::new(&app); + // Custom model → fallback row + assert_eq!(view.selected_route_idx, WHALE_ROUTES.len()); + assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX"); + assert_eq!(view.resolved_effort(), ReasoningEffort::High); + } + + #[test] + fn whale_routes_down_from_last_is_noop() { + let (app, _lock) = create_test_app(); + let mut view = ModelPickerView::new(&app); + // Navigate to the last row + view.selected_route_idx = view.whale_route_row_count() - 1; + let result = view.move_down(); + assert!(!result); + } + + #[test] + fn whale_routes_up_from_first_is_noop() { + let (app, _lock) = create_test_app(); + let mut view = ModelPickerView::new(&app); + view.selected_route_idx = 0; + let result = view.move_up(); + assert!(!result); + } + #[test] fn immediate_esc_applies_current_selection() { let (app, _lock) = create_test_app(); @@ -592,9 +788,12 @@ mod tests { } #[test] - fn esc_after_selection_move_applies_highlighted_model() { - let (app, _lock) = create_test_app(); + fn esc_after_selection_move_applies_highlighted_route() { + let (mut app, _lock) = create_test_app(); + app.reasoning_effort = ReasoningEffort::High; let mut view = ModelPickerView::new(&app); + // Initial: Fin Whale (Pro+High), previous_effort=High + // Down → Sperm Whale (Pro+Off) view.handle_key(KeyEvent::new( KeyCode::Down, crossterm::event::KeyModifiers::NONE, @@ -608,13 +807,15 @@ mod tests { match action { ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied { model, - previous_model, + effort, + previous_effort, .. }) => { - assert_eq!(previous_model, "deepseek-v4-pro"); - assert_eq!(model, "deepseek-v4-flash"); + assert_eq!(model, "deepseek-v4-pro"); + assert_eq!(effort, ReasoningEffort::Off); + assert_eq!(previous_effort, ReasoningEffort::High); } - other => panic!("expected Esc to apply highlighted model, got {other:?}"), + other => panic!("expected Esc to apply highlighted route, got {other:?}"), } } diff --git a/crates/tui/src/tui/whale_routes.rs b/crates/tui/src/tui/whale_routes.rs new file mode 100644 index 00000000..f80c9c4c --- /dev/null +++ b/crates/tui/src/tui/whale_routes.rs @@ -0,0 +1,186 @@ +//! Whale-size route taxonomy for model + thinking-effort combinations (#2026). +//! +//! Maps each `(model, reasoning_effort)` pair to a friendly whale-species label, +//! sorted from largest/deepest to smallest/fastest. The labels share the same +//! species pool as sub-agent nicknames (#2016) but serve a different purpose: +//! route/tier names help users understand depth/cost/speed at a glance. +//! +//! ## Route ordering (size → speed) +//! +//! 1. Blue Whale — Pro + max thinking (largest, deepest) +//! 2. Fin Whale — Pro + high thinking +//! 3. Sperm Whale — Pro + no thinking +//! 4. Humpback — Flash + max thinking +//! 5. Minke Whale — Flash + high thinking +//! 6. Porpoise — Flash + no thinking (smallest, fastest) +//! +//! Unknown or non-DeepSeek models fall back to the raw model id without +//! fake whale labeling. + +use crate::tui::app::ReasoningEffort; + +/// One whale-sized route: a model + thinking-effort combination with +/// a friendly label, sort order, and descriptive hint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WhaleRoute { + /// Whale-species label, e.g. "Blue Whale". + pub label: &'static str, + /// Model id, e.g. "deepseek-v4-pro". + pub model: &'static str, + /// Reasoning effort tier. + pub effort: ReasoningEffort, + /// Sort index (0 = largest / deepest). + pub sort_order: usize, + /// Short inline hint, e.g. "Pro + max thinking". + pub hint: &'static str, + /// Longer description for tooltips / route receipts. + pub description: &'static str, +} + +/// Six canonical routes, sorted largest → smallest. +pub const WHALE_ROUTES: &[WhaleRoute] = &[ + WhaleRoute { + label: "Blue Whale", + model: "deepseek-v4-pro", + effort: ReasoningEffort::Max, + sort_order: 0, + hint: "Pro + max thinking", + description: "Flagship reasoning at maximum depth — architecture, debugging, security reviews", + }, + WhaleRoute { + label: "Fin Whale", + model: "deepseek-v4-pro", + effort: ReasoningEffort::High, + sort_order: 1, + hint: "Pro + high thinking", + description: "Deep reasoning for complex tasks — multi-file refactors, careful planning", + }, + WhaleRoute { + label: "Sperm Whale", + model: "deepseek-v4-pro", + effort: ReasoningEffort::Off, + sort_order: 2, + hint: "Pro + no thinking", + description: "Full model power without reasoning overhead — straightforward code generation", + }, + WhaleRoute { + label: "Humpback", + model: "deepseek-v4-flash", + effort: ReasoningEffort::Max, + sort_order: 3, + hint: "Flash + max thinking", + description: "Fast model with reasoning depth — lightweight analysis, first-pass reviews", + }, + WhaleRoute { + label: "Minke Whale", + model: "deepseek-v4-flash", + effort: ReasoningEffort::High, + sort_order: 4, + hint: "Flash + high thinking", + description: "Fast model, moderate reasoning — tool execution, read-only scouting", + }, + WhaleRoute { + label: "Porpoise", + model: "deepseek-v4-flash", + effort: ReasoningEffort::Off, + sort_order: 5, + hint: "Flash + no thinking", + description: "Fastest and cheapest — lookups, searches, simple edits", + }, +]; + +impl WhaleRoute { + /// Look up the whale route for a given model id and reasoning effort. + /// Returns `None` for non-DeepSeek models or unrecognized combinations. + #[must_use] + pub fn for_model_effort(model: &str, effort: ReasoningEffort) -> Option<&'static WhaleRoute> { + WHALE_ROUTES + .iter() + .find(|r| r.model.eq_ignore_ascii_case(model) && r.effort == effort) + } + + /// Look up a whale route by its sort-order index. + #[must_use] + #[allow(dead_code)] + pub fn by_sort_order(index: usize) -> Option<&'static WhaleRoute> { + WHALE_ROUTES.iter().find(|r| r.sort_order == index) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn routes_are_sorted_by_size() { + for window in WHALE_ROUTES.windows(2) { + assert!( + window[0].sort_order < window[1].sort_order, + "{} should sort before {}", + window[0].label, + window[1].label + ); + } + } + + #[test] + fn lookup_blue_whale_for_pro_max() { + let route = WhaleRoute::for_model_effort("deepseek-v4-pro", ReasoningEffort::Max) + .expect("blue whale route exists"); + assert_eq!(route.label, "Blue Whale"); + assert_eq!(route.model, "deepseek-v4-pro"); + assert_eq!(route.effort, ReasoningEffort::Max); + assert_eq!(route.sort_order, 0); + } + + #[test] + fn lookup_porpoise_for_flash_off() { + let route = WhaleRoute::for_model_effort("deepseek-v4-flash", ReasoningEffort::Off) + .expect("porpoise route exists"); + assert_eq!(route.label, "Porpoise"); + assert_eq!(route.sort_order, 5); + } + + #[test] + fn lookup_case_insensitive_model() { + let route = WhaleRoute::for_model_effort("DeepSeek-V4-Pro", ReasoningEffort::High) + .expect("case-insensitive match"); + assert_eq!(route.label, "Fin Whale"); + } + + #[test] + fn unknown_model_returns_none() { + assert!(WhaleRoute::for_model_effort("gpt-4o", ReasoningEffort::High).is_none()); + } + + #[test] + fn unknown_effort_with_valid_model_returns_none() { + // ReasoningEffort::Auto is not in any whale route + assert!(WhaleRoute::for_model_effort("deepseek-v4-pro", ReasoningEffort::Auto).is_none()); + } + + #[test] + fn by_sort_order_finds_correct_routes() { + assert_eq!(WhaleRoute::by_sort_order(0).unwrap().label, "Blue Whale"); + assert_eq!(WhaleRoute::by_sort_order(5).unwrap().label, "Porpoise"); + assert!(WhaleRoute::by_sort_order(99).is_none()); + } + + #[test] + fn every_route_has_unique_sort_order() { + let orders: Vec = WHALE_ROUTES.iter().map(|r| r.sort_order).collect(); + let mut sorted = orders.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(orders.len(), sorted.len(), "duplicate sort orders detected"); + } + + #[test] + fn every_route_has_unique_label() { + let labels: Vec<&str> = WHALE_ROUTES.iter().map(|r| r.label).collect(); + let mut sorted = labels.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(labels.len(), sorted.len(), "duplicate labels detected"); + } +} From bf0b7bcaaf60d60d729d160a691a4a52e4b2a2dc Mon Sep 17 00:00:00 2001 From: Justin Gao Date: Fri, 29 May 2026 10:15:27 +0800 Subject: [PATCH 15/26] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20type=20error,=20fallback=20row,=20provider=20gating?= =?UTF-8?q?,=20array=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix usize/u16 type mismatch in popup_height (row_count as u16 + 4) - Fallback rows: always show "auto" first, then custom model if applicable - Limit whale routes to official DeepSeek/DeepSeekCN providers only - Use array position (not sort_order) for selected_route_idx lookup - Update tests for new fallback row indices --- crates/tui/src/tui/model_picker.rs | 89 ++++++++++++++++++------------ 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index b39a98cc..9d8c9d26 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -74,7 +74,11 @@ impl ModelPickerView { #[must_use] pub fn new(app: &App) -> Self { let hide_deepseek_models = crate::config::provider_passes_model_through(app.api_provider); - let show_whale_routes = !hide_deepseek_models; + // Whale routes are DeepSeek-specific — only official providers get them. + let show_whale_routes = matches!( + app.api_provider, + crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN + ); let initial_model = if app.auto_model { "auto".to_string() } else { @@ -104,11 +108,23 @@ impl ModelPickerView { .position(|e| *e == normalized) .unwrap_or(2); // default to High if somehow unknown - // When showing whale routes, find the matching route index. + // When showing whale routes, find the matching route by position in the array + // (not by sort_order, which happens to match today but is semantically wrong). let selected_route_idx = if show_whale_routes { - WhaleRoute::for_model_effort(&initial_model, normalized) - .map(|r| r.sort_order) - .unwrap_or(WHALE_ROUTES.len()) // "auto" or custom falls after routes + WHALE_ROUTES + .iter() + .position(|r| { + r.model.eq_ignore_ascii_case(&initial_model) && r.effort == normalized + }) + .unwrap_or_else(|| { + // No matching whale route — fall back to "auto" (standard model) + // or the custom row (unrecognized model). + if show_custom_model_row { + WHALE_ROUTES.len() + 1 // custom model row + } else { + WHALE_ROUTES.len() // "auto" row + } + }) } else { 0 }; @@ -168,8 +184,11 @@ impl ModelPickerView { fn resolved_whale_model(&self) -> String { if self.selected_route_idx < WHALE_ROUTES.len() { WHALE_ROUTES[self.selected_route_idx].model.to_string() + } else if self.selected_route_idx == WHALE_ROUTES.len() { + // First fallback row: always "auto". + "auto".to_string() } else { - // Past the last whale route: "auto" or custom. + // Second fallback row: custom model. self.initial_model.clone() } } @@ -178,22 +197,23 @@ impl ModelPickerView { fn resolved_whale_effort(&self) -> ReasoningEffort { if self.selected_route_idx < WHALE_ROUTES.len() { WHALE_ROUTES[self.selected_route_idx].effort - } else if self - .resolved_whale_model() - .trim() - .eq_ignore_ascii_case("auto") - { + } else if self.selected_route_idx == WHALE_ROUTES.len() { + // First fallback row: "auto". ReasoningEffort::Auto } else { - // Custom model — keep the initial effort. + // Second fallback row: custom model — keep the initial effort. self.initial_effort } } - /// Number of rows in the whale-route list: routes + (auto or custom). + /// Number of rows in the whale-route list. fn whale_route_row_count(&self) -> usize { - // All whale routes + 1 for the fallback row (auto or custom). - WHALE_ROUTES.len() + 1 + let base = WHALE_ROUTES.len() + 1; // routes + auto + if self.show_custom_model_row { + base + 1 + } else { + base + } } fn move_up(&mut self) -> bool { @@ -368,7 +388,9 @@ impl ModalView for ModelPickerView { fn render_whale_routes(&self, area: Rect, buf: &mut Buffer) { let popup_width = 62.min(area.width.saturating_sub(4)).max(44); let row_count = self.whale_route_row_count(); - let popup_height = (row_count + 4).min(area.height.saturating_sub(4)).max(8) as u16; + let popup_height = (row_count as u16 + 4) + .min(area.height.saturating_sub(4)) + .max(8); let popup_area = Rect { x: area.x + (area.width.saturating_sub(popup_width)) / 2, y: area.y + (area.height.saturating_sub(popup_height)) / 2, @@ -409,22 +431,19 @@ impl ModalView for ModelPickerView { }) .collect(); - // Fallback row: "auto" if not set, otherwise the current custom model. - let fallback_label = if self.initial_model == "auto" { - "auto — select per turn".to_string() - } else if self.show_custom_model_row { - format!("{} — custom", self.initial_model) - } else { - "auto — select per turn".to_string() - }; - let fallback_hint = if self.initial_model == "auto" { - "Let CodeWhale pick the best model each turn".to_string() - } else if self.show_custom_model_row { - "Current model (not a standard route)".to_string() - } else { - "Let CodeWhale pick the best model each turn".to_string() - }; - rows.push((fallback_label, fallback_hint)); + // Fallback row 1: always "auto". + rows.push(( + "auto — select per turn".to_string(), + "Let CodeWhale pick the best model each turn".to_string(), + )); + + // Fallback row 2: custom model when the current model isn't recognized. + if self.show_custom_model_row { + rows.push(( + format!("{} — custom", self.initial_model), + "Current model (not a standard route)".to_string(), + )); + } self.render_pane( inner, @@ -746,10 +765,12 @@ mod tests { app.auto_model = false; app.reasoning_effort = ReasoningEffort::High; let view = ModelPickerView::new(&app); - // Custom model → fallback row - assert_eq!(view.selected_route_idx, WHALE_ROUTES.len()); + // Custom model → second fallback row (after "auto") + assert_eq!(view.selected_route_idx, WHALE_ROUTES.len() + 1); assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX"); assert_eq!(view.resolved_effort(), ReasoningEffort::High); + // Row count includes routes + auto + custom + assert_eq!(view.whale_route_row_count(), WHALE_ROUTES.len() + 2); } #[test] From bf30aa3efed11ad641475fa8ddbbe48655784359 Mon Sep 17 00:00:00 2001 From: Justin Gao Date: Fri, 29 May 2026 10:22:44 +0800 Subject: [PATCH 16/26] fix: move render_whale_routes/render_classic out of ModalView trait impl These two methods were accidentally placed inside the ModalView trait implementation block, which caused E0407 compilation errors on CI. They are now in a separate impl ModelPickerView block. --- crates/tui/src/tui/model_picker.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 9d8c9d26..80128148 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -383,7 +383,9 @@ impl ModalView for ModelPickerView { self.render_classic(area, buf); } } +} +impl ModelPickerView { /// Single-column whale-route list for DeepSeek providers. fn render_whale_routes(&self, area: Rect, buf: &mut Buffer) { let popup_width = 62.min(area.width.saturating_sub(4)).max(44); From 2ed13999b3c106ccc39fc01ba1926bd69a84ae98 Mon Sep 17 00:00:00 2001 From: Justin Gao Date: Fri, 29 May 2026 10:28:10 +0800 Subject: [PATCH 17/26] fix: remove unused WhaleRoute import, allow dead_code on for_model_effort - model_picker.rs no longer directly references WhaleRoute (uses WHALE_ROUTES.iter().position() instead of WhaleRoute::for_model_effort) - whale_routes.rs: for_model_effort is only used in tests; add #[allow(dead_code)] to suppress -D warnings in release builds --- crates/tui/src/tui/model_picker.rs | 2 +- crates/tui/src/tui/whale_routes.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 80128148..398b6407 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -24,7 +24,7 @@ use ratatui::{ use crate::palette; use crate::tui::app::{App, ReasoningEffort}; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; -use crate::tui::whale_routes::{WHALE_ROUTES, WhaleRoute}; +use crate::tui::whale_routes::WHALE_ROUTES; /// Models the picker exposes by default. Kept short on purpose — power /// users can still type `/model ` for anything else. diff --git a/crates/tui/src/tui/whale_routes.rs b/crates/tui/src/tui/whale_routes.rs index f80c9c4c..d62e90be 100644 --- a/crates/tui/src/tui/whale_routes.rs +++ b/crates/tui/src/tui/whale_routes.rs @@ -93,6 +93,7 @@ impl WhaleRoute { /// Look up the whale route for a given model id and reasoning effort. /// Returns `None` for non-DeepSeek models or unrecognized combinations. #[must_use] + #[allow(dead_code)] pub fn for_model_effort(model: &str, effort: ReasoningEffort) -> Option<&'static WhaleRoute> { WHALE_ROUTES .iter() From d7e6c85db5c4648a8dc9033a93f34b4db39f7013 Mon Sep 17 00:00:00 2001 From: jimmyzhuu Date: Sat, 30 May 2026 10:42:18 +0800 Subject: [PATCH 18/26] feat: add baidu web search backend --- crates/tui/src/config.rs | 61 ++++++- crates/tui/src/tools/web_search.rs | 257 ++++++++++++++++++++++++++++- 2 files changed, 311 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 282d2023..c0beec79 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -661,6 +661,9 @@ pub enum SearchProvider { /// or `METASO_API_KEY` env var; configurable via `[search] api_key`. #[serde(alias = "metaso")] Metaso, + /// Baidu AI Search API (). Requires api_key. + #[serde(alias = "baidu-search", alias = "baidu_ai_search")] + Baidu, } impl SearchProvider { @@ -671,6 +674,9 @@ impl SearchProvider { "duckduckgo" | "duck-duck-go" | "duck_duck_go" | "ddg" => Some(Self::DuckDuckGo), "tavily" => Some(Self::Tavily), "bocha" => Some(Self::Bocha), + "metaso" => Some(Self::Metaso), + "baidu" | "baidu-search" | "baidu_search" | "baidu-ai-search" + | "baidu_ai_search" => Some(Self::Baidu), _ => None, } } @@ -683,6 +689,7 @@ impl SearchProvider { Self::Tavily => "tavily", Self::Bocha => "bocha", Self::Metaso => "metaso", + Self::Baidu => "baidu", } } } @@ -714,11 +721,12 @@ pub struct SearchProviderResolution { /// Web search provider configuration (`[search]` table in config.toml). #[derive(Debug, Clone, Deserialize, Default)] pub struct SearchConfig { - /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha` | `metaso`. Default: `duckduckgo`. + /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha` | `metaso` | `baidu`. Default: `duckduckgo`. #[serde(default)] pub provider: Option, - /// API key for Tavily, Bocha, or Metaso. Not required for Bing or DuckDuckGo. + /// API key for Tavily, Bocha, Metaso, or Baidu. Not required for Bing or DuckDuckGo. /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in default. + /// Baidu also falls back to `BAIDU_SEARCH_API_KEY` env var. #[serde(default)] pub api_key: Option, } @@ -4281,6 +4289,35 @@ mod tests { ); } + #[test] + fn explicit_baidu_search_provider_is_preserved() { + let config: Config = toml::from_str( + r#" + [search] + provider = "baidu" + "#, + ) + .expect("search config"); + + assert_eq!( + config.search.and_then(|search| search.provider), + Some(SearchProvider::Baidu) + ); + } + + #[test] + fn baidu_search_provider_aliases_parse() { + assert_eq!(SearchProvider::parse("baidu"), Some(SearchProvider::Baidu)); + assert_eq!( + SearchProvider::parse("baidu-search"), + Some(SearchProvider::Baidu) + ); + assert_eq!( + SearchProvider::parse("baidu_ai_search"), + Some(SearchProvider::Baidu) + ); + } + #[test] fn search_provider_resolution_reports_default_source() { let _guard = lock_test_env(); @@ -4334,6 +4371,26 @@ mod tests { assert_eq!(resolution.source, SearchProviderSource::EnvOverride); } + #[test] + fn search_provider_env_override_accepts_baidu() { + let _guard = lock_test_env(); + let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER"); + unsafe { env::set_var("DEEPSEEK_SEARCH_PROVIDER", "baidu") }; + let config: Config = toml::from_str( + r#" + [search] + provider = "duckduckgo" + "#, + ) + .expect("search config"); + + let resolution = config.search_provider_resolution(); + + unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) }; + assert_eq!(resolution.provider, SearchProvider::Baidu); + assert_eq!(resolution.source, SearchProviderSource::EnvOverride); + } + #[test] fn search_provider_resolution_ignores_invalid_env_override() { let _guard = lock_test_env(); diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index 16c7b632..c5f68ab7 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -1,6 +1,6 @@ //! Web search tool backed by multiple providers: Bing HTML scrape, DuckDuckGo -//! (HTML scrape with Bing fallback), Tavily API, Bocha (博查) API, and -//! Metaso API (). +//! (HTML scrape with Bing fallback), Tavily API, Bocha (博查) API, +//! Metaso API (), and Baidu AI Search. //! //! This is the primary web search surface for agents. For browsing workflows //! (page open, click, screenshot) use a direct URL approach instead. @@ -27,6 +27,7 @@ const BING_HOST: &str = "www.bing.com"; const TAVILY_ENDPOINT: &str = "https://api.tavily.com/search"; const BOCHA_ENDPOINT: &str = "https://api.bochaai.com/v1/ai/search"; const METASO_ENDPOINT: &str = "https://metaso.cn/api/v1"; +const BAIDU_ENDPOINT: &str = "https://qianfan.baidubce.com/v2/ai_search/web_search"; /// Intentionally public default key provided by Metaso for open-source/community use. /// Last-resort fallback after config and env var. Rate-limited to ~100 searches/day. const METASO_DEFAULT_API_KEY: &str = "mk-E384C1DD5E8501BB7EFE27C949AFDE5B"; @@ -57,6 +58,7 @@ static TAG_RE: OnceLock = OnceLock::new(); static BING_RESULT_RE: OnceLock = OnceLock::new(); static BING_TITLE_RE: OnceLock = OnceLock::new(); static BING_SNIPPET_RE: OnceLock = OnceLock::new(); +static BEARER_TOKEN_RE: OnceLock = OnceLock::new(); fn get_title_re() -> &'static Regex { TITLE_RE.get_or_init(|| { @@ -99,6 +101,13 @@ fn get_bing_snippet_re() -> &'static Regex { }) } +fn get_bearer_token_re() -> &'static Regex { + BEARER_TOKEN_RE.get_or_init(|| { + Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+") + .expect("bearer token regex pattern is valid") + }) +} + const DEFAULT_MAX_RESULTS: usize = 5; const MAX_RESULTS: usize = 10; const DEFAULT_TIMEOUT_MS: u64 = 15_000; @@ -129,7 +138,7 @@ impl ToolSpec for WebSearchTool { } fn description(&self) -> &'static str { - "Search the web and return ranked results with URLs and snippets. Default backend is DuckDuckGo with Bing fallback; set `[search] provider = \"bing\" | \"tavily\" | \"bocha\"` in config.toml to switch backends. Use this instead of scraping search engines with `curl` in `exec_shell`. For a known canonical URL, prefer `fetch_url` directly." + "Search the web and return ranked results with URLs and snippets. Default backend is DuckDuckGo with Bing fallback; set `[search] provider = \"bing\" | \"tavily\" | \"bocha\" | \"metaso\" | \"baidu\"` in config.toml to switch backends. Use this instead of scraping search engines with `curl` in `exec_shell`. For a known canonical URL, prefer `fetch_url` directly." } fn input_schema(&self) -> Value { @@ -210,6 +219,13 @@ impl ToolSpec for WebSearchTool { .run_metaso_search(&query, max_results, timeout_ms, context) .await; } + SearchProvider::Baidu => { + let decider = context.network_policy.as_ref(); + check_policy(decider, "qianfan.baidubce.com")?; + return self + .run_baidu_search(&query, max_results, timeout_ms, context) + .await; + } SearchProvider::Bing | SearchProvider::DuckDuckGo => {} } @@ -645,6 +661,88 @@ impl WebSearchTool { search_tool_result(query.to_string(), "metaso", results, None) } + + /// Search via Baidu AI Search API (). + async fn run_baidu_search( + &self, + query: &str, + max_results: usize, + timeout_ms: u64, + context: &ToolContext, + ) -> Result { + let env_key = std::env::var("BAIDU_SEARCH_API_KEY").ok(); + let api_key = context + .search_api_key + .as_deref() + .or(env_key.as_deref()) + .ok_or_else(|| { + ToolError::execution_failed( + "Baidu search requires an API key. Set `BAIDU_SEARCH_API_KEY` or `[search] api_key` in config.toml.", + ) + })?; + + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .build() + .map_err(|e| { + ToolError::execution_failed(format!("Failed to build HTTP client: {e}")) + })?; + + let payload = json!({ + "messages": [ + { + "role": "user", + "content": query, + } + ], + "search_source": "baidu_search", + "resource_type_filter": [ + { + "type": "web", + "top_k": max_results, + } + ], + }); + + let resp = client + .post(BAIDU_ENDPOINT) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {api_key}")) + .json(&payload) + .send() + .await + .map_err(|e| { + ToolError::execution_failed(format!("Baidu search request failed: {e}")) + })?; + + let status = resp.status(); + let body = resp.text().await.map_err(|e| { + ToolError::execution_failed(format!("Failed to read Baidu response: {e}")) + })?; + + if !status.is_success() { + let msg = match status.as_u16() { + 401 | 403 => "Baidu search API key rejected — check BAIDU_SEARCH_API_KEY or `[search] api_key` in config.toml".to_string(), + 429 => "Baidu search rate-limited — wait and retry, or check your Baidu AI Search quota".to_string(), + _ => { + let truncated = truncate_error_body(&body); + format!("Baidu search failed: HTTP {} — {truncated}", status.as_u16()) + } + }; + return Err(ToolError::execution_failed(msg)); + } + + let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| { + ToolError::execution_failed(format!("Failed to parse Baidu response: {e}")) + })?; + + if let Some(error) = baidu_error_message(&parsed) { + return Err(ToolError::execution_failed(error)); + } + + let results = parse_baidu_results(&parsed, max_results); + search_tool_result(query.to_string(), "baidu", results, None) + } } fn truncate_error_body(body: &str) -> String { @@ -662,12 +760,69 @@ fn truncate_error_body(body: &str) -> String { fn sanitize_error_body(body: &str) -> String { let stripped = strip_html_tags(body); - stripped + let visible: String = stripped .chars() .filter(|c| !c.is_control() || c.is_ascii_whitespace()) + .collect(); + get_bearer_token_re() + .replace_all(&visible, "Bearer [REDACTED]") + .to_string() +} + +fn parse_baidu_results(parsed: &Value, max_results: usize) -> Vec { + parsed + .get("references") + .and_then(|v| v.as_array()) + .into_iter() + .flat_map(|arr| arr.iter()) + .filter_map(|item| { + let title = item + .get("title") + .or_else(|| item.get("name")) + .and_then(|s| s.as_str())? + .trim(); + let url = item + .get("url") + .or_else(|| item.get("link")) + .and_then(|s| s.as_str())? + .trim(); + if title.is_empty() || url.is_empty() { + return None; + } + let snippet = item + .get("content") + .or_else(|| item.get("snippet")) + .or_else(|| item.get("summary")) + .and_then(|s| s.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string); + Some(WebSearchEntry { + title: title.to_string(), + url: url.to_string(), + snippet, + }) + }) + .take(max_results) .collect() } +fn baidu_error_message(parsed: &Value) -> Option { + let code = parsed + .get("error_code") + .or_else(|| parsed.get("code")) + .and_then(|v| v.as_i64())?; + if code == 0 { + return None; + } + let message = parsed + .get("error_msg") + .or_else(|| parsed.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + Some(format!("Baidu search API error (code {code}: {message})")) +} + fn extract_search_query(input: &Value) -> Result { for key in ["query", "q"] { if let Some(value) = input.get(key) { @@ -1028,7 +1183,7 @@ mod tests { use super::{ ERROR_BODY_PREVIEW_BYTES, WebSearchEntry, WebSearchTool, decode_html_entities, extract_search_query, is_likely_spam_results, optional_search_max_results, root_domain, - sanitize_error_body, truncate_error_body, + parse_baidu_results, sanitize_error_body, truncate_error_body, }; use serde_json::json; @@ -1295,6 +1450,69 @@ mod tests { assert_eq!(sanitized, "error"); } + #[test] + fn sanitize_error_body_redacts_bearer_tokens() { + let body = + r#"{"error":"bad token","authorization":"Bearer bce-v3/ALTAK-example/secret"}"#; + + let sanitized = sanitize_error_body(body); + + assert!(!sanitized.contains("bce-v3/ALTAK-example/secret")); + assert!(sanitized.contains("Bearer [REDACTED]")); + } + + #[test] + fn parse_baidu_references_extracts_ranked_results() { + let body = json!({ + "references": [ + { + "title": "Rust 官方文档", + "url": "https://www.rust-lang.org/", + "content": "Rust 是一门注重性能和可靠性的语言。" + }, + { + "title": "Cargo Book", + "url": "https://doc.rust-lang.org/cargo/", + "snippet": "Cargo is Rust's package manager." + } + ] + }); + + let results = parse_baidu_results(&body, 10); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].title, "Rust 官方文档"); + assert_eq!(results[0].url, "https://www.rust-lang.org/"); + assert_eq!( + results[0].snippet.as_deref(), + Some("Rust 是一门注重性能和可靠性的语言。") + ); + assert_eq!(results[1].title, "Cargo Book"); + assert_eq!(results[1].url, "https://doc.rust-lang.org/cargo/"); + assert_eq!( + results[1].snippet.as_deref(), + Some("Cargo is Rust's package manager.") + ); + } + + #[test] + fn parse_baidu_references_skips_incomplete_entries() { + let body = json!({ + "references": [ + {"title": "No URL", "content": "missing url"}, + {"url": "https://example.com/no-title", "content": "missing title"}, + {"title": "Valid", "url": "https://example.com/valid"} + ] + }); + + let results = parse_baidu_results(&body, 10); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "Valid"); + assert_eq!(results[0].url, "https://example.com/valid"); + assert_eq!(results[0].snippet, None); + } + #[tokio::test] async fn tavily_provider_without_api_key_surfaces_clear_error_not_silent_fallback() { // Trust-boundary pin: if a user has opted into Tavily but @@ -1341,6 +1559,35 @@ mod tests { ); } + #[tokio::test] + async fn baidu_provider_without_api_key_surfaces_clear_error_not_silent_fallback() { + use crate::config::SearchProvider; + use crate::tools::spec::{ToolContext, ToolSpec}; + + let prev = std::env::var_os("BAIDU_SEARCH_API_KEY"); + unsafe { std::env::remove_var("BAIDU_SEARCH_API_KEY") }; + + let tmp = tempfile::tempdir().expect("tempdir"); + let mut ctx = ToolContext::new(tmp.path().to_path_buf()); + ctx.search_provider = SearchProvider::Baidu; + ctx.search_api_key = None; + let err = WebSearchTool + .execute(json!({"query": "anything"}), &ctx) + .await + .expect_err("missing api_key must surface as ToolError"); + + match prev { + Some(value) => unsafe { std::env::set_var("BAIDU_SEARCH_API_KEY", value) }, + None => unsafe { std::env::remove_var("BAIDU_SEARCH_API_KEY") }, + } + + let msg = err.to_string(); + assert!( + msg.contains("Baidu") && msg.contains("API key"), + "error must name the provider and missing key; got `{msg}`" + ); + } + #[tokio::test] async fn metaso_provider_uses_built_in_key_when_no_config_key_set() { // Unlike Tavily/Bocha, Metaso falls back to a built-in default, so From e227efbd800b47a3149fe18065a15cc98521c52e Mon Sep 17 00:00:00 2001 From: jimmyzhuu Date: Sat, 30 May 2026 10:48:16 +0800 Subject: [PATCH 19/26] docs: document baidu search backend --- config.example.toml | 28 ++++++++++++++++------------ crates/tui/src/config.rs | 5 +++-- crates/tui/src/core/engine.rs | 3 ++- crates/tui/src/tools/spec.rs | 3 ++- crates/tui/src/tools/web_search.rs | 9 ++++----- docs/CONFIGURATION.md | 17 ++++++++++++----- docs/TOOL_SURFACE.md | 2 +- 7 files changed, 40 insertions(+), 27 deletions(-) diff --git a/config.example.toml b/config.example.toml index c8a7155b..4169be0f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -265,25 +265,29 @@ max_subagents = 10 # optional (1-20) # ───────────────────────────────────────────────────────────────────────────────── # Web Search Provider # ───────────────────────────────────────────────────────────────────────────────── -# Choose which backend `web_search` uses. Default is Bing HTML scraping — no -# API key needed. DuckDuckGo remains selectable and still falls back to Bing -# when its HTML endpoint returns a bot challenge or no parseable results. -# Switch to Tavily or Bocha for reliable search in mainland China. +# Choose which backend `web_search` uses. Default is DuckDuckGo HTML scraping +# with Bing fallback — no API key needed. Bing remains selectable for users who +# explicitly prefer it. Switch to Tavily, Bocha, Metaso, or Baidu for +# API-backed search. # # [search] -# provider = "bing" # bing | duckduckgo | tavily | bocha | metaso +# provider = "duckduckgo" # duckduckgo | bing | tavily | bocha | metaso | baidu # # duckduckgo: HTML scrape with Bing fallback -# # tavily: https://tavily.com — AI search, needs api_key -# # bocha: https://bochaai.com — 博查AI搜索,国内友好,需api_key -# # metaso: https://metaso.cn — 秘塔AI搜索,每天 100 次免费 -# # 设置 METASO_API_KEY 或 [search] api_key 可提升额度 -# api_key = "tvly-YOUR_KEY" # required for tavily, bocha, and metaso (optional for metaso) -# # WARNING: treat config.toml like a secret file when -# # storing API keys. Use env vars or `auth set` instead. +# # bing: HTML scrape, no API key +# # tavily: https://tavily.com — AI search, needs api_key +# # bocha: https://bochaai.com — 博查AI搜索,国内友好,需api_key +# # metaso: https://metaso.cn — 秘塔AI搜索,每天 100 次免费 +# # 设置 METASO_API_KEY 或 [search] api_key 可提升额度 +# # baidu: 百度 AI Search via qianfan.baidubce.com,需 api_key +# api_key = "YOUR_SEARCH_KEY" # required for tavily, bocha, and baidu; optional for metaso +# # WARNING: treat config.toml like a secret file when +# # storing API keys. Prefer env vars for local smoke tests. # # Env-var overrides: # DEEPSEEK_SEARCH_PROVIDER → search.provider # DEEPSEEK_SEARCH_API_KEY → search.api_key +# METASO_API_KEY → metaso key fallback +# BAIDU_SEARCH_API_KEY → baidu key fallback # ───────────────────────────────────────────────────────────────────────────────── # Network Policy (#135) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index c0beec79..03c19344 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -675,8 +675,9 @@ impl SearchProvider { "tavily" => Some(Self::Tavily), "bocha" => Some(Self::Bocha), "metaso" => Some(Self::Metaso), - "baidu" | "baidu-search" | "baidu_search" | "baidu-ai-search" - | "baidu_ai_search" => Some(Self::Baidu), + "baidu" | "baidu-search" | "baidu_search" | "baidu-ai-search" | "baidu_ai_search" => { + Some(Self::Baidu) + } _ => None, } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 02737eb7..37877750 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -170,8 +170,9 @@ pub struct EngineConfig { pub workshop: Option, /// Which search backend `web_search` should use. Default: DuckDuckGo. pub search_provider: crate::config::SearchProvider, - /// API key for Tavily, Bocha, or Metaso. `None` for Bing or DuckDuckGo. + /// API key for Tavily, Bocha, Metaso, or Baidu. `None` for Bing or DuckDuckGo. /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key. + /// Baidu also falls back to `BAIDU_SEARCH_API_KEY`. pub search_api_key: Option, /// Per-step DeepSeek API timeout for sub-agent `create_message` requests. /// Resolved from `[subagents] api_timeout_secs` (clamped to 1..=1800) diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 8f2e186e..6a66c37f 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -165,8 +165,9 @@ pub struct ToolContext { /// Which search backend `web_search` should use. Default: DuckDuckGo. Set via /// `[search] provider` in config.toml. pub search_provider: crate::config::SearchProvider, - /// API key for Tavily, Bocha, or Metaso. `None` for Bing or DuckDuckGo. + /// API key for Tavily, Bocha, Metaso, or Baidu. `None` for Bing or DuckDuckGo. /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key. + /// Baidu also falls back to `BAIDU_SEARCH_API_KEY`. pub search_api_key: Option, /// Per-session workshop variable store (#548). Holds the raw content of diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index c5f68ab7..a86b3c0b 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -1182,8 +1182,8 @@ fn extract_query_param(url: &str, key: &str) -> Option { mod tests { use super::{ ERROR_BODY_PREVIEW_BYTES, WebSearchEntry, WebSearchTool, decode_html_entities, - extract_search_query, is_likely_spam_results, optional_search_max_results, root_domain, - parse_baidu_results, sanitize_error_body, truncate_error_body, + extract_search_query, is_likely_spam_results, optional_search_max_results, + parse_baidu_results, root_domain, sanitize_error_body, truncate_error_body, }; use serde_json::json; @@ -1452,12 +1452,11 @@ mod tests { #[test] fn sanitize_error_body_redacts_bearer_tokens() { - let body = - r#"{"error":"bad token","authorization":"Bearer bce-v3/ALTAK-example/secret"}"#; + let body = r#"{"error":"bad token","authorization":"Bearer test-token/with+chars="}"#; let sanitized = sanitize_error_body(body); - assert!(!sanitized.contains("bce-v3/ALTAK-example/secret")); + assert!(!sanitized.contains("test-token/with+chars=")); assert!(sanitized.contains("Bearer [REDACTED]")); } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f0b75de0..3d4ce5d1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -663,14 +663,21 @@ Use `codewhale-tui features list` to inspect known flags and their effective sta `web_search` uses DuckDuckGo by default and does not require an API key. The DuckDuckGo path keeps a Bing fallback when DDG returns a bot challenge or no parseable results. Bing remains selectable for users who explicitly want it, -and Tavily or Bocha can be selected when an API-backed provider is preferred. -**Metaso** ([metaso.cn](https://metaso.cn)) -100 searches/day free quota — set `METASO_API_KEY` or `[search] api_key` for a higher quota. +and Tavily, Bocha, Metaso, or Baidu can be selected when an API-backed provider +is preferred. + +**Metaso** ([metaso.cn](https://metaso.cn)) has a 100 searches/day free quota; +set `METASO_API_KEY` or `[search] api_key` for a higher quota. + +**Baidu** uses Baidu AI Search at +`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set +`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend +only; it does not add a Baidu model provider. ```toml [search] -provider = "duckduckgo" # duckduckgo | bing | tavily | bocha | metaso -# api_key = "YOUR_KEY" # required for tavily and bocha; optional for metaso (100 searches/day free quota) +provider = "baidu" # duckduckgo | bing | tavily | bocha | metaso | baidu +# api_key = "YOUR_KEY" # required for tavily, bocha, and baidu; optional for metaso ``` ## Local Media Attachments diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index aa31fb4e..36933b0d 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -35,7 +35,7 @@ chosen over the available shell equivalent. Companion to `crates/tui/src/prompts |---|---| | `grep_files` | Regex search file contents within the workspace; structured matches + context lines. Pure-Rust (`regex` crate), no `rg`/`grep` shell-out. | | `file_search` | Fuzzy-match filenames (not contents). Use when you know roughly the name. | -| `web_search` | DuckDuckGo by default with Bing fallback; Bing, Tavily, and Bocha are selectable in config. Ranked snippets + `ref_id` for citation. | +| `web_search` | DuckDuckGo by default with Bing fallback; Bing, Tavily, Bocha, Metaso, and Baidu are selectable in config. Ranked snippets + `ref_id` for citation. | | `fetch_url` | Direct HTTP GET on a known URL. Faster than `web_search` when the link is already known. HTML stripped to text by default. | ### Shell From 7842908028f5e43ac8058cda6e9a1329ab93526b Mon Sep 17 00:00:00 2001 From: jimmyzhuu Date: Sat, 30 May 2026 11:13:37 +0800 Subject: [PATCH 20/26] fix: use official baidu search source --- crates/tui/src/tools/web_search.rs | 69 ++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index a86b3c0b..071cb098 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -688,21 +688,7 @@ impl WebSearchTool { ToolError::execution_failed(format!("Failed to build HTTP client: {e}")) })?; - let payload = json!({ - "messages": [ - { - "role": "user", - "content": query, - } - ], - "search_source": "baidu_search", - "resource_type_filter": [ - { - "type": "web", - "top_k": max_results, - } - ], - }); + let payload = baidu_search_payload(query, max_results); let resp = client .post(BAIDU_ENDPOINT) @@ -823,6 +809,24 @@ fn baidu_error_message(parsed: &Value) -> Option { Some(format!("Baidu search API error (code {code}: {message})")) } +fn baidu_search_payload(query: &str, max_results: usize) -> Value { + json!({ + "messages": [ + { + "role": "user", + "content": query, + } + ], + "search_source": "baidu_search_v2", + "resource_type_filter": [ + { + "type": "web", + "top_k": max_results, + } + ], + }) +} + fn extract_search_query(input: &Value) -> Result { for key in ["query", "q"] { if let Some(value) = input.get(key) { @@ -1181,9 +1185,10 @@ fn extract_query_param(url: &str, key: &str) -> Option { #[cfg(test)] mod tests { use super::{ - ERROR_BODY_PREVIEW_BYTES, WebSearchEntry, WebSearchTool, decode_html_entities, - extract_search_query, is_likely_spam_results, optional_search_max_results, - parse_baidu_results, root_domain, sanitize_error_body, truncate_error_body, + ERROR_BODY_PREVIEW_BYTES, WebSearchEntry, WebSearchTool, baidu_search_payload, + decode_html_entities, extract_search_query, is_likely_spam_results, + optional_search_max_results, parse_baidu_results, root_domain, sanitize_error_body, + truncate_error_body, }; use serde_json::json; @@ -1512,6 +1517,34 @@ mod tests { assert_eq!(results[0].snippet, None); } + #[test] + fn baidu_search_payload_uses_official_search_source() { + let payload = baidu_search_payload("Rust cargo workspace", 3); + + assert_eq!( + payload.get("search_source").and_then(|v| v.as_str()), + Some("baidu_search_v2") + ); + assert_eq!( + payload + .get("messages") + .and_then(|v| v.as_array()) + .and_then(|messages| messages.first()) + .and_then(|message| message.get("content")) + .and_then(|v| v.as_str()), + Some("Rust cargo workspace") + ); + assert_eq!( + payload + .get("resource_type_filter") + .and_then(|v| v.as_array()) + .and_then(|filters| filters.first()) + .and_then(|filter| filter.get("top_k")) + .and_then(|v| v.as_u64()), + Some(3) + ); + } + #[tokio::test] async fn tavily_provider_without_api_key_surfaces_clear_error_not_silent_fallback() { // Trust-boundary pin: if a user has opted into Tavily but From ce959f6841e85f4dd039b6e2ba3737b23dae72e1 Mon Sep 17 00:00:00 2001 From: jimmyzhuu Date: Sat, 30 May 2026 11:34:07 +0800 Subject: [PATCH 21/26] fix: honor search api key env override --- crates/tui/src/config.rs | 24 ++++++++++++++++++++++++ crates/tui/src/tools/web_search.rs | 1 - 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 03c19344..81fd0e75 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2855,6 +2855,14 @@ fn apply_env_overrides(config: &mut Config) { if let Ok(value) = std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH") { config.managed_config_path = Some(value); } + if let Ok(value) = std::env::var("DEEPSEEK_SEARCH_API_KEY") + && !value.trim().is_empty() + { + config + .search + .get_or_insert_with(SearchConfig::default) + .api_key = Some(value); + } if let Ok(value) = std::env::var("DEEPSEEK_REQUIREMENTS_PATH") { config.requirements_path = Some(value); } @@ -4392,6 +4400,22 @@ mod tests { assert_eq!(resolution.source, SearchProviderSource::EnvOverride); } + #[test] + fn apply_env_overrides_sets_search_api_key() { + let _guard = lock_test_env(); + let prev = env::var_os("DEEPSEEK_SEARCH_API_KEY"); + unsafe { env::set_var("DEEPSEEK_SEARCH_API_KEY", "search-env-key") }; + let mut config = Config::default(); + + apply_env_overrides(&mut config); + + unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_API_KEY", prev) }; + assert_eq!( + config.search.and_then(|search| search.api_key), + Some("search-env-key".to_string()) + ); + } + #[test] fn search_provider_resolution_ignores_invalid_env_override() { let _guard = lock_test_env(); diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index 071cb098..b4fd04d8 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -692,7 +692,6 @@ impl WebSearchTool { let resp = client .post(BAIDU_ENDPOINT) - .header("Content-Type", "application/json") .header("Authorization", format!("Bearer {api_key}")) .json(&payload) .send() From ab81d1a2e1d114e376ea4d1cdf3866f4c98702ba Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sat, 30 May 2026 22:30:51 -0700 Subject: [PATCH 22/26] fix(runtime): report custom skills search directories --- crates/tui/src/runtime_api.rs | 50 ++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index ffa7fd77..553d308a 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use std::convert::Infallible; use std::fs; use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path as FsPath, PathBuf}; use std::process::Command; use std::sync::Arc; use std::time::Duration; @@ -961,10 +961,9 @@ async fn list_skills( State(state): State, ) -> Result, ApiError> { let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let registry = - crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); + let registry = crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); let skill_state = state.skill_state.lock().await; - let directories = crate::skills::skills_directories(&state.workspace); + let directories = skills_search_directories(&state.workspace, &skills_dir); let skills = registry .list() .iter() @@ -990,12 +989,12 @@ async fn set_skill_enabled( Json(req): Json, ) -> Result, ApiError> { let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let registry = - crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); + let registry = crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); let exists = registry.list().iter().any(|skill| skill.name == name); if !exists { return Err(ApiError::not_found(format!( - "skill '{name}' not found" + "skill '{name}' not found in searched directories: {}", + format_skill_search_paths(&skills_search_directories(&state.workspace, &skills_dir)) ))); } @@ -1771,6 +1770,25 @@ fn resolve_skills_dir(config: &Config, workspace: &std::path::Path) -> PathBuf { config.skills_dir() } +fn skills_search_directories(workspace: &FsPath, skills_dir: &FsPath) -> Vec { + let mut directories = crate::skills::skills_directories(workspace); + if skills_dir.is_dir() && !directories.iter().any(|path| path == skills_dir) { + directories.push(skills_dir.to_path_buf()); + } + directories +} + +fn format_skill_search_paths(directories: &[PathBuf]) -> String { + if directories.is_empty() { + return "".to_string(); + } + directories + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") +} + fn load_mcp_config_or_default(path: &std::path::Path) -> Result { crate::mcp::load_config(path) .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e:#}"))) @@ -3890,6 +3908,24 @@ mod tests { assert_eq!(resolved, expected); } + #[test] + fn skills_search_directories_includes_custom_skills_dir() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path().join("workspace"); + let custom_skills = tmp.path().join("custom-skills"); + fs::create_dir_all(&workspace).expect("create workspace"); + fs::create_dir_all(&custom_skills).expect("create custom skills"); + + let directories = skills_search_directories(&workspace, &custom_skills); + + assert!( + directories.iter().any(|dir| dir == &custom_skills), + "custom skills_dir must be reported when discovery searches it" + ); + let message = format_skill_search_paths(&directories); + assert!(message.contains("custom-skills")); + } + /// A `skills` symlink that points outside the workspace must NOT be /// returned as the resolved skills directory. Containment check ensures /// the canonicalized candidate stays under the canonicalized workspace From 39fc14b948d677808a5928e27f4ab6c3bc907391 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sat, 30 May 2026 22:42:47 -0700 Subject: [PATCH 23/26] fix(runtime): identify bundled skill entries by path --- crates/tui/src/runtime_api.rs | 81 ++++++++++++++++++++++++++++++++--- crates/tui/src/skills/mod.rs | 4 ++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 553d308a..3b2de2f6 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -961,9 +961,8 @@ async fn list_skills( State(state): State, ) -> Result, ApiError> { let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let registry = crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); + let (registry, directories) = discover_skills_for_runtime_api(&state.workspace, &skills_dir); let skill_state = state.skill_state.lock().await; - let directories = skills_search_directories(&state.workspace, &skills_dir); let skills = registry .list() .iter() @@ -972,7 +971,7 @@ async fn list_skills( description: skill.description.clone(), path: skill.path.clone(), enabled: skill_state.is_enabled(&skill.name), - is_bundled: crate::skills::is_bundled_skill_name(&skill.name), + is_bundled: skill_entry_is_bundled(skill, &skills_dir), }) .collect(); Ok(Json(SkillsResponse { @@ -989,12 +988,12 @@ async fn set_skill_enabled( Json(req): Json, ) -> Result, ApiError> { let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let registry = crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); + let (registry, directories) = discover_skills_for_runtime_api(&state.workspace, &skills_dir); let exists = registry.list().iter().any(|skill| skill.name == name); if !exists { return Err(ApiError::not_found(format!( "skill '{name}' not found in searched directories: {}", - format_skill_search_paths(&skills_search_directories(&state.workspace, &skills_dir)) + format_skill_search_paths(&directories) ))); } @@ -1778,6 +1777,31 @@ fn skills_search_directories(workspace: &FsPath, skills_dir: &FsPath) -> Vec (crate::skills::SkillRegistry, Vec) { + let directories = skills_search_directories(workspace, skills_dir); + let registry = crate::skills::discover_from_directories(directories.clone()); + (registry, directories) +} + +fn skill_entry_is_bundled(skill: &crate::skills::Skill, skills_dir: &FsPath) -> bool { + if !crate::skills::is_bundled_skill_name(&skill.name) { + return false; + } + + let expected_path = skills_dir.join(&skill.name).join("SKILL.md"); + paths_refer_to_same_file(&skill.path, &expected_path) +} + +fn paths_refer_to_same_file(left: &FsPath, right: &FsPath) -> bool { + match (fs::canonicalize(left), fs::canonicalize(right)) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + fn format_skill_search_paths(directories: &[PathBuf]) -> String { if directories.is_empty() { return "".to_string(); @@ -3926,6 +3950,53 @@ mod tests { assert!(message.contains("custom-skills")); } + #[test] + fn skill_entry_is_bundled_requires_configured_bundle_path() { + let tmp = tempfile::tempdir().expect("tempdir"); + let bundled_skills_dir = tmp.path().join("bundled-skills"); + let bundled_skill_path = bundled_skills_dir.join("delegate").join("SKILL.md"); + let override_skill_path = tmp + .path() + .join("workspace") + .join(".agents") + .join("skills") + .join("delegate") + .join("SKILL.md"); + fs::create_dir_all(bundled_skill_path.parent().expect("bundled parent")) + .expect("create bundled skill dir"); + fs::create_dir_all(override_skill_path.parent().expect("override parent")) + .expect("create override skill dir"); + fs::write( + &bundled_skill_path, + "---\nname: delegate\ndescription: bundled\n---\n", + ) + .expect("write bundled skill"); + fs::write( + &override_skill_path, + "---\nname: delegate\ndescription: override\n---\n", + ) + .expect("write override skill"); + + let bundled_skill = crate::skills::Skill { + name: "delegate".to_string(), + description: String::new(), + body: String::new(), + path: bundled_skill_path, + }; + let override_skill = crate::skills::Skill { + name: "delegate".to_string(), + description: String::new(), + body: String::new(), + path: override_skill_path, + }; + + assert!(skill_entry_is_bundled(&bundled_skill, &bundled_skills_dir)); + assert!(!skill_entry_is_bundled( + &override_skill, + &bundled_skills_dir + )); + } + /// A `skills` symlink that points outside the workspace must NOT be /// returned as the resolved skills directory. Containment check ensures /// the canonicalized candidate stays under the canonicalized workspace diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index d962d32e..d2c2f6ad 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -580,6 +580,10 @@ fn discover_for_workspace_dirs_and_dir(mut dirs: Vec, skills_dir: &Path dirs.push(skills_dir.to_path_buf()); } + discover_from_directories(dirs) +} + +pub(crate) fn discover_from_directories(dirs: impl IntoIterator) -> SkillRegistry { let mut merged = SkillRegistry::default(); for dir in dirs { let registry = SkillRegistry::discover(&dir); From 6df08a3dc2357be533788c770497f2ad865ece54 Mon Sep 17 00:00:00 2001 From: Justin Gao Date: Sun, 31 May 2026 13:42:57 +0800 Subject: [PATCH 24/26] fix(#2338): prevent known model + Auto effort from falling through to auto row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whale-route fallback in the picker constructor used show_custom_model_row as the gate for selecting the 'auto' vs custom row, but a known DeepSeek model (e.g. v4-pro) paired with ReasoningEffort::Auto would not match any whale route yet still have show_custom_model_row=false — silently landing on the auto row and replacing the explicit model with 'auto' on apply. Key the fallback on whether the initial model is actually 'auto' instead. When a whale-route fallback selects the custom row, ensure show_custom_model_row is set to true so the row is visible in the picker UI. Also: - Add regression test: known-model + Auto effort must not fall to auto row. - Clean up picker_auto_model_forces_auto_effort_on_apply: remove manual mutations of selected_model_idx / selected_effort_idx which whale-route mode never reads. - Rename Porpoise → Beluga per #2016, which excludes porpoises from the user-facing whale pool. --- crates/tui/src/tui/model_picker.rs | 52 +++++++++++++++++++++--------- crates/tui/src/tui/whale_routes.rs | 12 +++---- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 398b6407..105f4bdc 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -2,7 +2,7 @@ //! //! For DeepSeek providers the picker shows whale-sized routes — model + effort //! combinations sorted largest → fastest with friendly whale-species labels -//! (Blue Whale, Fin Whale, …, Porpoise). A single ↑/↓ selection sets both +//! (Blue Whale, Fin Whale, …, Beluga). A single ↑/↓ selection sets both //! model and effort at once. The "auto" option is always available; custom //! (unrecognised) model ids appear as a separate row. //! @@ -110,23 +110,30 @@ impl ModelPickerView { // When showing whale routes, find the matching route by position in the array // (not by sort_order, which happens to match today but is semantically wrong). - let selected_route_idx = if show_whale_routes { - WHALE_ROUTES + let (selected_route_idx, show_custom_model_row) = if show_whale_routes { + let idx = WHALE_ROUTES .iter() .position(|r| { r.model.eq_ignore_ascii_case(&initial_model) && r.effort == normalized }) .unwrap_or_else(|| { - // No matching whale route — fall back to "auto" (standard model) - // or the custom row (unrecognized model). - if show_custom_model_row { - WHALE_ROUTES.len() + 1 // custom model row - } else { + // No matching whale route — key the fallback on whether the + // current model is actually "auto", not on show_custom_model_row. + // Otherwise a known DeepSeek model (e.g. v4-pro) paired with + // ReasoningEffort::Auto silently falls through to the "auto" row + // and replaces the explicit model on apply. + if initial_model.eq_ignore_ascii_case("auto") { WHALE_ROUTES.len() // "auto" row + } else { + WHALE_ROUTES.len() + 1 // custom model row } - }) + }); + // When the whale-route fallback selected the custom row, ensure it is + // visible so the user can see their current model in the picker. + let show_custom = show_custom_model_row || idx == WHALE_ROUTES.len() + 1; + (idx, show_custom) } else { - 0 + (0, show_custom_model_row) }; Self { @@ -621,12 +628,7 @@ mod tests { app.auto_model = true; app.reasoning_effort = ReasoningEffort::Off; - let mut view = ModelPickerView::new(&app); - view.selected_model_idx = 0; - view.selected_effort_idx = PICKER_EFFORTS - .iter() - .position(|effort| *effort == ReasoningEffort::Max) - .expect("max effort row"); + let view = ModelPickerView::new(&app); assert_eq!(view.resolved_model(), "auto"); assert_eq!(view.resolved_effort(), ReasoningEffort::Auto); @@ -747,6 +749,24 @@ mod tests { assert_eq!(view.resolved_effort(), ReasoningEffort::Max); } + #[test] + fn whale_routes_known_model_auto_effort_does_not_fall_to_auto() { + // Regression: a known DeepSeek model paired with ReasoningEffort::Auto + // must NOT fall through to the "auto" row — that would silently replace + // the explicit model with "auto" on apply. + let (mut app, _lock) = create_test_app(); + app.model = "deepseek-v4-pro".to_string(); + app.auto_model = false; + app.reasoning_effort = ReasoningEffort::Auto; + let view = ModelPickerView::new(&app); + // Should fall to custom row (WHALE_ROUTES.len() + 1), not auto row. + assert_eq!(view.selected_route_idx, WHALE_ROUTES.len() + 1); + assert_eq!(view.resolved_model(), "deepseek-v4-pro"); + assert_eq!(view.resolved_effort(), ReasoningEffort::Auto); + // The custom row must be visible so the user sees their current model. + assert!(view.show_custom_model_row); + } + #[test] fn whale_routes_auto_effort_maps_to_fallback_row() { let (mut app, _lock) = create_test_app(); diff --git a/crates/tui/src/tui/whale_routes.rs b/crates/tui/src/tui/whale_routes.rs index d62e90be..d4ef086f 100644 --- a/crates/tui/src/tui/whale_routes.rs +++ b/crates/tui/src/tui/whale_routes.rs @@ -12,7 +12,7 @@ //! 3. Sperm Whale — Pro + no thinking //! 4. Humpback — Flash + max thinking //! 5. Minke Whale — Flash + high thinking -//! 6. Porpoise — Flash + no thinking (smallest, fastest) +//! 6. Beluga — Flash + no thinking (smallest, fastest) //! //! Unknown or non-DeepSeek models fall back to the raw model id without //! fake whale labeling. @@ -80,7 +80,7 @@ pub const WHALE_ROUTES: &[WhaleRoute] = &[ description: "Fast model, moderate reasoning — tool execution, read-only scouting", }, WhaleRoute { - label: "Porpoise", + label: "Beluga", model: "deepseek-v4-flash", effort: ReasoningEffort::Off, sort_order: 5, @@ -135,10 +135,10 @@ mod tests { } #[test] - fn lookup_porpoise_for_flash_off() { + fn lookup_beluga_for_flash_off() { let route = WhaleRoute::for_model_effort("deepseek-v4-flash", ReasoningEffort::Off) - .expect("porpoise route exists"); - assert_eq!(route.label, "Porpoise"); + .expect("beluga route exists"); + assert_eq!(route.label, "Beluga"); assert_eq!(route.sort_order, 5); } @@ -163,7 +163,7 @@ mod tests { #[test] fn by_sort_order_finds_correct_routes() { assert_eq!(WhaleRoute::by_sort_order(0).unwrap().label, "Blue Whale"); - assert_eq!(WhaleRoute::by_sort_order(5).unwrap().label, "Porpoise"); + assert_eq!(WhaleRoute::by_sort_order(5).unwrap().label, "Beluga"); assert!(WhaleRoute::by_sort_order(99).is_none()); } From b75a1591760f534f9911a4acb3769b0d277b3844 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sat, 30 May 2026 22:58:51 -0700 Subject: [PATCH 25/26] fix: harden slop ledger rescue paths --- crates/tui/src/commands/config.rs | 2 +- crates/tui/src/commands/mod.rs | 2 +- crates/tui/src/core/engine.rs | 57 +++++++++------- crates/tui/src/localization.rs | 7 ++ crates/tui/src/slop_ledger.rs | 108 +++++++++++++++++++++++++----- 5 files changed, 131 insertions(+), 45 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 30c08c61..22b33f29 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -721,7 +721,7 @@ pub fn slop(_app: &mut App, arg: Option<&str>) -> CommandResult { let _ = writeln!( out, "[{}] {} ({:?} | {:?}) — {}", - &entry.id[..8], + crate::slop_ledger::short_id(&entry.id), entry.bucket.as_str(), entry.severity, entry.status, diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index e6afed10..f146952b 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -545,7 +545,7 @@ pub const COMMANDS: &[CommandInfo] = &[ name: "slop", aliases: &["canzha"], usage: "/slop [query|export]", - description_id: MessageId::CmdHelpDescription, + description_id: MessageId::CmdSlopDescription, }, ]; diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index b8070b25..8295ee58 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -12,7 +12,7 @@ use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::sync::{Arc, Mutex as StdMutex}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; use anyhow::Result; use futures_util::StreamExt; @@ -331,11 +331,10 @@ pub struct Engine { /// Diagnostics collected during the current step's tool calls. Drained /// and forwarded as a synthetic user message before the next API call. pending_lsp_blocks: Vec, - /// Cached SlopLedger gate block so `refresh_system_prompt` doesn't hit - /// the filesystem on every turn (#2127). `None` = not yet loaded; - /// `Some(None)` = loaded, no open entries; `Some(Some(...))` = loaded, - /// gate block ready. - slop_ledger_gate_cache: Option>, + /// Cached SlopLedger gate block keyed by the ledger file's modified time. + /// This keeps prompt refreshes cheap while still noticing append/update + /// writes from slop ledger tools during the same session. + slop_ledger_gate_cache: Option<(Option, Option)>, } // === Internal tool helpers === @@ -1851,25 +1850,8 @@ impl Engine { // SlopLedger completion-gate: inject unresolved slop entries into the // system prompt so the agent can autonomously review them before - // claiming the task is done (#2127). Cached to avoid filesystem I/O on - // every turn — only re-loaded when the cache is empty (first call or - // after invalidation). - let gate_block = match &self.slop_ledger_gate_cache { - Some(cached) => cached.clone(), - None => { - let loaded = crate::slop_ledger::SlopLedger::load() - .ok() - .and_then(|ledger| { - if ledger.has_open_entries() { - ledger.completion_gate_summary() - } else { - None - } - }); - self.slop_ledger_gate_cache = Some(loaded.clone()); - loaded - } - }; + // claiming the task is done (#2127). + let gate_block = self.slop_ledger_gate_block(); if let Some(ref block) = gate_block { if let Some(SystemPrompt::Text(prompt_text)) = &mut stable_prompt { prompt_text.push_str("\n\n"); @@ -1888,6 +1870,31 @@ impl Engine { } } + fn slop_ledger_gate_block(&mut self) -> Option { + let modified = crate::slop_ledger::SlopLedger::default_path() + .ok() + .and_then(|path| std::fs::metadata(path).ok()) + .and_then(|metadata| metadata.modified().ok()); + + if let Some((cached_modified, cached_block)) = &self.slop_ledger_gate_cache + && *cached_modified == modified + { + return cached_block.clone(); + } + + let loaded = crate::slop_ledger::SlopLedger::load() + .ok() + .and_then(|ledger| { + if ledger.has_open_entries() { + ledger.completion_gate_summary() + } else { + None + } + }); + self.slop_ledger_gate_cache = Some((modified, loaded.clone())); + loaded + } + fn merge_compaction_summary(&mut self, summary_prompt: Option) { if summary_prompt.is_none() { return; diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 874bb2ec..054b4fe6 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -298,6 +298,7 @@ pub enum MessageId { CmdSettingsDescription, CmdSkillDescription, CmdSkillsDescription, + CmdSlopDescription, CmdStashDescription, CmdStatusDescription, CmdStatuslineDescription, @@ -531,6 +532,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdSettingsDescription, MessageId::CmdSkillDescription, MessageId::CmdSkillsDescription, + MessageId::CmdSlopDescription, MessageId::CmdStashDescription, MessageId::CmdStatusDescription, MessageId::CmdStatuslineDescription, @@ -979,6 +981,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdSkillsDescription => { "List local skills (filter by `/skills `; --remote browses the curated registry)" } + MessageId::CmdSlopDescription => "Inspect or export the SlopLedger", MessageId::CmdStashDescription => { "Park or restore a composer draft (Ctrl+S to push, /stash list/pop)" } @@ -1367,6 +1370,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdSkillsDescription => { "ローカルスキルを一覧表示(`/skills ` で絞り込み、--remote で精選レジストリを参照)" } + MessageId::CmdSlopDescription => "Inspect or export the SlopLedger", MessageId::CmdStashDescription => { "コンポーザーの下書きを退避/復元(Ctrl+S で退避、/stash list|pop)" } @@ -1708,6 +1712,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdSkillsDescription => { "列出本地技能(用 `/skills ` 按名称前缀过滤,--remote 浏览精选注册表)" } + MessageId::CmdSlopDescription => "Inspect or export the SlopLedger", MessageId::CmdStashDescription => "暂存或恢复输入草稿(Ctrl+S 暂存,/stash list|pop)", MessageId::CmdStatusDescription => "显示当前运行状态", MessageId::CmdStatuslineDescription => "配置底栏要显示哪些条目", @@ -2045,6 +2050,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdSkillsDescription => { "Listar skills locais (filtre com `/skills `; --remote navega pelo registro curado)" } + MessageId::CmdSlopDescription => "Inspect or export the SlopLedger", MessageId::CmdStashDescription => { "Estacionar ou restaurar rascunho do compositor (Ctrl+S estaciona, /stash list|pop)" } @@ -2436,6 +2442,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CmdSkillsDescription => { "Listar skills locales (filtra con `/skills `; --remote navega el registro curado)" } + MessageId::CmdSlopDescription => "Inspect or export the SlopLedger", MessageId::CmdStashDescription => { "Estacionar o restaurar borrador del compositor (Ctrl+S estaciona, /stash list|pop)" } diff --git a/crates/tui/src/slop_ledger.rs b/crates/tui/src/slop_ledger.rs index 7a0f6048..30571252 100644 --- a/crates/tui/src/slop_ledger.rs +++ b/crates/tui/src/slop_ledger.rs @@ -304,9 +304,9 @@ impl SlopLedger { } /// Append one or more entries. Returns the new entry count and - /// the short ids of the appended entries (first 8 chars). + /// the short ids of the appended entries. pub fn append(&mut self, entries: Vec) -> (usize, Vec) { - let ids: Vec = entries.iter().map(|e| e.id[..8].to_string()).collect(); + let ids: Vec = entries.iter().map(|e| short_id(&e.id)).collect(); self.entries.extend(entries); (self.entries.len(), ids) } @@ -375,18 +375,21 @@ impl SlopLedger { status: SlopEntryStatus, cleanup_recommendation: Option, ) -> io::Result> { - let entry = match self.find_mut(id) { - Some(e) => e, - None => return Ok(None), + let full_id = { + let entry = match self.find_mut(id) { + Some(e) => e, + None => return Ok(None), + }; + entry.status = status; + entry.updated_at = chrono::Utc::now().to_rfc3339(); + if let Some(rec) = cleanup_recommendation { + entry.cleanup_recommendation = Some(rec); + } + entry.id.clone() }; - entry.status = status; - entry.updated_at = chrono::Utc::now().to_rfc3339(); - if let Some(rec) = cleanup_recommendation { - entry.cleanup_recommendation = Some(rec); - } self.save()?; - // Return a shared ref to the updated entry - Ok(self.entries.iter().find(|e| e.id == id)) + // Return a shared ref to the updated entry. + Ok(self.entries.iter().find(|e| e.id == full_id)) } /// Export all entries as a Markdown string suitable for handoff or @@ -430,7 +433,7 @@ impl SlopLedger { let title = truncate_str(&e.title, 60); out.push_str(&format!( "| {} | {:?} | {:?} | {:?} | {title} | {source} |\n", - &e.id[..8], + short_id(&e.id), e.severity, e.confidence, e.status @@ -440,7 +443,7 @@ impl SlopLedger { // Detailed entries for e in bucket_entries { - out.push_str(&format!("### {} — {}\n\n", &e.id[..8], e.title)); + out.push_str(&format!("### {} — {}\n\n", short_id(&e.id), e.title)); out.push_str(&format!("- **Severity**: {:?}\n", e.severity)); out.push_str(&format!("- **Confidence**: {:?}\n", e.confidence)); out.push_str(&format!("- **Status**: {:?}\n", e.status)); @@ -729,7 +732,7 @@ impl ToolSpec for SlopLedgerQueryTool { for entry in &results { out.push_str(&format!( "- [{}] **{}** ({:?} | {:?} | {:?}) — {}\n", - &entry.id[..8], + short_id(&entry.id), entry.bucket.as_str(), entry.severity, entry.confidence, @@ -806,7 +809,7 @@ impl ToolSpec for SlopLedgerUpdateTool { match ledger.update_status(id, status, cleanup) { Ok(Some(entry)) => Ok(ToolResult::success(format!( "Updated slop ledger entry {} ({}) → {:?}", - &entry.id[..8], + short_id(&entry.id), entry.title, entry.status ))), @@ -912,6 +915,14 @@ fn truncate_str(s: &str, max_chars: usize) -> String { format!("{truncated}…") } +/// Return a display-safe short id without assuming byte offsets are char +/// boundaries. Ledger ids are normally UUIDs, but imported or hand-edited +/// ledgers may contain shorter or non-ASCII ids. +#[must_use] +pub fn short_id(id: &str) -> String { + id.chars().take(8).collect() +} + /// Redact sensitive patterns from exported text: API keys and secrets /// paths. Scan the output for known key prefixes (`sk-`, `Bearer `, `dsk-`) /// and replace the token until a whitespace / punctuation boundary with @@ -961,7 +972,7 @@ fn redact_exported_text(text: &mut String) { impl SlopLedger { /// Completion-gate / verifier hook: returns `true` when there are - /// unresolved slop entries (status `Open` or `Investigate`) that the + /// unresolved slop entries (status `Open` or `InProgress`) that the /// agent should review before claiming the task is done. /// /// Tools and engine hooks can call this on claim-of-done to surface @@ -1005,7 +1016,7 @@ impl SlopLedger { out.push_str(&format!( "- **{}** `{}` ({:?}/{:?}): {}\n", e.bucket.as_str(), - &e.id[..8], + short_id(&e.id), e.severity, e.confidence, truncate_str(&e.title, 80), @@ -1062,6 +1073,44 @@ mod tests { assert_eq!(loaded.entries[0].title, "README is outdated"); } + #[test] + fn short_id_handles_short_and_non_ascii_ids() { + assert_eq!(short_id("abc"), "abc"); + assert_eq!(short_id("abcdefghi"), "abcdefgh"); + assert_eq!(short_id("残渣-ledger-entry"), "残渣-ledge"); + } + + #[test] + fn display_paths_do_not_panic_on_short_or_non_ascii_ids() { + let (_tmp, mut ledger) = temp_ledger(); + + let mut short = SlopEntry::new( + SlopBucket::StaleDocs, + SlopSeverity::Low, + SlopConfidence::High, + "short id".into(), + "desc".into(), + ); + short.id = "abc".into(); + + let mut unicode = SlopEntry::new( + SlopBucket::ToolGaps, + SlopSeverity::Medium, + SlopConfidence::Medium, + "unicode id".into(), + "desc".into(), + ); + unicode.id = "残渣-ledger-entry".into(); + + let (_total, ids) = ledger.append(vec![short, unicode]); + assert_eq!(ids, vec!["abc", "残渣-ledge"]); + + let md = ledger.export_markdown(None, None); + assert!(md.contains("| abc |")); + assert!(md.contains("| 残渣-ledge |")); + assert!(ledger.completion_gate_summary().is_some()); + } + #[test] fn query_by_bucket() { let (_tmp, mut ledger) = temp_ledger(); @@ -1144,6 +1193,29 @@ mod tests { ); } + #[test] + fn update_status_returns_entry_for_prefix_match() { + let (_tmp, mut ledger) = temp_ledger(); + + let entry = SlopEntry::new( + SlopBucket::NamingDrift, + SlopSeverity::Low, + SlopConfidence::High, + "naming issue".into(), + "desc".into(), + ); + let id = entry.id.clone(); + let prefix = short_id(&id); + let _ = ledger.append(vec![entry]); + ledger.save().unwrap(); + + let result = ledger + .update_status(&prefix, SlopEntryStatus::Resolved, None) + .unwrap(); + + assert_eq!(result.map(|entry| entry.id.as_str()), Some(id.as_str())); + } + #[test] fn export_markdown() { let (_tmp, mut ledger) = temp_ledger(); From a9c0a4ae3c5e62cb39dbf29aa9a26b58c4cfedbe Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sat, 30 May 2026 23:08:59 -0700 Subject: [PATCH 26/26] fix: cover slop command in Vietnamese locale --- crates/tui/src/localization.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index cca85a16..a2f0cc44 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1460,6 +1460,7 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CmdSkillsDescription => { "Liệt kê các kỹ năng cục bộ (lọc bằng `/skills `; --remote để duyệt kho lưu trữ được kiểm duyệt)" } + MessageId::CmdSlopDescription => "Kiểm tra hoặc xuất SlopLedger", MessageId::CmdStashDescription => { "Tạm cất hoặc khôi phục bản nháp (Ctrl+S để cất, /stash list/pop để xem/lấy ra)" }