diff --git a/crates/tui/src/commands/anchor.rs b/crates/tui/src/commands/anchor.rs new file mode 100644 index 00000000..2ae70901 --- /dev/null +++ b/crates/tui/src/commands/anchor.rs @@ -0,0 +1,280 @@ +//! Anchor command: keep critical facts across compaction. +//! +//! Unlike `/note` (active lookup), anchors are passive. They are automatically +//! re-injected into context after every compaction cycle. Use anchors to +//! preserve invariants like "This API's status field is unreliable" or +//! ".ssh/ must never be touched". + +use crate::tui::app::App; +use std::fs; +use std::io::Write; + +use super::CommandResult; + +const USAGE: &str = "/anchor | /anchor list | /anchor remove "; + +/// Handle the `/anchor` command with subcommands: +/// /anchor - add a new anchor +/// /anchor list - list all anchors +/// /anchor remove - remove anchor by 1-based index +pub fn anchor(app: &mut App, content: Option<&str>) -> CommandResult { + let input = match content { + Some(c) => c.trim(), + None => { + return CommandResult::error(format!("Usage: {USAGE}")); + } + }; + + if input.is_empty() { + return CommandResult::error(format!("Usage: {USAGE}")); + } + + // Parse subcommands. + if input.eq_ignore_ascii_case("list") { + return list_anchors(app); + } + + if let Some(rest) = input + .strip_prefix("remove ") + .or_else(|| input.strip_prefix("rm ")) + .or_else(|| input.strip_prefix("delete ")) + { + return remove_anchor(app, rest.trim()); + } + + // Default: add a new anchor. + add_anchor(app, input) +} + +fn anchors_path(app: &App) -> std::path::PathBuf { + app.workspace.join(".deepseek").join("anchors.md") +} + +/// Read and split anchors from the file. Each anchor is separated by "\n---\n". +fn read_anchors(app: &App) -> Vec { + let path = anchors_path(app); + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + content + .split("\n---\n") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Write anchors back to the file, joined by "\n---\n". +fn write_anchors(app: &App, anchors: &[String]) -> Result<(), String> { + let path = anchors_path(app); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create anchors directory: {e}"))?; + } + + let content = anchors.join("\n---\n"); + fs::write(&path, content).map_err(|e| format!("Failed to write anchors file: {e}")) +} + +fn add_anchor(app: &mut App, text: &str) -> CommandResult { + let path = anchors_path(app); + + // Ensure parent directory exists. + if let Some(parent) = path.parent() + && let Err(e) = fs::create_dir_all(parent) + { + return CommandResult::error(format!("Failed to create anchors directory: {e}")); + } + + // Append to anchors file. + let mut file = match fs::OpenOptions::new().create(true).append(true).open(&path) { + Ok(f) => f, + Err(e) => { + return CommandResult::error(format!("Failed to open anchors file: {e}")); + } + }; + + // Write separator and anchor content. + if let Err(e) = writeln!(file, "\n---\n{}", text) { + return CommandResult::error(format!("Failed to write anchor: {e}")); + } + + CommandResult::message(format!( + "Anchor pinned. It will be auto-injected into context after each compaction.\n\ + Stored in: {}", + path.display() + )) +} + +fn list_anchors(app: &App) -> CommandResult { + let anchors = read_anchors(app); + + if anchors.is_empty() { + return CommandResult::message( + "No anchors set. Use /anchor to pin a fact that survives compaction.", + ); + } + + let mut output = format!("Pinned anchors ({} total):\n", anchors.len()); + for (i, anchor) in anchors.iter().enumerate() { + output.push_str(&format!("\n {}. {}", i + 1, anchor)); + } + output.push_str("\n\nUse /anchor remove to remove an anchor."); + + CommandResult::message(output) +} + +fn remove_anchor(app: &mut App, index_str: &str) -> CommandResult { + let index: usize = match index_str.parse() { + Ok(n) if n >= 1 => n, + _ => { + return CommandResult::error( + "Invalid index. Use /anchor list to see anchor numbers, then /anchor remove .", + ); + } + }; + + let mut anchors = read_anchors(app); + + if index > anchors.len() { + return CommandResult::error(format!( + "Anchor #{index} does not exist. You have {} anchor(s). Use /anchor list to see them.", + anchors.len() + )); + } + + let removed = anchors.remove(index - 1); + if let Err(e) = write_anchors(app, &anchors) { + return CommandResult::error(e); + } + + CommandResult::message(format!("Removed anchor #{index}: {removed}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use tempfile::TempDir; + + fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: tmpdir.path().to_path_buf(), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: tmpdir.path().join("skills"), + memory_path: tmpdir.path().join("memory.md"), + notes_path: tmpdir.path().join("notes.txt"), + mcp_config_path: tmpdir.path().join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + App::new(options, &Config::default()) + } + + #[test] + fn test_anchor_without_content_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, None); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage:")); + } + + #[test] + fn test_anchor_with_empty_content_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some(" ")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage:")); + } + + #[test] + fn test_anchor_add() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some("API status field is unreliable")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("Anchor pinned")); + + let path = tmpdir.path().join(".deepseek").join("anchors.md"); + assert!(path.exists()); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("API status field is unreliable")); + } + + #[test] + fn test_anchor_list_empty() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some("list")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("No anchors set")); + } + + #[test] + fn test_anchor_list_with_items() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + anchor(&mut app, Some("First anchor")); + anchor(&mut app, Some("Second anchor")); + + let result = anchor(&mut app, Some("list")); + let msg = result.message.unwrap(); + assert!(msg.contains("2 total")); + assert!(msg.contains("1. First anchor")); + assert!(msg.contains("2. Second anchor")); + } + + #[test] + fn test_anchor_remove() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + anchor(&mut app, Some("First anchor")); + anchor(&mut app, Some("Second anchor")); + + let result = anchor(&mut app, Some("remove 1")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("Removed anchor #1")); + + let result = anchor(&mut app, Some("list")); + let msg = result.message.unwrap(); + assert!(msg.contains("1 total")); + assert!(msg.contains("Second anchor")); + assert!(!msg.contains("First anchor")); + } + + #[test] + fn test_anchor_remove_invalid_index() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + anchor(&mut app, Some("Only anchor")); + + let result = anchor(&mut app, Some("remove 5")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("does not exist")); + } + + #[test] + fn test_anchor_remove_non_numeric() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some("remove abc")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Invalid index")); + } +} diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index a08e49a0..093a61aa 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -3,6 +3,7 @@ //! This module provides a modular command system inspired by Codex-rs. //! Commands are organized by category and dispatched through a central registry. +mod anchor; mod attachment; mod config; mod core; @@ -134,6 +135,12 @@ impl CommandInfo { /// All registered commands pub const COMMANDS: &[CommandInfo] = &[ // Core commands + CommandInfo { + name: "anchor", + aliases: &[], + usage: "/anchor | /anchor list | /anchor remove ", + description_id: MessageId::CmdAnchorDescription, + }, CommandInfo { name: "help", aliases: &["?"], @@ -489,6 +496,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { // Match command or alias match command { // Core commands + "anchor" => anchor::anchor(app, arg), "help" | "?" => core::help(app, arg), "clear" => core::clear(app), "exit" | "quit" | "q" => core::exit(), diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 291679f6..83b12ac5 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -949,6 +949,44 @@ pub async fn compact_messages_safe( .unwrap_or_else(|| anyhow::anyhow!("Compaction failed after {MAX_RETRIES} retries"))) } +fn read_workspace_anchors(workspace: Option<&Path>) -> Vec { + let Some(ws) = workspace else { + return Vec::new(); + }; + + let anchors_path = ws.join(".deepseek").join("anchors.md"); + let Ok(content) = std::fs::read_to_string(anchors_path) else { + return Vec::new(); + }; + + content + .split("\n---\n") + .map(str::trim) + .filter(|anchor| !anchor.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn anchor_summary_section(workspace: Option<&Path>) -> String { + let anchors = read_workspace_anchors(workspace); + if anchors.is_empty() { + return String::new(); + } + + let mut section = String::from( + "## Pinned Facts (User Anchors)\n\n\ + The following facts were explicitly anchored by the user with `/anchor`. \ + Preserve them across compaction cycles.\n\n", + ); + + for anchor in anchors { + let _ = writeln!(section, "- {anchor}"); + } + + section.push_str("\n---\n\n"); + section +} + pub async fn compact_messages( client: &DeepSeekClient, messages: &[Message], @@ -984,11 +1022,14 @@ pub async fn compact_messages( // Extract workflow context (files touched, tasks in progress, etc.) let workflow_context = extract_workflow_context(&to_summarize, workspace); + let anchors_section = anchor_summary_section(workspace); + // Build new message list with enhanced summary as system block let summary_block = SystemBlock { block_type: "text".to_string(), text: format!( - "## 📋 Conversation Summary (Auto-Generated)\n\n\ + "{anchors_section}\ + ## 📋 Conversation Summary (Auto-Generated)\n\n\ {summary}\n\n\ ---\n\n\ ## 🔍 Workflow Context\n\n\ @@ -1454,6 +1495,33 @@ mod tests { } } + #[test] + fn anchor_summary_section_is_empty_without_workspace_or_file() { + assert!(anchor_summary_section(None).is_empty()); + + let tmpdir = tempfile::TempDir::new().unwrap(); + assert!(anchor_summary_section(Some(tmpdir.path())).is_empty()); + } + + #[test] + fn anchor_summary_section_parses_anchor_file_into_bullets() { + let tmpdir = tempfile::TempDir::new().unwrap(); + let deepseek_dir = tmpdir.path().join(".deepseek"); + std::fs::create_dir_all(&deepseek_dir).unwrap(); + std::fs::write( + deepseek_dir.join("anchors.md"), + "\n---\nDo not touch .ssh\n---\nStatus field is unreliable\n", + ) + .unwrap(); + + let section = anchor_summary_section(Some(tmpdir.path())); + + assert!(section.contains("## Pinned Facts (User Anchors)")); + assert!(section.contains("- Do not touch .ssh\n")); + assert!(section.contains("- Status field is unreliable\n")); + assert!(!section.contains("\n---\nDo not touch")); + } + #[test] fn truncate_chars_respects_unicode_boundaries() { let text = "abc😀é"; diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 02fee24d..00cadf80 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -216,6 +216,7 @@ pub enum MessageId { HelpFooterClose, CmdAgentDescription, CmdAttachDescription, + CmdAnchorDescription, CmdCacheDescription, CmdClearDescription, CmdCompactDescription, @@ -405,6 +406,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::HelpFooterJump, MessageId::HelpFooterClose, MessageId::CmdAgentDescription, + MessageId::CmdAnchorDescription, MessageId::CmdAttachDescription, MessageId::CmdCacheDescription, MessageId::CmdClearDescription, @@ -714,6 +716,9 @@ fn english(id: MessageId) -> &'static str { MessageId::HelpFooterJump => " PgUp/PgDn jump ", MessageId::HelpFooterClose => " Esc close ", MessageId::CmdAgentDescription => "Switch to agent mode", + MessageId::CmdAnchorDescription => { + "Pin a fact that survives compaction (auto-injected into context)" + } MessageId::CmdAttachDescription => { "Attach image/video media; use @path for text files or directories" } @@ -994,6 +999,9 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::HelpFooterJump => " PgUp/PgDn ジャンプ ", MessageId::HelpFooterClose => " Esc 閉じる ", MessageId::CmdAgentDescription => "Agent モードに切り替え", + MessageId::CmdAnchorDescription => { + "コンパクション後も保持される重要な事実をピン留め(コンテキストに自動注入)" + } MessageId::CmdAttachDescription => { "画像・動画メディアを添付(テキストファイルやディレクトリは @path)" } @@ -1266,6 +1274,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::HelpFooterJump => " PgUp/PgDn 跳转 ", MessageId::HelpFooterClose => " Esc 关闭 ", MessageId::CmdAgentDescription => "切换到 Agent 模式", + MessageId::CmdAnchorDescription => "钉选关键事实,在压缩后自动注入上下文", MessageId::CmdAttachDescription => "附加图片或视频媒体;文本文件或目录请使用 @path", MessageId::CmdCacheDescription => "显示最近 N 轮的 DeepSeek 前缀缓存命中/未命中统计", MessageId::CmdClearDescription => "清除对话历史", @@ -1508,6 +1517,9 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::HelpFooterJump => " PgUp/PgDn salta ", MessageId::HelpFooterClose => " Esc fecha ", MessageId::CmdAgentDescription => "Mudar para o modo agent", + MessageId::CmdAnchorDescription => { + "Fixar um fato que sobrevive à compactação (injetado automaticamente no contexto)" + } MessageId::CmdAttachDescription => { "Anexar imagem ou vídeo; use @path para arquivos de texto ou diretórios" }