diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index bc34532b..9ec4d193 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -224,7 +224,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "note", aliases: &[], - usage: "/note ", + usage: "/note [add|list|show|edit|remove|clear|path]", description_id: MessageId::CmdNoteDescription, }, CommandInfo { diff --git a/crates/tui/src/commands/note.rs b/crates/tui/src/commands/note.rs index c5bda2c1..618f226e 100644 --- a/crates/tui/src/commands/note.rs +++ b/crates/tui/src/commands/note.rs @@ -1,52 +1,265 @@ -//! Note command: append to persistent notes file +//! Note command: manage persistent workspace notes. use crate::tui::app::App; use std::fs; use std::io::Write; +use std::path::{Path, PathBuf}; use super::CommandResult; -/// Append a note to the persistent notes file +const USAGE: &str = "/note | /note add | /note list | /note show | /note edit | /note remove | /note clear | /note path"; + +/// Manage the persistent workspace notes file. pub fn note(app: &mut App, content: Option<&str>) -> CommandResult { - let note_content = match content { + let input = match content { Some(c) => c.trim(), None => { - return CommandResult::error("Usage: /note "); + return CommandResult::error(format!("Usage: {USAGE}")); } }; - if note_content.is_empty() { + if input.is_empty() { return CommandResult::error("Note content cannot be empty"); } - // Determine notes path: workspace/.deepseek/notes.md - let notes_path = app.workspace.join(".deepseek").join("notes.md"); + let notes_path = notes_path(app); + let (command, rest) = split_command(input); - // Ensure parent directory exists - if let Some(parent) = notes_path.parent() - && let Err(e) = fs::create_dir_all(parent) - { - return CommandResult::error(format!("Failed to create notes directory: {e}")); + match command.to_ascii_lowercase().as_str() { + "add" => append_note_command(¬es_path, rest), + "list" => list_notes_command(¬es_path), + "show" => show_note_command(¬es_path, rest), + "edit" => edit_note_command(¬es_path, rest), + "remove" | "rm" | "delete" => remove_note_command(¬es_path, rest), + "clear" => clear_notes_command(¬es_path), + "path" => CommandResult::message(format!("Notes path: {}", notes_path.display())), + "help" => CommandResult::message(format!("Usage: {USAGE}")), + _ => append_note_command(¬es_path, Some(input)), + } +} + +fn notes_path(app: &App) -> PathBuf { + app.workspace.join(".deepseek").join("notes.md") +} + +fn split_command(input: &str) -> (&str, Option<&str>) { + match input.find(char::is_whitespace) { + Some(index) => (&input[..index], Some(input[index..].trim())), + None => (input, None), + } +} + +fn append_note_command(notes_path: &Path, content: Option<&str>) -> CommandResult { + let Some(note_content) = content.map(str::trim).filter(|content| !content.is_empty()) else { + return CommandResult::error("Usage: /note add "); + }; + + match append_note(notes_path, note_content) { + Ok(()) => CommandResult::message(format!("Note appended to {}", notes_path.display())), + Err(e) => CommandResult::error(e), + } +} + +fn list_notes_command(notes_path: &Path) -> CommandResult { + let notes = match read_notes(notes_path) { + Ok(notes) => notes, + Err(e) => return CommandResult::error(e), + }; + + if notes.is_empty() { + return CommandResult::message(format!("No notes found at {}", notes_path.display())); } - // Append to notes file + let mut output = format!("Notes in {}:", notes_path.display()); + for (index, note) in notes.iter().enumerate() { + output.push_str(&format!("\n\n{}. {}", index + 1, note_preview(note))); + } + CommandResult::message(output) +} + +fn show_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult { + let notes = match read_notes(notes_path) { + Ok(notes) => notes, + Err(e) => return CommandResult::error(e), + }; + let index = match parse_note_index(rest, notes.len(), "/note show ") { + Ok(index) => index, + Err(e) => return CommandResult::error(e), + }; + + CommandResult::message(format!("Note {}:\n\n{}", index + 1, notes[index])) +} + +fn edit_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult { + let Some(rest) = rest else { + return CommandResult::error("Usage: /note edit "); + }; + let (index_text, new_content) = match split_command(rest) { + (index_text, Some(new_content)) if !new_content.trim().is_empty() => { + (index_text, new_content.trim()) + } + _ => return CommandResult::error("Usage: /note edit "), + }; + + let mut notes = match read_notes(notes_path) { + Ok(notes) => notes, + Err(e) => return CommandResult::error(e), + }; + let index = match parse_note_index(Some(index_text), notes.len(), "/note edit ") { + Ok(index) => index, + Err(e) => return CommandResult::error(e), + }; + + notes[index] = new_content.to_string(); + match write_notes(notes_path, ¬es) { + Ok(()) => CommandResult::message(format!( + "Note {} updated in {}", + index + 1, + notes_path.display() + )), + Err(e) => CommandResult::error(e), + } +} + +fn remove_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult { + let mut notes = match read_notes(notes_path) { + Ok(notes) => notes, + Err(e) => return CommandResult::error(e), + }; + let index = match parse_note_index(rest, notes.len(), "/note remove ") { + Ok(index) => index, + Err(e) => return CommandResult::error(e), + }; + + notes.remove(index); + match write_notes(notes_path, ¬es) { + Ok(()) => CommandResult::message(format!( + "Note {} removed from {}", + index + 1, + notes_path.display() + )), + Err(e) => CommandResult::error(e), + } +} + +fn clear_notes_command(notes_path: &Path) -> CommandResult { + match write_notes(notes_path, &[]) { + Ok(()) => CommandResult::message(format!("Notes cleared in {}", notes_path.display())), + Err(e) => CommandResult::error(e), + } +} + +fn append_note(notes_path: &Path, note_content: &str) -> Result<(), String> { + ensure_notes_parent(notes_path)?; + let mut file = match fs::OpenOptions::new() .create(true) .append(true) - .open(¬es_path) + .open(notes_path) { Ok(f) => f, Err(e) => { - return CommandResult::error(format!("Failed to open notes file: {e}")); + return Err(format!("Failed to open notes file: {e}")); } }; // Write separator and note content if let Err(e) = writeln!(file, "\n---\n{}", note_content) { - return CommandResult::error(format!("Failed to write note: {e}")); + return Err(format!("Failed to write note: {e}")); } - CommandResult::message(format!("Note appended to {}", notes_path.display())) + Ok(()) +} + +fn read_notes(notes_path: &Path) -> Result, String> { + match fs::read_to_string(notes_path) { + Ok(content) => Ok(parse_notes(&content)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(format!("Failed to read notes file: {e}")), + } +} + +fn write_notes(notes_path: &Path, notes: &[String]) -> Result<(), String> { + ensure_notes_parent(notes_path)?; + let content = notes + .iter() + .map(|note| format!("---\n{}", note.trim())) + .collect::>() + .join("\n\n"); + fs::write(notes_path, content).map_err(|e| format!("Failed to write notes file: {e}")) +} + +fn ensure_notes_parent(notes_path: &Path) -> Result<(), String> { + if let Some(parent) = notes_path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create notes directory: {e}"))?; + } + Ok(()) +} + +fn parse_notes(content: &str) -> Vec { + let mut notes = Vec::new(); + let mut current = Vec::new(); + let mut saw_separator = false; + + for line in content.lines() { + if line.trim() == "---" { + if saw_separator || !current.is_empty() { + push_note(&mut notes, ¤t); + current.clear(); + } + saw_separator = true; + } else if saw_separator || !line.trim().is_empty() { + current.push(line); + } + } + + if saw_separator { + push_note(&mut notes, ¤t); + } else { + let trimmed = content.trim(); + if !trimmed.is_empty() { + notes.push(trimmed.to_string()); + } + } + + notes +} + +fn push_note(notes: &mut Vec, lines: &[&str]) { + let note = lines.join("\n").trim().to_string(); + if !note.is_empty() { + notes.push(note); + } +} + +fn note_preview(note: &str) -> String { + let first_line = note + .lines() + .find_map(|line| { + let trimmed = line.trim(); + (!trimmed.is_empty()).then_some(trimmed) + }) + .unwrap_or("(empty note)"); + if note.lines().filter(|line| !line.trim().is_empty()).count() > 1 { + format!("{first_line} ...") + } else { + first_line.to_string() + } +} + +fn parse_note_index(rest: Option<&str>, note_count: usize, usage: &str) -> Result { + let Some(index_text) = rest.map(str::trim).filter(|text| !text.is_empty()) else { + return Err(format!("Usage: {usage}")); + }; + let index = index_text + .parse::() + .map_err(|_| format!("Invalid note number: {index_text}"))?; + if index == 0 || index > note_count { + return Err(format!( + "Note number {index} out of range; there are {note_count} note(s)" + )); + } + Ok(index - 1) } #[cfg(test)] @@ -54,6 +267,7 @@ mod tests { use super::*; use crate::config::Config; use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; use tempfile::TempDir; fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { @@ -81,6 +295,14 @@ mod tests { App::new(options, &Config::default()) } + fn notes_path(tmpdir: &TempDir) -> PathBuf { + tmpdir.path().join(".deepseek").join("notes.md") + } + + fn message(result: CommandResult) -> String { + result.message.expect("command message") + } + #[test] fn test_note_without_content_returns_error() { let tmpdir = TempDir::new().unwrap(); @@ -105,10 +327,10 @@ mod tests { let mut app = create_test_app_with_tmpdir(&tmpdir); let result = note(&mut app, Some("Test note content")); assert!(result.message.is_some()); - let msg = result.message.unwrap(); + let msg = message(result); assert!(msg.contains("Note appended to")); - let notes_path = tmpdir.path().join(".deepseek").join("notes.md"); + let notes_path = notes_path(&tmpdir); assert!(notes_path.exists()); let content = std::fs::read_to_string(¬es_path).unwrap(); assert!(content.contains("Test note content")); @@ -121,11 +343,108 @@ mod tests { note(&mut app, Some("First note")); note(&mut app, Some("Second note")); - let notes_path = tmpdir.path().join(".deepseek").join("notes.md"); + let notes_path = notes_path(&tmpdir); let content = std::fs::read_to_string(¬es_path).unwrap(); assert!(content.contains("First note")); assert!(content.contains("Second note")); // Should have two separators assert_eq!(content.matches("---").count(), 2); } + + #[test] + fn test_note_list_numbers_entries_without_storing_numbers() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + note(&mut app, Some("Alpha note")); + note(&mut app, Some("Beta note")); + + let listed = message(note(&mut app, Some("list"))); + assert!(listed.contains("1. Alpha note")); + assert!(listed.contains("2. Beta note")); + + let content = std::fs::read_to_string(notes_path(&tmpdir)).unwrap(); + assert!(content.contains("Alpha note")); + assert!(!content.contains("1. Alpha note")); + } + + #[test] + fn test_note_show_displays_full_multiline_note() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + note(&mut app, Some("add first line\nsecond line")); + + let shown = message(note(&mut app, Some("show 1"))); + assert!(shown.contains("Note 1:")); + assert!(shown.contains("first line\nsecond line")); + } + + #[test] + fn test_note_edit_updates_numbered_entry() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + note(&mut app, Some("First note")); + note(&mut app, Some("Second note")); + + let edited = message(note(&mut app, Some("edit 2 Updated second note"))); + assert!(edited.contains("Note 2 updated")); + + let content = std::fs::read_to_string(notes_path(&tmpdir)).unwrap(); + assert!(content.contains("First note")); + assert!(content.contains("Updated second note")); + assert!(!content.contains("Second note")); + } + + #[test] + fn test_note_remove_renumbers_remaining_entries() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + note(&mut app, Some("First note")); + note(&mut app, Some("Second note")); + note(&mut app, Some("Third note")); + + let removed = message(note(&mut app, Some("remove 2"))); + assert!(removed.contains("Note 2 removed")); + + let listed = message(note(&mut app, Some("list"))); + assert!(listed.contains("1. First note")); + assert!(listed.contains("2. Third note")); + assert!(!listed.contains("Second note")); + } + + #[test] + fn test_note_clear_empties_file() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + note(&mut app, Some("First note")); + + let cleared = message(note(&mut app, Some("clear"))); + assert!(cleared.contains("Notes cleared")); + assert_eq!(std::fs::read_to_string(notes_path(&tmpdir)).unwrap(), ""); + } + + #[test] + fn test_note_path_prints_workspace_notes_file() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let path = message(note(&mut app, Some("path"))); + assert!(path.contains(".deepseek")); + assert!(path.contains("notes.md")); + } + + #[test] + fn test_note_rejects_out_of_range_index() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + note(&mut app, Some("Only note")); + + let result = note(&mut app, Some("show 2")); + assert!(result.message.unwrap().contains("out of range")); + } + + #[test] + fn test_parse_notes_handles_plain_text_before_separator() { + let parsed = parse_notes("plain note\n---\nseparated note"); + assert_eq!(parsed, vec!["plain note", "separated note"]); + } } diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 5fd8d1ca..f9ea02bb 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -816,9 +816,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdModelDescription => "Switch or view current model", MessageId::CmdModelsDescription => "List available models from API", MessageId::CmdNetworkDescription => "Manage network allow and deny rules", - MessageId::CmdNoteDescription => { - "Append note to persistent notes file (.deepseek/notes.md)" - } + MessageId::CmdNoteDescription => "Add, list, edit, or remove workspace notes", MessageId::CmdThemeDescription => "Toggle between dark and light theme", MessageId::CmdProviderDescription => { "Switch or view the active LLM backend (deepseek | nvidia-nim | ollama)" @@ -1157,7 +1155,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdModelDescription => "現在のモデルを切り替え・確認", MessageId::CmdModelsDescription => "API から利用可能なモデルを一覧表示", MessageId::CmdNetworkDescription => "ネットワーク許可・拒否ルールを管理", - MessageId::CmdNoteDescription => "永続ノートファイル(.deepseek/notes.md)に追記", + MessageId::CmdNoteDescription => "ワークスペースノートの追加、一覧、編集、削除", MessageId::CmdThemeDescription => "テーマ(ダーク/ライト)を切り替え", MessageId::CmdProviderDescription => { "現在の LLM バックエンドを切り替え・確認(deepseek | nvidia-nim | ollama)" @@ -1476,7 +1474,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdModelDescription => "切换或查看当前模型", MessageId::CmdModelsDescription => "列出 API 中可用的模型", MessageId::CmdNetworkDescription => "管理网络允许和拒绝规则", - MessageId::CmdNoteDescription => "将笔记追加到持久笔记文件(.deepseek/notes.md)", + MessageId::CmdNoteDescription => "添加、列出、编辑或删除工作区笔记", MessageId::CmdThemeDescription => "在浅色和深色主题之间切换", MessageId::CmdProviderDescription => { "切换或查看当前 LLM 后端(deepseek | nvidia-nim | ollama)" @@ -1781,9 +1779,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdModelDescription => "Trocar ou exibir o modelo atual", MessageId::CmdModelsDescription => "Listar os modelos disponíveis pela API", MessageId::CmdNetworkDescription => "Gerenciar regras de rede permitidas e bloqueadas", - MessageId::CmdNoteDescription => { - "Adicionar nota ao arquivo persistente (.deepseek/notes.md)" - } + MessageId::CmdNoteDescription => "Adicionar, listar, editar ou remover notas do workspace", MessageId::CmdThemeDescription => "Alternar entre o tema claro e escuro", MessageId::CmdProviderDescription => { "Trocar ou exibir o backend LLM ativo (deepseek | nvidia-nim | ollama)" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7e6e09f2..b4459c01 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -394,7 +394,7 @@ If you are upgrading from older releases: It is visible in `/config` and can be changed from the TUI. The new path is used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool requires restarting the TUI. -- `notes_path` (string, optional): defaults to `~/.deepseek/notes.txt` and is used by the `note` tool. +- `notes_path` (string, optional): defaults to `~/.deepseek/notes.txt` and is used by the model-visible `note` tool. - `[memory].enabled` (bool, optional): defaults to `false`. When `true`, the TUI loads the user memory file into a `` prompt block, enables `# foo` quick-capture in the composer, surfaces the `/memory` @@ -463,6 +463,27 @@ If you are upgrading from older releases: - `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`). - `features.*` (optional): feature flag overrides (see below). +### Workspace notes + +`/note` manages a simple notes file in the current workspace at +`.deepseek/notes.md`. Existing `/note ` usage still appends a note. +The management forms are: + +| Command | Action | +|---|---| +| `/note ` | Append a note (legacy shorthand) | +| `/note add ` | Append a note explicitly | +| `/note list` | List notes with temporary 1-based numbers | +| `/note show ` | Show the full note at number `n` | +| `/note edit ` | Replace note `n` with new text | +| `/note remove ` | Delete note `n`; `rm` and `delete` are aliases | +| `/note clear` | Empty the workspace notes file | +| `/note path` | Show the resolved workspace notes path | + +The numbers shown by `/note list` are not stored in the file; they are derived +from the current order each time notes are read. This keeps the file format +compatible with the existing `---`-separated notes. + ### User memory User memory is split across one top-level path setting and one opt-in