feat: add note management commands

Extend /note beyond append-only usage with list, show, edit, remove,
  clear, path, and explicit add subcommands.

  Keep existing /note <text> behavior compatible, preserve the existing
  --- separated file format, and number notes only at display time so the
  stored notes stay clean.

  Update command help, localization, docs, and tests.
This commit is contained in:
reidliu41
2026-05-11 08:29:20 +08:00
committed by Hunter Bown
parent 5d3ec1b439
commit 6d099d425c
4 changed files with 366 additions and 30 deletions
+1 -1
View File
@@ -224,7 +224,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "note",
aliases: &[],
usage: "/note <text>",
usage: "/note [add|list|show|edit|remove|clear|path]",
description_id: MessageId::CmdNoteDescription,
},
CommandInfo {
+339 -20
View File
@@ -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 <text> | /note add <text> | /note list | /note show <n> | /note edit <n> <text> | /note remove <n> | /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 <text>");
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(&notes_path, rest),
"list" => list_notes_command(&notes_path),
"show" => show_note_command(&notes_path, rest),
"edit" => edit_note_command(&notes_path, rest),
"remove" | "rm" | "delete" => remove_note_command(&notes_path, rest),
"clear" => clear_notes_command(&notes_path),
"path" => CommandResult::message(format!("Notes path: {}", notes_path.display())),
"help" => CommandResult::message(format!("Usage: {USAGE}")),
_ => append_note_command(&notes_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 <text>");
};
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 <n>") {
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 <n> <text>");
};
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 <n> <text>"),
};
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 <n> <text>") {
Ok(index) => index,
Err(e) => return CommandResult::error(e),
};
notes[index] = new_content.to_string();
match write_notes(notes_path, &notes) {
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 <n>") {
Ok(index) => index,
Err(e) => return CommandResult::error(e),
};
notes.remove(index);
match write_notes(notes_path, &notes) {
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(&notes_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<Vec<String>, 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::<Vec<_>>()
.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<String> {
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, &current);
current.clear();
}
saw_separator = true;
} else if saw_separator || !line.trim().is_empty() {
current.push(line);
}
}
if saw_separator {
push_note(&mut notes, &current);
} else {
let trimmed = content.trim();
if !trimmed.is_empty() {
notes.push(trimmed.to_string());
}
}
notes
}
fn push_note(notes: &mut Vec<String>, 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<usize, String> {
let Some(index_text) = rest.map(str::trim).filter(|text| !text.is_empty()) else {
return Err(format!("Usage: {usage}"));
};
let index = index_text
.parse::<usize>()
.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(&notes_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(&notes_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"]);
}
}
+4 -8
View File
@@ -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)"
+22 -1
View File
@@ -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 `<user_memory>` 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 <text>` usage still appends a note.
The management forms are:
| Command | Action |
|---|---|
| `/note <text>` | Append a note (legacy shorthand) |
| `/note add <text>` | Append a note explicitly |
| `/note list` | List notes with temporary 1-based numbers |
| `/note show <n>` | Show the full note at number `n` |
| `/note edit <n> <text>` | Replace note `n` with new text |
| `/note remove <n>` | 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