Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
3529509
docs: add requirements-only plan for TUI backlinks
claude Oct 21, 2026
f5d2d8c
docs(backlinks): derive every stable ID deterministically
claude Oct 21, 2026
f11b854
docs(backlinks): align objective with archived-as-broken decision
claude Oct 21, 2026
0e323db
docs(backlinks): rewrite only [[...]] spans added in the current edit
claude Oct 21, 2026
efcbdf3
docs(backlinks): keep the draft when a save is rejected
claude Oct 21, 2026
c4b9498
docs(backlinks): defer picker/autocomplete design to planning explicitly
claude Oct 21, 2026
fcfcbc0
docs(backlinks): add implementation planning
claude Oct 21, 2026
a444339
docs(backlinks): capture the edit baseline once per session
claude Oct 21, 2026
44ad53d
docs(backlinks): limit jumps to active, non-divergent entries
claude Oct 21, 2026
ee1e4fb
docs(backlinks): isolate TUI tests from the user's config file
claude Oct 21, 2026
69a239e
docs(backlinks): keep submit order so delete/clear confirmations survive
claude Oct 21, 2026
1f20b6e
docs(backlinks): key the backlink map by target ID so archived items …
claude Oct 21, 2026
6fbe565
docs(backlinks): drop the single-write note method
claude Oct 21, 2026
b85358c
docs(backlinks): defer stale-row restore hardening
claude Oct 21, 2026
c3a0cb0
docs(backlinks): define [[ dropdown ordering, selection, Enter and Esc
claude Oct 21, 2026
604dc4f
docs(backlinks): fix glyphs and styles for link, broken and divergent
claude Oct 21, 2026
942bb94
docs(backlinks): keep rejection messages readable until the next key
claude Oct 21, 2026
6a35251
docs(backlinks): give v and o feedback when the pane cannot show
claude Oct 21, 2026
0b2b59f
docs(backlinks): specify side pane empty, focus, order and divergent …
claude Oct 21, 2026
6c9d05d
docs(backlinks): scope R19 uniqueness to the creating client
claude Oct 21, 2026
17d4ddc
docs(backlinks): treat only lowercase [[...]] as references and name …
claude Oct 21, 2026
202e583
feat(common): derive stable item IDs from the creation timestamp
claude Oct 21, 2026
b89cc1e
feat(client): give every new item a unique creation timestamp
claude Oct 21, 2026
ca78eda
feat(common): scan [[...]] spans and rewrite added references on save
claude Oct 21, 2026
bf49d01
feat(common): add a link index with backlinks and rendering
claude Oct 21, 2026
634070b
feat(tui): keep an active-item link index current in every view
claude Oct 21, 2026
89e6eac
feat(tui): rewrite added [[...]] references on save and keep rejected…
claude Oct 21, 2026
0aaf9b3
feat(tui): render links as titles and show stable IDs in rows
claude Oct 21, 2026
a011355
feat(tui): add [[ link autocomplete and the l link key
claude Oct 21, 2026
dc2451b
feat(tui): add a links side pane with jump-to
claude Oct 21, 2026
fc637dc
docs: document TUI links, backlinks and stable IDs
claude Oct 21, 2026
55b69b1
refactor(backlinks): consolidate link helpers and trim per-frame work
claude Oct 21, 2026
f4bcd9a
fix(tui): keep note edits in active storage from Archive view
claude Oct 21, 2026
4e2f50c
test(tui): prove the edit baseline is retained across retries
claude Oct 21, 2026
2913712
test(tui): cover link-pane keys, divergent jump and hidden-pane fallback
claude Oct 21, 2026
2d47be3
refactor(links): drop LinkIndex.outgoing, which only a test read
claude Oct 21, 2026
47d6733
docs(links): correct scan()'s note-join comment
claude Oct 21, 2026
407afce
docs(plan): defer saving legacy text with an unclosed `[[`
claude Oct 21, 2026
1ece374
test: define the supported store size and test at it
claude Oct 22, 2026
6f2c41a
docs: document the design envelope and how to measure it
claude Oct 22, 2026
236207b
docs: frame the size envelope around human use, not performance
claude Oct 22, 2026
9fd0b97
feat(limits): add SizeNotice for stores past the envelope
claude Oct 22, 2026
4382e54
feat(mcp): append a size notice to calls that add active items
claude Oct 22, 2026
dd930b3
feat(tui): show a size notice in the header past the envelope
claude Oct 22, 2026
7289582
docs: describe the size notice
claude Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ crates/
│ ├── board.rs # Board name handling, normalization, CLI input parsing
│ ├── due.rs # Due-date parsing/formatting/status (epoch millis)
│ ├── encryption.rs # AES-256-GCM encrypt/decrypt
│ ├── limits.rs # Human-scale size envelope (recommended/supported)
│ ├── links.rs # [[stable-id]] link scanning, save-time rewrite, LinkIndex
│ ├── stable_id.rs # Stable item IDs derived from _timestamp (never stored)
│ ├── error.rs # CommonError type
│ └── models/ # StorageItem, Task, Note, Item trait
│
Expand All @@ -73,7 +76,7 @@ crates/
│ │ ├── mod.rs # StorageBackend trait
│ │ ├── local.rs # LocalStorage (file-based)
│ │ └── remote.rs # RemoteStorage (HTTP + encryption)
│ └── tui/ # Interactive TUI (ratatui + crossterm)
│ └── tui/ # Interactive TUI (ratatui + crossterm; widgets/link_pane.rs = backlinks pane)
│
└── taskbook-server/ # Server binary (tb-server)
└── src/
Expand Down Expand Up @@ -117,7 +120,11 @@ crates/
(honoring `$XDG_CONFIG_HOME`), falling back to the legacy
`~/.taskbook.json` when it exists and the XDG file does not.

6. **Storage Structure (Local)**:
6. **Design Envelope (human scale)**: taskbook is a task board a person reads and keeps up with, as the original was; that purpose sets the bound, not storage performance. Recommended: under 512 active items. Supported, as headroom: 1,024 active and 4,096 archived (`taskbook-common/src/limits.rs`). Agent use via MCP is expected to stay within the same bounds. Past the recommendation (or the archive limit), `limits::SizeNotice` drives an informational notice in the TUI header and as an extra text block on MCP `create_task`/`create_note`/`restore_items` results; it never blocks, and suggests clearing only when completed tasks exist. Design features for what a person using the board can produce; scenarios only reachable at machine scale (thousands of active items, bulk writes within one millisecond, very large archives) are unsupported, not edge cases to handle. The JSON storage (whole-file read/rewrite per operation, full-set sync) fits this size, so O(n) work per operation is fine and indexes, caches or incremental storage are out of scope; measured numbers are in README "Limits". Envelope tests in `taskbook.rs` and `tui/widgets/item_row.rs` check correctness at the supported size; the timing check is `#[ignore]`d (`cargo test -- --ignored`).

7. **Stable IDs and Links**: An item's numeric `_id` changes on archive/restore, so links use a stable ID instead: an invertible mix of `_timestamp` written as 16 letters `k`-`z` (`taskbook-common/src/stable_id.rs`). It is computed on read and never stored; create paths keep `_timestamp` unique, and golden test vectors freeze the mapping (changing it breaks every link). Two items created in the same millisecond on different devices share a stable ID (divergent, shown `??`); that tradeoff is acceptable within the design envelope. Links are `[[stable-id]]` tokens in item text, resolved only in the TUI.

8. **Storage Structure (Local)**:
```
~/.taskbook/
├── storage/storage.json # Active items
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ Access commands directly from the TUI with `/` or Tab.

![Commands](screenshots/commands.png)

### Links and Backlinks
Link any task or note to another, Workflowy-style. Every item has a stable
ID (shown after its number, e.g. `3 kqzx`) derived from when it was created;
unlike the number, it never changes when items are archived or restored.

- Type `[[` while writing a task, note or edit to pick a target from a list,
or type `[[12]]` (a number) or `[[kq]]` (a stable-ID prefix): it is saved
as a full `[[stable-id]]` token. A reference that matches nothing, or more
than one item, is not saved and your draft is kept.
- Press `l` on an item to link it to another without editing text.
- On terminals at least 100 columns wide, a side pane lists the selected
item's links and backlinks; `v` toggles it, `o` focuses it, Enter jumps.
- Links to archived items show as broken (`✗`) until the item is restored.
Two items created in the same millisecond by older tools share an ID and
show `??`; they cannot be linked to.

The CLI, MCP server and sync server show `[[stable-id]]` tokens as raw text.

### Help
Full keyboard shortcut reference available with `?`.

Expand Down Expand Up @@ -162,6 +180,37 @@ See the [docs](docs/) folder for detailed documentation:

This implementation uses the same data format and directory (`~/.taskbook/`) as the original Node.js version, allowing seamless migration.

## Limits

taskbook is a task board for a person, like the original: something you read
and keep up with yourself. We recommend staying under **512 active items**;
more than that is hard for anyone to keep track of. The supported size,
**1,024 active and 4,096 archived items**, is headroom above that
recommendation, and it applies when agents use taskbook through MCP too.
Larger stores are unsupported, and features are not designed for them.
Past 512 active items (or 4,096 archived), the TUI header and MCP create
results show a notice; it blocks nothing, and suggests `/clear` (`tb --clear`)
only when there are completed tasks to archive. For
example, two items created in the same millisecond on different devices can
share a stable ID (shown as `??`), which a person using the board will not
realistically hit.

Storage is a JSON file that every command reads and rewrites in full, and
sync uploads every item on each change, which is fast at this size.
Measured on a 4-vCPU 2.8 GHz Xeon with a release build (median of 7 runs):

| Active / archived | Files | Board view | Create |
|---|---|---|---|
| 256 / 4096 | 2.1 MB | 4 ms | 19 ms |
| 512 / 4096 | 2.2 MB | 9 ms | 18 ms |
| 1024 / 4096 | 2.4 MB | 9 ms | 20 ms |
| 2048 / 8192 | 4.9 MB | 16 ms | 41 ms |

Cost grows linearly with the number of stored items, about 4–5 ms per
1,000 (active plus archived) per create on this machine. Sync mode transfers the whole store on every change, so its
practical limit depends on your connection. To reproduce:
`cargo build --release -p taskbook-client && python3 scripts/bench-envelope.py target/release/tb`.

## License

MIT - see [LICENSE](LICENSE)
Expand Down
8 changes: 6 additions & 2 deletions crates/taskbook-client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,12 +377,16 @@ impl Config {

/// Save the configuration to file
pub fn save(&self) -> Result<()> {
let config_path = Self::config_file_path();
self.save_to(&Self::config_file_path())
}

/// Save the configuration to an explicit path
pub fn save_to(&self, config_path: &Path) -> Result<()> {
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_string_pretty(self)?;
fs::write(&config_path, data)?;
fs::write(config_path, data)?;
Ok(())
}

Expand Down
30 changes: 27 additions & 3 deletions crates/taskbook-client/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,24 @@ pub fn create_note_in_editor() -> Result<Option<NoteContent>> {
edit_in_external_editor(NEW_NOTE_TEMPLATE)
}

/// Open editor for editing an existing note
/// Open editor for editing an existing note, optionally leading with a
/// notice comment (for example why the previous save was rejected)
pub fn edit_existing_note_in_editor(
title: &str,
body: Option<&str>,
notice: Option<&str>,
) -> Result<Option<NoteContent>> {
edit_in_external_editor(&existing_note_content(title, body, notice))
}

/// The editor buffer for an existing note
fn existing_note_content(title: &str, body: Option<&str>, notice: Option<&str>) -> String {
let mut content = String::new();
if let Some(notice) = notice {
content.push_str("// ");
content.push_str(notice);
content.push('\n');
}
content.push_str(title);
content.push('\n');

Expand All @@ -107,8 +119,7 @@ pub fn edit_existing_note_in_editor(
content.push_str("\n\n");
content.push_str("// Lines starting with // are comments and will be ignored.\n");
content.push_str("// Delete all content (or leave only comments) to cancel.\n");

edit_in_external_editor(&content)
content
}

/// Parse editor content into title and body
Expand Down Expand Up @@ -175,6 +186,19 @@ fn parse_note_content(content: &str) -> Result<Option<NoteContent>> {
mod tests {
use super::*;

#[test]
fn existing_note_content_leads_with_the_notice() {
let content =
existing_note_content("Title", Some("body"), Some("Not saved: [[99]] not found"));
assert!(
content.starts_with("// Not saved: [[99]] not found\n"),
"{content}"
);
let parsed = parse_note_content(&content).unwrap().unwrap();
assert_eq!(parsed.title, "Title");
assert_eq!(parsed.body.as_deref(), Some("body"));
}

#[test]
fn test_parse_empty_content() {
assert!(parse_note_content("").unwrap().is_none());
Expand Down
60 changes: 60 additions & 0 deletions crates/taskbook-client/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,39 @@ mod tests {
Taskbook::with_storage(Box::new(storage))
}

/// A store holding `pending` open tasks and `done` completed ones.
fn seeded_taskbook(pending: u64, done: u64) -> Taskbook {
use crate::storage::StorageBackend;
use taskbook_common::{StorageItem, Task};
let dir = std::env::temp_dir().join(format!("tb-mcp-test-{}", uuid::Uuid::new_v4()));
let storage = LocalStorage::new(&dir).unwrap();
let items = (1..=pending + done)
.map(|id| {
let mut task = Task::new(id, format!("t{id}"), vec!["my board".into()], 1);
task.is_complete = id > pending;
(id.to_string(), StorageItem::Task(task))
})
.collect();
storage.set(&items).unwrap();
Taskbook::with_storage(Box::new(storage))
}

fn create_task_content(taskbook: &Taskbook) -> Vec<String> {
let resp = request(
taskbook,
json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"create_task","arguments":{"description":"one more"}
}}),
);
assert_eq!(resp["result"]["isError"], false);
resp["result"]["content"]
.as_array()
.unwrap()
.iter()
.map(|c| c["text"].as_str().unwrap().to_string())
.collect()
}

fn request(taskbook: &Taskbook, msg: Value) -> Value {
let response = handle_message(taskbook, &msg.to_string()).expect("expected a response");
serde_json::from_str(&response).unwrap()
Expand Down Expand Up @@ -245,6 +278,33 @@ mod tests {
assert_eq!(item["due_date"], "2026-07-01");
}

#[test]
fn create_within_the_recommendation_has_no_notice() {
let tb = seeded_taskbook(511, 0);
assert_eq!(create_task_content(&tb).len(), 1);
}

#[test]
fn create_past_the_recommendation_adds_a_notice() {
let tb = seeded_taskbook(512, 0);
let content = create_task_content(&tb);
assert_eq!(content.len(), 2, "{content:?}");
assert!(content[1].contains("513 active items, over the 512 recommended"));
assert!(content[1].contains("do not archive or delete items"));
assert!(!content[1].contains("tb --clear"), "nothing to clear");
}

#[test]
fn notice_suggests_clearing_only_completed_tasks() {
let tb = seeded_taskbook(500, 12);
let content = create_task_content(&tb);
assert!(
content[1].contains("12 of the active items are completed tasks"),
"{content:?}"
);
assert!(content[1].contains("tb --clear"));
}

#[test]
fn set_task_state_and_filters() {
let tb = test_taskbook();
Expand Down
38 changes: 34 additions & 4 deletions crates/taskbook-client/src/mcp/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use serde_json::{json, Value};

use crate::taskbook::Taskbook;
use taskbook_common::limits::SizeNotice;
use taskbook_common::{board, due, StorageItem};

/// JSON Schema definitions for all exposed tools.
Expand Down Expand Up @@ -135,17 +136,46 @@ pub fn call(taskbook: &Taskbook, params: &Value) -> Result<Value, (i64, String)>
};

Ok(match outcome {
Ok(value) => json!({
"content": [{ "type": "text", "text": value.to_string() }],
"isError": false
}),
Ok(value) => {
let mut content = vec![json!({ "type": "text", "text": value.to_string() })];
// Tools that add active items say when the store is past the envelope.
if matches!(name, "create_task" | "create_note" | "restore_items") {
if let Some(notice) = size_notice(taskbook) {
content.push(json!({ "type": "text", "text": notice }));
}
}
json!({ "content": content, "isError": false })
}
Err(message) => json!({
"content": [{ "type": "text", "text": message }],
"isError": true
}),
})
}

/// A notice for the agent to pass on when the store is past the size taskbook
/// is designed for. Read failures skip the notice rather than fail the call.
fn size_notice(taskbook: &Taskbook) -> Option<String> {
let active = taskbook.get_all_items().ok()?;
let archived = taskbook.get_all_archive_items().ok()?.len();
let notice = SizeNotice::check(active.len(), archived)?;
let mut text = format!(
"Notice: {}. taskbook is a board meant for a person to keep up with.",
notice.summary()
);
let completed = active
.values()
.filter(|item| item.as_task().is_some_and(|task| task.is_complete))
.count();
if notice.active_over_recommended() && completed > 0 {
text.push_str(&format!(
" {completed} of the active items are completed tasks the user can archive with `tb --clear`."
));
}
text.push_str(" Mention this to the user; do not archive or delete items on their behalf.");
Some(text)
}

fn item_to_json(item: &StorageItem) -> Value {
let mut v = json!({
"id": item.id(),
Expand Down
Loading
Loading