From 35295096d9e6ea21e366ec88b4aaaa1601dfb257 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 21:57:32 +0000 Subject: [PATCH 01/45] docs: add requirements-only plan for TUI backlinks Product contract for Workflowy-style [[id]] links in the TUI, built on a jj-style stable item ID beside the existing numeric ID. Covers identity, link syntax and rewrite-on-save, side-pane backlinks, archive behaviour, and sync-safe deterministic backfill. CLI/MCP/server unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- .../2026-10-21-2154-feat-backlinks-plan.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/plans/2026-10-21-2154-feat-backlinks-plan.md diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md new file mode 100644 index 0000000..d31701c --- /dev/null +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -0,0 +1,194 @@ +--- +title: Backlinks - Plan +type: feat +date: 2026-10-21 +topic: backlinks +artifact_contract: ce-unified-plan/v1 +product_contract_source: ce-brainstorm +execution: code +--- + +# Backlinks - Plan + +## Goal Capsule + +- **Objective:** In the TUI, a user can link any task or note to another with an inline `[[…]]` reference, see from any item what it links to and what links to it, and jump between them, with links that keep working through edits, moves, archive, restore, and sync. +- **Product authority:** This Product Contract. CLI, MCP, and server behavior are explicitly out of active scope (R18). +- **Open blockers:** None. Node.js taskbook compatibility is an unverified assumption, not a blocker (see Dependencies / Assumptions). + +--- + +## Product Contract + +### Summary + +Items gain a permanent, letters-only stable ID alongside the existing numeric ID, modeled on jj's change ID vs commit ID split. Links are `[[stable-id]]` tokens in item text, which is the only place they are stored. The TUI resolves them into titles, shows outgoing links and backlinks in a side pane, and lets the user jump between linked items. + +### Problem Frame + +Taskbook has no durable way to say "this note is the context for that task" or "this task depends on that one." The only handle on an item is its numeric ID, and that ID is positional: it is `max + 1` at creation, it is reassigned when an item is archived, reassigned again when it is restored, and reused when the highest-numbered item is deleted. A reference written as "see #12" today can silently point at a different item tomorrow. + +This is the same problem jj solves by separating the change ID (random, permanent, what users see and type) from the commit ID (content-derived, rewritten constantly, secondary). Taskbook's numeric ID plays the commit-ID role and has no change-ID counterpart. + +Because sync stores items as opaque client-encrypted blobs and replaces the whole set on each `PUT /items`, any linking scheme has to work entirely on the client and tolerate last-write-wins across devices. + +### Key Decisions + +- **Inline links, text as the single source of truth, plus a non-text way to add one.** Links live in the item text; a TUI keybind appends a token rather than maintaining a separate relation list. Governs R7, R11. (session-settled: user-directed — chosen over explicit-only structured relations and over inline-only: inline matches the Workflowy model, the keybind covers linking without editing text.) +- **Tokens carry only the stable ID.** Raw text shows `[[kqzxmvtn…]]`; views render the title. Governs R7, R13. (session-settled: user-directed — chosen over `[[Title|id]]` and `[[Title]]`: no stale titles in text, no rename or duplicate-title breakage.) +- **A new stable ID beside the numeric ID, shown jj-style in the TUI.** Numeric IDs keep working unchanged; the stable ID is displayed as a shortest unique prefix. Governs R1, R4, R6. (session-settled: user-directed — chosen over making numeric IDs permanent, over a hidden-only stable ID, and over replacing numeric IDs everywhere: it follows jj's model without breaking CLI muscle memory or pulling the CLI into scope.) +- **Letters-only alphabet, full ID stored, shortest prefix displayed.** Lexical disjointness makes `[[12]]` and `[[kqz]]` unambiguous without lookup; prefixes never go into stored text because a unique prefix can become ambiguous as items are added. Governs R2, R6, R7, R8. (session-settled: user-approved — proposed from jj's reverse-hex change IDs with the prefix-ambiguity tradeoff shown.) +- **Deterministic backfill.** Pre-existing items get IDs derived from immutable data so two synced clients upgrading independently agree. Governs R3. (session-settled: user-approved — chosen over random backfill: random IDs plus last-write-wins `PUT /items` would silently break links made on the losing client.) +- **TUI only for v1.** Governs R18. (session-settled: user-directed — chosen over also covering CLI and MCP.) +- **Outside the TUI, tokens stay raw.** Governs R18. (session-settled: user-directed — chosen over read-only title rendering in CLI/MCP: zero work outside the TUI and lossless MCP round-tripping.) +- **Archived targets are broken links.** Governs R16. (session-settled: user-directed — chosen over resolving archived targets with an "archived" marker and over full archive parity; the jj hidden-commit analogy was raised afterward and the choice was kept.) +- **Side pane for links and backlinks.** Governs R14, R15, R17. (session-settled: user-directed — chosen over a filter-view swap and a popup: links stay visible while browsing.) + +### Requirements + +**Stable identity** + +- R1. Every task and note has a stable ID that is assigned once and never changes across edit, move, star, state change, archive, restore, or sync. +- R2. Stable IDs use a letters-only alphabet, so no stable ID or prefix of one can be read as a numeric ID. +- R3. An item with no stable ID receives one the first time the new version loads it, derived deterministically from data that never changes after creation, so independent clients assign identical IDs. +- R4. Numeric IDs keep their current allocation and behavior for every existing CLI, TUI, and MCP operation. +- R5. Older taskbook-rs versions can still read item data written by this version. +- R6. The TUI shows each item's stable ID as its shortest unique prefix, visually distinguished, next to the numeric ID. + +**Writing links** + +- R7. A link is a `[[]]` token in a task description, note title, or note body; only well-formed full stable-ID tokens are links, and any other `[[…]]` in stored text is plain text. +- R8. When text is saved from TUI input or from the TUI-launched `$EDITOR`, each `[[N]]` (numeric) and `[[]]` (stable-ID prefix) is rewritten to the full stable-ID token of the matching active item. +- R9. A save that contains a numeric or prefix reference matching no active item, or more than one, is rejected with a message naming the reference, and nothing is written. +- R10. Full stable-ID tokens already in the text are preserved verbatim on save, whatever the state of their target. +- R11. A TUI keybind on the selected item lets the user pick a target item and appends a link token to the selected item's description without manual text editing. +- R12. Typing `[[` in TUI text input opens item autocomplete, and accepting a suggestion inserts the full stable-ID token. + +**Viewing and following links** + +- R13. TUI item rows render each link token as the target's current title, visually marked as a link, instead of the raw ID. +- R14. A side pane shows the selected item's outgoing links and its backlinks (active items whose text links to it), each as title plus short stable ID, and updates as the selection moves. +- R15. From the side pane the user can jump to any listed item, and the TUI switches board or view as needed so that item is selected and visible. +- R16. A link whose target is archived or absent renders as a broken-link marker, archived items contribute no backlinks, and restoring an item makes its inbound and outbound links resolve again with no other action. +- R17. The side pane can be toggled with a key and is hidden automatically when the terminal is narrower than a minimum width. + +**Outside the TUI** + +- R18. CLI output and input, MCP tools, and the server are unchanged, and link tokens appear there verbatim. + +```mermaid +flowchart TB + T["Item text: description, note title, note body (sole store of links)"] --> P[Parse full stable-ID tokens] + P --> I[In-memory link index built at load] + I --> R["TUI rows: token rendered as title (R13)"] + I --> S["Side pane: outgoing + backlinks (R14)"] + I --> J["Jump to item (R15)"] + T -. "verbatim, no resolution" .-> X["CLI / MCP / server (R18)"] +``` + +```mermaid +flowchart LR + subgraph TUI + direction LR + M["Main list: board, timeline, journal; rows show numeric ID and short stable ID"] --- SP["Side pane (toggle, hides when narrow): Links to, Linked from"] + end +``` + +### Key Flows + +- F1. Link while writing + - **Trigger:** User creates or edits a task in the TUI. + - **Steps:** Types `[[`; autocomplete lists items; user picks a note; the full token is inserted; user saves. + - **Outcome:** The task row shows the note's title as a link; selecting the note shows the task under its backlinks. + - **Covered by:** R7, R12, R13, R14 +- F2. Link without editing text + - **Trigger:** User presses the link keybind on a selected item. + - **Steps:** Picks a target; token is appended to the selected item's description. + - **Outcome:** Same as F1. + - **Covered by:** R11, R14 +- F3. Follow a backlink + - **Trigger:** User selects an item with backlinks. + - **Steps:** Side pane lists referrers; user moves to an entry and confirms. + - **Outcome:** The referrer is selected and visible, on its own board or view. + - **Covered by:** R14, R15 +- F4. Archive and restore a linked item + - **Trigger:** User deletes an item other items link to, then later restores it. + - **Steps:** Delete moves it to the archive; referrers show broken markers. Restore brings it back with a new numeric ID and the same stable ID. + - **Outcome:** Links resolve again and the backlinks reappear. + - **Covered by:** R1, R16 + +### Acceptance Examples + +- AE1. **Covers R8, R9.** + - **Given** active item 12 exists, no item 99 exists, and two active items have stable IDs starting `kq`. + - **When** the user saves `see [[12]]`, `see [[99]]`, and `see [[kq]]` in turn. + - **Then** the first is stored as the full token for item 12; the second and third are rejected with a message naming `[[99]]` and `[[kq]]`, and nothing is written. +- AE2. **Covers R10, R16.** + - **Given** a note body containing a full-ID link to an archived task. + - **When** the user edits an unrelated line of the note in `$EDITOR` and saves. + - **Then** the save succeeds, the token is unchanged, and the link still renders as broken. +- AE3. **Covers R7, R18.** + - **Given** a task created via the CLI as `tb --task "see [[12]]"`. + - **When** it is viewed in the TUI. + - **Then** `[[12]]` is plain text, not a link, and no backlink appears on item 12. +- AE4. **Covers R3.** + - **Given** two synced clients holding the same pre-upgrade items. + - **When** both upgrade and load before either syncs. + - **Then** each item has the same stable ID on both clients, and links made on either client resolve on both after sync. +- AE5. **Covers R1, R16.** + - **Given** task 7 is linked from three items. + - **When** task 7 is deleted, then restored and assigned numeric ID 15. + - **Then** while archived, all three referrers show a broken marker and task 7 appears in no backlink list; after restore, all three links resolve to task 15 and its side pane lists the three referrers. +- AE6. **Covers R17.** + - **Given** a terminal narrower than the minimum width. + - **When** the TUI starts or is resized below it. + - **Then** the side pane is hidden and the main list uses the full width; widening the terminal restores the pane if it was toggled on. + +### Scope Boundaries + +**Deferred for later** + +- CLI rendering of link tokens, `tb --link`, `tb --backlinks`, and accepting stable IDs as CLI targets (the CLI `@` sigil already means boards). +- MCP link fields on `list_items`, a link tool, and protecting tokens from being dropped by `edit_item`. +- A warning when deleting an item that has backlinks. +- Resolving archived targets with an "archived" marker instead of a broken one. + +**Outside this work's identity** + +- Workflowy-style mirrors or transclusion (one item shown live in two places). +- Links to boards or tags, and hierarchical parent/child items. +- Any server-side link index; the server never sees plaintext. + +### Dependencies / Assumptions + +- The original Node.js taskbook has not been checked for whether it keeps unknown item fields when it rewrites `storage.json`. If it drops them, editing the same data with Node once removes every stable ID and breaks every link. +- MCP `edit_item` replaces the whole description (`crates/taskbook-client/src/mcp/tools.rs:387`), so an agent that rewrites a title without copying a token removes that link, and nothing catches it. +- The first load after upgrade writes the full dataset once to persist backfilled IDs; with sync enabled that is a one-time full upload. +- Sync stays last-write-wins on the whole item set (`crates/taskbook-server/src/handlers/items.rs:144`); this plan does not change it. + +### Outstanding Questions + +**Resolve Before Planning** + +- None. + +**Deferred to Planning** + +- Stable-ID length and exact alphabet, and how a random ID that collides with an existing one is handled. +- Which immutable inputs drive the deterministic backfill, and how two items created in the same millisecond with identical content are kept distinct. +- Whether R11's appended token goes into a note's title or body. +- Side-pane focus model and keybinds (switching focus between list and pane, the link keybind, the toggle key), and the minimum-width threshold. +- How the `[[` autocomplete trigger fits the existing autocomplete, which today runs only for `/`-prefixed command input. +- Whether a self-link or a repeated link to the same target is shown once or per occurrence. +- Verifying the Node.js unknown-field behavior, and documenting the result. + +### Sources / Research + +- ID allocation and renumbering: `crates/taskbook-client/src/taskbook.rs:71` (`generate_id`, `max + 1`), `:323` (`save_item_to_archive`), `:337` (`save_item_to_storage`). +- Item models and serde compatibility: `crates/taskbook-common/src/models/task.rs`, `crates/taskbook-common/src/models/note.rs`. +- Existing sigils: `+tag` and `@board` parsing in `crates/taskbook-common/src/board.rs:90`; `@` targets in `crates/taskbook-client/src/taskbook.rs:730`; `[[` is unused in the client. +- Sync storage: opaque per-item blobs keyed by `item_key` in `crates/taskbook-server/src/migrations/001_initial.sql`; full-set replace in `crates/taskbook-server/src/handlers/items.rs`. +- TUI layout: a single vertical split in `crates/taskbook-client/src/tui/ui.rs:20`; `PopupState` has only `Help` (`crates/taskbook-client/src/tui/app.rs:106`); no width-based layout today. +- TUI autocomplete: `@` suggests boards, item suggestions come from bare tokens after `/` commands (`crates/taskbook-client/src/tui/autocomplete.rs:42`, `:67`, `:160`). +- Note bodies: edited in `$EDITOR` (`crates/taskbook-client/src/editor.rs`); the TUI shows only a `[...]` marker or the journal's first line (`crates/taskbook-client/src/tui/widgets/item_row.rs:80`, `crates/taskbook-client/src/tui/widgets/journal_view.rs:150`). +- jj change ID vs commit ID, reverse-hex alphabet, shortest-prefix display: https://jj-vcs.github.io/jj/latest/glossary/#change-id From f5d2d8c8c8c89ffe7a879bca5c67863399884979 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 21:59:40 +0000 Subject: [PATCH 02/45] docs(backlinks): derive every stable ID deterministically Older taskbook-rs binaries (including a long-running tb --mcp) drop the unknown stable-ID field on write. Random IDs for new items could never be recovered, silently breaking links in a mixed-version window. Making every ID a pure function of immutable creation fields lets the next load re-derive them identically. Review: feasibility + adversarial (independent agreement). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index d31701c..b23beca 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -50,7 +50,7 @@ Because sync stores items as opaque client-encrypted blobs and replaces the whol - R1. Every task and note has a stable ID that is assigned once and never changes across edit, move, star, state change, archive, restore, or sync. - R2. Stable IDs use a letters-only alphabet, so no stable ID or prefix of one can be read as a numeric ID. -- R3. An item with no stable ID receives one the first time the new version loads it, derived deterministically from data that never changes after creation, so independent clients assign identical IDs. +- R3. Every stable ID, for new and pre-existing items alike, is a deterministic function of creation fields that never change and that older versions preserve, so any client that finds an item without one (on first upgrade, or after an older version dropped it) assigns the identical ID. - R4. Numeric IDs keep their current allocation and behavior for every existing CLI, TUI, and MCP operation. - R5. Older taskbook-rs versions can still read item data written by this version. - R6. The TUI shows each item's stable ID as its shortest unique prefix, visually distinguished, next to the numeric ID. @@ -161,7 +161,8 @@ flowchart LR ### Dependencies / Assumptions -- The original Node.js taskbook has not been checked for whether it keeps unknown item fields when it rewrites `storage.json`. If it drops them, editing the same data with Node once removes every stable ID and breaks every link. +- Older taskbook-rs versions, including a long-running `tb --mcp` process started before the upgrade, drop the unknown stable-ID field on any write because the item structs keep no unknown fields. R3 re-derives the same IDs on the next load by the new version, so links survive a mixed-version window. +- The original Node.js taskbook has not been checked for whether it keeps unknown item fields when it rewrites `storage.json`. If it drops them, R3 re-derives the IDs as long as Node preserves the creation fields they are derived from. - MCP `edit_item` replaces the whole description (`crates/taskbook-client/src/mcp/tools.rs:387`), so an agent that rewrites a title without copying a token removes that link, and nothing catches it. - The first load after upgrade writes the full dataset once to persist backfilled IDs; with sync enabled that is a one-time full upload. - Sync stays last-write-wins on the whole item set (`crates/taskbook-server/src/handlers/items.rs:144`); this plan does not change it. @@ -174,8 +175,8 @@ flowchart LR **Deferred to Planning** -- Stable-ID length and exact alphabet, and how a random ID that collides with an existing one is handled. -- Which immutable inputs drive the deterministic backfill, and how two items created in the same millisecond with identical content are kept distinct. +- Stable-ID length and exact alphabet, and how a derived ID that collides with an existing one is handled. +- Which immutable creation fields drive the derivation (`_timestamp` and `_isTask` are the only ones that never change today), and how two items with the same creation timestamp get distinct IDs. - Whether R11's appended token goes into a note's title or body. - Side-pane focus model and keybinds (switching focus between list and pane, the link keybind, the toggle key), and the minimum-width threshold. - How the `[[` autocomplete trigger fits the existing autocomplete, which today runs only for `/`-prefixed command input. From f11b8548cdabd82ccc9eb2aea1d4849792dbd5a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:00:39 +0000 Subject: [PATCH 03/45] docs(backlinks): align objective with archived-as-broken decision The Goal Capsule promised links keep working through archive, contradicting R16. Review: coherence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index b23beca..b6a049b 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -12,7 +12,7 @@ execution: code ## Goal Capsule -- **Objective:** In the TUI, a user can link any task or note to another with an inline `[[…]]` reference, see from any item what it links to and what links to it, and jump between them, with links that keep working through edits, moves, archive, restore, and sync. +- **Objective:** In the TUI, a user can link any task or note to another with an inline `[[…]]` reference, see from any item what it links to and what links to it, and jump between them, with links that survive edits, moves, archive, restore, and sync; a link to an archived item shows as broken until that item is restored. - **Product authority:** This Product Contract. CLI, MCP, and server behavior are explicitly out of active scope (R18). - **Open blockers:** None. Node.js taskbook compatibility is an unverified assumption, not a blocker (see Dependencies / Assumptions). From 0e323db98de89be97d2d20b58555e08951019707 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:00:40 +0000 Subject: [PATCH 04/45] docs(backlinks): rewrite only [[...]] spans added in the current edit Otherwise a later unrelated TUI edit would turn stored plain-text [[12]] into a permanent link to whatever holds numeric ID 12, or block the save, contradicting R7. Review: adversarial. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index b6a049b..563c518 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -58,8 +58,8 @@ Because sync stores items as opaque client-encrypted blobs and replaces the whol **Writing links** - R7. A link is a `[[]]` token in a task description, note title, or note body; only well-formed full stable-ID tokens are links, and any other `[[…]]` in stored text is plain text. -- R8. When text is saved from TUI input or from the TUI-launched `$EDITOR`, each `[[N]]` (numeric) and `[[]]` (stable-ID prefix) is rewritten to the full stable-ID token of the matching active item. -- R9. A save that contains a numeric or prefix reference matching no active item, or more than one, is rejected with a message naming the reference, and nothing is written. +- R8. When text is saved from TUI input or from the TUI-launched `$EDITOR`, each `[[N]]` (numeric) and `[[]]` (stable-ID prefix) added in this edit is rewritten to the full stable-ID token of the matching active item; such spans already present in the text before the edit stay verbatim as plain text. +- R9. A save that adds a numeric or prefix reference matching no active item, or more than one, is rejected with a message naming the reference, and nothing is written. - R10. Full stable-ID tokens already in the text are preserved verbatim on save, whatever the state of their target. - R11. A TUI keybind on the selected item lets the user pick a target item and appends a link token to the selected item's description without manual text editing. - R12. Typing `[[` in TUI text input opens item autocomplete, and accepting a suggestion inserts the full stable-ID token. @@ -127,10 +127,10 @@ flowchart LR - **Given** a note body containing a full-ID link to an archived task. - **When** the user edits an unrelated line of the note in `$EDITOR` and saves. - **Then** the save succeeds, the token is unchanged, and the link still renders as broken. -- AE3. **Covers R7, R18.** +- AE3. **Covers R7, R8, R9, R18.** - **Given** a task created via the CLI as `tb --task "see [[12]]"`. - - **When** it is viewed in the TUI. - - **Then** `[[12]]` is plain text, not a link, and no backlink appears on item 12. + - **When** it is viewed in the TUI, and later edited there (for example, a typo fix elsewhere in the text) and saved. + - **Then** `[[12]]` stays plain text, not a link, both times; the edit saves normally and no backlink appears on item 12. - AE4. **Covers R3.** - **Given** two synced clients holding the same pre-upgrade items. - **When** both upgrade and load before either syncs. From efcbdf3152a70f189fb1d8015789ad3a2d14e188 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:00:40 +0000 Subject: [PATCH 05/45] docs(backlinks): keep the draft when a save is rejected One mistyped [[99]] would otherwise discard a whole \$EDITOR session. Review: design-lens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index 563c518..ea945f1 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -59,7 +59,7 @@ Because sync stores items as opaque client-encrypted blobs and replaces the whol - R7. A link is a `[[]]` token in a task description, note title, or note body; only well-formed full stable-ID tokens are links, and any other `[[…]]` in stored text is plain text. - R8. When text is saved from TUI input or from the TUI-launched `$EDITOR`, each `[[N]]` (numeric) and `[[]]` (stable-ID prefix) added in this edit is rewritten to the full stable-ID token of the matching active item; such spans already present in the text before the edit stay verbatim as plain text. -- R9. A save that adds a numeric or prefix reference matching no active item, or more than one, is rejected with a message naming the reference, and nothing is written. +- R9. A save that adds a numeric or prefix reference matching no active item, or more than one, is rejected with a message naming the reference, nothing is written, and the user's draft is kept: TUI input stays open with its text, and `$EDITOR` reopens on the edited content. - R10. Full stable-ID tokens already in the text are preserved verbatim on save, whatever the state of their target. - R11. A TUI keybind on the selected item lets the user pick a target item and appends a link token to the selected item's description without manual text editing. - R12. Typing `[[` in TUI text input opens item autocomplete, and accepting a suggestion inserts the full stable-ID token. From c4b9498a7bdf729c1e08060d996f740eef449880 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:00:40 +0000 Subject: [PATCH 06/45] docs(backlinks): defer picker/autocomplete design to planning explicitly No existing TUI pattern covers an item picker. Review: design-lens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index ea945f1..9d9f47b 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -178,6 +178,7 @@ flowchart LR - Stable-ID length and exact alphabet, and how a derived ID that collides with an existing one is handled. - Which immutable creation fields drive the derivation (`_timestamp` and `_isTask` are the only ones that never change today), and how two items with the same creation timestamp get distinct IDs. - Whether R11's appended token goes into a note's title or body. +- Candidate format, match rule, and cancel behavior shared by the link picker (R11) and `[[` autocomplete (R12): one format of title, short stable ID, and board; active items only. - Side-pane focus model and keybinds (switching focus between list and pane, the link keybind, the toggle key), and the minimum-width threshold. - How the `[[` autocomplete trigger fits the existing autocomplete, which today runs only for `/`-prefixed command input. - Whether a self-link or a repeated link to the same target is shown once or per occurrence. From fcfcbc0cdc49d581c978132465c75d17bf0804f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:19:33 +0000 Subject: [PATCH 07/45] docs(backlinks): add implementation planning Enrich the requirements-only plan in place with a Planning Contract (18 KTDs, design diagrams), ten implementation units, a verification contract and definition of done. Product Contract changes, confirmed in plan scoping: stable IDs are a pure function of _timestamp (HUID-style), computed on read and never stored; uniqueness enforced at creation (R19); same-timestamp legacy items are divergent (R20); R8/R10 clarified. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- .../2026-10-21-2154-feat-backlinks-plan.md | 502 ++++++++++++++++-- 1 file changed, 467 insertions(+), 35 deletions(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index 9d9f47b..142aeb4 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -13,8 +13,12 @@ execution: code ## Goal Capsule - **Objective:** In the TUI, a user can link any task or note to another with an inline `[[…]]` reference, see from any item what it links to and what links to it, and jump between them, with links that survive edits, moves, archive, restore, and sync; a link to an archived item shows as broken until that item is restored. -- **Product authority:** This Product Contract. CLI, MCP, and server behavior are explicitly out of active scope (R18). -- **Open blockers:** None. Node.js taskbook compatibility is an unverified assumption, not a blocker (see Dependencies / Assumptions). +- **Means:** a jj-style stable ID computed from each item's creation timestamp, and `[[stable-id]]` tokens in item text resolved by a client-side link index (KTD1, KTD5). +- **Authority:** the Product Contract wins on behavior; Key Technical Decisions win on mechanism within their cited requirements; units override neither. +- **Product Contract preservation:** changed: R3 (ID derived from `_timestamp` alone, computed on read, never stored), R8 (which `[[…]]` spans count as references), R10 (newly added full tokens are kept too), Key Decision "Deterministic, creation-time IDs" reworded; added R19, R20, AE7; Dependencies bullets about older binaries dropping a stored field and a first-load full write removed; planning questions resolved into KTDs. All confirmed by the user in plan scoping. +- **Stop conditions:** stop and ask if implementing R3 would require storing a new field, if the derivation function would need to change after any link has been written, or if a unit cannot meet R9 without changing behavior outside the TUI. +- **Execution profile:** Rust workspace; client and common crates only; the server is untouched. +- **Open blockers:** none. --- @@ -22,13 +26,13 @@ execution: code ### Summary -Items gain a permanent, letters-only stable ID alongside the existing numeric ID, modeled on jj's change ID vs commit ID split. Links are `[[stable-id]]` tokens in item text, which is the only place they are stored. The TUI resolves them into titles, shows outgoing links and backlinks in a side pane, and lets the user jump between linked items. +Items gain a permanent, letters-only stable ID alongside the existing numeric ID, modeled on jj's change ID vs commit ID split and computed from the item's creation time. Links are `[[stable-id]]` tokens in item text, which is the only place they are stored. The TUI resolves them into titles, shows outgoing links and backlinks in a side pane, and lets the user jump between linked items. ### Problem Frame Taskbook has no durable way to say "this note is the context for that task" or "this task depends on that one." The only handle on an item is its numeric ID, and that ID is positional: it is `max + 1` at creation, it is reassigned when an item is archived, reassigned again when it is restored, and reused when the highest-numbered item is deleted. A reference written as "see #12" today can silently point at a different item tomorrow. -This is the same problem jj solves by separating the change ID (random, permanent, what users see and type) from the commit ID (content-derived, rewritten constantly, secondary). Taskbook's numeric ID plays the commit-ID role and has no change-ID counterpart. +This is the same problem jj solves by separating the change ID (permanent, what users see and type) from the commit ID (content-derived, rewritten constantly, secondary). Taskbook's numeric ID plays the commit-ID role and has no change-ID counterpart. Because sync stores items as opaque client-encrypted blobs and replaces the whole set on each `PUT /items`, any linking scheme has to work entirely on the client and tolerate last-write-wins across devices. @@ -37,8 +41,9 @@ Because sync stores items as opaque client-encrypted blobs and replaces the whol - **Inline links, text as the single source of truth, plus a non-text way to add one.** Links live in the item text; a TUI keybind appends a token rather than maintaining a separate relation list. Governs R7, R11. (session-settled: user-directed — chosen over explicit-only structured relations and over inline-only: inline matches the Workflowy model, the keybind covers linking without editing text.) - **Tokens carry only the stable ID.** Raw text shows `[[kqzxmvtn…]]`; views render the title. Governs R7, R13. (session-settled: user-directed — chosen over `[[Title|id]]` and `[[Title]]`: no stale titles in text, no rename or duplicate-title breakage.) - **A new stable ID beside the numeric ID, shown jj-style in the TUI.** Numeric IDs keep working unchanged; the stable ID is displayed as a shortest unique prefix. Governs R1, R4, R6. (session-settled: user-directed — chosen over making numeric IDs permanent, over a hidden-only stable ID, and over replacing numeric IDs everywhere: it follows jj's model without breaking CLI muscle memory or pulling the CLI into scope.) -- **Letters-only alphabet, full ID stored, shortest prefix displayed.** Lexical disjointness makes `[[12]]` and `[[kqz]]` unambiguous without lookup; prefixes never go into stored text because a unique prefix can become ambiguous as items are added. Governs R2, R6, R7, R8. (session-settled: user-approved — proposed from jj's reverse-hex change IDs with the prefix-ambiguity tradeoff shown.) -- **Deterministic backfill.** Pre-existing items get IDs derived from immutable data so two synced clients upgrading independently agree. Governs R3. (session-settled: user-approved — chosen over random backfill: random IDs plus last-write-wins `PUT /items` would silently break links made on the losing client.) +- **Letters-only alphabet, full ID in tokens, shortest prefix displayed.** Lexical disjointness makes `[[12]]` and `[[kqz]]` unambiguous without lookup; prefixes never go into stored text because a unique prefix can become ambiguous as items are added. Governs R2, R6, R7, R8. (session-settled: user-approved — proposed from jj's reverse-hex change IDs with the prefix-ambiguity tradeoff shown.) +- **Deterministic, creation-time IDs.** Every client derives the same ID for the same item, so sync and mixed devices agree without coordination. Governs R3. (session-settled: user-approved — chosen over random backfill: random IDs plus last-write-wins `PUT /items` would silently break links made on the losing client.) +- **Creation time alone is the identity, HUID-style.** The ID depends only on `_timestamp`, is never stored, and uniqueness is enforced when an item is created; items that already share a timestamp are accepted as divergent. Governs R3, R5, R19, R20. (session-settled: user-directed — chosen over deriving from `_timestamp` plus `_isTask` into a stored field: no data-format change and nothing for older binaries to drop, at a scale where same-millisecond creation is negligible; precedent: the HUID scheme in alycda/dotfiles `tasks/README.md`.) - **TUI only for v1.** Governs R18. (session-settled: user-directed — chosen over also covering CLI and MCP.) - **Outside the TUI, tokens stay raw.** Governs R18. (session-settled: user-directed — chosen over read-only title rendering in CLI/MCP: zero work outside the TUI and lossless MCP round-tripping.) - **Archived targets are broken links.** Governs R16. (session-settled: user-directed — chosen over resolving archived targets with an "archived" marker and over full archive parity; the jj hidden-commit analogy was raised afterward and the choice was kept.) @@ -50,7 +55,9 @@ Because sync stores items as opaque client-encrypted blobs and replaces the whol - R1. Every task and note has a stable ID that is assigned once and never changes across edit, move, star, state change, archive, restore, or sync. - R2. Stable IDs use a letters-only alphabet, so no stable ID or prefix of one can be read as a numeric ID. -- R3. Every stable ID, for new and pre-existing items alike, is a deterministic function of creation fields that never change and that older versions preserve, so any client that finds an item without one (on first upgrade, or after an older version dropped it) assigns the identical ID. +- R3. Every stable ID is a deterministic function of the item's creation timestamp alone, computed whenever the item is read and never stored, so every client and every version that keeps `_timestamp` agrees on it. +- R19. Creating an item gives it a creation timestamp that no other active or archived item has, so new items never share a stable ID. +- R20. Items that already share a creation timestamp are divergent: the TUI marks them, they are not jump targets, and a save that adds a reference to one is rejected per R9. - R4. Numeric IDs keep their current allocation and behavior for every existing CLI, TUI, and MCP operation. - R5. Older taskbook-rs versions can still read item data written by this version. - R6. The TUI shows each item's stable ID as its shortest unique prefix, visually distinguished, next to the numeric ID. @@ -58,9 +65,9 @@ Because sync stores items as opaque client-encrypted blobs and replaces the whol **Writing links** - R7. A link is a `[[]]` token in a task description, note title, or note body; only well-formed full stable-ID tokens are links, and any other `[[…]]` in stored text is plain text. -- R8. When text is saved from TUI input or from the TUI-launched `$EDITOR`, each `[[N]]` (numeric) and `[[]]` (stable-ID prefix) added in this edit is rewritten to the full stable-ID token of the matching active item; such spans already present in the text before the edit stay verbatim as plain text. +- R8. When text is saved from TUI input or from the TUI-launched `$EDITOR`, each `[[N]]` (digits only) and `[[]]` (stable-ID alphabet letters only) added in this edit is rewritten to the full stable-ID token of the matching active item; such spans already present in the text before the edit, and any `[[…]]` containing other characters, stay verbatim as plain text. - R9. A save that adds a numeric or prefix reference matching no active item, or more than one, is rejected with a message naming the reference, nothing is written, and the user's draft is kept: TUI input stays open with its text, and `$EDITOR` reopens on the edited content. -- R10. Full stable-ID tokens already in the text are preserved verbatim on save, whatever the state of their target. +- R10. Full stable-ID tokens, whether already in the text or newly added, are preserved verbatim on save, whatever the state of their target. - R11. A TUI keybind on the selected item lets the user pick a target item and appends a link token to the selected item's description without manual text editing. - R12. Typing `[[` in TUI text input opens item autocomplete, and accepting a suggestion inserts the full stable-ID token. @@ -69,7 +76,7 @@ Because sync stores items as opaque client-encrypted blobs and replaces the whol - R13. TUI item rows render each link token as the target's current title, visually marked as a link, instead of the raw ID. - R14. A side pane shows the selected item's outgoing links and its backlinks (active items whose text links to it), each as title plus short stable ID, and updates as the selection moves. - R15. From the side pane the user can jump to any listed item, and the TUI switches board or view as needed so that item is selected and visible. -- R16. A link whose target is archived or absent renders as a broken-link marker, archived items contribute no backlinks, and restoring an item makes its inbound and outbound links resolve again with no other action. +- R16. A link whose target is archived or absent renders as a broken-link marker, archived items contribute no backlinks, and restoring an item makes its backlinks and outgoing links resolve again with no other action. - R17. The side pane can be toggled with a key and is hidden automatically when the terminal is narrower than a minimum width. **Outside the TUI** @@ -108,12 +115,12 @@ flowchart LR - **Covered by:** R11, R14 - F3. Follow a backlink - **Trigger:** User selects an item with backlinks. - - **Steps:** Side pane lists referrers; user moves to an entry and confirms. - - **Outcome:** The referrer is selected and visible, on its own board or view. + - **Steps:** Side pane lists backlinks; user moves to an entry and confirms. + - **Outcome:** The backlinking item is selected and visible, on its own board or view. - **Covered by:** R14, R15 - F4. Archive and restore a linked item - **Trigger:** User deletes an item other items link to, then later restores it. - - **Steps:** Delete moves it to the archive; referrers show broken markers. Restore brings it back with a new numeric ID and the same stable ID. + - **Steps:** Delete moves it to the archive; items linking to it show broken markers. Restore brings it back with a new numeric ID and the same stable ID. - **Outcome:** Links resolve again and the backlinks reappear. - **Covered by:** R1, R16 @@ -138,11 +145,15 @@ flowchart LR - AE5. **Covers R1, R16.** - **Given** task 7 is linked from three items. - **When** task 7 is deleted, then restored and assigned numeric ID 15. - - **Then** while archived, all three referrers show a broken marker and task 7 appears in no backlink list; after restore, all three links resolve to task 15 and its side pane lists the three referrers. + - **Then** while archived, all three linking items show a broken marker and task 7 appears in no backlink list; after restore, all three links resolve to task 15 and its side pane lists the three backlinks. - AE6. **Covers R17.** - **Given** a terminal narrower than the minimum width. - **When** the TUI starts or is resized below it. - **Then** the side pane is hidden and the main list uses the full width; widening the terminal restores the pane if it was toggled on. +- AE7. **Covers R19, R20.** + - **Given** an MCP agent creates three tasks within the same millisecond, and the store already holds two legacy notes that share one `_timestamp`. + - **When** the TUI loads. + - **Then** the three new tasks have three distinct stable IDs; the two legacy notes show a divergent marker, are not offered by `[[` autocomplete, and saving `[[]]` is rejected. ### Scope Boundaries @@ -161,29 +172,12 @@ flowchart LR ### Dependencies / Assumptions -- Older taskbook-rs versions, including a long-running `tb --mcp` process started before the upgrade, drop the unknown stable-ID field on any write because the item structs keep no unknown fields. R3 re-derives the same IDs on the next load by the new version, so links survive a mixed-version window. -- The original Node.js taskbook has not been checked for whether it keeps unknown item fields when it rewrites `storage.json`. If it drops them, R3 re-derives the IDs as long as Node preserves the creation fields they are derived from. +- Nothing in taskbook-rs changes `_timestamp` after creation (only `Task::new`, `Note::new`, `Note::new_with_body` set it), and older taskbook-rs versions keep it on every write, so their writes cannot change a stable ID. +- The original Node.js taskbook is assumed to keep `_timestamp` unchanged when it rewrites `storage.json`; it has not been checked. If it rewrote timestamps, links to affected items would break. +- Items created by older binaries, Node taskbook, or two devices in the same millisecond can share a timestamp; R20 governs how they appear. - MCP `edit_item` replaces the whole description (`crates/taskbook-client/src/mcp/tools.rs:387`), so an agent that rewrites a title without copying a token removes that link, and nothing catches it. -- The first load after upgrade writes the full dataset once to persist backfilled IDs; with sync enabled that is a one-time full upload. - Sync stays last-write-wins on the whole item set (`crates/taskbook-server/src/handlers/items.rs:144`); this plan does not change it. -### Outstanding Questions - -**Resolve Before Planning** - -- None. - -**Deferred to Planning** - -- Stable-ID length and exact alphabet, and how a derived ID that collides with an existing one is handled. -- Which immutable creation fields drive the derivation (`_timestamp` and `_isTask` are the only ones that never change today), and how two items with the same creation timestamp get distinct IDs. -- Whether R11's appended token goes into a note's title or body. -- Candidate format, match rule, and cancel behavior shared by the link picker (R11) and `[[` autocomplete (R12): one format of title, short stable ID, and board; active items only. -- Side-pane focus model and keybinds (switching focus between list and pane, the link keybind, the toggle key), and the minimum-width threshold. -- How the `[[` autocomplete trigger fits the existing autocomplete, which today runs only for `/`-prefixed command input. -- Whether a self-link or a repeated link to the same target is shown once or per occurrence. -- Verifying the Node.js unknown-field behavior, and documenting the result. - ### Sources / Research - ID allocation and renumbering: `crates/taskbook-client/src/taskbook.rs:71` (`generate_id`, `max + 1`), `:323` (`save_item_to_archive`), `:337` (`save_item_to_storage`). @@ -193,4 +187,442 @@ flowchart LR - TUI layout: a single vertical split in `crates/taskbook-client/src/tui/ui.rs:20`; `PopupState` has only `Help` (`crates/taskbook-client/src/tui/app.rs:106`); no width-based layout today. - TUI autocomplete: `@` suggests boards, item suggestions come from bare tokens after `/` commands (`crates/taskbook-client/src/tui/autocomplete.rs:42`, `:67`, `:160`). - Note bodies: edited in `$EDITOR` (`crates/taskbook-client/src/editor.rs`); the TUI shows only a `[...]` marker or the journal's first line (`crates/taskbook-client/src/tui/widgets/item_row.rs:80`, `crates/taskbook-client/src/tui/widgets/journal_view.rs:150`). -- jj change ID vs commit ID, reverse-hex alphabet, shortest-prefix display: https://jj-vcs.github.io/jj/latest/glossary/#change-id +- jj change ID vs commit ID, reverse-hex alphabet, shortest-prefix display, divergent changes: https://jj-vcs.github.io/jj/latest/glossary/#change-id +- HUID (creation time as identity, collision handled at creation): alycda/dotfiles, branch `effver`, `tasks/README.md` and `CONCEPTS.md`. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **Stable ID = invertible 64-bit mix of `_timestamp`, written as 16 letters `k`–`z`.** The timestamp's bits (as `u64`, two's complement, so 0 and negative values are covered) go through a fixed invertible mixer (an xorshift-multiply finalizer with odd multipliers, in the style of splitmix64's finalizer), and each 4-bit nibble maps to one letter of `klmnopqrstuvwxyz` (jj's reverse-hex alphabet). Invertibility means distinct timestamps never collide; mixing means items created minutes apart get unrelated prefixes, keeping displayed prefixes short. Hand-written, no new dependency; never `std`'s `DefaultHasher`, whose output is not stable across Rust releases. Lives in a new pure module `crates/taskbook-common/src/stable_id.rs`, frozen by golden test vectors. Implements R2, R3. (session-settled: user-directed — chosen over hashing `_timestamp` plus `_isTask` into a stored field: no data-format change, HUID precedent.) +- KTD2. **Computed on read, never serialized.** `stable_id()` is an accessor on the `Item` trait and the mirrored inherent methods of `StorageItem` that computes from `timestamp`; no serde field is added, so `storage.json`, the sync payload, and older binaries are unaffected (R3, R5). The link index caches computed IDs per refresh. +- KTD3. **Uniqueness at creation by bumping `_timestamp`.** Every `Taskbook` create path takes the candidate timestamp and, if any active or archived item already has it, increments by 1 ms until free; this covers CLI, TUI, MCP, and editor-created notes because they all go through `Taskbook` (R19). The archive is read on create to make the check complete; in remote mode that is one extra `GET` per create, acceptable at taskbook's scale. A bump of a few milliseconds has no visible effect: `timestamp` only drives ordering and age display. +- KTD4. **Divergence is detected, not repaired.** The link index groups active items by stable ID; any ID held by more than one active item is divergent (R20). An archived copy sharing an ID with an active item (a half-finished delete) is ignored, because archived items contribute nothing to the index. Automatic repair is deferred. +- KTD5. **Link logic is a pure module, `crates/taskbook-common/src/links.rs`.** It owns three things: the span scanner, the save-time rewrite, and the link index. It takes plain item data, has no TUI or storage dependency, and carries the bulk of the tests. Follows the shape of `crates/taskbook-common/src/due.rs`. +- KTD6. **Span classification.** The scanner finds closed `[[…]]` spans; the inner text classifies as Full (exactly 16 alphabet letters), Numeric (digits only), Prefix (1–15 alphabet letters), or Plain (anything else, including mixed or other letters). Matching is case-insensitive and rewritten tokens are lowercase. An unclosed `[[` is not a span. +- KTD7. **"Added in this edit" is a multiset diff against the stored baseline.** The baseline is the item's stored text when the edit session started (empty for a create), re-read at save time and held fixed across reject-and-retry passes, so a rejected `[[99]]` cannot become "pre-existing" on the second try. For notes, title and body are diffed together so moving a span between them is not an addition. For each span text, the first k occurrences (k = baseline count) stay verbatim and later occurrences are added (R8, R10). +- KTD8. **Rewrite outcome is a value, not an error.** `rewrite_on_save(baseline, draft, index)` returns either the rewritten text or a list of rejected references with reasons (not found, ambiguous prefix, divergent target). Full tokens are never rejected (R10). Callers in the TUI show the message as a status and keep the draft. +- KTD9. **TUI keeps an active-items snapshot and a `LinkIndex` independent of the view.** `App` loads active items in every view (Archive view currently replaces `app.items`), rebuilding the index in `refresh_items`, `set_view`, the SSE handler for both active and archive changes, and after restore. Prefix uniqueness is computed over active items; an archived row's prefix is computed over active items plus itself. +- KTD10. **Command-line submit only clears on success.** Submit currently deactivates the command line before executing and action errors propagate out of `run_app`, closing the TUI. The link-rewrite path returns a rejection that sets an error status and leaves the input text and cursor in place (R9). Other storage errors keep today's behavior. +- KTD11. **Note editor saves validate first, then write once.** A new `Taskbook::edit_note_silent(id, title, body)` replaces the title-then-body pair of writes in `edit_note_external`, so a rejection can never leave a half-saved note. On rejection the editor reopens on the edited title and body, with the rejection message as a leading `//` comment line (already stripped by `parse_note_content`). The `/note` create-then-editor flow uses the same validation with an empty baseline. +- KTD12. **The link keybind reuses the command line.** `l` on a selected active item opens the command line as `/edit @ [[` with item suggestions already showing; accepting one and pressing Enter saves through the normal rewrite path (R11). For notes the token goes on the title (the `/edit` target). No new picker widget. +- KTD13. **`[[` autocomplete.** `update_suggestions` gains a branch, checked before the `@` branch, for an unclosed `[[` before the cursor in the description position of `/task`, `/note`, and `/edit`. Candidates: active, non-divergent items, excluding the item being edited, shown as rendered title, short stable ID, and board, matched by substring on rendered title, numeric ID, or stable-ID prefix. Accepting inserts `[[]]`. While a `[[` dropdown is open, Enter accepts instead of submitting. +- KTD14. **Keys:** `l` link, `v` toggle the side pane, `o` move focus into the pane (`j`/`k` move, Enter jumps, `o` or Esc returns to the list). Tab and `h` are already bound. Pane visibility is session-local in v1 and starts visible. +- KTD15. **Jump does not touch saved settings.** Jumping clears the search term and board filter, switches Archive view to Board view through a non-persisting path, and reveals a completed target with a transient override instead of flipping the persisted hide-completed setting; a status line names what changed. Broken and divergent entries are listed but not jumpable (R15, R16, R20). +- KTD16. **Rendering substitutes one level.** A token renders as the target's current title; tokens inside that title render as short stable IDs, so cycles cannot recurse. Broken tokens render as the short ID with a broken marker; divergent ones with a `??` suffix, as jj does. The same render function feeds rows, the pane, suggestions, and the journal preview (which also moves from byte to character truncation). +- KTD17. **Pane content rules.** Each target appears once per list however many times it is linked; a self-link is allowed and listed under outgoing links but never as a backlink of itself. Broken outgoing links are listed. When an archived item is selected in Archive view, its backlinks list shows the active items still linking to it. +- KTD18. **Pane layout.** When visible and the terminal is at least 100 columns wide, the content area splits horizontally with the pane on the right (about 30% width, minimum 32 columns); below 100 columns it is hidden and the toggle state is kept (R17). The autocomplete overlay is drawn after the pane so it is never covered. + +### High-Level Technical Design + +Components and data flow. Items load through `Taskbook`; the TUI builds one `LinkIndex` from the active set; every surface reads from it. + +```mermaid +flowchart TB + subgraph common["taskbook-common"] + SID["stable_id.rs: timestamp -> 16-letter ID, prefix helpers"] + LNK["links.rs: scan spans, rewrite_on_save, LinkIndex"] + MOD["models: Item::stable_id() computed from timestamp"] + end + subgraph client["taskbook-client"] + TB["taskbook.rs: unique creation timestamp, edit_note_silent"] + APP["tui/app.rs: active snapshot + LinkIndex, rebuilt on refresh"] + ACT["tui/actions.rs: save path, keys l / v / o, jump"] + AC["tui/autocomplete.rs: [[ suggestions"] + W["widgets: item rows, journal, link pane"] + end + SID --> MOD + MOD --> LNK + MOD --> TB + TB --> APP + LNK --> APP + APP --> ACT + APP --> AC + APP --> W + ACT -- "rewrite_on_save" --> LNK + ACT -- "edit / create" --> TB +``` + +Save path for any TUI text save (command line or `$EDITOR`): + +```mermaid +flowchart TB + S["User submits draft"] --> B["Re-read stored text (baseline fixed for this edit session)"] + B --> D["Multiset diff: spans added vs baseline"] + D --> C{"Each added span"} + C -- "Full token" --> K["Keep verbatim"] + C -- "Plain" --> K + C -- "Numeric / Prefix" --> R{"Resolves to exactly one non-divergent active item?"} + R -- yes --> W["Rewrite to full token"] + R -- "no / ambiguous / divergent" --> X["Collect rejection"] + K --> E{"Any rejections?"} + W --> E + X --> E + E -- yes --> KEEP["Show message; keep draft (input stays open / editor reopens)"] + KEEP --> S + E -- no --> SAVE["Single write through Taskbook; refresh index"] +``` + +Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix(timestamp as u64))`, where `mix` is a bijection on `u64` and `encode` emits 16 nibbles most-significant first. The exact constants are fixed once in U1 and never changed afterwards. + +### Alternatives Considered + +- **Stored field plus deterministic backfill** from `_timestamp` and `_isTask`. Rejected: older binaries drop unknown fields on write, so the stored value buys nothing that recomputation does not, and it adds a data-format change and a first-load full write that races last-write-wins sync. +- **Truncated hash instead of a bijection.** Rejected: introduces birthday collisions between distinct timestamps for no benefit. +- **Direct base-16 encoding of the timestamp without mixing.** Rejected: items created the same day share long prefixes, defeating short display IDs. +- **A dedicated picker popup for the link keybind.** Rejected for v1: the command line with `[[` autocomplete already provides search, selection, and cancel. + +### Assumptions + +- At taskbook's scale (hundreds to low thousands of items), rebuilding the link index on every refresh is cheap enough without incremental updates. +- Users do not routinely write literal `[[…]]` text made only of `k`–`z` letters; when they do and it matches nothing, R9 rejects the save with a message naming it. + +--- + +## Implementation Units + +### U1. Stable ID derivation + +**Goal:** a frozen, pure function from creation timestamp to stable ID, plus prefix helpers, exposed on every item. + +**Requirements:** R1, R2, R3, R5; AE4. Implements KTD1, KTD2. + +**Dependencies:** none. + +**Files:** +- `crates/taskbook-common/src/stable_id.rs` (new) +- `crates/taskbook-common/src/lib.rs` +- `crates/taskbook-common/src/models/item.rs` +- `crates/taskbook-common/src/models/mod.rs` +- Tests: inline `#[cfg(test)] mod tests` in `stable_id.rs`, `models/task.rs`, `models/note.rs` + +**Approach:** +1. Implement the mixer, its inverse (test-only is fine), and the `k`–`z` encoder and decoder. +2. Add helpers: `is_valid_full_id`, `is_valid_prefix`, and `shortest_unique_prefix_lengths` over a set of IDs. +3. Add `stable_id()` to the `Item` trait and the mirrored `StorageItem` inherent methods; no serde change. + +**Patterns to follow:** `crates/taskbook-common/src/due.rs` (pure module, exhaustive unit tests); the accessor pairs in `crates/taskbook-common/src/models/mod.rs`. + +**Test scenarios:** +- Golden vectors: at least five fixed timestamps (including 0, -1, `i64::MIN`, `i64::MAX`, and a 2026 timestamp) map to fixed 16-letter IDs; these values are written once and must never change. +- Round trip: decoding then inverting the mix returns the original timestamp for a spread of values. +- Every output is exactly 16 characters from `klmnopqrstuvwxyz`. +- Two timestamps 1 ms apart produce IDs whose first letters differ for most sampled pairs (prefix dispersion). +- `stable_id()` is identical for a task and a note with the same timestamp, and unchanged after changing description, boards, tags, star, completion, and numeric ID. +- Covers AE4. Serializing an item and deserializing it on another `StorageItem` yields the same stable ID, and serialized JSON contains no new field. +- Shortest-unique-prefix: with IDs sharing a two-letter prefix the result is length 3; a lone ID gets length 1. + +**Verification:** stable IDs appear through the accessor; `storage.json` written by this version is byte-identical in shape to before (no new keys). + +### U2. Unique creation timestamps + +**Goal:** no two items created through taskbook-rs share a timestamp. + +**Requirements:** R19, R1; AE7 (creation half). Implements KTD3. + +**Dependencies:** U1. + +**Files:** +- `crates/taskbook-client/src/taskbook.rs` +- Tests: new `#[cfg(test)] mod tests` in `crates/taskbook-client/src/taskbook.rs` + +**Approach:** +1. Add one private helper that, given the candidate timestamp, reads active and archive data and returns the smallest unused timestamp at or after it. +2. Apply it in every create path: `create_task`, `create_task_direct_with_tags`, `create_note`, `create_note_direct_with_tags`, `create_note_with_body_direct`, `create_note_with_editor` (the constructors keep calling `Local::now()`; the helper overwrites `timestamp` before insert). + +**Patterns to follow:** the `Taskbook::with_storage(Box::new(LocalStorage::new(temp_dir().join(..))))` fixture in `crates/taskbook-client/src/mcp/mod.rs` tests. + +**Test scenarios:** +- Covers AE7. Creating 20 tasks and notes back to back yields 20 distinct timestamps and 20 distinct stable IDs. +- An archived item holding timestamp T forces a new item created at T to T+1. +- Active items at T and T+1 force a new item created at T to T+2. +- Archive and restore leave the timestamp and stable ID unchanged while the numeric ID changes. + +**Verification:** every create path goes through the helper; existing CLI and MCP tests still pass unchanged (R4). + +### U3. Span scanner and save-time rewrite + +**Goal:** a pure function that turns a draft into the text to save, or a list of rejected references. + +**Requirements:** R7, R8, R9, R10, R20; AE1, AE2, AE3. Implements KTD6, KTD7, KTD8. + +**Dependencies:** U1. + +**Files:** +- `crates/taskbook-common/src/links.rs` (new) +- `crates/taskbook-common/src/lib.rs` +- Tests: inline in `links.rs` + +**Approach:** +1. Scanner returns closed spans with byte ranges and classification. +2. `rewrite_on_save` takes the baseline text, the draft text, and a resolver view of the active set (numeric ID to stable ID, prefix lookup, divergent set) and applies the multiset rule. +3. Note callers pass title and body joined with a separator that cannot occur inside a span, and split the result back. + +**Test scenarios:** +- Covers AE1. `see [[12]]` with item 12 active rewrites to its full token; `[[99]]` is rejected as not found; `[[kq]]` with two matches is rejected as ambiguous. +- Covers AE3. Baseline `see [[12]]`, draft `see [[12]] typo-fixed` returns the draft unchanged. +- Covers AE2. A pre-existing full token to a missing target is kept and the save succeeds. +- A newly added full token to a missing target is kept (R10). +- Baseline with one `[[12]]`, draft with two: the first stays plain, the second is rewritten. +- A span moved from note body to title is not treated as added. +- `[[todo]]`, `[[bin]]`, `[[12a]]`, and `[[ ]]` are plain text and never rejected. +- Uppercase `[[KQZ]]` resolves case-insensitively and is written lowercase. +- A reference resolving to a divergent ID is rejected with a divergent reason (R20). +- An unclosed `[[12` is left as text. +- Retry: after a rejection, calling again with the same baseline and a draft that still contains `[[99]]` rejects again. + +**Verification:** the function is total (never panics) on arbitrary UTF-8 input, including multibyte text adjacent to brackets. + +### U4. Link index and rendering + +**Goal:** one structure that answers every display question: outgoing links, backlinks, prefix lengths, divergence, and rendered text. + +**Requirements:** R6, R13, R14, R16, R20. Implements KTD4, KTD16, KTD17. + +**Dependencies:** U1, U3. + +**Files:** +- `crates/taskbook-common/src/links.rs` +- Tests: inline in `links.rs` + +**Approach:** +1. `LinkIndex::build(active items)` computes ID→items, divergent set, prefix lengths, and per-item outgoing targets from description, title, and body. +2. Backlinks are the reverse map, deduplicated, excluding self. +3. A render function turns text into segments (plain, link with resolved title, broken, divergent) with one level of substitution; callers style the segments. + +**Test scenarios:** +- Item A links B twice and itself once: A's outgoing lists B and A once each; B's backlinks list A; A's backlinks do not list A. +- A links to an ID not in the active set: outgoing shows it as broken, and no backlink is created anywhere. +- Covers AE5 (index half). Building with B removed (archived) makes A's link broken; rebuilding with B present again restores the backlink. +- Two active items share an ID: the ID is in the divergent set and renders with the divergent marker. +- A titled "see [[B]]" and B titled "re [[A]]": rendering A shows B's title with A as a short ID, without recursion. +- Prefix lengths are the shortest unique over active items; an archived item's prefix computed against active plus itself. + +**Verification:** index build over 5,000 synthetic items completes without noticeable delay in a debug test run. + +### U5. TUI link state and refresh + +**Goal:** the TUI always holds a current index of active items, in every view, including right after restore. + +**Requirements:** R14, R16; AE5. Implements KTD9. + +**Dependencies:** U4. + +**Files:** +- `crates/taskbook-client/src/tui/app.rs` +- `crates/taskbook-client/src/tui/mod.rs` +- `crates/taskbook-client/src/tui/actions.rs` +- Tests: new `#[cfg(test)] mod tests` in `crates/taskbook-client/src/tui/app.rs` + +**Approach:** +1. Add an `App::with_taskbook(Taskbook, Config)` test constructor so tests avoid the real user config. +2. Add the active snapshot and `LinkIndex` to `App`; rebuild in `refresh_items`, `set_view`, after restore, and in the SSE handler for both data and archive changes. +3. Make restore reload the archive list and the index explicitly; `r` on a row that no longer exists sets a status instead of returning an error. + +**Patterns to follow:** existing `refresh_items` and SSE `DataChanged` handling. + +**Test scenarios:** +- Covers AE5. Create A, B, C linking to T; delete T: index shows three broken links and T has no backlinks; restore T from the archive view: index shows three backlinks on T without any other action. +- In Archive view, the index still reflects active items, not archived ones. +- Pressing restore twice on the same archived row leaves the TUI running and shows a status. + +**Verification:** switching views never empties the side pane's source data. + +### U6. TUI save path with rewrite and reject + +**Goal:** every TUI text save goes through the rewrite, and rejection keeps the draft. + +**Requirements:** R8, R9, R10; AE1, AE2, AE3. Implements KTD10, KTD11. + +**Dependencies:** U3, U5. + +**Files:** +- `crates/taskbook-client/src/tui/actions.rs` +- `crates/taskbook-client/src/editor.rs` +- `crates/taskbook-client/src/taskbook.rs` +- Tests: `crates/taskbook-client/src/tui/app.rs` tests module; `crates/taskbook-client/src/editor.rs` tests; `crates/taskbook-client/src/taskbook.rs` tests + +**Approach:** +1. Route `/task`, `/note`, and `/edit` descriptions through `rewrite_on_save` before calling `Taskbook`; baseline is empty for creates and the stored description for `/edit`, read when the command executes. +2. Reorder submit so the command line clears only after success; on rejection set an error status and keep the input and cursor. +3. Add `Taskbook::edit_note_silent(id, title, body)` as a single write and use it in `edit_note_external`. +4. Capture the stored title and body once when the editor session starts; on rejection reopen the editor on the edited content with a leading `//` message line, looping until the save succeeds or the user cancels. + +**Patterns to follow:** status handling for parse errors in `crates/taskbook-client/src/tui/actions.rs`; `edit_existing_note_in_editor` for re-serializing note content. + +**Test scenarios:** +- Covers AE1. Submitting `/task see [[99]]` leaves the command line active with the same text and an error status naming `[[99]]`; no item is created. +- Submitting `/edit @3 see [[12]]` stores the full token for item 12. +- Covers AE3. `/edit` of a CLI-created `see [[12]]` with an unrelated change saves with `[[12]]` still plain. +- `edit_note_silent` writes title and body in one storage write (one `set` on a counting test backend). +- Covers AE2. Editing a note whose body links an archived task saves successfully. +- The editor retry loop: a rejected save followed by a corrected one writes once; the baseline stays the original stored text across passes. + +**Verification:** no path from a link rejection reaches `run_app` as an error. + +### U7. Row and preview rendering + +**Goal:** rows show titles for links and the short stable ID beside the numeric ID. + +**Requirements:** R6, R13, R16, R20. Implements KTD16. + +**Dependencies:** U5. + +**Files:** +- `crates/taskbook-client/src/tui/widgets/item_row.rs` +- `crates/taskbook-client/src/tui/widgets/journal_view.rs` +- `crates/taskbook-client/src/tui/autocomplete.rs` (existing item suggestions show rendered text) +- `crates/taskbook-client/src/tui/theme.rs` (link, broken, divergent, and ID-prefix styles) +- Tests: `crates/taskbook-client/src/tui/app.rs` tests module, or a small pure helper test in `item_row.rs` + +**Approach:** +1. Replace the single description span with the rendered segments, styled per segment kind. +2. Show the stable-ID prefix highlighted (unique part) next to the numeric ID, dimming the remainder up to a fixed display width, as jj does. +3. Change the journal preview to render first and truncate by characters. + +**Test scenarios:** +- A row whose description holds a link shows the target's title and not the raw ID. +- A broken link shows the broken marker; a divergent one shows `??`. +- A journal preview of a note whose first line holds multibyte text longer than 50 bytes renders without panicking. + +**Verification:** manual check in a narrow and a wide terminal that rows stay on one line. + +### U8. `[[` autocomplete and the link keybind + +**Goal:** linking without typing IDs, through suggestions or one key. + +**Requirements:** R11, R12; F1, F2. Implements KTD12, KTD13, KTD14. + +**Dependencies:** U6, U7. + +**Files:** +- `crates/taskbook-client/src/tui/autocomplete.rs` +- `crates/taskbook-client/src/tui/actions.rs` +- `crates/taskbook-client/src/tui/app.rs` +- `crates/taskbook-client/src/tui/widgets/command_line.rs` +- `crates/taskbook-client/src/tui/widgets/help_popup.rs` +- Tests: `crates/taskbook-client/src/tui/autocomplete.rs` (new tests module) + +**Approach:** +1. Add the `[[` branch to `update_suggestions` before the `@` branch, with a link suggestion kind and icon. +2. While a `[[` dropdown is open, make Enter accept; Esc closes the dropdown. +3. Bind `l` (not in Archive view) to open `/edit @ [[` with suggestions active. +4. Add `l`, `v`, and `o` to the help popup. + +**Test scenarios:** +- Covers F1. Typing `/task buy [[mil` suggests an item titled "Milk run"; accepting inserts its full token and closes the brackets. +- Suggestions exclude the item being edited and divergent items. +- Matching by numeric ID and by stable-ID prefix both work. +- Enter with the `[[` dropdown open accepts the suggestion instead of submitting. +- Covers F2. Pressing `l` on item 4 opens `/edit @4 [[` with suggestions visible. + +**Verification:** existing `@board` and item autocomplete behaves as before. + +### U9. Side pane and jump + +**Goal:** see outgoing links and backlinks for the selection and jump to them. + +**Requirements:** R14, R15, R16, R17; F3, AE6. Implements KTD14, KTD15, KTD17, KTD18. + +**Dependencies:** U5, U7. + +**Files:** +- `crates/taskbook-client/src/tui/widgets/link_pane.rs` (new) +- `crates/taskbook-client/src/tui/widgets/mod.rs` +- `crates/taskbook-client/src/tui/ui.rs` +- `crates/taskbook-client/src/tui/app.rs` +- `crates/taskbook-client/src/tui/actions.rs` +- Tests: `crates/taskbook-client/src/tui/app.rs` tests module + +**Approach:** +1. Add pane state to `App`: visible flag, focus flag, pane cursor. +2. Split the content area in `render_content` when visible and wide enough; draw the autocomplete overlay after the pane. +3. Add `v` and `o` handling, plus `j`/`k`/Enter/Esc while the pane is focused; focus returns to the list if the pane auto-hides. +4. Implement jump per KTD15: select the target's index in `display_order` after adjusting view and filters. + +**Test scenarios:** +- Covers F3. With A linking B, selecting B and jumping from its backlinks selects A. +- Jumping to a completed item while hide-completed is on reveals it without changing the saved config. +- Jumping from Archive view switches to Board view and leaves `default_view` unchanged in the config file. +- Jumping clears an active search term and board filter and sets a status naming that. +- Broken and divergent entries are not jumpable. +- Covers AE6. At 80 columns the pane is not rendered even when toggled on; at 120 columns it is. +- Pane cursor clamps when a refresh shortens its list. + +**Verification:** manual walkthrough of F3 in a real terminal at both widths. + +### U10. Documentation + +**Goal:** users and contributors can find the feature and the new modules. + +**Requirements:** R6, R11, R12, R14, R17. + +**Dependencies:** U8, U9. + +**Files:** +- `README.md` (TUI section: links, keys) +- `docs/README.md` +- `CLAUDE.md` (architecture tree: `stable_id.rs`, `links.rs`, `link_pane.rs`) + +**Approach:** document `[[` linking, the `l`/`v`/`o` keys, divergent and broken markers, and that CLI and MCP show tokens raw. + +**Test expectation:** none -- documentation only. + +**Verification:** the help popup and README list the same keys. + +--- + +## Verification Contract + +| Gate | Command (inside `devenv shell`) | Applies to | +|---|---|---| +| Format | `cargo fmt --all -- --check` | every unit | +| Lint | `cargo clippy --workspace -- -D warnings` | every unit | +| Tests | `cargo test --workspace` | every unit | +| MSRV | build stays within Rust 1.75 (avoid newer std APIs such as `Option::is_none_or`) | every unit | +| Manual TUI | `cargo run --package taskbook-client -- --taskbook-dir `: create two items, link with `[[`, link with `l`, follow from the pane, delete and restore the target | U6–U9 | +| Data format | write a store with this build, inspect `storage/storage.json`: no new keys | U1, U2 | + +These mirror CI in `.github/workflows/ci.yml`. + +--- + +## Definition of Done + +- Every unit's test scenarios exist and pass; all gates in the Verification Contract pass. +- The U1 golden vectors are committed and unchanged by later units. +- No new keys appear in stored item JSON; the server crate has no diff. +- CLI and MCP output are unchanged for existing commands (R4, R18). +- Each acceptance example (AE1–AE7) is covered by at least one named test. +- Abandoned or experimental code from approaches that did not pan out is removed from the diff. + +--- + +## Scope Boundaries (planning) + +### Deferred to Follow-Up Work + +- Automatic repair of divergent items (bumping one twin's timestamp). +- Handling a non-zero `$EDITOR` exit as a cancel instead of an error that closes the TUI (pre-existing behavior). +- A "jump back" key after following a link. +- Matching `/search` against rendered titles. + +### Considered and not built + +- Writing derived IDs back on load: nothing is stored, so there is nothing to write; it would only race last-write-wins sync. +- A token-to-title legend in `$EDITOR` comments: useful, but R9 only requires the message; revisit if raw IDs in the editor prove confusing. +- Persisting pane visibility in the config: session-local is enough for v1; revisit if users toggle it every session. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Changing the derivation function later silently breaks every link | U1 golden vectors; Stop condition in the Goal Capsule | +| Node taskbook or a hand edit rewrites `_timestamp` | Documented assumption; affected links render broken, not wrong | +| Same-millisecond items from older binaries or two devices | Shown as divergent (R20), never silently merged | +| Enter-accepts-suggestion changes muscle memory | Only while a `[[` dropdown is open; other dropdowns unchanged | +| Extra archive read on create in remote mode | One `GET` per create; acceptable at taskbook's scale | From a4443396bbe9678aec32e7af3f063f07c1041bbd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:25:58 +0000 Subject: [PATCH 08/45] docs(backlinks): capture the edit baseline once per session KTD7, U6 and the save-path diagram disagreed on when the baseline is read; a save-time re-read could reclassify spans synced mid-edit. Review: coherence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index 142aeb4..f3fdb5f 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -202,7 +202,7 @@ flowchart LR - KTD4. **Divergence is detected, not repaired.** The link index groups active items by stable ID; any ID held by more than one active item is divergent (R20). An archived copy sharing an ID with an active item (a half-finished delete) is ignored, because archived items contribute nothing to the index. Automatic repair is deferred. - KTD5. **Link logic is a pure module, `crates/taskbook-common/src/links.rs`.** It owns three things: the span scanner, the save-time rewrite, and the link index. It takes plain item data, has no TUI or storage dependency, and carries the bulk of the tests. Follows the shape of `crates/taskbook-common/src/due.rs`. - KTD6. **Span classification.** The scanner finds closed `[[…]]` spans; the inner text classifies as Full (exactly 16 alphabet letters), Numeric (digits only), Prefix (1–15 alphabet letters), or Plain (anything else, including mixed or other letters). Matching is case-insensitive and rewritten tokens are lowercase. An unclosed `[[` is not a span. -- KTD7. **"Added in this edit" is a multiset diff against the stored baseline.** The baseline is the item's stored text when the edit session started (empty for a create), re-read at save time and held fixed across reject-and-retry passes, so a rejected `[[99]]` cannot become "pre-existing" on the second try. For notes, title and body are diffed together so moving a span between them is not an addition. For each span text, the first k occurrences (k = baseline count) stay verbatim and later occurrences are added (R8, R10). +- KTD7. **"Added in this edit" is a multiset diff against the stored baseline.** The baseline is the item's stored text captured once when the edit session starts (when `e` or `l` prefills the command line, when a typed `/edit` is first submitted, or when the editor opens; empty for a create) and reused unchanged for every retry, so a rejected `[[99]]` cannot become "pre-existing" on the second try. For notes, title and body are diffed together so moving a span between them is not an addition. For each span text, the first k occurrences (k = baseline count) stay verbatim and later occurrences are added (R8, R10). - KTD8. **Rewrite outcome is a value, not an error.** `rewrite_on_save(baseline, draft, index)` returns either the rewritten text or a list of rejected references with reasons (not found, ambiguous prefix, divergent target). Full tokens are never rejected (R10). Callers in the TUI show the message as a status and keep the draft. - KTD9. **TUI keeps an active-items snapshot and a `LinkIndex` independent of the view.** `App` loads active items in every view (Archive view currently replaces `app.items`), rebuilding the index in `refresh_items`, `set_view`, the SSE handler for both active and archive changes, and after restore. Prefix uniqueness is computed over active items; an archived row's prefix is computed over active items plus itself. - KTD10. **Command-line submit only clears on success.** Submit currently deactivates the command line before executing and action errors propagate out of `run_app`, closing the TUI. The link-rewrite path returns a rejection that sets an error status and leaves the input text and cursor in place (R9). Other storage errors keep today's behavior. @@ -249,8 +249,8 @@ Save path for any TUI text save (command line or `$EDITOR`): ```mermaid flowchart TB - S["User submits draft"] --> B["Re-read stored text (baseline fixed for this edit session)"] - B --> D["Multiset diff: spans added vs baseline"] + B["Capture stored text once at edit-session start (baseline)"] --> S["User submits draft"] + S --> D["Multiset diff: spans added vs baseline"] D --> C{"Each added span"} C -- "Full token" --> K["Keep verbatim"] C -- "Plain" --> K @@ -445,7 +445,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix - Tests: `crates/taskbook-client/src/tui/app.rs` tests module; `crates/taskbook-client/src/editor.rs` tests; `crates/taskbook-client/src/taskbook.rs` tests **Approach:** -1. Route `/task`, `/note`, and `/edit` descriptions through `rewrite_on_save` before calling `Taskbook`; baseline is empty for creates and the stored description for `/edit`, read when the command executes. +1. Route `/task`, `/note`, and `/edit` descriptions through `rewrite_on_save` before calling `Taskbook`; baseline is empty for creates and, for `/edit`, the stored description captured at session start per KTD7 and kept on `App` across retries. 2. Reorder submit so the command line clears only after success; on rejection set an error status and keep the input and cursor. 3. Add `Taskbook::edit_note_silent(id, title, body)` as a single write and use it in `edit_note_external`. 4. Capture the stored title and body once when the editor session starts; on rejection reopen the editor on the edited content with a leading `//` message line, looping until the save succeeds or the user cancels. From 44ad53d9c0a0c3cf9011ef53d211c3a4732f11ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:25:58 +0000 Subject: [PATCH 09/45] docs(backlinks): limit jumps to active, non-divergent entries R15 promised every listed entry was jumpable, contradicting R16/R20 and KTD15: broken links have no target and divergent IDs are ambiguous. Review: coherence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index f3fdb5f..c5fcd60 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -75,7 +75,7 @@ Because sync stores items as opaque client-encrypted blobs and replaces the whol - R13. TUI item rows render each link token as the target's current title, visually marked as a link, instead of the raw ID. - R14. A side pane shows the selected item's outgoing links and its backlinks (active items whose text links to it), each as title plus short stable ID, and updates as the selection moves. -- R15. From the side pane the user can jump to any listed item, and the TUI switches board or view as needed so that item is selected and visible. +- R15. From the side pane the user can jump to any listed item that is active and not divergent, and the TUI switches board or view as needed so that item is selected and visible. - R16. A link whose target is archived or absent renders as a broken-link marker, archived items contribute no backlinks, and restoring an item makes its backlinks and outgoing links resolve again with no other action. - R17. The side pane can be toggled with a key and is hidden automatically when the terminal is narrower than a minimum width. From ee1e4fb666678a47b63ff3af340d5a04e742e63f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:26:12 +0000 Subject: [PATCH 10/45] docs(backlinks): isolate TUI tests from the user's config file Config::save always writes the real config path, so App tests calling set_view would overwrite it and race each other. Review: feasibility. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index c5fcd60..ad61bef 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -417,7 +417,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix - Tests: new `#[cfg(test)] mod tests` in `crates/taskbook-client/src/tui/app.rs` **Approach:** -1. Add an `App::with_taskbook(Taskbook, Config)` test constructor so tests avoid the real user config. +1. Add an `App::with_taskbook(Taskbook, Config, config_path)` test constructor, and route every `self.config.save()` in `App` (`set_view`, `toggle_hide_completed`, `cycle_sort_method`) through a helper that writes to that path, so tests never touch `~/.config/taskbook/taskbook.json`. 2. Add the active snapshot and `LinkIndex` to `App`; rebuild in `refresh_items`, `set_view`, after restore, and in the SSE handler for both data and archive changes. 3. Make restore reload the archive list and the index explicitly; `r` on a row that no longer exists sets a status instead of returning an error. @@ -545,7 +545,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix **Test scenarios:** - Covers F3. With A linking B, selecting B and jumping from its backlinks selects A. - Jumping to a completed item while hide-completed is on reveals it without changing the saved config. -- Jumping from Archive view switches to Board view and leaves `default_view` unchanged in the config file. +- Jumping from Archive view switches to Board view and leaves `default_view` unchanged in the test's temp config file. - Jumping clears an active search term and board filter and sets a status naming that. - Broken and divergent entries are not jumpable. - Covers AE6. At 80 columns the pane is not rendered even when toggled on; at 120 columns it is. From 69a239e98e44887248dca01c932bc3016bea5e30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:26:12 +0000 Subject: [PATCH 11/45] docs(backlinks): keep submit order so delete/clear confirmations survive Clearing the command line only after success would wipe pending_confirm set by /delete and /clear. Re-activate on rejection instead. Review: feasibility. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index ad61bef..94e3806 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -205,7 +205,7 @@ flowchart LR - KTD7. **"Added in this edit" is a multiset diff against the stored baseline.** The baseline is the item's stored text captured once when the edit session starts (when `e` or `l` prefills the command line, when a typed `/edit` is first submitted, or when the editor opens; empty for a create) and reused unchanged for every retry, so a rejected `[[99]]` cannot become "pre-existing" on the second try. For notes, title and body are diffed together so moving a span between them is not an addition. For each span text, the first k occurrences (k = baseline count) stay verbatim and later occurrences are added (R8, R10). - KTD8. **Rewrite outcome is a value, not an error.** `rewrite_on_save(baseline, draft, index)` returns either the rewritten text or a list of rejected references with reasons (not found, ambiguous prefix, divergent target). Full tokens are never rejected (R10). Callers in the TUI show the message as a status and keep the draft. - KTD9. **TUI keeps an active-items snapshot and a `LinkIndex` independent of the view.** `App` loads active items in every view (Archive view currently replaces `app.items`), rebuilding the index in `refresh_items`, `set_view`, the SSE handler for both active and archive changes, and after restore. Prefix uniqueness is computed over active items; an archived row's prefix is computed over active items plus itself. -- KTD10. **Command-line submit only clears on success.** Submit currently deactivates the command line before executing and action errors propagate out of `run_app`, closing the TUI. The link-rewrite path returns a rejection that sets an error status and leaves the input text and cursor in place (R9). Other storage errors keep today's behavior. +- KTD10. **A rejected submit re-opens the command line with the draft.** Submit keeps today's order (push history, deactivate, execute), because `deactivate_command_line` resets `pending_confirm` and moving it after execution would cancel the `/delete` and `/clear` confirmations. When the link rewrite rejects, the action re-activates the command line with the submitted input and cursor and sets the error status (R9). Other storage errors keep today's behavior. - KTD11. **Note editor saves validate first, then write once.** A new `Taskbook::edit_note_silent(id, title, body)` replaces the title-then-body pair of writes in `edit_note_external`, so a rejection can never leave a half-saved note. On rejection the editor reopens on the edited title and body, with the rejection message as a leading `//` comment line (already stripped by `parse_note_content`). The `/note` create-then-editor flow uses the same validation with an empty baseline. - KTD12. **The link keybind reuses the command line.** `l` on a selected active item opens the command line as `/edit @ [[` with item suggestions already showing; accepting one and pressing Enter saves through the normal rewrite path (R11). For notes the token goes on the title (the `/edit` target). No new picker widget. - KTD13. **`[[` autocomplete.** `update_suggestions` gains a branch, checked before the `@` branch, for an unclosed `[[` before the cursor in the description position of `/task`, `/note`, and `/edit`. Candidates: active, non-divergent items, excluding the item being edited, shown as rendered title, short stable ID, and board, matched by substring on rendered title, numeric ID, or stable-ID prefix. Accepting inserts `[[]]`. While a `[[` dropdown is open, Enter accepts instead of submitting. @@ -446,7 +446,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix **Approach:** 1. Route `/task`, `/note`, and `/edit` descriptions through `rewrite_on_save` before calling `Taskbook`; baseline is empty for creates and, for `/edit`, the stored description captured at session start per KTD7 and kept on `App` across retries. -2. Reorder submit so the command line clears only after success; on rejection set an error status and keep the input and cursor. +2. On rejection, re-activate the command line with the submitted input and cursor and set the error status (KTD10); leave the submit order unchanged. 3. Add `Taskbook::edit_note_silent(id, title, body)` as a single write and use it in `edit_note_external`. 4. Capture the stored title and body once when the editor session starts; on rejection reopen the editor on the edited content with a leading `//` message line, looping until the save succeeds or the user cancels. @@ -455,6 +455,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix **Test scenarios:** - Covers AE1. Submitting `/task see [[99]]` leaves the command line active with the same text and an error status naming `[[99]]`; no item is created. - Submitting `/edit @3 see [[12]]` stores the full token for item 12. +- `/delete 3` still opens its confirmation prompt, and confirming deletes item 3. - Covers AE3. `/edit` of a CLI-created `see [[12]]` with an unrelated change saves with `[[12]]` still plain. - `edit_note_silent` writes title and body in one storage write (one `set` on a counting test backend). - Covers AE2. Editing a note whose body links an archived task saves successfully. From 1f20b6e7173c3cdfa30c84e1c105589aff832110 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:26:12 +0000 Subject: [PATCH 12/45] docs(backlinks): key the backlink map by target ID so archived items can list inbound links U4/U5 tests forbade recording links to non-active IDs, which made the KTD17 Archive-view backlinks impossible. Review: feasibility. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index 94e3806..2f65307 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -389,12 +389,12 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix **Approach:** 1. `LinkIndex::build(active items)` computes ID→items, divergent set, prefix lengths, and per-item outgoing targets from description, title, and body. -2. Backlinks are the reverse map, deduplicated, excluding self. +2. Backlinks are a reverse map keyed by target stable ID, including IDs with no active item, deduplicated and excluding self; active items look up their own ID, and an archived item selected in Archive view looks up its ID the same way (KTD17). 3. A render function turns text into segments (plain, link with resolved title, broken, divergent) with one level of substitution; callers style the segments. **Test scenarios:** - Item A links B twice and itself once: A's outgoing lists B and A once each; B's backlinks list A; A's backlinks do not list A. -- A links to an ID not in the active set: outgoing shows it as broken, and no backlink is created anywhere. +- A links to an ID not in the active set: outgoing shows it as broken, no active item gains a backlink, and looking up that ID's inbound links returns A. - Covers AE5 (index half). Building with B removed (archived) makes A's link broken; rebuilding with B present again restores the backlink. - Two active items share an ID: the ID is in the divergent set and renders with the divergent marker. - A titled "see [[B]]" and B titled "re [[A]]": rendering A shows B's title with A as a short ID, without recursion. @@ -424,7 +424,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix **Patterns to follow:** existing `refresh_items` and SSE `DataChanged` handling. **Test scenarios:** -- Covers AE5. Create A, B, C linking to T; delete T: index shows three broken links and T has no backlinks; restore T from the archive view: index shows three backlinks on T without any other action. +- Covers AE5. Create A, B, C linking to T; delete T: index shows three broken links, T appears in no active backlink list, and inbound links for T's ID still return A, B, C; restore T from the archive view: index shows three backlinks on T without any other action. - In Archive view, the index still reflects active items, not archived ones. - Pressing restore twice on the same archived row leaves the TUI running and shows a status. @@ -551,6 +551,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix - Broken and divergent entries are not jumpable. - Covers AE6. At 80 columns the pane is not rendered even when toggled on; at 120 columns it is. - Pane cursor clamps when a refresh shortens its list. +- Selecting archived T in Archive view lists A, B, C under backlinks. **Verification:** manual walkthrough of F3 in a real terminal at both widths. From 6fbe56536d01af78ed4b6dfb6fe9b190469b59af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:26:29 +0000 Subject: [PATCH 13/45] docs(backlinks): drop the single-write note method Validating title and body together before either existing write already prevents a half-saved note on rejection; the new method added work for no requirement. Review: scope-guardian. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index 2f65307..7a90446 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -206,7 +206,7 @@ flowchart LR - KTD8. **Rewrite outcome is a value, not an error.** `rewrite_on_save(baseline, draft, index)` returns either the rewritten text or a list of rejected references with reasons (not found, ambiguous prefix, divergent target). Full tokens are never rejected (R10). Callers in the TUI show the message as a status and keep the draft. - KTD9. **TUI keeps an active-items snapshot and a `LinkIndex` independent of the view.** `App` loads active items in every view (Archive view currently replaces `app.items`), rebuilding the index in `refresh_items`, `set_view`, the SSE handler for both active and archive changes, and after restore. Prefix uniqueness is computed over active items; an archived row's prefix is computed over active items plus itself. - KTD10. **A rejected submit re-opens the command line with the draft.** Submit keeps today's order (push history, deactivate, execute), because `deactivate_command_line` resets `pending_confirm` and moving it after execution would cancel the `/delete` and `/clear` confirmations. When the link rewrite rejects, the action re-activates the command line with the submitted input and cursor and sets the error status (R9). Other storage errors keep today's behavior. -- KTD11. **Note editor saves validate first, then write once.** A new `Taskbook::edit_note_silent(id, title, body)` replaces the title-then-body pair of writes in `edit_note_external`, so a rejection can never leave a half-saved note. On rejection the editor reopens on the edited title and body, with the rejection message as a leading `//` comment line (already stripped by `parse_note_content`). The `/note` create-then-editor flow uses the same validation with an empty baseline. +- KTD11. **Note editor saves validate title and body together before either write.** `edit_note_external` keeps its existing `edit_description_silent` plus `edit_note_body_silent` pair, but both run only after the combined rewrite succeeds, so a rejection never reaches storage. On rejection the editor reopens on the edited title and body, with the rejection message as a leading `//` comment line (already stripped by `parse_note_content`). The `/note` create-then-editor flow uses the same validation with an empty baseline. - KTD12. **The link keybind reuses the command line.** `l` on a selected active item opens the command line as `/edit @ [[` with item suggestions already showing; accepting one and pressing Enter saves through the normal rewrite path (R11). For notes the token goes on the title (the `/edit` target). No new picker widget. - KTD13. **`[[` autocomplete.** `update_suggestions` gains a branch, checked before the `@` branch, for an unclosed `[[` before the cursor in the description position of `/task`, `/note`, and `/edit`. Candidates: active, non-divergent items, excluding the item being edited, shown as rendered title, short stable ID, and board, matched by substring on rendered title, numeric ID, or stable-ID prefix. Accepting inserts `[[]]`. While a `[[` dropdown is open, Enter accepts instead of submitting. - KTD14. **Keys:** `l` link, `v` toggle the side pane, `o` move focus into the pane (`j`/`k` move, Enter jumps, `o` or Esc returns to the list). Tab and `h` are already bound. Pane visibility is session-local in v1 and starts visible. @@ -227,7 +227,7 @@ flowchart TB MOD["models: Item::stable_id() computed from timestamp"] end subgraph client["taskbook-client"] - TB["taskbook.rs: unique creation timestamp, edit_note_silent"] + TB["taskbook.rs: unique creation timestamp"] APP["tui/app.rs: active snapshot + LinkIndex, rebuilt on refresh"] ACT["tui/actions.rs: save path, keys l / v / o, jump"] AC["tui/autocomplete.rs: [[ suggestions"] @@ -441,13 +441,12 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix **Files:** - `crates/taskbook-client/src/tui/actions.rs` - `crates/taskbook-client/src/editor.rs` -- `crates/taskbook-client/src/taskbook.rs` -- Tests: `crates/taskbook-client/src/tui/app.rs` tests module; `crates/taskbook-client/src/editor.rs` tests; `crates/taskbook-client/src/taskbook.rs` tests +- Tests: `crates/taskbook-client/src/tui/app.rs` tests module; `crates/taskbook-client/src/editor.rs` tests **Approach:** 1. Route `/task`, `/note`, and `/edit` descriptions through `rewrite_on_save` before calling `Taskbook`; baseline is empty for creates and, for `/edit`, the stored description captured at session start per KTD7 and kept on `App` across retries. 2. On rejection, re-activate the command line with the submitted input and cursor and set the error status (KTD10); leave the submit order unchanged. -3. Add `Taskbook::edit_note_silent(id, title, body)` as a single write and use it in `edit_note_external`. +3. In `edit_note_external`, run the combined title-and-body rewrite before calling the existing two write methods (KTD11). 4. Capture the stored title and body once when the editor session starts; on rejection reopen the editor on the edited content with a leading `//` message line, looping until the save succeeds or the user cancels. **Patterns to follow:** status handling for parse errors in `crates/taskbook-client/src/tui/actions.rs`; `edit_existing_note_in_editor` for re-serializing note content. @@ -457,7 +456,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix - Submitting `/edit @3 see [[12]]` stores the full token for item 12. - `/delete 3` still opens its confirmation prompt, and confirming deletes item 3. - Covers AE3. `/edit` of a CLI-created `see [[12]]` with an unrelated change saves with `[[12]]` still plain. -- `edit_note_silent` writes title and body in one storage write (one `set` on a counting test backend). +- A note edit whose body adds `[[99]]` leaves both the stored title and body unchanged. - Covers AE2. Editing a note whose body links an archived task saves successfully. - The editor retry loop: a rejected save followed by a corrected one writes once; the baseline stays the original stored text across passes. @@ -613,6 +612,7 @@ These mirror CI in `.github/workflows/ci.yml`. ### Considered and not built +- A single-write `edit_note_silent`: validation already happens before either write, so a rejection cannot half-save a note; a storage failure between the two writes is pre-existing and out of scope. - Writing derived IDs back on load: nothing is stored, so there is nothing to write; it would only race last-write-wins sync. - A token-to-title legend in `$EDITOR` comments: useful, but R9 only requires the message; revisit if raw IDs in the editor prove confusing. - Persisting pane visibility in the config: session-local is enough for v1; revisit if users toggle it every session. From b85358c0add348451423274d2e0903b01e88fdd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:26:29 +0000 Subject: [PATCH 14/45] docs(backlinks): defer stale-row restore hardening Pre-existing error path unrelated to backlinks; the explicit post-restore reload (kept, since set_view is a no-op inside Archive view) already removes the stale row. Review: scope-guardian. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index 7a90446..cdb11e1 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -419,14 +419,13 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix **Approach:** 1. Add an `App::with_taskbook(Taskbook, Config, config_path)` test constructor, and route every `self.config.save()` in `App` (`set_view`, `toggle_hide_completed`, `cycle_sort_method`) through a helper that writes to that path, so tests never touch `~/.config/taskbook/taskbook.json`. 2. Add the active snapshot and `LinkIndex` to `App`; rebuild in `refresh_items`, `set_view`, after restore, and in the SSE handler for both data and archive changes. -3. Make restore reload the archive list and the index explicitly; `r` on a row that no longer exists sets a status instead of returning an error. +3. Make restore reload the archive list and the index explicitly (`set_view(Archive)` is a no-op while already in Archive view), so the restored row disappears at once. **Patterns to follow:** existing `refresh_items` and SSE `DataChanged` handling. **Test scenarios:** - Covers AE5. Create A, B, C linking to T; delete T: index shows three broken links, T appears in no active backlink list, and inbound links for T's ID still return A, B, C; restore T from the archive view: index shows three backlinks on T without any other action. - In Archive view, the index still reflects active items, not archived ones. -- Pressing restore twice on the same archived row leaves the TUI running and shows a status. **Verification:** switching views never empties the side pane's source data. @@ -608,6 +607,7 @@ These mirror CI in `.github/workflows/ci.yml`. - Automatic repair of divergent items (bumping one twin's timestamp). - Handling a non-zero `$EDITOR` exit as a cancel instead of an error that closes the TUI (pre-existing behavior). - A "jump back" key after following a link. +- Making `r` on a stale archived row set a status instead of an error (pre-existing; the explicit reload in U5 removes the stale row). - Matching `/search` against rendered titles. ### Considered and not built From c3a0cb099d08747d8ea816c1ef2f06577e03f8ad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:26:48 +0000 Subject: [PATCH 15/45] docs(backlinks): define [[ dropdown ordering, selection, Enter and Esc The l flow opened on an empty query that suggests nothing, Enter could take an unhighlighted row, and Enter with no match saved a stray '[[query'. Review: design-lens, adversarial. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index cdb11e1..524866e 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -208,7 +208,7 @@ flowchart LR - KTD10. **A rejected submit re-opens the command line with the draft.** Submit keeps today's order (push history, deactivate, execute), because `deactivate_command_line` resets `pending_confirm` and moving it after execution would cancel the `/delete` and `/clear` confirmations. When the link rewrite rejects, the action re-activates the command line with the submitted input and cursor and sets the error status (R9). Other storage errors keep today's behavior. - KTD11. **Note editor saves validate title and body together before either write.** `edit_note_external` keeps its existing `edit_description_silent` plus `edit_note_body_silent` pair, but both run only after the combined rewrite succeeds, so a rejection never reaches storage. On rejection the editor reopens on the edited title and body, with the rejection message as a leading `//` comment line (already stripped by `parse_note_content`). The `/note` create-then-editor flow uses the same validation with an empty baseline. - KTD12. **The link keybind reuses the command line.** `l` on a selected active item opens the command line as `/edit @ [[` with item suggestions already showing; accepting one and pressing Enter saves through the normal rewrite path (R11). For notes the token goes on the title (the `/edit` target). No new picker widget. -- KTD13. **`[[` autocomplete.** `update_suggestions` gains a branch, checked before the `@` branch, for an unclosed `[[` before the cursor in the description position of `/task`, `/note`, and `/edit`. Candidates: active, non-divergent items, excluding the item being edited, shown as rendered title, short stable ID, and board, matched by substring on rendered title, numeric ID, or stable-ID prefix. Accepting inserts `[[]]`. While a `[[` dropdown is open, Enter accepts instead of submitting. +- KTD13. **`[[` autocomplete.** `update_suggestions` gains a branch, checked before the `@` branch, for an unclosed `[[` before the cursor in the description position of `/task`, `/note`, and `/edit`. Candidates: active, non-divergent items, excluding the item being edited, shown as rendered title, short stable ID, and board, matched by substring on rendered title, numeric ID, or stable-ID prefix. Accepting inserts `[[]]`. While a `[[` dropdown is open, Enter accepts instead of submitting. An empty `[[` query lists active, non-divergent items newest first, current board first, so the `l` flow opens on useful candidates (today an empty query suggests nothing). The first row is pre-selected whenever a `[[` dropdown opens. Each row shows the rendered title truncated to fit the dropdown, then the short stable ID, with the board as the description. Enter is decided by context, not by whether rows are showing: while an unclosed `[[` sits before the cursor in a description position, Enter accepts the selected row if there is one and otherwise does not submit, setting a hint to pick an item, close the brackets, or press Esc. Esc inside an open `[[` dropdown closes only the dropdown, which stays closed until the cursor leaves that `[[`; a second Esc cancels the command line as today. - KTD14. **Keys:** `l` link, `v` toggle the side pane, `o` move focus into the pane (`j`/`k` move, Enter jumps, `o` or Esc returns to the list). Tab and `h` are already bound. Pane visibility is session-local in v1 and starts visible. - KTD15. **Jump does not touch saved settings.** Jumping clears the search term and board filter, switches Archive view to Board view through a non-persisting path, and reveals a completed target with a transient override instead of flipping the persisted hide-completed setting; a status line names what changed. Broken and divergent entries are listed but not jumpable (R15, R16, R20). - KTD16. **Rendering substitutes one level.** A token renders as the target's current title; tokens inside that title render as short stable IDs, so cycles cannot recurse. Broken tokens render as the short ID with a broken marker; divergent ones with a `??` suffix, as jj does. The same render function feeds rows, the pane, suggestions, and the journal preview (which also moves from byte to character truncation). @@ -506,7 +506,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix **Approach:** 1. Add the `[[` branch to `update_suggestions` before the `@` branch, with a link suggestion kind and icon. -2. While a `[[` dropdown is open, make Enter accept; Esc closes the dropdown. +2. Implement the KTD13 Enter and Esc rules for an unclosed `[[`, and the empty-query ordering. 3. Bind `l` (not in Archive view) to open `/edit @ [[` with suggestions active. 4. Add `l`, `v`, and `o` to the help popup. @@ -515,6 +515,9 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix - Suggestions exclude the item being edited and divergent items. - Matching by numeric ID and by stable-ID prefix both work. - Enter with the `[[` dropdown open accepts the suggestion instead of submitting. +- Pressing `l`, typing a query that matches nothing, then pressing Enter leaves the stored description unchanged and shows the hint. +- An empty `[[` query lists recent items with the first row pre-selected. +- Esc with a `[[` dropdown open closes only the dropdown; a second Esc cancels the command line. - Covers F2. Pressing `l` on item 4 opens `/edit @4 [[` with suggestions visible. **Verification:** existing `@board` and item autocomplete behaves as before. From 604dc4f1093ebf8247b3ea03948730abe9b2379c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:27:04 +0000 Subject: [PATCH 16/45] docs(backlinks): fix glyphs and styles for link, broken and divergent The broken marker, link style and ID field width were unspecified, so U7 tests could not assert anything concrete. Review: design-lens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index 524866e..55e8160 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -211,7 +211,7 @@ flowchart LR - KTD13. **`[[` autocomplete.** `update_suggestions` gains a branch, checked before the `@` branch, for an unclosed `[[` before the cursor in the description position of `/task`, `/note`, and `/edit`. Candidates: active, non-divergent items, excluding the item being edited, shown as rendered title, short stable ID, and board, matched by substring on rendered title, numeric ID, or stable-ID prefix. Accepting inserts `[[]]`. While a `[[` dropdown is open, Enter accepts instead of submitting. An empty `[[` query lists active, non-divergent items newest first, current board first, so the `l` flow opens on useful candidates (today an empty query suggests nothing). The first row is pre-selected whenever a `[[` dropdown opens. Each row shows the rendered title truncated to fit the dropdown, then the short stable ID, with the board as the description. Enter is decided by context, not by whether rows are showing: while an unclosed `[[` sits before the cursor in a description position, Enter accepts the selected row if there is one and otherwise does not submit, setting a hint to pick an item, close the brackets, or press Esc. Esc inside an open `[[` dropdown closes only the dropdown, which stays closed until the cursor leaves that `[[`; a second Esc cancels the command line as today. - KTD14. **Keys:** `l` link, `v` toggle the side pane, `o` move focus into the pane (`j`/`k` move, Enter jumps, `o` or Esc returns to the list). Tab and `h` are already bound. Pane visibility is session-local in v1 and starts visible. - KTD15. **Jump does not touch saved settings.** Jumping clears the search term and board filter, switches Archive view to Board view through a non-persisting path, and reveals a completed target with a transient override instead of flipping the persisted hide-completed setting; a status line names what changed. Broken and divergent entries are listed but not jumpable (R15, R16, R20). -- KTD16. **Rendering substitutes one level.** A token renders as the target's current title; tokens inside that title render as short stable IDs, so cycles cannot recurse. Broken tokens render as the short ID with a broken marker; divergent ones with a `??` suffix, as jj does. The same render function feeds rows, the pane, suggestions, and the journal preview (which also moves from byte to character truncation). +- KTD16. **Rendering substitutes one level.** A token renders as the target's current title; tokens inside that title render as short stable IDs, so cycles cannot recurse. Broken tokens render as the short ID with a broken marker; divergent ones with a `??` suffix, as jj does. The same render function feeds rows, the pane, suggestions, and the journal preview (which also moves from byte to character truncation). Encodings use a glyph as well as color so they survive monochrome and `NO_COLOR` terminals: a link is the target title underlined in the info style; a broken link is `✗` plus the short stable ID in the error style; a divergent ID is the short ID plus `??` in the warning style. The stable-ID field after the numeric ID is 6 characters: the unique prefix highlighted, the rest dimmed. - KTD17. **Pane content rules.** Each target appears once per list however many times it is linked; a self-link is allowed and listed under outgoing links but never as a backlink of itself. Broken outgoing links are listed. When an archived item is selected in Archive view, its backlinks list shows the active items still linking to it. - KTD18. **Pane layout.** When visible and the terminal is at least 100 columns wide, the content area splits horizontally with the pane on the right (about 30% width, minimum 32 columns); below 100 columns it is hidden and the toggle state is kept (R17). The autocomplete overlay is drawn after the pane so it is never covered. @@ -484,6 +484,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix **Test scenarios:** - A row whose description holds a link shows the target's title and not the raw ID. - A broken link shows the broken marker; a divergent one shows `??`. +- Rendered segments carry the exact glyphs and modifiers from KTD16 (underline for links, `✗` for broken, `??` for divergent), asserted on the segment list. - A journal preview of a note whose first line holds multibyte text longer than 50 bytes renders without panicking. **Verification:** manual check in a narrow and a wide terminal that rows stay on one line. From 942bb941acd7b19eef58d36e49d0e68508303968 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:27:04 +0000 Subject: [PATCH 17/45] docs(backlinks): keep rejection messages readable until the next key Status messages expire after 3 s and clip at terminal width, so R9's message naming the reference could vanish. Review: design-lens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index 55e8160..cee5b2b 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -205,7 +205,7 @@ flowchart LR - KTD7. **"Added in this edit" is a multiset diff against the stored baseline.** The baseline is the item's stored text captured once when the edit session starts (when `e` or `l` prefills the command line, when a typed `/edit` is first submitted, or when the editor opens; empty for a create) and reused unchanged for every retry, so a rejected `[[99]]` cannot become "pre-existing" on the second try. For notes, title and body are diffed together so moving a span between them is not an addition. For each span text, the first k occurrences (k = baseline count) stay verbatim and later occurrences are added (R8, R10). - KTD8. **Rewrite outcome is a value, not an error.** `rewrite_on_save(baseline, draft, index)` returns either the rewritten text or a list of rejected references with reasons (not found, ambiguous prefix, divergent target). Full tokens are never rejected (R10). Callers in the TUI show the message as a status and keep the draft. - KTD9. **TUI keeps an active-items snapshot and a `LinkIndex` independent of the view.** `App` loads active items in every view (Archive view currently replaces `app.items`), rebuilding the index in `refresh_items`, `set_view`, the SSE handler for both active and archive changes, and after restore. Prefix uniqueness is computed over active items; an archived row's prefix is computed over active items plus itself. -- KTD10. **A rejected submit re-opens the command line with the draft.** Submit keeps today's order (push history, deactivate, execute), because `deactivate_command_line` resets `pending_confirm` and moving it after execution would cancel the `/delete` and `/clear` confirmations. When the link rewrite rejects, the action re-activates the command line with the submitted input and cursor and sets the error status (R9). Other storage errors keep today's behavior. +- KTD10. **A rejected submit re-opens the command line with the draft.** Submit keeps today's order (push history, deactivate, execute), because `deactivate_command_line` resets `pending_confirm` and moving it after execution would cancel the `/delete` and `/clear` confirmations. When the link rewrite rejects, the action re-activates the command line with the submitted input and cursor and sets the error status (R9). Other storage errors keep today's behavior. The rejection status stays until the next key press instead of expiring after 3 seconds; each reference reads as `[[99]] not found`, `[[kq]] matches 2 items`, or `[[kq]] is divergent`, and when the line would clip, later references collapse into a `+N more` suffix so the first is always fully visible. - KTD11. **Note editor saves validate title and body together before either write.** `edit_note_external` keeps its existing `edit_description_silent` plus `edit_note_body_silent` pair, but both run only after the combined rewrite succeeds, so a rejection never reaches storage. On rejection the editor reopens on the edited title and body, with the rejection message as a leading `//` comment line (already stripped by `parse_note_content`). The `/note` create-then-editor flow uses the same validation with an empty baseline. - KTD12. **The link keybind reuses the command line.** `l` on a selected active item opens the command line as `/edit @ [[` with item suggestions already showing; accepting one and pressing Enter saves through the normal rewrite path (R11). For notes the token goes on the title (the `/edit` target). No new picker widget. - KTD13. **`[[` autocomplete.** `update_suggestions` gains a branch, checked before the `@` branch, for an unclosed `[[` before the cursor in the description position of `/task`, `/note`, and `/edit`. Candidates: active, non-divergent items, excluding the item being edited, shown as rendered title, short stable ID, and board, matched by substring on rendered title, numeric ID, or stable-ID prefix. Accepting inserts `[[]]`. While a `[[` dropdown is open, Enter accepts instead of submitting. An empty `[[` query lists active, non-divergent items newest first, current board first, so the `l` flow opens on useful candidates (today an empty query suggests nothing). The first row is pre-selected whenever a `[[` dropdown opens. Each row shows the rendered title truncated to fit the dropdown, then the short stable ID, with the board as the description. Enter is decided by context, not by whether rows are showing: while an unclosed `[[` sits before the cursor in a description position, Enter accepts the selected row if there is one and otherwise does not submit, setting a hint to pick an item, close the brackets, or press Esc. Esc inside an open `[[` dropdown closes only the dropdown, which stays closed until the cursor leaves that `[[`; a second Esc cancels the command line as today. @@ -452,6 +452,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix **Test scenarios:** - Covers AE1. Submitting `/task see [[99]]` leaves the command line active with the same text and an error status naming `[[99]]`; no item is created. +- A draft with three rejected references shows the first reference's text in full and a `+2 more` suffix, and the status remains after 3 seconds. - Submitting `/edit @3 see [[12]]` stores the full token for item 12. - `/delete 3` still opens its confirmation prompt, and confirming deletes item 3. - Covers AE3. `/edit` of a CLI-created `see [[12]]` with an unrelated change saves with `[[12]]` still plain. From 6a35251dca2309c40d7ae42af4e7131df7c8bf05 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:27:04 +0000 Subject: [PATCH 18/45] docs(backlinks): give v and o feedback when the pane cannot show In a default 80-column terminal the keys otherwise appear to do nothing. Review: design-lens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index cee5b2b..f38337a 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -209,7 +209,7 @@ flowchart LR - KTD11. **Note editor saves validate title and body together before either write.** `edit_note_external` keeps its existing `edit_description_silent` plus `edit_note_body_silent` pair, but both run only after the combined rewrite succeeds, so a rejection never reaches storage. On rejection the editor reopens on the edited title and body, with the rejection message as a leading `//` comment line (already stripped by `parse_note_content`). The `/note` create-then-editor flow uses the same validation with an empty baseline. - KTD12. **The link keybind reuses the command line.** `l` on a selected active item opens the command line as `/edit @ [[` with item suggestions already showing; accepting one and pressing Enter saves through the normal rewrite path (R11). For notes the token goes on the title (the `/edit` target). No new picker widget. - KTD13. **`[[` autocomplete.** `update_suggestions` gains a branch, checked before the `@` branch, for an unclosed `[[` before the cursor in the description position of `/task`, `/note`, and `/edit`. Candidates: active, non-divergent items, excluding the item being edited, shown as rendered title, short stable ID, and board, matched by substring on rendered title, numeric ID, or stable-ID prefix. Accepting inserts `[[]]`. While a `[[` dropdown is open, Enter accepts instead of submitting. An empty `[[` query lists active, non-divergent items newest first, current board first, so the `l` flow opens on useful candidates (today an empty query suggests nothing). The first row is pre-selected whenever a `[[` dropdown opens. Each row shows the rendered title truncated to fit the dropdown, then the short stable ID, with the board as the description. Enter is decided by context, not by whether rows are showing: while an unclosed `[[` sits before the cursor in a description position, Enter accepts the selected row if there is one and otherwise does not submit, setting a hint to pick an item, close the brackets, or press Esc. Esc inside an open `[[` dropdown closes only the dropdown, which stays closed until the cursor leaves that `[[`; a second Esc cancels the command line as today. -- KTD14. **Keys:** `l` link, `v` toggle the side pane, `o` move focus into the pane (`j`/`k` move, Enter jumps, `o` or Esc returns to the list). Tab and `h` are already bound. Pane visibility is session-local in v1 and starts visible. +- KTD14. **Keys:** `l` link, `v` toggle the side pane, `o` move focus into the pane (`j`/`k` move, Enter jumps, `o` or Esc returns to the list). Tab and `h` are already bound. Pane visibility is session-local in v1 and starts visible. Below the pane's minimum width, `v` still flips the stored toggle and sets an info status `Link pane needs 100 columns (toggle on)`; `o` while the pane is hidden or empty does nothing but set `Link pane hidden` or `No links for this item`. - KTD15. **Jump does not touch saved settings.** Jumping clears the search term and board filter, switches Archive view to Board view through a non-persisting path, and reveals a completed target with a transient override instead of flipping the persisted hide-completed setting; a status line names what changed. Broken and divergent entries are listed but not jumpable (R15, R16, R20). - KTD16. **Rendering substitutes one level.** A token renders as the target's current title; tokens inside that title render as short stable IDs, so cycles cannot recurse. Broken tokens render as the short ID with a broken marker; divergent ones with a `??` suffix, as jj does. The same render function feeds rows, the pane, suggestions, and the journal preview (which also moves from byte to character truncation). Encodings use a glyph as well as color so they survive monochrome and `NO_COLOR` terminals: a link is the target title underlined in the info style; a broken link is `✗` plus the short stable ID in the error style; a divergent ID is the short ID plus `??` in the warning style. The stable-ID field after the numeric ID is 6 characters: the unique prefix highlighted, the rest dimmed. - KTD17. **Pane content rules.** Each target appears once per list however many times it is linked; a self-link is allowed and listed under outgoing links but never as a backlink of itself. Broken outgoing links are listed. When an archived item is selected in Archive view, its backlinks list shows the active items still linking to it. @@ -553,6 +553,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix - Jumping clears an active search term and board filter and sets a status naming that. - Broken and divergent entries are not jumpable. - Covers AE6. At 80 columns the pane is not rendered even when toggled on; at 120 columns it is. +- At 80 columns, `v` sets the needs-100-columns status and `o` sets `Link pane hidden`; neither changes focus. - Pane cursor clamps when a refresh shortens its list. - Selecting archived T in Archive view lists A, B, C under backlinks. From 0b2b59f489b15c4707c67e963b61086b6511987d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:27:04 +0000 Subject: [PATCH 19/45] docs(backlinks): specify side pane empty, focus, order and divergent states Unspecified states would be invented per implementer, and backlinks of a divergent item cannot be attributed. Review: design-lens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index f38337a..5da9ca4 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -212,7 +212,7 @@ flowchart LR - KTD14. **Keys:** `l` link, `v` toggle the side pane, `o` move focus into the pane (`j`/`k` move, Enter jumps, `o` or Esc returns to the list). Tab and `h` are already bound. Pane visibility is session-local in v1 and starts visible. Below the pane's minimum width, `v` still flips the stored toggle and sets an info status `Link pane needs 100 columns (toggle on)`; `o` while the pane is hidden or empty does nothing but set `Link pane hidden` or `No links for this item`. - KTD15. **Jump does not touch saved settings.** Jumping clears the search term and board filter, switches Archive view to Board view through a non-persisting path, and reveals a completed target with a transient override instead of flipping the persisted hide-completed setting; a status line names what changed. Broken and divergent entries are listed but not jumpable (R15, R16, R20). - KTD16. **Rendering substitutes one level.** A token renders as the target's current title; tokens inside that title render as short stable IDs, so cycles cannot recurse. Broken tokens render as the short ID with a broken marker; divergent ones with a `??` suffix, as jj does. The same render function feeds rows, the pane, suggestions, and the journal preview (which also moves from byte to character truncation). Encodings use a glyph as well as color so they survive monochrome and `NO_COLOR` terminals: a link is the target title underlined in the info style; a broken link is `✗` plus the short stable ID in the error style; a divergent ID is the short ID plus `??` in the warning style. The stable-ID field after the numeric ID is 6 characters: the unique prefix highlighted, the rest dimmed. -- KTD17. **Pane content rules.** Each target appears once per list however many times it is linked; a self-link is allowed and listed under outgoing links but never as a backlink of itself. Broken outgoing links are listed. When an archived item is selected in Archive view, its backlinks list shows the active items still linking to it. +- KTD17. **Pane content rules.** Each target appears once per list however many times it is linked; a self-link is allowed and listed under outgoing links but never as a backlink of itself. Broken outgoing links are listed. When an archived item is selected in Archive view, its backlinks list shows the active items still linking to it. The pane has two fixed headings, `Links to` and `Linked from`, each showing a dim `(none)` when empty. Outgoing entries follow first appearance in the text; backlinks follow the main list's current order. Entries are one line: title truncated by characters with an ellipsis, short stable ID always visible. A focused pane draws its border in the info style and changes the key hint to `j/k move, Enter jump, Esc back`; after a jump, focus returns to the list. A selected divergent item lists no backlinks and shows a `?? divergent` notice instead, because backlinks cannot be attributed to one of the twins. - KTD18. **Pane layout.** When visible and the terminal is at least 100 columns wide, the content area splits horizontally with the pane on the right (about 30% width, minimum 32 columns); below 100 columns it is hidden and the toggle state is kept (R17). The autocomplete overlay is drawn after the pane so it is never covered. ### High-Level Technical Design @@ -556,6 +556,10 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix - At 80 columns, `v` sets the needs-100-columns status and `o` sets `Link pane hidden`; neither changes focus. - Pane cursor clamps when a refresh shortens its list. - Selecting archived T in Archive view lists A, B, C under backlinks. +- An item with no links shows `(none)` under both headings. +- A long title is truncated with an ellipsis and its short stable ID stays visible at 32 columns. +- Selecting a divergent item shows the `?? divergent` notice and no backlinks. +- After Enter jumps, focus is back on the list. **Verification:** manual walkthrough of F3 in a real terminal at both widths. From 6c9d05d5878055176605f1bcfdbdfcbfae83bc2b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:39:33 +0000 Subject: [PATCH 20/45] docs(backlinks): scope R19 uniqueness to the creating client R19 promised no shared IDs at all, but cross-device same-millisecond creates can collide; the plan already treats those as divergent (R20). Review: coherence; approved by user. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index 5da9ca4..a42d3fc 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -56,7 +56,7 @@ Because sync stores items as opaque client-encrypted blobs and replaces the whol - R1. Every task and note has a stable ID that is assigned once and never changes across edit, move, star, state change, archive, restore, or sync. - R2. Stable IDs use a letters-only alphabet, so no stable ID or prefix of one can be read as a numeric ID. - R3. Every stable ID is a deterministic function of the item's creation timestamp alone, computed whenever the item is read and never stored, so every client and every version that keeps `_timestamp` agrees on it. -- R19. Creating an item gives it a creation timestamp that no other active or archived item has, so new items never share a stable ID. +- R19. Creating an item gives it a creation timestamp that no other active or archived item in this store has, so items created on this client never share a stable ID; ties created on other devices are divergent per R20. - R20. Items that already share a creation timestamp are divergent: the TUI marks them, they are not jump targets, and a save that adds a reference to one is rejected per R9. - R4. Numeric IDs keep their current allocation and behavior for every existing CLI, TUI, and MCP operation. - R5. Older taskbook-rs versions can still read item data written by this version. From 17d4ddcc0304e2d5c4d05644669649f61b536bbd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:39:33 +0000 Subject: [PATCH 21/45] docs(backlinks): treat only lowercase [[...]] as references and name rewrites A bracketed word of k-z letters silently became a link whenever exactly one ID prefix matched. Lowercase-only matching keeps capitalized wiki text plain, and naming each rewrite makes accidental matches visible. Review: adversarial (FYI); adopted by user. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index a42d3fc..ef4a60f 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -201,9 +201,9 @@ flowchart LR - KTD3. **Uniqueness at creation by bumping `_timestamp`.** Every `Taskbook` create path takes the candidate timestamp and, if any active or archived item already has it, increments by 1 ms until free; this covers CLI, TUI, MCP, and editor-created notes because they all go through `Taskbook` (R19). The archive is read on create to make the check complete; in remote mode that is one extra `GET` per create, acceptable at taskbook's scale. A bump of a few milliseconds has no visible effect: `timestamp` only drives ordering and age display. - KTD4. **Divergence is detected, not repaired.** The link index groups active items by stable ID; any ID held by more than one active item is divergent (R20). An archived copy sharing an ID with an active item (a half-finished delete) is ignored, because archived items contribute nothing to the index. Automatic repair is deferred. - KTD5. **Link logic is a pure module, `crates/taskbook-common/src/links.rs`.** It owns three things: the span scanner, the save-time rewrite, and the link index. It takes plain item data, has no TUI or storage dependency, and carries the bulk of the tests. Follows the shape of `crates/taskbook-common/src/due.rs`. -- KTD6. **Span classification.** The scanner finds closed `[[…]]` spans; the inner text classifies as Full (exactly 16 alphabet letters), Numeric (digits only), Prefix (1–15 alphabet letters), or Plain (anything else, including mixed or other letters). Matching is case-insensitive and rewritten tokens are lowercase. An unclosed `[[` is not a span. +- KTD6. **Span classification.** The scanner finds closed `[[…]]` spans; the inner text classifies as Full (exactly 16 alphabet letters), Numeric (digits only), Prefix (1–15 alphabet letters), or Plain (anything else, including mixed or other letters). Only lowercase inner text can be Full or Prefix; any uppercase letter makes the span Plain, so capitalized wiki-style text such as `[[Work]]` never becomes a link. An unclosed `[[` is not a span. - KTD7. **"Added in this edit" is a multiset diff against the stored baseline.** The baseline is the item's stored text captured once when the edit session starts (when `e` or `l` prefills the command line, when a typed `/edit` is first submitted, or when the editor opens; empty for a create) and reused unchanged for every retry, so a rejected `[[99]]` cannot become "pre-existing" on the second try. For notes, title and body are diffed together so moving a span between them is not an addition. For each span text, the first k occurrences (k = baseline count) stay verbatim and later occurrences are added (R8, R10). -- KTD8. **Rewrite outcome is a value, not an error.** `rewrite_on_save(baseline, draft, index)` returns either the rewritten text or a list of rejected references with reasons (not found, ambiguous prefix, divergent target). Full tokens are never rejected (R10). Callers in the TUI show the message as a status and keep the draft. +- KTD8. **Rewrite outcome is a value, not an error.** `rewrite_on_save(baseline, draft, index)` returns either the rewritten text or a list of rejected references with reasons (not found, ambiguous prefix, divergent target). Full tokens are never rejected (R10). Callers in the TUI show the message as a status and keep the draft; on success, the status names each reference that was rewritten with its target's title (for example `Linked [[now]] → Morning review`), so an accidental match is visible at once. - KTD9. **TUI keeps an active-items snapshot and a `LinkIndex` independent of the view.** `App` loads active items in every view (Archive view currently replaces `app.items`), rebuilding the index in `refresh_items`, `set_view`, the SSE handler for both active and archive changes, and after restore. Prefix uniqueness is computed over active items; an archived row's prefix is computed over active items plus itself. - KTD10. **A rejected submit re-opens the command line with the draft.** Submit keeps today's order (push history, deactivate, execute), because `deactivate_command_line` resets `pending_confirm` and moving it after execution would cancel the `/delete` and `/clear` confirmations. When the link rewrite rejects, the action re-activates the command line with the submitted input and cursor and sets the error status (R9). Other storage errors keep today's behavior. The rejection status stays until the next key press instead of expiring after 3 seconds; each reference reads as `[[99]] not found`, `[[kq]] matches 2 items`, or `[[kq]] is divergent`, and when the line would clip, later references collapse into a `+N more` suffix so the first is always fully visible. - KTD11. **Note editor saves validate title and body together before either write.** `edit_note_external` keeps its existing `edit_description_silent` plus `edit_note_body_silent` pair, but both run only after the combined rewrite succeeds, so a rejection never reaches storage. On rejection the editor reopens on the edited title and body, with the rejection message as a leading `//` comment line (already stripped by `parse_note_content`). The `/note` create-then-editor flow uses the same validation with an empty baseline. @@ -277,7 +277,7 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix ### Assumptions - At taskbook's scale (hundreds to low thousands of items), rebuilding the link index on every refresh is cheap enough without incremental updates. -- Users do not routinely write literal `[[…]]` text made only of `k`–`z` letters; when they do and it matches nothing, R9 rejects the save with a message naming it. +- Users do not routinely write literal lowercase `[[…]]` text made only of `k`–`z` letters; when they do, a match is named in the success status (KTD8) and no match is rejected per R9. --- @@ -368,7 +368,8 @@ Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix - Baseline with one `[[12]]`, draft with two: the first stays plain, the second is rewritten. - A span moved from note body to title is not treated as added. - `[[todo]]`, `[[bin]]`, `[[12a]]`, and `[[ ]]` are plain text and never rejected. -- Uppercase `[[KQZ]]` resolves case-insensitively and is written lowercase. +- Uppercase or mixed-case `[[KQZ]]` and `[[Work]]` stay plain text and are never rejected. +- A successful save that rewrote `[[now]]` reports the reference and its target title. - A reference resolving to a divergent ID is rejected with a divergent reason (R20). - An unclosed `[[12` is left as text. - Retry: after a rejection, calling again with the same baseline and a draft that still contains `[[99]]` rejects again. From 202e583225a3acd1b8c7a0f3d7d9ce8164cd9f37 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:44:23 +0000 Subject: [PATCH 22/45] feat(common): derive stable item IDs from the creation timestamp Add stable_id: an invertible 64-bit mix (splitmix64 finalizer) of _timestamp written as 16 letters k-z, jj change-ID style. Computed on read via Item::stable_id() / StorageItem::stable_id(); nothing is serialized, so the storage format is unchanged. Golden vectors, computed independently, freeze the mapping. Plan: U1 (R1-R3, R5, KTD1-KTD2). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-common/src/lib.rs | 1 + crates/taskbook-common/src/models/item.rs | 6 + crates/taskbook-common/src/models/mod.rs | 65 ++++++++ crates/taskbook-common/src/stable_id.rs | 172 ++++++++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 crates/taskbook-common/src/stable_id.rs diff --git a/crates/taskbook-common/src/lib.rs b/crates/taskbook-common/src/lib.rs index b2a7a4e..a485a38 100644 --- a/crates/taskbook-common/src/lib.rs +++ b/crates/taskbook-common/src/lib.rs @@ -4,6 +4,7 @@ pub mod due; pub mod encryption; pub mod error; pub mod models; +pub mod stable_id; pub use error::{CommonError, CommonResult}; pub use models::{Item, Note, StorageItem, Task}; diff --git a/crates/taskbook-common/src/models/item.rs b/crates/taskbook-common/src/models/item.rs index 64dae16..4cfaeb7 100644 --- a/crates/taskbook-common/src/models/item.rs +++ b/crates/taskbook-common/src/models/item.rs @@ -8,4 +8,10 @@ pub trait Item { fn boards(&self) -> &[String]; fn tags(&self) -> &[String]; fn is_task(&self) -> bool; + + /// The item's stable ID, derived from its creation timestamp. + /// See [`crate::stable_id`]. + fn stable_id(&self) -> String { + crate::stable_id::from_timestamp(self.timestamp()) + } } diff --git a/crates/taskbook-common/src/models/mod.rs b/crates/taskbook-common/src/models/mod.rs index 3bf7d35..e772f3f 100644 --- a/crates/taskbook-common/src/models/mod.rs +++ b/crates/taskbook-common/src/models/mod.rs @@ -148,6 +148,12 @@ impl StorageItem { matches!(self, StorageItem::Task(_)) } + /// The item's stable ID, derived from its creation timestamp. + /// See [`crate::stable_id`]. + pub fn stable_id(&self) -> String { + crate::stable_id::from_timestamp(self.timestamp()) + } + pub fn set_description(&mut self, desc: String) { match self { StorageItem::Task(t) => t.description = desc, @@ -238,3 +244,62 @@ impl StorageItem { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::stable_id; + + fn task_at(ts: i64) -> StorageItem { + let mut t = Task::new(3, "Write report".to_string(), vec!["Work".to_string()], 1); + t.timestamp = ts; + StorageItem::Task(t) + } + + fn note_at(ts: i64) -> StorageItem { + let mut n = Note::new(4, "Meeting notes".to_string(), vec!["Work".to_string()]); + n.timestamp = ts; + StorageItem::Note(n) + } + + #[test] + fn stable_id_is_derived_from_timestamp_only() { + let ts = 1_790_000_000_000; + assert_eq!(task_at(ts).stable_id(), stable_id::from_timestamp(ts)); + assert_eq!(task_at(ts).stable_id(), note_at(ts).stable_id()); + + let via_trait: &dyn Item = &task_at(ts); + assert_eq!(via_trait.stable_id(), stable_id::from_timestamp(ts)); + } + + #[test] + fn stable_id_survives_every_mutation_but_timestamp() { + let mut item = task_at(1_790_000_000_000); + let before = item.stable_id(); + + item.set_description("Rewritten".to_string()); + item.set_starred(true); + item.set_boards(vec!["Home".to_string()]); + item.set_tags(vec!["urgent".to_string()]); + if let Some(t) = item.as_task_mut() { + t.is_complete = true; + t.in_progress = true; + t.id = 99; + } + + assert_eq!(item.stable_id(), before); + } + + #[test] + fn stable_id_round_trips_without_a_stored_field() { + let item = note_at(1_790_000_000_123); + let json = serde_json::to_string(&item).unwrap(); + assert!( + !json.contains(&item.stable_id()), + "stable id must not be serialized: {json}" + ); + + let back: StorageItem = serde_json::from_str(&json).unwrap(); + assert_eq!(back.stable_id(), item.stable_id()); + } +} diff --git a/crates/taskbook-common/src/stable_id.rs b/crates/taskbook-common/src/stable_id.rs new file mode 100644 index 0000000..80aca98 --- /dev/null +++ b/crates/taskbook-common/src/stable_id.rs @@ -0,0 +1,172 @@ +//! Stable item identity derived from the creation timestamp. +//! +//! An item's numeric `_id` is positional and changes on archive and restore, +//! so links cannot use it. The stable ID plays the role of a jj change ID: it +//! is a pure function of `_timestamp` (which never changes after creation), +//! computed whenever an item is read and never stored. +//! +//! The timestamp is passed through an invertible 64-bit mixer, so distinct +//! timestamps never collide and nearby timestamps get unrelated prefixes, then +//! written as 16 letters from jj's "reverse hex" alphabet (`k`..=`z`). The +//! letters-only alphabet keeps stable IDs lexically disjoint from numeric IDs. +//! +//! The mapping is part of the data contract: every `[[id]]` token written by a +//! user depends on it. Never change the mixer constants or the alphabet. + +use std::collections::HashMap; + +/// The 16-letter alphabet; index `n` encodes nibble value `n`. +pub const ALPHABET: &[u8; 16] = b"klmnopqrstuvwxyz"; + +/// Length of a full stable ID. +pub const LEN: usize = 16; + +/// Derive the stable ID for an item created at `timestamp` (epoch millis). +pub fn from_timestamp(timestamp: i64) -> String { + let mixed = mix(timestamp as u64); + (0..LEN) + .map(|i| { + let nibble = (mixed >> (4 * (LEN - 1 - i))) & 0xf; + ALPHABET[nibble as usize] as char + }) + .collect() +} + +/// The splitmix64 finalizer: a bijection on `u64` (xorshifts and odd +/// multipliers are each invertible), with good avalanche between nearby inputs. +fn mix(mut z: u64) -> u64 { + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) +} + +fn is_alphabet_run(s: &str) -> bool { + s.bytes().all(|b| ALPHABET.contains(&b)) +} + +/// True when `s` is a full stable ID: exactly 16 lowercase alphabet letters. +pub fn is_full_id(s: &str) -> bool { + s.len() == LEN && is_alphabet_run(s) +} + +/// True when `s` is a non-empty, shorter-than-full run of lowercase alphabet +/// letters, i.e. something that can only be a stable-ID prefix. +pub fn is_prefix(s: &str) -> bool { + !s.is_empty() && s.len() < LEN && is_alphabet_run(s) +} + +/// For each distinct ID, the length of its shortest prefix that no other ID in +/// the set shares. IDs that occur more than once get the full length. +pub fn shortest_unique_prefix_lengths<'a, I>(ids: I) -> HashMap +where + I: IntoIterator, +{ + let mut sorted: Vec<&str> = ids.into_iter().collect(); + sorted.sort_unstable(); + + let common = |a: &str, b: &str| a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count(); + + let mut lengths = HashMap::with_capacity(sorted.len()); + for (i, id) in sorted.iter().enumerate() { + let prev = i.checked_sub(1).map(|j| sorted[j]); + let next = sorted.get(i + 1).copied(); + let len = if prev == Some(*id) || next == Some(*id) { + LEN + } else { + let shared = prev + .map_or(0, |p| common(p, id)) + .max(next.map_or(0, |n| common(n, id))); + (shared + 1).min(id.len()) + }; + lengths.insert((*id).to_string(), len); + } + lengths +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Golden vectors, computed independently of this module. They freeze the + /// derivation: if any of these change, every stored link breaks. + #[test] + fn golden_vectors_never_change() { + let cases: [(i64, &str); 6] = [ + (0, "kkkkkkkkkkkkkkkk"), + (-1, "voxkppzwzmwvvxrv"), + (i64::MIN, "mpwmqyuprtwyutsu"), + (i64::MAX, "puqsmuzyrtqpxyvx"), + (1_790_000_000_000, "nuyzmslzwrsnzpqn"), + (1_790_000_000_001, "tqqzmlsvvrtzwzqz"), + ]; + for (ts, expected) in cases { + assert_eq!(from_timestamp(ts), expected, "timestamp {ts}"); + } + } + + #[test] + fn output_is_sixteen_alphabet_letters() { + for ts in [0, 1, 42, -7, 1_700_000_000_000, i64::MAX, i64::MIN] { + let id = from_timestamp(ts); + assert_eq!(id.len(), LEN); + assert!(id.bytes().all(|b| ALPHABET.contains(&b)), "{id}"); + assert!(is_full_id(&id)); + } + } + + #[test] + fn distinct_timestamps_never_collide() { + let base = 1_790_000_000_000_i64; + let mut seen = std::collections::HashSet::new(); + for offset in 0..10_000 { + assert!(seen.insert(from_timestamp(base + offset))); + } + } + + #[test] + fn adjacent_timestamps_spread_their_first_letter() { + let base = 1_790_000_000_000_i64; + let differing = (0..1_000) + .filter(|&i| { + from_timestamp(base + i).as_bytes()[0] != from_timestamp(base + i + 1).as_bytes()[0] + }) + .count(); + // A uniform spread differs 15 times in 16; demand most of that. + assert!(differing > 850, "only {differing} of 1000 differ"); + } + + #[test] + fn full_id_and_prefix_recognition() { + assert!(is_full_id("kqzxmvtnplsrkqzx")); + assert!(!is_full_id("kqzxmvtnplsrkqz")); + assert!(!is_full_id("kqzxmvtnplsrkqzxk")); + assert!(!is_full_id("KQZXMVTNPLSRKQZX")); + assert!(!is_full_id("aqzxmvtnplsrkqzx")); + + assert!(is_prefix("k")); + assert!(is_prefix("kqzxmvtnplsrkqz")); + assert!(!is_prefix("")); + assert!(!is_prefix("kqzxmvtnplsrkqzx")); + assert!(!is_prefix("Kq")); + assert!(!is_prefix("todo")); + assert!(!is_prefix("12")); + } + + #[test] + fn shortest_unique_prefixes() { + let ids = ["kqzaaaaaaaaaaaaa", "kqyaaaaaaaaaaaaa", "maaaaaaaaaaaaaaa"]; + let lens = shortest_unique_prefix_lengths(ids.iter().copied()); + assert_eq!(lens["kqzaaaaaaaaaaaaa"], 3); + assert_eq!(lens["kqyaaaaaaaaaaaaa"], 3); + assert_eq!(lens["maaaaaaaaaaaaaaa"], 1); + } + + #[test] + fn lone_id_gets_length_one_and_duplicates_get_full_length() { + let lens = shortest_unique_prefix_lengths(["kqzxmvtnplsrkqzx"]); + assert_eq!(lens["kqzxmvtnplsrkqzx"], 1); + + let dup = shortest_unique_prefix_lengths(["kqzxmvtnplsrkqzx", "kqzxmvtnplsrkqzx", "mkkk"]); + assert_eq!(dup["kqzxmvtnplsrkqzx"], LEN); + } +} From b89cc1e7ba577332d4307e485465755f51d276b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:45:43 +0000 Subject: [PATCH 23/45] feat(client): give every new item a unique creation timestamp Stable IDs derive from _timestamp, so two items created in the same millisecond (MCP loops, scripts) would share one. Every Taskbook create path now bumps the timestamp past any active or archived item before inserting; the shift is a few ms at most and only affects ordering. Plan: U2 (R19, R1; AE7 creation half; KTD3). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/taskbook.rs | 165 ++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 6 deletions(-) diff --git a/crates/taskbook-client/src/taskbook.rs b/crates/taskbook-client/src/taskbook.rs index 186a5e9..f32f107 100644 --- a/crates/taskbook-client/src/taskbook.rs +++ b/crates/taskbook-client/src/taskbook.rs @@ -77,6 +77,32 @@ impl Taskbook { max + 1 } + /// Give a new item a creation timestamp no active or archived item has. + fn stamp_unique( + &self, + item: &mut StorageItem, + active: &HashMap, + ) -> Result<()> { + let archive = self.get_archive()?; + let ts = unique_timestamp(item.timestamp(), active, &archive); + match item { + StorageItem::Task(t) => t.timestamp = ts, + StorageItem::Note(n) => n.timestamp = ts, + } + Ok(()) + } + + /// Insert a newly created item into `data` with a unique creation timestamp. + fn insert_new( + &self, + data: &mut HashMap, + mut item: StorageItem, + ) -> Result<()> { + self.stamp_unique(&mut item, data)?; + data.insert(item.id().to_string(), item); + Ok(()) + } + fn remove_duplicates(&self, ids: &[u64]) -> Vec { let mut seen = HashSet::with_capacity(ids.len()); ids.iter().filter(|id| seen.insert(**id)).copied().collect() @@ -396,7 +422,7 @@ impl Taskbook { let id = self.generate_id(&data); let task = Task::new_with_tags(id, description, boards, priority, tags).with_due_date(due_date); - data.insert(id.to_string(), StorageItem::Task(task)); + self.insert_new(&mut data, StorageItem::Task(task))?; self.save(&data)?; Ok(id) } @@ -421,7 +447,7 @@ impl Taskbook { let mut data = self.get_data()?; let id = self.generate_id(&data); let note = Note::new_with_tags(id, description, boards, tags); - data.insert(id.to_string(), StorageItem::Note(note)); + self.insert_new(&mut data, StorageItem::Note(note))?; self.save(&data)?; Ok(id) } @@ -441,7 +467,7 @@ impl Taskbook { let mut data = self.get_data()?; let id = self.generate_id(&data); let note = Note::new_with_body(id, title, body, boards); - data.insert(id.to_string(), StorageItem::Note(note)); + self.insert_new(&mut data, StorageItem::Note(note))?; self.save(&data)?; Ok(id) } @@ -692,7 +718,7 @@ impl Taskbook { let note = Note::new_with_tags(id, description, boards, tags); let mut data = self.get_data()?; - data.insert(id.to_string(), StorageItem::Note(note)); + self.insert_new(&mut data, StorageItem::Note(note))?; self.save(&data)?; self.render.success_create(id, false); Ok(()) @@ -712,7 +738,7 @@ impl Taskbook { note_content.body, vec![DEFAULT_BOARD.to_string()], ); - data.insert(id.to_string(), StorageItem::Note(note)); + self.insert_new(&mut data, StorageItem::Note(note))?; self.save(&data)?; self.render.success_create(id, false); Ok(()) @@ -796,7 +822,7 @@ impl Taskbook { let task = Task::new_with_tags(id, description, boards, priority, tags).with_due_date(due_date); let mut data = self.get_data()?; - data.insert(id.to_string(), StorageItem::Task(task)); + self.insert_new(&mut data, StorageItem::Task(task))?; self.save(&data)?; self.render.success_create(id, true); Ok(()) @@ -1408,3 +1434,130 @@ impl Taskbook { self.save(&data) } } + +/// The smallest timestamp at or after `candidate` that no active or archived +/// item already has. Stable IDs derive from the creation timestamp, so this is +/// what keeps new items from sharing one. +fn unique_timestamp( + candidate: i64, + active: &HashMap, + archive: &HashMap, +) -> i64 { + let taken: HashSet = active + .values() + .chain(archive.values()) + .map(|item| item.timestamp()) + .collect(); + let mut ts = candidate; + while taken.contains(&ts) { + ts += 1; + } + ts +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn test_taskbook() -> Taskbook { + let dir = std::env::temp_dir().join(format!("tb-taskbook-test-{}", uuid::Uuid::new_v4())); + Taskbook::with_storage(Box::new(LocalStorage::new(&dir).unwrap())) + } + + fn task_at(id: u64, ts: i64) -> StorageItem { + let mut t = Task::new(id, format!("task {id}"), vec![DEFAULT_BOARD.to_string()], 1); + t.timestamp = ts; + StorageItem::Task(t) + } + + fn map_of(items: Vec) -> HashMap { + items.into_iter().map(|i| (i.id().to_string(), i)).collect() + } + + #[test] + fn unique_timestamp_keeps_a_free_candidate() { + let active = map_of(vec![task_at(1, 100)]); + let archive = HashMap::new(); + assert_eq!(unique_timestamp(200, &active, &archive), 200); + } + + #[test] + fn unique_timestamp_skips_archived_timestamps() { + let active = HashMap::new(); + let archive = map_of(vec![task_at(1, 500)]); + assert_eq!(unique_timestamp(500, &active, &archive), 501); + } + + #[test] + fn unique_timestamp_skips_runs_across_both_sets() { + let active = map_of(vec![task_at(1, 500), task_at(2, 501)]); + let archive = map_of(vec![task_at(1, 502)]); + assert_eq!(unique_timestamp(500, &active, &archive), 503); + } + + /// Covers AE7 (creation half): rapid creates through every path get + /// distinct timestamps and therefore distinct stable IDs. + #[test] + fn rapid_creates_get_distinct_stable_ids() { + let tb = test_taskbook(); + let board = vec![DEFAULT_BOARD.to_string()]; + for i in 0..5 { + tb.create_task_direct_with_tags(board.clone(), format!("t{i}"), 1, vec![], None) + .unwrap(); + tb.create_note_direct_with_tags(board.clone(), format!("n{i}"), vec![]) + .unwrap(); + tb.create_note_with_body_direct(board.clone(), format!("b{i}"), Some("body".into())) + .unwrap(); + tb.create_task(&[format!("cli-task-{i}")]).unwrap(); + tb.create_note(&[format!("cli-note-{i}")]).unwrap(); + } + + let data = tb.get_data().unwrap(); + assert_eq!(data.len(), 25); + let timestamps: HashSet = data.values().map(|i| i.timestamp()).collect(); + let stable_ids: HashSet = data.values().map(|i| i.stable_id()).collect(); + assert_eq!(timestamps.len(), 25); + assert_eq!(stable_ids.len(), 25); + } + + #[test] + fn archive_and_restore_keep_timestamp_and_stable_id() { + let tb = test_taskbook(); + let id = tb + .create_task_direct_with_tags( + vec![DEFAULT_BOARD.to_string()], + "x".into(), + 1, + vec![], + None, + ) + .unwrap(); + // A second item so the restored one gets a different numeric ID. + tb.create_task_direct_with_tags( + vec![DEFAULT_BOARD.to_string()], + "y".into(), + 1, + vec![], + None, + ) + .unwrap(); + let before = tb.get_data().unwrap()[&id.to_string()].clone(); + + tb.delete_items_silent(&[id]).unwrap(); + let archived = tb.get_archive().unwrap(); + let in_archive = archived.values().find(|i| i.description() == "x").unwrap(); + assert_eq!(in_archive.stable_id(), before.stable_id()); + + tb.restore_items_silent(&[in_archive.id()]).unwrap(); + let restored = tb + .get_data() + .unwrap() + .into_values() + .find(|i| i.description() == "x") + .unwrap(); + assert_ne!(restored.id(), before.id()); + assert_eq!(restored.timestamp(), before.timestamp()); + assert_eq!(restored.stable_id(), before.stable_id()); + } +} From ca78eda74744fd0c85105b7017f770644fe9ed45 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:47:29 +0000 Subject: [PATCH 24/45] feat(common): scan [[...]] spans and rewrite added references on save links::scan classifies closed, single-line spans as full stable-ID tokens, numeric refs, lowercase k-z prefix refs, or plain text. rewrite_on_save diffs the draft against the edit's baseline as a multiset: only references added in this edit are resolved to full tokens; unresolvable, ambiguous or divergent ones are returned as rejections, and full tokens and plain text are never touched. join_note/split_note let a note's title and body diff as one text. Plan: U3 (R7-R10, R20; AE1-AE3; KTD6-KTD8). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-common/src/lib.rs | 1 + crates/taskbook-common/src/links.rs | 484 ++++++++++++++++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 crates/taskbook-common/src/links.rs diff --git a/crates/taskbook-common/src/lib.rs b/crates/taskbook-common/src/lib.rs index a485a38..4e51598 100644 --- a/crates/taskbook-common/src/lib.rs +++ b/crates/taskbook-common/src/lib.rs @@ -3,6 +3,7 @@ pub mod board; pub mod due; pub mod encryption; pub mod error; +pub mod links; pub mod models; pub mod stable_id; diff --git a/crates/taskbook-common/src/links.rs b/crates/taskbook-common/src/links.rs new file mode 100644 index 0000000..988d79b --- /dev/null +++ b/crates/taskbook-common/src/links.rs @@ -0,0 +1,484 @@ +//! Inline `[[…]]` links between items. +//! +//! A link is a `[[]]` token in an item's text (task +//! description, note title, note body); the text is the only place links are +//! stored. When the TUI saves text, references the user *added in this edit* +//! as `[[12]]` (numeric ID) or `[[kq]]` (stable-ID prefix) are rewritten to full +//! tokens, and unresolvable ones reject the save. + +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::ops::Range; + +use crate::models::StorageItem; +use crate::stable_id; + +/// How the text between `[[` and `]]` is interpreted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpanKind { + /// Exactly 16 lowercase stable-ID letters: a link. + Full, + /// Digits only: a numeric-ID reference, resolved on save. + Numeric, + /// 1-15 lowercase stable-ID letters: a prefix reference, resolved on save. + Prefix, + /// Anything else, including uppercase or other letters: plain text. + Plain, +} + +/// A closed `[[…]]` span within a text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Span { + /// Byte range of the whole span, brackets included. + pub range: Range, + /// Byte range of the inner text. + pub inner: Range, + pub kind: SpanKind, +} + +/// Find every closed `[[…]]` span. Spans never contain a newline, so a note +/// title and body joined by `\n` cannot produce a span across the two. +pub fn scan(text: &str) -> Vec { + let mut spans = Vec::new(); + let mut i = 0; + while let Some(offset) = text[i..].find("[[") { + let start = i + offset; + let inner_start = start + 2; + let Some(close) = text[inner_start..].find("]]") else { + break; + }; + let inner = &text[inner_start..inner_start + close]; + if let Some(nested) = inner.rfind("[[") { + // A later `[[` opens the span that this `]]` closes. + i = inner_start + nested; + continue; + } + if let Some(newline) = inner.find('\n') { + i = inner_start + newline + 1; + continue; + } + let end = inner_start + close + 2; + spans.push(Span { + range: start..end, + inner: inner_start..inner_start + close, + kind: classify(inner), + }); + i = end; + } + spans +} + +fn classify(inner: &str) -> SpanKind { + if stable_id::is_full_id(inner) { + SpanKind::Full + } else if !inner.is_empty() && inner.bytes().all(|b| b.is_ascii_digit()) { + SpanKind::Numeric + } else if stable_id::is_prefix(inner) { + SpanKind::Prefix + } else { + SpanKind::Plain + } +} + +/// Lookups the save-time rewrite needs over the active items. +#[derive(Debug, Default)] +pub struct Resolver { + by_numeric: HashMap, + ids: Vec, + divergent: HashSet, +} + +impl Resolver { + pub fn from_items<'a, I>(items: I) -> Self + where + I: IntoIterator, + { + let mut by_numeric = HashMap::new(); + let mut counts: HashMap = HashMap::new(); + for item in items { + let id = item.stable_id(); + by_numeric.insert(item.id(), id.clone()); + *counts.entry(id).or_default() += 1; + } + let divergent = counts + .iter() + .filter(|(_, &n)| n > 1) + .map(|(id, _)| id.clone()) + .collect(); + let mut ids: Vec = counts.into_keys().collect(); + ids.sort_unstable(); + Self { + by_numeric, + ids, + divergent, + } + } + + /// Distinct active stable IDs starting with `prefix`, in sorted order. + pub fn ids_with_prefix(&self, prefix: &str) -> &[String] { + let start = self.ids.partition_point(|id| id.as_str() < prefix); + let len = self.ids[start..] + .iter() + .take_while(|id| id.starts_with(prefix)) + .count(); + &self.ids[start..start + len] + } + + /// The stable ID of the active item with this numeric ID. + pub fn stable_id_for(&self, numeric: u64) -> Option<&str> { + self.by_numeric.get(&numeric).map(String::as_str) + } + + fn resolve(&self, kind: SpanKind, inner: &str) -> Result { + let id = match kind { + SpanKind::Numeric => inner + .parse::() + .ok() + .and_then(|n| self.stable_id_for(n)) + .ok_or(RejectReason::NotFound)?, + SpanKind::Prefix => match self.ids_with_prefix(inner) { + [] => return Err(RejectReason::NotFound), + [one] => one.as_str(), + many => return Err(RejectReason::Ambiguous(many.len())), + }, + SpanKind::Full | SpanKind::Plain => unreachable!("only references are resolved"), + }; + if self.is_divergent(id) { + Err(RejectReason::Divergent) + } else { + Ok(id.to_string()) + } + } + + /// True when more than one active item has this stable ID. + pub fn is_divergent(&self, id: &str) -> bool { + self.divergent.contains(id) + } +} + +/// Why a reference added in this edit could not be resolved. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RejectReason { + NotFound, + Ambiguous(usize), + Divergent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rejection { + /// The span as written, e.g. `[[99]]`. + pub reference: String, + pub reason: RejectReason, +} + +impl fmt::Display for Rejection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.reason { + RejectReason::NotFound => write!(f, "{} not found", self.reference), + RejectReason::Ambiguous(n) => write!(f, "{} matches {} items", self.reference, n), + RejectReason::Divergent => write!(f, "{} is divergent", self.reference), + } + } +} + +/// The text to store, plus each reference that was rewritten. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rewrite { + pub text: String, + /// `(reference as written, full stable ID it now links to)`. + pub linked: Vec<(String, String)>, +} + +/// Rewrite the numeric and prefix references `draft` adds relative to +/// `baseline` (the stored text when the edit began; empty for a create). +/// +/// For each span text, the first k occurrences in `draft` (k = its count in +/// `baseline`) are pre-existing and stay verbatim; later ones are added. +/// Full tokens and plain spans are never changed or rejected. +pub fn rewrite_on_save( + baseline: &str, + draft: &str, + resolver: &Resolver, +) -> Result> { + let is_reference = |kind| matches!(kind, SpanKind::Numeric | SpanKind::Prefix); + + let mut baseline_counts: HashMap<&str, usize> = HashMap::new(); + for span in scan(baseline).into_iter().filter(|s| is_reference(s.kind)) { + *baseline_counts.entry(&baseline[span.range]).or_default() += 1; + } + + let mut seen: HashMap<&str, usize> = HashMap::new(); + let mut text = String::with_capacity(draft.len()); + let mut copied_to = 0; + let mut linked = Vec::new(); + let mut rejections = Vec::new(); + + for span in scan(draft).into_iter().filter(|s| is_reference(s.kind)) { + let reference = &draft[span.range.clone()]; + let occurrence = seen.entry(reference).or_default(); + *occurrence += 1; + if *occurrence <= baseline_counts.get(reference).copied().unwrap_or(0) { + continue; + } + + match resolver.resolve(span.kind, &draft[span.inner.clone()]) { + Ok(id) => { + text.push_str(&draft[copied_to..span.range.start]); + text.push_str("[["); + text.push_str(&id); + text.push_str("]]"); + copied_to = span.range.end; + linked.push((reference.to_string(), id)); + } + Err(reason) => rejections.push(Rejection { + reference: reference.to_string(), + reason, + }), + } + } + + if !rejections.is_empty() { + return Err(rejections); + } + text.push_str(&draft[copied_to..]); + Ok(Rewrite { text, linked }) +} + +/// Separator used to diff a note's title and body as one text, so a reference +/// moved between the two is not treated as added. It never occurs in a span's +/// reference forms, so no reference can straddle the two fields. +const NOTE_FIELD_SEPARATOR: char = '\u{1e}'; + +/// Join a note's title and body for [`rewrite_on_save`]. +pub fn join_note(title: &str, body: Option<&str>) -> String { + match body { + Some(body) => format!("{title}{NOTE_FIELD_SEPARATOR}{body}"), + None => title.to_string(), + } +} + +/// Split text produced from [`join_note`] back into title and body. +pub fn split_note(text: &str) -> (String, Option) { + match text.split_once(NOTE_FIELD_SEPARATOR) { + Some((title, body)) => (title.to_string(), Some(body.to_string())), + None => (text.to_string(), None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Note, Task}; + + fn task(id: u64, ts: i64) -> StorageItem { + let mut t = Task::new(id, format!("task {id}"), vec!["My Board".to_string()], 1); + t.timestamp = ts; + StorageItem::Task(t) + } + + fn note(id: u64, ts: i64) -> StorageItem { + let mut n = Note::new(id, format!("note {id}"), vec!["My Board".to_string()]); + n.timestamp = ts; + StorageItem::Note(n) + } + + /// Find a timestamp whose stable ID starts with `prefix`, at or after `from`. + fn ts_with_prefix(prefix: &str, from: i64) -> i64 { + (from..) + .find(|&ts| stable_id::from_timestamp(ts).starts_with(prefix)) + .unwrap() + } + + fn kinds(text: &str) -> Vec<(String, SpanKind)> { + scan(text) + .into_iter() + .map(|s| (text[s.range.clone()].to_string(), s.kind)) + .collect() + } + + #[test] + fn scan_classifies_spans() { + let full = stable_id::from_timestamp(1); + let text = format!("a [[12]] b [[kq]] c [[{full}]] d [[todo]] e [[KQZ]] f [[]] g [[12a]]"); + assert_eq!( + kinds(&text), + vec![ + ("[[12]]".to_string(), SpanKind::Numeric), + ("[[kq]]".to_string(), SpanKind::Prefix), + (format!("[[{full}]]"), SpanKind::Full), + ("[[todo]]".to_string(), SpanKind::Plain), + ("[[KQZ]]".to_string(), SpanKind::Plain), + ("[[]]".to_string(), SpanKind::Plain), + ("[[12a]]".to_string(), SpanKind::Plain), + ] + ); + } + + #[test] + fn scan_ignores_unclosed_and_newline_spans() { + assert!(scan("see [[12").is_empty()); + assert!(scan("title [[\n12]] body").is_empty()); + // The later `[[` starts the span. + assert_eq!( + kinds("[[ [[12]]"), + vec![("[[12]]".to_string(), SpanKind::Numeric)] + ); + } + + #[test] + fn scan_handles_multibyte_text() { + let text = "café [[12]] — ünïcode [[kq]]"; + let found = kinds(text); + assert_eq!(found.len(), 2); + assert_eq!(found[0].0, "[[12]]"); + } + + /// Covers AE1. + #[test] + fn numeric_reference_rewrites_and_unknowns_reject() { + let a = ts_with_prefix("kq", 0); + let b = ts_with_prefix("kq", a + 1); + let items = [task(12, 1_000), task(3, a), note(4, b)]; + let r = Resolver::from_items(items.iter()); + + let ok = rewrite_on_save("", "see [[12]]", &r).unwrap(); + let full = stable_id::from_timestamp(1_000); + assert_eq!(ok.text, format!("see [[{full}]]")); + assert_eq!(ok.linked, vec![("[[12]]".to_string(), full)]); + + let err = rewrite_on_save("", "see [[99]]", &r).unwrap_err(); + assert_eq!(err[0].to_string(), "[[99]] not found"); + + let err = rewrite_on_save("", "see [[kq]]", &r).unwrap_err(); + assert_eq!(err[0].to_string(), "[[kq]] matches 2 items"); + } + + #[test] + fn unique_prefix_rewrites() { + let ts = ts_with_prefix("mz", 0); + let items = [task(1, ts)]; + let r = Resolver::from_items(items.iter()); + let ok = rewrite_on_save("", "re [[mz]]", &r).unwrap(); + assert_eq!(ok.text, format!("re [[{}]]", stable_id::from_timestamp(ts))); + } + + /// Covers AE3. + #[test] + fn pre_existing_numeric_text_stays_plain() { + let items = [task(12, 1_000)]; + let r = Resolver::from_items(items.iter()); + let ok = rewrite_on_save("see [[12]]", "see [[12]] typo-fixed", &r).unwrap(); + assert_eq!(ok.text, "see [[12]] typo-fixed"); + assert!(ok.linked.is_empty()); + } + + /// Covers AE2, plus R10 for newly added tokens. + #[test] + fn full_tokens_are_kept_whatever_their_target() { + let r = Resolver::from_items(std::iter::empty()); + let missing = stable_id::from_timestamp(42); + let existing = format!("x [[{missing}]]"); + assert_eq!( + rewrite_on_save(&existing, &existing, &r).unwrap().text, + existing + ); + + let added = format!("y [[{missing}]]"); + assert_eq!(rewrite_on_save("y", &added, &r).unwrap().text, added); + } + + #[test] + fn only_occurrences_beyond_the_baseline_count_are_added() { + let items = [task(12, 1_000)]; + let r = Resolver::from_items(items.iter()); + let full = stable_id::from_timestamp(1_000); + let ok = rewrite_on_save("[[12]]", "[[12]] and [[12]]", &r).unwrap(); + assert_eq!(ok.text, format!("[[12]] and [[{full}]]")); + } + + #[test] + fn moving_a_span_between_title_and_body_is_not_an_addition() { + let r = Resolver::from_items(std::iter::empty()); + let ok = rewrite_on_save("title\nbody [[99]]", "title [[99]]\nbody", &r).unwrap(); + assert_eq!(ok.text, "title [[99]]\nbody"); + } + + #[test] + fn plain_spans_are_never_rejected() { + let r = Resolver::from_items(std::iter::empty()); + for text in [ + "[[todo]]", "[[bin]]", "[[12a]]", "[[ ]]", "[[KQZ]]", "[[Work]]", + ] { + assert_eq!(rewrite_on_save("", text, &r).unwrap().text, text); + } + } + + #[test] + fn divergent_targets_reject() { + let ts = ts_with_prefix("pq", 0); + let items = [task(1, ts), note(2, ts)]; + let r = Resolver::from_items(items.iter()); + let id = stable_id::from_timestamp(ts); + assert!(r.is_divergent(&id)); + + let err = rewrite_on_save("", "[[pq]]", &r).unwrap_err(); + assert_eq!(err[0].to_string(), "[[pq]] is divergent"); + let err = rewrite_on_save("", "[[1]]", &r).unwrap_err(); + assert_eq!(err[0].reason, RejectReason::Divergent); + } + + #[test] + fn unclosed_reference_is_left_as_text() { + let r = Resolver::from_items(std::iter::empty()); + assert_eq!( + rewrite_on_save("", "see [[12", &r).unwrap().text, + "see [[12" + ); + } + + #[test] + fn retry_with_the_same_baseline_rejects_again() { + let r = Resolver::from_items(std::iter::empty()); + assert!(rewrite_on_save("", "a [[99]]", &r).is_err()); + assert!(rewrite_on_save("", "a [[99]] edited", &r).is_err()); + } + + #[test] + fn note_fields_round_trip_through_a_rewrite() { + let items = [task(12, 1_000)]; + let r = Resolver::from_items(items.iter()); + let baseline = join_note("Title", Some("body [[12]]")); + let draft = join_note("Title [[12]]", Some("body")); + let (title, body) = split_note(&rewrite_on_save(&baseline, &draft, &r).unwrap().text); + assert_eq!(title, "Title [[12]]"); + assert_eq!(body.as_deref(), Some("body")); + + let draft = join_note("New", None); + assert_eq!(split_note(&draft), ("New".to_string(), None)); + } + + #[test] + fn rewrite_never_panics_on_odd_input() { + let r = Resolver::from_items(std::iter::empty()); + for text in [ + "[[", + "]]", + "[[]", + "[[[[]]]]", + "é[[é]]é", + "[[\u{1e}12]]", + "[[1]]]]", + "]][[", + ] { + let _ = rewrite_on_save(text, text, &r); + let _ = rewrite_on_save("", text, &r); + } + } + + #[test] + fn every_rejection_is_reported() { + let r = Resolver::from_items(std::iter::empty()); + let err = rewrite_on_save("", "[[98]] [[99]] [[mm]]", &r).unwrap_err(); + assert_eq!(err.len(), 3); + } +} From bf49d01534a1b9e3df165fb5aad85b4828745e56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:48:44 +0000 Subject: [PATCH 25/45] feat(common): add a link index with backlinks and rendering LinkIndex is built from the active items and answers every display question: outgoing links per item, backlinks keyed by target stable ID (so an archived item can still list who links to it), divergent IDs, shortest unique display prefixes (also for IDs outside the active set), and rendering text into plain, link, broken and divergent segments with one level of title substitution so link cycles cannot recurse. Plan: U4 (R6, R13, R14, R16, R20; KTD4, KTD16, KTD17). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-common/src/links.rs | 347 ++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) diff --git a/crates/taskbook-common/src/links.rs b/crates/taskbook-common/src/links.rs index 988d79b..6488c82 100644 --- a/crates/taskbook-common/src/links.rs +++ b/crates/taskbook-common/src/links.rs @@ -244,6 +244,197 @@ pub fn rewrite_on_save( Ok(Rewrite { text, linked }) } +/// Full stable IDs that `text` links to, deduplicated, in order of first +/// appearance. +pub fn link_targets(text: &str) -> Vec { + let mut targets: Vec = Vec::new(); + for span in scan(text).into_iter().filter(|s| s.kind == SpanKind::Full) { + let id = &text[span.inner]; + if !targets.iter().any(|t| t == id) { + targets.push(id.to_string()); + } + } + targets +} + +/// What a link's target resolves to among the active items. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Target { + Active { numeric: u64 }, + Divergent, + Missing, +} + +/// One piece of rendered text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Segment { + Text(String), + /// A resolved link: the target's current title, with tokens inside that + /// title shown as short stable IDs (one level, so cycles cannot recurse). + Link { + id: String, + title: String, + }, + Broken { + id: String, + }, + Divergent { + id: String, + }, +} + +/// Outgoing links, backlinks, display prefixes and rendering over the active +/// items, rebuilt whenever the item set changes. +#[derive(Debug, Default)] +pub struct LinkIndex { + resolver: Resolver, + titles: HashMap, + numeric_by_id: HashMap, + prefix_lens: HashMap, + outgoing: HashMap>, + inbound: HashMap>, +} + +impl LinkIndex { + pub fn build<'a, I>(items: I) -> Self + where + I: IntoIterator, + { + let items: Vec<&StorageItem> = items.into_iter().collect(); + let resolver = Resolver::from_items(items.iter().copied()); + + let mut titles = HashMap::new(); + let mut numeric_by_id = HashMap::new(); + let mut outgoing = HashMap::new(); + let mut inbound: HashMap> = HashMap::new(); + for item in &items { + let own_id = item.stable_id(); + titles.insert(own_id.clone(), item.description().to_string()); + numeric_by_id.insert(own_id.clone(), item.id()); + + let mut targets = link_targets(item.description()); + if let Some(body) = item.note_body() { + for id in link_targets(body) { + if !targets.contains(&id) { + targets.push(id); + } + } + } + for id in targets.iter().filter(|id| **id != own_id) { + inbound.entry(id.clone()).or_default().push(item.id()); + } + outgoing.insert(item.id(), targets); + } + for sources in inbound.values_mut() { + sources.sort_unstable(); + sources.dedup(); + } + + let prefix_lens = + stable_id::shortest_unique_prefix_lengths(resolver.ids.iter().map(String::as_str)); + + Self { + resolver, + titles, + numeric_by_id, + prefix_lens, + outgoing, + inbound, + } + } + + pub fn resolver(&self) -> &Resolver { + &self.resolver + } + + pub fn target(&self, id: &str) -> Target { + if self.resolver.is_divergent(id) { + return Target::Divergent; + } + self.numeric_by_id + .get(id) + .map_or(Target::Missing, |&numeric| Target::Active { numeric }) + } + + /// Stable IDs the active item `numeric` links to. + pub fn outgoing(&self, numeric: u64) -> &[String] { + self.outgoing.get(&numeric).map_or(&[], Vec::as_slice) + } + + /// Active items (numeric IDs, ascending) whose text links to `id`, + /// excluding the item that holds `id` itself. Works for IDs with no active + /// item, such as an archived one. + pub fn backlinks(&self, id: &str) -> &[u64] { + self.inbound.get(id).map_or(&[], Vec::as_slice) + } + + /// Length of the shortest prefix that tells `id` apart from every active + /// item's ID (computed against the active set plus `id` itself). + pub fn prefix_len(&self, id: &str) -> usize { + if let Some(&len) = self.prefix_lens.get(id) { + return len; + } + let ids = &self.resolver.ids; + let at = ids.partition_point(|other| other.as_str() < id); + let common = |other: &String| { + other + .bytes() + .zip(id.bytes()) + .take_while(|(a, b)| a == b) + .count() + }; + let shared = at + .checked_sub(1) + .map_or(0, |i| common(&ids[i])) + .max(ids.get(at).map_or(0, common)); + (shared + 1).min(id.len()) + } + + /// `id` cut to its shortest unique prefix. + pub fn short_id<'a>(&self, id: &'a str) -> &'a str { + &id[..self.prefix_len(id).min(id.len())] + } + + /// Split `text` into plain text and link segments. + pub fn render(&self, text: &str) -> Vec { + let mut segments = Vec::new(); + let mut copied_to = 0; + for span in scan(text).into_iter().filter(|s| s.kind == SpanKind::Full) { + if span.range.start > copied_to { + segments.push(Segment::Text(text[copied_to..span.range.start].to_string())); + } + let id = text[span.inner.clone()].to_string(); + segments.push(match self.target(&id) { + Target::Active { .. } => Segment::Link { + title: self.flat_title(&id), + id, + }, + Target::Divergent => Segment::Divergent { id }, + Target::Missing => Segment::Broken { id }, + }); + copied_to = span.range.end; + } + if copied_to < text.len() { + segments.push(Segment::Text(text[copied_to..].to_string())); + } + segments + } + + /// A target's title with its own tokens shown as short IDs. + fn flat_title(&self, id: &str) -> String { + let title = self.titles.get(id).map_or("", String::as_str); + let mut flat = String::with_capacity(title.len()); + let mut copied_to = 0; + for span in scan(title).into_iter().filter(|s| s.kind == SpanKind::Full) { + flat.push_str(&title[copied_to..span.range.start]); + flat.push_str(self.short_id(&title[span.inner.clone()])); + copied_to = span.range.end; + } + flat.push_str(&title[copied_to..]); + flat + } +} + /// Separator used to diff a note's title and body as one text, so a reference /// moved between the two is not treated as added. It never occurs in a span's /// reference forms, so no reference can straddle the two fields. @@ -481,4 +672,160 @@ mod tests { let err = rewrite_on_save("", "[[98]] [[99]] [[mm]]", &r).unwrap_err(); assert_eq!(err.len(), 3); } + // ----- link index ----- + + fn titled_task(id: u64, ts: i64, desc: String) -> StorageItem { + let mut t = Task::new(id, desc, vec!["My Board".to_string()], 1); + t.timestamp = ts; + StorageItem::Task(t) + } + + fn token(ts: i64) -> String { + format!("[[{}]]", stable_id::from_timestamp(ts)) + } + + #[test] + fn outgoing_and_backlinks_dedupe_and_exclude_self() { + let (a, b) = (1_000, 2_000); + let items = [ + titled_task(1, a, format!("A {} {} {}", token(b), token(b), token(a))), + titled_task(2, b, "B".to_string()), + ]; + let idx = LinkIndex::build(items.iter()); + let (ida, idb) = (stable_id::from_timestamp(a), stable_id::from_timestamp(b)); + + assert_eq!(idx.outgoing(1), [idb.clone(), ida.clone()]); + assert_eq!(idx.backlinks(&idb), [1]); + assert!(idx.backlinks(&ida).is_empty()); + } + + #[test] + fn link_to_missing_id_is_broken_but_still_findable_by_id() { + let missing = stable_id::from_timestamp(9_999); + let items = [titled_task(1, 1_000, format!("A [[{missing}]]"))]; + let idx = LinkIndex::build(items.iter()); + + assert_eq!(idx.target(&missing), Target::Missing); + assert_eq!(idx.backlinks(&missing), [1]); + assert_eq!( + idx.render(&format!("A [[{missing}]]")), + vec![ + Segment::Text("A ".to_string()), + Segment::Broken { id: missing } + ] + ); + } + + /// Covers AE5 (index half). + #[test] + fn archiving_and_restoring_a_target_toggles_its_links() { + let t = 5_000; + let linking = |id| titled_task(id, 1_000 + id as i64, format!("see {}", token(t))); + let target = titled_task(7, t, "Target".to_string()); + let tid = stable_id::from_timestamp(t); + + let without = [linking(1), linking(2), linking(3)]; + let idx = LinkIndex::build(without.iter()); + assert_eq!(idx.target(&tid), Target::Missing); + assert_eq!(idx.backlinks(&tid), [1, 2, 3]); + + let with: Vec = without.iter().cloned().chain([target]).collect(); + let idx = LinkIndex::build(with.iter()); + assert_eq!(idx.target(&tid), Target::Active { numeric: 7 }); + assert_eq!(idx.backlinks(&tid), [1, 2, 3]); + } + + #[test] + fn shared_ids_are_divergent() { + let ts = 3_000; + let items = [titled_task(1, ts, "x".into()), note(2, ts)]; + let idx = LinkIndex::build(items.iter()); + let id = stable_id::from_timestamp(ts); + assert_eq!(idx.target(&id), Target::Divergent); + assert_eq!( + idx.render(&format!("[[{id}]]")), + vec![Segment::Divergent { id }] + ); + } + + #[test] + fn rendering_substitutes_one_level_only() { + let (a, b) = (1_000, 2_000); + let items = [ + titled_task(1, a, format!("see {}", token(b))), + titled_task(2, b, format!("re {}", token(a))), + ]; + let idx = LinkIndex::build(items.iter()); + let ida = stable_id::from_timestamp(a); + let idb = stable_id::from_timestamp(b); + + let rendered = idx.render(&format!("x {}", token(a))); + assert_eq!( + rendered, + vec![ + Segment::Text("x ".to_string()), + Segment::Link { + id: ida, + title: format!("see {}", idx.short_id(&idb)), + }, + ] + ); + } + + #[test] + fn prefix_lengths_cover_active_and_outside_ids() { + let a = ts_with_prefix("kq", 0); + let b = ts_with_prefix("kq", a + 1); + let c = ts_with_prefix("z", 0); + let items = [task(1, a), task(2, b), task(3, c)]; + let idx = LinkIndex::build(items.iter()); + let ida = stable_id::from_timestamp(a); + + assert!(idx.prefix_len(&ida) >= 3); + assert_eq!(idx.prefix_len(&stable_id::from_timestamp(c)), 1); + // An archived ID sharing "kq" needs at least 3 letters too. + let outside = "kqkkkkkkkkkkkkkk"; + assert!(idx.prefix_len(outside) >= 3); + assert!(idx.short_id(outside).starts_with("kq")); + } + + #[test] + fn link_targets_in_order_of_first_appearance() { + let text = format!("{} {} {} [[12]]", token(2), token(1), token(2)); + assert_eq!( + link_targets(&text), + vec![stable_id::from_timestamp(2), stable_id::from_timestamp(1)] + ); + } + + #[test] + fn note_bodies_contribute_links() { + let mut n = Note::new_with_body( + 1, + "N".into(), + Some(format!("body {}", token(2_000))), + vec![], + ); + n.timestamp = 1_000; + let items = [StorageItem::Note(n), titled_task(2, 2_000, "T".into())]; + let idx = LinkIndex::build(items.iter()); + assert_eq!(idx.backlinks(&stable_id::from_timestamp(2_000)), [1]); + } + + #[test] + fn index_build_scales_to_thousands_of_items() { + let items: Vec = (0..5_000) + .map(|i| { + titled_task( + i + 1, + 1_000 + i as i64, + format!("item {} {}", i, token(1_000 + (i as i64 + 1) % 5_000)), + ) + }) + .collect(); + let start = std::time::Instant::now(); + let idx = LinkIndex::build(items.iter()); + assert_eq!(idx.backlinks(&stable_id::from_timestamp(1_000)).len(), 1); + assert!(start.elapsed() < std::time::Duration::from_secs(2)); + } } From 634070b773f55f8938775004966efc6d8b007fa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:50:11 +0000 Subject: [PATCH 26/45] feat(tui): keep an active-item link index current in every view App now loads active items and rebuilds a LinkIndex on every refresh, view switch, SSE data change (including while in Archive view) and restore. refresh_items is view-aware, so refreshing in Archive view keeps showing the archive, and restore reloads explicitly because set_view(Archive) is a no-op there. Config writes go through one helper so a test constructor can point them at a temp file. Plan: U5 (R14, R16; AE5; KTD9). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/config.rs | 8 +- crates/taskbook-client/src/tui/actions.rs | 3 +- crates/taskbook-client/src/tui/app.rs | 156 ++++++++++++++++++---- crates/taskbook-client/src/tui/mod.rs | 5 +- 4 files changed, 144 insertions(+), 28 deletions(-) diff --git a/crates/taskbook-client/src/config.rs b/crates/taskbook-client/src/config.rs index 4eeb276..ba16a75 100644 --- a/crates/taskbook-client/src/config.rs +++ b/crates/taskbook-client/src/config.rs @@ -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(()) } diff --git a/crates/taskbook-client/src/tui/actions.rs b/crates/taskbook-client/src/tui/actions.rs index 574aba9..5ad0be8 100644 --- a/crates/taskbook-client/src/tui/actions.rs +++ b/crates/taskbook-client/src/tui/actions.rs @@ -594,7 +594,8 @@ fn delete_items(app: &mut App, ids: &[u64]) -> Result<()> { fn restore_item(app: &mut App, id: u64) -> Result<()> { app.taskbook.restore_items_silent(&[id])?; - app.set_view(ViewMode::Archive)?; + // Already in Archive view, so set_view would not reload anything. + app.refresh_items()?; app.set_status(format!("Restored item {}", id), StatusKind::Success); Ok(()) } diff --git a/crates/taskbook-client/src/tui/app.rs b/crates/taskbook-client/src/tui/app.rs index 27ffa2c..696f305 100644 --- a/crates/taskbook-client/src/tui/app.rs +++ b/crates/taskbook-client/src/tui/app.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; @@ -9,6 +9,7 @@ use crate::error::Result; use crate::render::Stats; use crate::taskbook::Taskbook; use taskbook_common::board; +use taskbook_common::links::LinkIndex; use taskbook_common::StorageItem; /// Sort items by the given method @@ -59,8 +60,12 @@ pub struct App { pub selected_index: usize, /// List of boards for navigation pub boards: Vec, - /// Cached items grouped by board/date + /// Items shown by the current view (the archive in Archive view) pub items: HashMap, + /// Active items, loaded in every view; the link index is built from these + pub active_items: HashMap, + /// Links and backlinks among the active items + pub links: LinkIndex, /// Active popup/dialog state (Help only) pub popup: Option, /// Command line state @@ -91,6 +96,8 @@ pub struct App { pub history_index: Option, /// Saved input before browsing history pub history_saved_input: String, + /// Where config changes are written (`None` = the user's config file) + config_path: Option, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -182,6 +189,17 @@ impl App { pub fn new(taskbook_dir: Option<&Path>) -> Result { let taskbook = Taskbook::new(taskbook_dir)?; let config = Config::load_or_default(); + Self::build(taskbook, config, None) + } + + /// Build an app over an explicit store, writing config changes to + /// `config_path` instead of the user's config file (for tests). + #[cfg(test)] + pub fn with_taskbook(taskbook: Taskbook, config: Config, config_path: PathBuf) -> Result { + Self::build(taskbook, config, Some(config_path)) + } + + fn build(taskbook: Taskbook, config: Config, config_path: Option) -> Result { let theme = TuiTheme::from(&config.theme.resolve()); let initial_view = config.default_view; @@ -192,6 +210,8 @@ impl App { selected_index: 0, boards: Vec::new(), items: HashMap::new(), + active_items: HashMap::new(), + links: LinkIndex::default(), popup: None, command_line: CommandLineState::default(), status_message: None, @@ -209,6 +229,7 @@ impl App { command_history: Vec::new(), history_index: None, history_saved_input: String::new(), + config_path, cached_stats: Stats { percent: 0, complete: 0, @@ -220,20 +241,19 @@ impl App { app.refresh_items()?; - // If restoring archive view, load archive items instead - if initial_view == ViewMode::Archive { - app.items = app.taskbook.get_all_archive_items()?; - app.update_display_order(); - app.recalculate_stats(); - } - Ok(app) } - /// Refresh items from storage + /// Refresh items from storage and rebuild the link index pub fn refresh_items(&mut self) -> Result<()> { - self.items = self.taskbook.get_all_items()?; + self.active_items = self.taskbook.get_all_items()?; + self.links = LinkIndex::build(self.active_items.values()); self.boards = self.taskbook.get_all_boards()?; + self.items = if self.view == ViewMode::Archive { + self.taskbook.get_all_archive_items()? + } else { + self.active_items.clone() + }; self.update_display_order(); self.recalculate_stats(); @@ -400,7 +420,7 @@ impl App { pub fn cycle_sort_method(&mut self) { self.sort_method = self.sort_method.next(); self.config.sort_method = self.sort_method; - let _ = self.config.save(); + self.save_config(); self.update_display_order(); } @@ -408,7 +428,7 @@ impl App { pub fn toggle_hide_completed(&mut self) { self.filter.hide_completed = !self.filter.hide_completed; self.config.display_complete_tasks = !self.filter.hide_completed; - let _ = self.config.save(); + self.save_config(); self.update_display_order(); // Clamp selection if !self.display_order.is_empty() && self.selected_index >= self.display_order.len() { @@ -517,21 +537,21 @@ impl App { // Persist the view choice self.config.default_view = view; - let _ = self.config.save(); + self.save_config(); - // Reload data for archive view - if view == ViewMode::Archive { - self.items = self.taskbook.get_all_archive_items()?; - } else { - self.items = self.taskbook.get_all_items()?; - } - - self.update_display_order(); - self.recalculate_stats(); + self.refresh_items()?; } Ok(()) } + /// Persist config changes; failures are not worth interrupting the TUI. + fn save_config(&self) { + let _ = match &self.config_path { + Some(path) => self.config.save_to(path), + None => self.config.save(), + }; + } + /// Activate the command line with an optional prefix pub fn activate_command_line(&mut self, prefix: &str) { self.command_line.input = prefix.to_string(); @@ -568,3 +588,93 @@ impl App { self.running = false; } } + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::storage::LocalStorage; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use taskbook_common::links::Target; + + /// An `App` over a fresh temp store whose config writes go to a temp file. + pub(crate) fn test_app() -> (App, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("tb-app-test-{}", uuid::Uuid::new_v4())); + let taskbook = Taskbook::with_storage(Box::new(LocalStorage::new(&dir).unwrap())); + let config_path = dir.join("config.json"); + let app = App::with_taskbook(taskbook, Config::default(), config_path.clone()).unwrap(); + (app, config_path) + } + + pub(crate) fn key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + } + + fn create_task(app: &mut App, desc: &str) -> u64 { + let id = app + .taskbook + .create_task_direct_with_tags(vec!["My Board".into()], desc.into(), 1, vec![], None) + .unwrap(); + app.refresh_items().unwrap(); + id + } + + fn stable_id_of(app: &App, numeric: u64) -> String { + app.items[&numeric.to_string()].stable_id() + } + + /// Covers AE5. + #[test] + fn restoring_a_linked_item_revives_its_backlinks() { + let (mut app, _) = test_app(); + let t = create_task(&mut app, "Target"); + let tid = stable_id_of(&app, t); + for name in ["A", "B", "C"] { + create_task(&mut app, &format!("{name} [[{tid}]]")); + } + assert_eq!(app.links.backlinks(&tid).len(), 3); + + app.taskbook.delete_items_silent(&[t]).unwrap(); + app.refresh_items().unwrap(); + assert_eq!(app.links.target(&tid), Target::Missing); + assert_eq!(app.links.backlinks(&tid).len(), 3); + + app.set_view(ViewMode::Archive).unwrap(); + let archived = app.display_order[0]; + app.selected_index = 0; + super::super::actions::handle_key_event(&mut app, key('r')).unwrap(); + + assert!(matches!(app.links.target(&tid), Target::Active { .. })); + assert_eq!(app.links.backlinks(&tid).len(), 3); + assert!( + !app.items.contains_key(&archived.to_string()), + "restored row must leave the archive list at once" + ); + } + + #[test] + fn archive_view_keeps_an_index_of_active_items() { + let (mut app, _) = test_app(); + let t = create_task(&mut app, "Target"); + let tid = stable_id_of(&app, t); + create_task(&mut app, &format!("A [[{tid}]]")); + + app.set_view(ViewMode::Archive).unwrap(); + assert!(app.items.is_empty()); + assert_eq!(app.links.target(&tid), Target::Active { numeric: t }); + assert_eq!(app.links.backlinks(&tid).len(), 1); + + app.refresh_items().unwrap(); + assert!( + app.items.is_empty(), + "refresh in archive view keeps showing the archive" + ); + } + + #[test] + fn config_writes_go_to_the_test_path() { + let (mut app, config_path) = test_app(); + app.set_view(ViewMode::Archive).unwrap(); + let saved = std::fs::read_to_string(&config_path).unwrap(); + assert!(saved.contains("archive"), "{saved}"); + } +} diff --git a/crates/taskbook-client/src/tui/mod.rs b/crates/taskbook-client/src/tui/mod.rs index 55533c3..0d18cb1 100644 --- a/crates/taskbook-client/src/tui/mod.rs +++ b/crates/taskbook-client/src/tui/mod.rs @@ -163,10 +163,11 @@ fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> app.items = app.taskbook.get_all_archive_items()?; app.update_display_order(); } - (ViewMode::Board | ViewMode::Timeline | ViewMode::Journal, false) => { + // Active items feed the link index in every view. + (_, false) => { app.refresh_items()?; } - _ => {} // Data will be loaded when user switches views + _ => {} // Archive data will be loaded when user switches views } } } From 89e6eac9f666ef4a4295498b19e7e6d9020fb65e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:52:50 +0000 Subject: [PATCH 27/45] feat(tui): rewrite added [[...]] references on save and keep rejected drafts /task, /note and /edit run the description through rewrite_on_save against the edit-session baseline (the e prefill, or storage on first submit), held across retries. A rejection re-opens the command line with the draft and cursor and shows a sticky "Not saved: ..." status that collapses later references into "+N more" to fit; submit order is unchanged so /delete and /clear confirmations still work. The note editor validates title and body together before either write and, on rejection, reopens on the edited content with the reason as a leading // comment. Successful saves name each rewritten reference and the title it now links to. Plan: U6 (R8-R10; AE1-AE3; KTD7, KTD8, KTD10, KTD11). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/editor.rs | 30 +- crates/taskbook-client/src/taskbook.rs | 2 +- crates/taskbook-client/src/tui/actions.rs | 422 ++++++++++++++++++++-- crates/taskbook-client/src/tui/app.rs | 20 +- crates/taskbook-client/src/tui/ui.rs | 1 + crates/taskbook-common/src/links.rs | 6 + 6 files changed, 442 insertions(+), 39 deletions(-) diff --git a/crates/taskbook-client/src/editor.rs b/crates/taskbook-client/src/editor.rs index 5323d96..ec8a6d5 100644 --- a/crates/taskbook-client/src/editor.rs +++ b/crates/taskbook-client/src/editor.rs @@ -90,12 +90,24 @@ pub fn create_note_in_editor() -> Result> { 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> { + 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'); @@ -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 @@ -175,6 +186,19 @@ fn parse_note_content(content: &str) -> Result> { 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()); diff --git a/crates/taskbook-client/src/taskbook.rs b/crates/taskbook-client/src/taskbook.rs index f32f107..3774bd7 100644 --- a/crates/taskbook-client/src/taskbook.rs +++ b/crates/taskbook-client/src/taskbook.rs @@ -784,7 +784,7 @@ impl Taskbook { .ok_or_else(|| TaskbookError::General("Item is not a note".to_string()))?; // Open editor with current content - let content = editor::edit_existing_note_in_editor(note.title(), note.body())?; + let content = editor::edit_existing_note_in_editor(note.title(), note.body(), None)?; match content { Some(note_content) => { diff --git a/crates/taskbook-client/src/tui/actions.rs b/crates/taskbook-client/src/tui/actions.rs index 5ad0be8..cd50448 100644 --- a/crates/taskbook-client/src/tui/actions.rs +++ b/crates/taskbook-client/src/tui/actions.rs @@ -3,6 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::editor; use crate::error::Result; use taskbook_common::board; +use taskbook_common::links::{self, Rejection, Rewrite}; use super::app::{App, PendingAction, PopupState, StatusKind, ViewMode}; use super::autocomplete; @@ -11,6 +12,11 @@ use super::input_handler::{handle_text_input, InputResult}; /// Handle a key event pub fn handle_key_event(app: &mut App, key: KeyEvent) -> Result<()> { + // A sticky status (e.g. a rejected save) stays until the next key press. + if app.status_message.as_ref().is_some_and(|m| m.sticky) { + app.status_message = None; + } + // 1. Help popup → scroll with j/k/arrows, dismiss with q/Esc/other if let Some(PopupState::Help { ref mut scroll }) = app.popup { match key.code { @@ -144,10 +150,19 @@ fn handle_command_line_key(app: &mut App, key: KeyEvent) -> Result<()> { } InputResult::Submit => { let input = app.command_line.input.clone(); + let cursor = app.command_line.cursor; + let baseline = app.command_line.link_baseline.take(); app.push_history(input.clone()); app.deactivate_command_line(); if !input.trim().is_empty() { - execute_input(app, &input)?; + if let Some(rejected) = execute_input(app, &input, baseline)? { + // Give the draft back so the user can fix the references. + app.activate_command_line(&input); + app.command_line.cursor = cursor; + app.command_line.link_baseline = rejected.baseline; + let message = format_rejections(&rejected.rejections, app.status_width); + app.set_sticky_status(message, StatusKind::Error); + } } } InputResult::Changed { @@ -177,19 +192,34 @@ fn accept_suggestion(app: &mut App) { } } +/// A save whose added `[[…]]` references could not all be resolved. +struct Rejected { + rejections: Vec, + /// The baseline the draft was diffed against, kept for the retry. + baseline: Option<(u64, String)>, +} + /// Parse and execute the command line input -fn execute_input(app: &mut App, input: &str) -> Result<()> { +fn execute_input( + app: &mut App, + input: &str, + baseline: Option<(u64, String)>, +) -> Result> { match command_parser::parse_command(input) { - Ok(cmd) => execute_command(app, cmd), + Ok(cmd) => execute_command(app, cmd, baseline), Err(e) => { app.set_status(e.message, StatusKind::Error); - Ok(()) + Ok(None) } } } /// Execute a parsed command -fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { +fn execute_command( + app: &mut App, + cmd: ParsedCommand, + baseline: Option<(u64, String)>, +) -> Result> { match cmd { ParsedCommand::Task { board, @@ -198,38 +228,66 @@ fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { tags, due_date, } => { + let rewrite = match links::rewrite_on_save("", &description, app.links.resolver()) { + Ok(rewrite) => rewrite, + Err(rejections) => { + return Ok(Some(Rejected { + rejections, + baseline: None, + })) + } + }; let board_name = board .map(|b| board::normalize_board_name(&b)) .or_else(|| app.filter.board_filter.clone()) .unwrap_or_else(|| "my board".to_string()); app.taskbook.create_task_direct_with_tags( vec![board_name.clone()], - description, + rewrite.text.clone(), priority, tags, due_date, )?; app.refresh_items()?; let display = board::display_name(&board_name); - app.set_status(format!("Task created in {}", display), StatusKind::Success); + let message = format!( + "Task created in {}{}", + display, + linked_suffix(app, &rewrite) + ); + app.set_status(message, StatusKind::Success); } ParsedCommand::Note { board, description, tags, } => { + let rewrite = match links::rewrite_on_save("", &description, app.links.resolver()) { + Ok(rewrite) => rewrite, + Err(rejections) => { + return Ok(Some(Rejected { + rejections, + baseline: None, + })) + } + }; let board_name = board .map(|b| board::normalize_board_name(&b)) .or_else(|| app.filter.board_filter.clone()) .unwrap_or_else(|| "my board".to_string()); app.taskbook.create_note_direct_with_tags( vec![board_name.clone()], - description, + rewrite.text.clone(), tags, )?; app.refresh_items()?; let display = board::display_name(&board_name); - app.set_status(format!("Note created in {}", display), StatusKind::Success); + let message = format!( + "Note created in {}{}", + display, + linked_suffix(app, &rewrite) + ); + app.set_status(message, StatusKind::Success); // Open editor for the newly created note // Find the highest ID note (just created) @@ -242,7 +300,26 @@ fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { } } ParsedCommand::Edit { id, description } => { - edit_description(app, id, &description)?; + // The baseline is captured once per edit session (KTD7): from the + // `e`/`l` prefill, or from storage on the first submit. + let base = baseline + .filter(|(base_id, _)| *base_id == id) + .map(|(_, text)| text) + .or_else(|| { + app.active_items + .get(&id.to_string()) + .map(|item| item.description().to_string()) + }) + .unwrap_or_default(); + match links::rewrite_on_save(&base, &description, app.links.resolver()) { + Ok(rewrite) => edit_description(app, id, &rewrite)?, + Err(rejections) => { + return Ok(Some(Rejected { + rejections, + baseline: Some((id, base)), + })) + } + } } ParsedCommand::Move { id, board } => { move_to_board(app, id, &board)?; @@ -326,7 +403,7 @@ fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { app.quit(); } } - Ok(()) + Ok(None) } /// Handle shortcut keys in normal (unfocused) mode @@ -442,6 +519,7 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { let id = item.id(); let desc = item.description().to_string(); app.activate_command_line(&format!("/edit @{} {}", id, desc)); + app.command_line.link_baseline = Some((id, desc)); } } KeyCode::Char('m') if app.view != ViewMode::Archive => { @@ -552,13 +630,58 @@ fn toggle_star(app: &mut App, id: u64) -> Result<()> { Ok(()) } -fn edit_description(app: &mut App, id: u64, new_desc: &str) -> Result<()> { - app.taskbook.edit_description_silent(id, new_desc)?; +fn edit_description(app: &mut App, id: u64, rewrite: &Rewrite) -> Result<()> { + app.taskbook.edit_description_silent(id, &rewrite.text)?; app.refresh_items()?; - app.set_status(format!("Updated item {}", id), StatusKind::Success); + let message = format!("Updated item {}{}", id, linked_suffix(app, rewrite)); + app.set_status(message, StatusKind::Success); Ok(()) } +/// Name each reference a save rewrote and the title it now links to, so an +/// accidental match is visible at once. +fn linked_suffix(app: &App, rewrite: &Rewrite) -> String { + if rewrite.linked.is_empty() { + return String::new(); + } + let parts: Vec = rewrite + .linked + .iter() + .map(|(reference, id)| { + let title = app.links.title(id).unwrap_or_default(); + format!("{} \u{2192} {}", reference, title) + }) + .collect(); + format!(" \u{00b7} linked {}", parts.join(", ")) +} + +/// "Not saved: …" naming every rejected reference; when that would not fit +/// in `width` columns, later references collapse into "+N more" (the first is +/// always shown in full). +fn format_rejections(rejections: &[Rejection], width: u16) -> String { + let width = usize::from(width); + let mut message = String::from("Not saved: "); + for (i, rejection) in rejections.iter().enumerate() { + let part = rejection.to_string(); + let remaining = rejections.len() - i - 1; + let tail = if remaining > 0 { + format!(" +{} more", remaining) + } else { + String::new() + }; + let separator = if i == 0 { "" } else { ", " }; + let fits = + message.chars().count() + separator.len() + part.chars().count() + tail.len() <= width; + if i > 0 && !fits { + message.push_str(&format!(" +{} more", rejections.len() - i)); + break; + } + message.push_str(separator); + message.push_str(&part); + } + message +} + fn move_to_board(app: &mut App, id: u64, board: &str) -> Result<()> { let board_name = board::normalize_board_name(board); app.taskbook @@ -679,33 +802,264 @@ fn edit_note_external(app: &mut App, id: u64) -> Result<()> { } }; - let title = note.title().to_string(); - let body = note.body().map(|s| s.to_string()); + // The stored text when the session starts is the baseline for every retry. + let base_title = note.title().to_string(); + let base_body = note.body().map(|s| s.to_string()); + let mut title = base_title.clone(); + let mut body = base_body.clone(); + let mut notice: Option = None; - // Suspend TUI to run external editor - let guard = super::suspend_tui()?; + loop { + // Suspend TUI to run external editor + let guard = super::suspend_tui()?; - // Open external editor - let content = editor::edit_existing_note_in_editor(&title, body.as_deref()); + // Open external editor + let content = + editor::edit_existing_note_in_editor(&title, body.as_deref(), notice.as_deref()); - // Resume TUI - guard.resume()?; + // Resume TUI + guard.resume()?; - // After suspend/resume, ratatui's internal buffer is stale — force full redraw - app.needs_full_redraw = true; + // After suspend/resume, ratatui's internal buffer is stale — force full redraw + app.needs_full_redraw = true; - match content? { - Some(note_content) => { - app.taskbook - .edit_description_silent(id, ¬e_content.title)?; - app.taskbook.edit_note_body_silent(id, note_content.body)?; - app.refresh_items()?; - app.set_status(format!("Updated note {}", id), StatusKind::Success); - } - None => { + let Some(note_content) = content? else { app.set_status("Edit cancelled".to_string(), StatusKind::Info); + return Ok(()); + }; + + let baseline = (base_title.as_str(), base_body.as_deref()); + match save_note_edit( + app, + id, + baseline, + ¬e_content.title, + note_content.body.as_deref(), + )? { + Ok(rewrite) => { + let message = format!("Updated note {}{}", id, linked_suffix(app, &rewrite)); + app.set_status(message, StatusKind::Success); + return Ok(()); + } + Err(rejections) => { + // Reopen on the edited content, explaining what to fix. + notice = Some(format_rejections(&rejections, u16::MAX)); + title = note_content.title; + body = note_content.body; + } } } +} - Ok(()) +/// Validate a note's edited title and body together and, only if every added +/// reference resolves, write both. A rejection writes nothing. +fn save_note_edit( + app: &mut App, + id: u64, + baseline: (&str, Option<&str>), + title: &str, + body: Option<&str>, +) -> Result>> { + let base = links::join_note(baseline.0, baseline.1); + let draft = links::join_note(title, body); + let rewrite = match links::rewrite_on_save(&base, &draft, app.links.resolver()) { + Ok(rewrite) => rewrite, + Err(rejections) => return Ok(Err(rejections)), + }; + let (title, body) = links::split_note(&rewrite.text); + app.taskbook.edit_description_silent(id, &title)?; + app.taskbook.edit_note_body_silent(id, body)?; + app.refresh_items()?; + Ok(Ok(rewrite)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::tests::{key, test_app}; + use taskbook_common::links::{RejectReason, Rejection}; + + fn press(app: &mut App, code: KeyCode) { + handle_key_event(app, KeyEvent::new(code, KeyModifiers::NONE)).unwrap(); + } + + fn submit(app: &mut App, input: &str) { + app.activate_command_line(input); + press(app, KeyCode::Enter); + } + + fn create(app: &mut App, desc: &str) -> u64 { + let id = app + .taskbook + .create_task_direct_with_tags(vec!["my board".into()], desc.into(), 1, vec![], None) + .unwrap(); + app.refresh_items().unwrap(); + id + } + + fn description(app: &App, id: u64) -> String { + app.active_items[&id.to_string()].description().to_string() + } + + fn status(app: &App) -> String { + app.status_message + .as_ref() + .map(|m| m.text.clone()) + .unwrap_or_default() + } + + /// Covers AE1. + #[test] + fn rejected_create_keeps_the_draft_open() { + let (mut app, _) = test_app(); + submit(&mut app, "/task see [[99]]"); + + assert!(app.command_line.focused); + assert_eq!(app.command_line.input, "/task see [[99]]"); + assert!( + status(&app).contains("[[99]] not found"), + "{}", + status(&app) + ); + assert!(app.active_items.is_empty()); + } + + #[test] + fn edit_rewrites_a_numeric_reference_and_names_it() { + let (mut app, _) = test_app(); + let target = create(&mut app, "Milk run"); + let edited = create(&mut app, "x"); + submit(&mut app, &format!("/edit @{edited} see [[{target}]]")); + + let tid = app.active_items[&target.to_string()].stable_id(); + assert_eq!(description(&app, edited), format!("see [[{tid}]]")); + assert!(!app.command_line.focused); + assert!(status(&app).contains("Milk run"), "{}", status(&app)); + } + + /// Covers AE3. + #[test] + fn editing_cli_text_keeps_numeric_brackets_plain() { + let (mut app, _) = test_app(); + create(&mut app, "other"); + let id = create(&mut app, "see [[1]]"); + app.selected_index = app.display_order.iter().position(|&i| i == id).unwrap(); + + handle_key_event(&mut app, key('e')).unwrap(); + for c in " fixed".chars() { + handle_key_event(&mut app, key(c)).unwrap(); + } + press(&mut app, KeyCode::Enter); + + assert_eq!(description(&app, id), "see [[1]] fixed"); + } + + #[test] + fn retry_after_rejection_uses_the_original_baseline() { + let (mut app, _) = test_app(); + let id = create(&mut app, "a"); + submit(&mut app, &format!("/edit @{id} a [[99]]")); + assert!(app.command_line.focused); + + // Edit the kept draft and submit again: [[99]] is still an addition. + app.command_line.input.push_str(" b"); + press(&mut app, KeyCode::Enter); + assert!(app.command_line.focused); + assert_eq!(description(&app, id), "a"); + } + + #[test] + fn delete_still_asks_for_confirmation() { + let (mut app, _) = test_app(); + let id = create(&mut app, "doomed"); + submit(&mut app, &format!("/delete {id}")); + assert!(app.command_line.pending_confirm.is_some()); + + press(&mut app, KeyCode::Enter); + assert!(app.active_items.is_empty()); + } + + #[test] + fn rejection_status_stays_until_the_next_key() { + let (mut app, _) = test_app(); + submit(&mut app, "/task [[98]] [[97]] [[96]]"); + if let Some(msg) = app.status_message.as_mut() { + msg.expires_at = std::time::Instant::now() - std::time::Duration::from_secs(1); + } + app.tick(); + assert!( + app.status_message.is_some(), + "sticky status expired on tick" + ); + + press(&mut app, KeyCode::Left); + assert!(app.status_message.is_none()); + } + + #[test] + fn rejection_messages_collapse_to_fit() { + let rejections: Vec = ["[[98]]", "[[97]]", "[[96]]"] + .iter() + .map(|r| Rejection { + reference: r.to_string(), + reason: RejectReason::NotFound, + }) + .collect(); + let wide = format_rejections(&rejections, 200); + assert!(wide.contains("[[98]] not found") && wide.contains("[[96]] not found")); + + let narrow = format_rejections(&rejections, 30); + assert!(narrow.contains("[[98]] not found"), "{narrow}"); + assert!(narrow.ends_with("+2 more"), "{narrow}"); + } + + #[test] + fn rejected_note_edit_writes_nothing() { + let (mut app, _) = test_app(); + let id = app + .taskbook + .create_note_with_body_direct( + vec!["my board".into()], + "Title".into(), + Some("body".into()), + ) + .unwrap(); + app.refresh_items().unwrap(); + + let outcome = save_note_edit( + &mut app, + id, + ("Title", Some("body")), + "Title", + Some("body [[99]]"), + ) + .unwrap(); + assert!(outcome.is_err()); + let note = app.active_items[&id.to_string()].clone(); + assert_eq!(note.description(), "Title"); + assert_eq!(note.note_body(), Some("body")); + } + + /// Covers AE2. + #[test] + fn note_edit_with_a_link_to_an_archived_item_saves() { + let (mut app, _) = test_app(); + let target = create(&mut app, "gone"); + let tid = app.active_items[&target.to_string()].stable_id(); + let body = format!("see [[{tid}]]"); + let id = app + .taskbook + .create_note_with_body_direct(vec!["my board".into()], "N".into(), Some(body.clone())) + .unwrap(); + app.taskbook.delete_items_silent(&[target]).unwrap(); + app.refresh_items().unwrap(); + + let edited = format!("{body}\nmore"); + let outcome = save_note_edit(&mut app, id, ("N", Some(&body)), "N", Some(&edited)).unwrap(); + assert!(outcome.is_ok()); + assert_eq!( + app.active_items[&id.to_string()].note_body(), + Some(edited.as_str()) + ); + } } diff --git a/crates/taskbook-client/src/tui/app.rs b/crates/taskbook-client/src/tui/app.rs index 696f305..a9e6b16 100644 --- a/crates/taskbook-client/src/tui/app.rs +++ b/crates/taskbook-client/src/tui/app.rs @@ -90,6 +90,8 @@ pub struct App { pub needs_full_redraw: bool, /// Last known content area height (updated each render frame) pub content_height: u16, + /// Last known status line width (updated each render frame) + pub status_width: u16, /// Command history (most recent last) pub command_history: Vec, /// Current position when browsing history (None = not browsing) @@ -130,6 +132,9 @@ pub struct CommandLineState { pub selected_suggestion: Option, /// Pending confirmation action pub pending_confirm: Option, + /// Stored text of the item being edited when this edit session began, + /// so retries after a rejected save diff against the same baseline + pub link_baseline: Option<(u64, String)>, } /// An autocomplete suggestion @@ -175,6 +180,8 @@ pub struct StatusMessage { pub text: String, pub kind: StatusKind, pub expires_at: Instant, + /// Stays until the next key press instead of expiring + pub sticky: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -226,6 +233,7 @@ impl App { display_order: Vec::new(), needs_full_redraw: false, content_height: 20, + status_width: 80, command_history: Vec::new(), history_index: None, history_saved_input: String::new(), @@ -511,14 +519,23 @@ impl App { text, kind, expires_at: Instant::now() + Duration::from_secs(3), + sticky: false, }); } + /// Set a status message that stays until the next key press + pub fn set_sticky_status(&mut self, text: String, kind: StatusKind) { + self.set_status(text, kind); + if let Some(msg) = self.status_message.as_mut() { + msg.sticky = true; + } + } + /// Tick - called periodically for time-based updates pub fn tick(&mut self) { // Clear expired status messages if let Some(ref msg) = self.status_message { - if Instant::now() >= msg.expires_at { + if !msg.sticky && Instant::now() >= msg.expires_at { self.status_message = None; } } @@ -560,6 +577,7 @@ impl App { self.command_line.suggestions.clear(); self.command_line.selected_suggestion = None; self.command_line.pending_confirm = None; + self.command_line.link_baseline = None; } /// Deactivate the command line and clear state diff --git a/crates/taskbook-client/src/tui/ui.rs b/crates/taskbook-client/src/tui/ui.rs index cce842a..56e2d64 100644 --- a/crates/taskbook-client/src/tui/ui.rs +++ b/crates/taskbook-client/src/tui/ui.rs @@ -28,6 +28,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { .split(frame.area()); app.content_height = chunks[1].height; + app.status_width = chunks[3].width; render_header(frame, app, chunks[0]); render_content(frame, app, chunks[1]); render_command_line(frame, app, chunks[2]); diff --git a/crates/taskbook-common/src/links.rs b/crates/taskbook-common/src/links.rs index 6488c82..a0da40f 100644 --- a/crates/taskbook-common/src/links.rs +++ b/crates/taskbook-common/src/links.rs @@ -420,6 +420,12 @@ impl LinkIndex { segments } + /// The current title of the active item with this stable ID, with its own + /// tokens shown as short IDs. + pub fn title(&self, id: &str) -> Option { + self.titles.contains_key(id).then(|| self.flat_title(id)) + } + /// A target's title with its own tokens shown as short IDs. fn flat_title(&self, id: &str) -> String { let title = self.titles.get(id).map_or("", String::as_str); From 0aaf9b399896d8c32a05e44d80599db8fbc9252d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:54:15 +0000 Subject: [PATCH 28/45] feat(tui): render links as titles and show stable IDs in rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows show a 6-character stable-ID field after the numeric ID (unique prefix highlighted, rest dimmed, ?? when divergent) and render [[id]] tokens as the underlined target title, broken links as ✗ plus the short ID, and divergent ones as the short ID plus ??. The journal preview and existing item suggestions use the rendered text; the preview now cuts by characters instead of bytes. Plan: U7 (R6, R13, R16, R20; KTD16). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- .../taskbook-client/src/tui/autocomplete.rs | 21 +-- crates/taskbook-client/src/tui/theme.rs | 32 ++++ .../src/tui/widgets/item_row.rs | 172 +++++++++++++++++- .../src/tui/widgets/journal_view.rs | 19 +- 4 files changed, 217 insertions(+), 27 deletions(-) diff --git a/crates/taskbook-client/src/tui/autocomplete.rs b/crates/taskbook-client/src/tui/autocomplete.rs index a4bc50d..d4c0621 100644 --- a/crates/taskbook-client/src/tui/autocomplete.rs +++ b/crates/taskbook-client/src/tui/autocomplete.rs @@ -1,4 +1,5 @@ use super::app::{App, Suggestion, SuggestionKind}; +use super::widgets::item_row::plain_text; use taskbook_common::board; /// Static list of all slash commands with descriptions @@ -170,23 +171,19 @@ fn suggest_items(app: &mut App, partial: &str) { .rposition(|c| *c == ' ') .unwrap_or(0); - // Collect and sort by ID for stable ordering - let mut matches: Vec<(u64, &str, bool, bool, bool)> = app + // Collect and sort by ID for stable ordering; match the text as it + // renders, so link tokens are searched by their titles + let mut matches: Vec<(u64, String, bool, bool, bool)> = app .items .values() - .filter(|item| item.description().to_lowercase().contains(&partial_lower)) - .map(|item| { + .map(|item| (item, plain_text(app, item.description()))) + .filter(|(_, desc)| desc.to_lowercase().contains(&partial_lower)) + .map(|(item, desc)| { let (is_complete, in_progress) = match item { taskbook_common::StorageItem::Task(t) => (t.is_complete, t.in_progress), taskbook_common::StorageItem::Note(_) => (false, false), }; - ( - item.id(), - item.description(), - item.is_task(), - is_complete, - in_progress, - ) + (item.id(), desc, item.is_task(), is_complete, in_progress) }) .collect(); matches.sort_by_key(|(id, _, _, _, _)| *id); @@ -203,7 +200,7 @@ fn suggest_items(app: &mut App, partial: &str) { }; // Truncate description for display - let display: String = if desc.len() > 35 { + let display: String = if desc.chars().count() > 35 { format!("{}…", desc.chars().take(34).collect::()) } else { desc.to_string() diff --git a/crates/taskbook-client/src/tui/theme.rs b/crates/taskbook-client/src/tui/theme.rs index fdeb45f..83f09e1 100644 --- a/crates/taskbook-client/src/tui/theme.rs +++ b/crates/taskbook-client/src/tui/theme.rs @@ -21,6 +21,16 @@ pub struct TuiTheme { pub board_name: Style, pub tag: Style, pub board_tag: Style, + /// A resolved `[[link]]`: the target title + pub link: Style, + /// A link whose target is archived or missing + pub link_broken: Style, + /// A stable ID held by more than one item + pub link_divergent: Style, + /// The unique prefix of a stable ID + pub stable_id: Style, + /// The rest of a displayed stable ID + pub stable_id_rest: Style, } impl From<&ThemeColors> for TuiTheme { @@ -76,6 +86,28 @@ impl From<&ThemeColors> for TuiTheme { .add_modifier(Modifier::BOLD), // Board tags - soft purple board_tag: Style::default().fg(Color::Rgb(170, 130, 200)), + // Links carry a glyph or modifier too, so they survive NO_COLOR + link: Style::default() + .fg(Color::Rgb(colors.info.r, colors.info.g, colors.info.b)) + .add_modifier(Modifier::UNDERLINED), + link_broken: Style::default().fg(Color::Rgb( + colors.error.r, + colors.error.g, + colors.error.b, + )), + link_divergent: Style::default().fg(Color::Rgb( + colors.warning.r, + colors.warning.g, + colors.warning.b, + )), + stable_id: Style::default() + .fg(Color::Rgb(colors.info.r, colors.info.g, colors.info.b)) + .add_modifier(Modifier::BOLD), + stable_id_rest: Style::default().fg(Color::Rgb( + colors.muted.r, + colors.muted.g, + colors.muted.b, + )), } } } diff --git a/crates/taskbook-client/src/tui/widgets/item_row.rs b/crates/taskbook-client/src/tui/widgets/item_row.rs index b39f8ca..9ebd8fa 100644 --- a/crates/taskbook-client/src/tui/widgets/item_row.rs +++ b/crates/taskbook-client/src/tui/widgets/item_row.rs @@ -6,8 +6,12 @@ use ratatui::{ use crate::tui::app::App; use taskbook_common::board; use taskbook_common::due; +use taskbook_common::links::Segment; use taskbook_common::StorageItem; +/// Width of the stable-ID field shown after the numeric ID. +const STABLE_ID_WIDTH: usize = 6; + /// Options for rendering an item row pub struct ItemRowOptions { pub show_boards: bool, @@ -39,12 +43,14 @@ pub fn render_item_line( ) -> Line<'static> { let mut spans: Vec = Vec::new(); - // Selection indicator + Item ID + // Selection indicator + Item ID + stable ID if is_selected { spans.push(Span::styled(format!(" >{} ", item.id()), app.theme.info)); } else { spans.push(Span::styled(format!(" {} ", item.id()), app.theme.item_id)); } + spans.extend(stable_id_spans(app, item)); + spans.push(Span::raw(" ")); // Icon let (icon, icon_style) = if let Some(task) = item.as_task() { @@ -75,7 +81,7 @@ pub fn render_item_line( } else { Style::default().fg(Color::Rgb(200, 200, 220)) }; - spans.push(Span::styled(desc, desc_style)); + spans.extend(text_spans(app, &desc, desc_style)); // Note body indicator if item.note_has_body() { @@ -159,6 +165,66 @@ pub fn render_item_line( line } +/// Spans for `text`, with `[[id]]` tokens rendered as link titles, broken +/// markers (`✗` + short ID) or divergent markers (short ID + `??`). +pub fn text_spans(app: &App, text: &str, base: Style) -> Vec> { + app.links + .render(text) + .into_iter() + .map(|segment| match segment { + Segment::Text(t) => Span::styled(t, base), + Segment::Link { title, .. } => Span::styled(title, app.theme.link), + Segment::Broken { id } => Span::styled( + format!("✗{}", app.links.short_id(&id)), + app.theme.link_broken, + ), + Segment::Divergent { id } => Span::styled( + format!("{}??", app.links.short_id(&id)), + app.theme.link_divergent, + ), + }) + .collect() +} + +/// `text` as it renders, without styling (for matching and truncation). +pub fn plain_text(app: &App, text: &str) -> String { + text_spans(app, text, Style::default()) + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +/// The stable-ID field: the unique prefix highlighted, the rest of the first +/// six letters dimmed, and `??` when the ID is divergent. +pub fn stable_id_spans(app: &App, item: &StorageItem) -> Vec> { + let id = item.stable_id(); + let unique = app.links.prefix_len(&id).clamp(1, id.len()); + let shown = unique.max(STABLE_ID_WIDTH).min(id.len()); + let mut spans = vec![ + Span::styled(id[..unique].to_string(), app.theme.stable_id), + Span::styled(id[unique..shown].to_string(), app.theme.stable_id_rest), + ]; + if app.links.resolver().is_divergent(&id) { + spans.push(Span::styled("??", app.theme.link_divergent)); + } + spans +} + +/// First line of a note body as rendered text, cut to 50 characters. +pub fn body_preview(app: &App, body: &str) -> String { + let Some(first_line) = body.lines().next() else { + return String::new(); + }; + let rendered = plain_text(app, first_line); + if rendered.chars().count() > 50 { + format!(" {}…", rendered.chars().take(50).collect::()) + } else if body.lines().count() > 1 { + format!(" {}…", rendered) + } else { + format!(" {}", rendered) + } +} + fn calculate_age(timestamp: i64) -> String { let now = chrono::Utc::now().timestamp_millis(); let diff = now - timestamp; @@ -176,3 +242,105 @@ fn calculate_age(timestamp: i64) -> String { format!("{}y", days / 365) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::tests::test_app; + use taskbook_common::links::LinkIndex; + use taskbook_common::{Note, Task}; + + fn line_text(line: &Line) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + fn task(id: u64, ts: i64, desc: &str) -> StorageItem { + let mut t = Task::new(id, desc.to_string(), vec!["my board".into()], 1); + t.timestamp = ts; + StorageItem::Task(t) + } + + fn app_with(items: Vec) -> crate::tui::app::App { + let (mut app, _) = test_app(); + app.active_items = items.into_iter().map(|i| (i.id().to_string(), i)).collect(); + app.links = LinkIndex::build(app.active_items.values()); + app.items = app.active_items.clone(); + app + } + + #[test] + fn link_renders_as_underlined_title() { + let target_id = taskbook_common::stable_id::from_timestamp(2_000); + let app = app_with(vec![ + task(1, 1_000, &format!("see [[{target_id}]]")), + task(2, 2_000, "Milk run"), + ]); + let line = render_item_line( + &app, + &app.items["1"], + false, + &ItemRowOptions::for_board_view(), + ); + let text = line_text(&line); + + assert!(text.contains("see Milk run"), "{text}"); + assert!(!text.contains(&target_id), "{text}"); + let link = line.spans.iter().find(|s| s.content == "Milk run").unwrap(); + assert!(link.style.add_modifier.contains(Modifier::UNDERLINED)); + } + + #[test] + fn broken_and_divergent_links_carry_glyphs() { + let missing = taskbook_common::stable_id::from_timestamp(9_999); + let shared = taskbook_common::stable_id::from_timestamp(3_000); + let app = app_with(vec![ + task(1, 1_000, &format!("a [[{missing}]] b [[{shared}]]")), + task(2, 3_000, "twin one"), + StorageItem::Note({ + let mut n = Note::new(3, "twin two".into(), vec!["my board".into()]); + n.timestamp = 3_000; + n + }), + ]); + let text = line_text(&render_item_line( + &app, + &app.items["1"], + false, + &ItemRowOptions::for_board_view(), + )); + assert!( + text.contains(&format!("✗{}", app.links.short_id(&missing))), + "{text}" + ); + assert!( + text.contains(&format!("{}??", app.links.short_id(&shared))), + "{text}" + ); + } + + #[test] + fn row_shows_a_six_character_stable_id_after_the_numeric_id() { + let app = app_with(vec![task(1, 1_000, "x")]); + let line = render_item_line( + &app, + &app.items["1"], + false, + &ItemRowOptions::for_board_view(), + ); + let sid = taskbook_common::stable_id::from_timestamp(1_000); + assert!( + line_text(&line).starts_with(&format!(" 1 {} ", &sid[..6])), + "{}", + line_text(&line) + ); + } + + #[test] + fn plain_rendering_and_preview_handle_multibyte_text() { + let app = app_with(vec![]); + let body = "ünïcödé ".repeat(10); + let preview = body_preview(&app, &body); + assert!(preview.chars().count() <= 53, "{preview}"); + assert!(preview.ends_with('…')); + } +} diff --git a/crates/taskbook-client/src/tui/widgets/journal_view.rs b/crates/taskbook-client/src/tui/widgets/journal_view.rs index 9ed874c..c8e6fbb 100644 --- a/crates/taskbook-client/src/tui/widgets/journal_view.rs +++ b/crates/taskbook-client/src/tui/widgets/journal_view.rs @@ -11,6 +11,7 @@ use ratatui::{ use crate::tui::app::App; use taskbook_common::{board, StorageItem}; +use super::item_row::{body_preview, text_spans}; use super::render_scrollable_list; pub fn render_journal_view(frame: &mut Frame, app: &App, area: Rect) { @@ -145,21 +146,13 @@ pub fn render_journal_view(frame: &mut Frame, app: &App, area: Rect) { title_spans.push(Span::styled("● ", app.theme.info)); } - title_spans.push(Span::styled(item.description().to_string(), desc_style)); + title_spans.extend(text_spans(app, item.description(), desc_style)); // Inline note body preview (first line, truncated) - if let Some(note) = item.as_note() { - if let Some(body) = note.body() { - if let Some(first_line) = body.lines().next() { - let preview = if first_line.len() > 50 { - format!(" {}…", &first_line[..50]) - } else if body.lines().count() > 1 { - format!(" {}…", first_line) - } else { - format!(" {}", first_line) - }; - title_spans.push(Span::styled(preview, app.theme.muted)); - } + if let Some(body) = item.note_body() { + let preview = body_preview(app, body); + if !preview.is_empty() { + title_spans.push(Span::styled(preview, app.theme.muted)); } } From a0113559793f2ae2209cb326ef24f0d5f243f792 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:56:22 +0000 Subject: [PATCH 29/45] feat(tui): add [[ link autocomplete and the l link key An unclosed [[ in the description of /task, /note or /edit lists active, non-divergent items (newest first, current board ahead, excluding the item being edited), matched by rendered title, numeric ID or stable-ID prefix; the first row is pre-selected and accepting inserts [[]]. Inside an unclosed [[, Enter accepts the selection or, with nothing to accept, shows a hint instead of saving a stray "[[query"; Esc closes only the dropdown until the cursor leaves that [[. l opens /edit on the selected item with the dropdown showing. Plan: U8 (R11, R12; F1, F2; KTD12-KTD14). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/tui/actions.rs | 41 ++- crates/taskbook-client/src/tui/app.rs | 4 + .../taskbook-client/src/tui/autocomplete.rs | 302 ++++++++++++++++++ .../src/tui/widgets/command_line.rs | 1 + .../src/tui/widgets/help_popup.rs | 10 + 5 files changed, 357 insertions(+), 1 deletion(-) diff --git a/crates/taskbook-client/src/tui/actions.rs b/crates/taskbook-client/src/tui/actions.rs index cd50448..fbcc9c0 100644 --- a/crates/taskbook-client/src/tui/actions.rs +++ b/crates/taskbook-client/src/tui/actions.rs @@ -5,7 +5,7 @@ use crate::error::Result; use taskbook_common::board; use taskbook_common::links::{self, Rejection, Rewrite}; -use super::app::{App, PendingAction, PopupState, StatusKind, ViewMode}; +use super::app::{App, PendingAction, PopupState, StatusKind, SuggestionKind, ViewMode}; use super::autocomplete; use super::command_parser::{self, ParsedCommand}; use super::input_handler::{handle_text_input, InputResult}; @@ -140,6 +140,36 @@ fn handle_command_line_key(app: &mut App, key: KeyEvent) -> Result<()> { _ => {} } + // Inside an unclosed `[[`, Enter picks a link target instead of + // submitting, and Esc first closes only the dropdown. + if let Some((open, _)) = autocomplete::open_link_query(app) { + let has_links = app + .command_line + .suggestions + .iter() + .any(|s| s.kind == SuggestionKind::Link); + match key.code { + KeyCode::Enter => { + if has_links { + accept_suggestion(app); + } else { + app.set_status( + "Pick an item, close the brackets with ]], or press Esc".to_string(), + StatusKind::Info, + ); + } + return Ok(()); + } + KeyCode::Esc if has_links => { + app.command_line.suggestions.clear(); + app.command_line.selected_suggestion = None; + app.command_line.link_dismissed_at = Some(open); + return Ok(()); + } + _ => {} + } + } + // Use the existing text input handler for editing let input = app.command_line.input.clone(); let cursor = app.command_line.cursor; @@ -522,6 +552,15 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { app.command_line.link_baseline = Some((id, desc)); } } + KeyCode::Char('l') if app.view != ViewMode::Archive => { + if let Some(item) = app.selected_item() { + let id = item.id(); + let desc = item.description().to_string(); + app.activate_command_line(&format!("/edit @{} {} [[", id, desc)); + app.command_line.link_baseline = Some((id, desc)); + autocomplete::update_suggestions(app); + } + } KeyCode::Char('m') if app.view != ViewMode::Archive => { if let Some(id) = app.selected_id() { app.activate_command_line(&format!("/move @{} @", id)); diff --git a/crates/taskbook-client/src/tui/app.rs b/crates/taskbook-client/src/tui/app.rs index a9e6b16..8a53f6e 100644 --- a/crates/taskbook-client/src/tui/app.rs +++ b/crates/taskbook-client/src/tui/app.rs @@ -135,6 +135,8 @@ pub struct CommandLineState { /// Stored text of the item being edited when this edit session began, /// so retries after a rejected save diff against the same baseline pub link_baseline: Option<(u64, String)>, + /// Char index of the `[[` whose link dropdown the user closed with Esc + pub link_dismissed_at: Option, } /// An autocomplete suggestion @@ -155,6 +157,8 @@ pub enum SuggestionKind { Command, Board, Item, + /// A `[[` link target + Link, } /// An action waiting for confirmation diff --git a/crates/taskbook-client/src/tui/autocomplete.rs b/crates/taskbook-client/src/tui/autocomplete.rs index d4c0621..a66cb23 100644 --- a/crates/taskbook-client/src/tui/autocomplete.rs +++ b/crates/taskbook-client/src/tui/autocomplete.rs @@ -44,6 +44,20 @@ pub fn update_suggestions(app: &mut App) { return; } + // An unclosed `[[` in a description suggests link targets, unless the + // user dismissed the dropdown for this `[[` with Esc. + match open_link_query(app) { + Some((open, query)) => { + if app.command_line.link_dismissed_at != Some(open) { + app.command_line.link_dismissed_at = None; + suggest_links(app, open, &query); + } + return; + } + None => app.command_line.link_dismissed_at = None, + } + let input = &app.command_line.input; + // Find the token being typed by scanning backward from cursor let chars: Vec = input.chars().collect(); let cursor = app.command_line.cursor.min(chars.len()); @@ -81,6 +95,105 @@ pub fn update_suggestions(app: &mut App) { } } +/// An unclosed `[[` before the cursor in the description of `/task`, `/note` +/// or `/edit`: its char index in the input and the query typed after it. +pub fn open_link_query(app: &App) -> Option<(usize, String)> { + let input = &app.command_line.input; + if !input.starts_with('/') { + return None; + } + let cursor = app.command_line.cursor.min(input.chars().count()); + let before: String = input.chars().take(cursor).collect(); + let (command, rest) = before[1..].split_once(' ')?; + let in_description = match command { + "task" | "note" => true, + // /edit @ + "edit" => rest.trim_start().split_once(' ').is_some(), + _ => false, + }; + if !in_description { + return None; + } + let open_byte = before.rfind("[[")?; + let query = &before[open_byte + 2..]; + if query.contains("]]") { + return None; + } + Some((before[..open_byte].chars().count(), query.to_string())) +} + +/// Suggest active, non-divergent items to link to, newest first with the +/// current board's items ahead; accepting one inserts `[[]]`. +fn suggest_links(app: &mut App, open: usize, query: &str) { + let query = query.to_lowercase(); + let input_chars: Vec = app.command_line.input.chars().collect(); + let cursor = app.command_line.cursor.min(input_chars.len()); + + // The item being edited cannot link to itself through the picker. + let editing: Option = app + .command_line + .input + .strip_prefix("/edit @") + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|id| id.parse().ok()); + let current_board = app + .filter + .board_filter + .clone() + .or_else(|| app.get_board_for_selected()); + let on_board = |boards: &[String]| { + current_board + .as_ref() + .is_some_and(|cb| boards.iter().any(|b| board::board_eq(b, cb))) + }; + + let mut candidates: Vec<(bool, i64, String, String, String)> = app + .active_items + .values() + .filter(|item| Some(item.id()) != editing) + .filter_map(|item| { + let id = item.stable_id(); + if app.links.resolver().is_divergent(&id) { + return None; + } + let title = plain_text(app, item.description()); + let matches = query.is_empty() + || title.to_lowercase().contains(&query) + || item.id().to_string().starts_with(&query) + || id.starts_with(&query); + matches.then(|| { + let board = item + .boards() + .first() + .map(|b| board::display_name(b)) + .unwrap_or_default(); + (on_board(item.boards()), item.timestamp(), id, title, board) + }) + }) + .collect(); + candidates.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1))); + + let before_open: String = input_chars[..open].iter().collect(); + let after_cursor: String = input_chars[cursor..].iter().collect(); + for (_, _, id, title, board_name) in candidates.into_iter().take(MAX_SUGGESTIONS) { + let title = if title.chars().count() > 28 { + format!("{}…", title.chars().take(27).collect::()) + } else { + title + }; + let display = format!("{} {}", title, app.links.short_id(&id)); + app.command_line.suggestions.push(Suggestion { + display, + completion: format!("{}[[{}]]{}", before_open, id, after_cursor), + description: Some(board_name), + kind: SuggestionKind::Link, + }); + } + if !app.command_line.suggestions.is_empty() { + app.command_line.selected_suggestion = Some(0); + } +} + /// Determine whether the current argument position should get item suggestions fn should_suggest_items(command: &str, text_to_cursor: &str, _last_space: usize) -> bool { let after_command = text_to_cursor @@ -223,3 +336,192 @@ fn suggest_items(app: &mut App, partial: &str) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::actions::handle_key_event; + use crate::tui::app::tests::{key, test_app}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use taskbook_common::links::LinkIndex; + use taskbook_common::{StorageItem, Task}; + + fn press(app: &mut App, code: KeyCode) { + handle_key_event(app, KeyEvent::new(code, KeyModifiers::NONE)).unwrap(); + } + + fn type_text(app: &mut App, text: &str) { + for c in text.chars() { + handle_key_event(app, key(c)).unwrap(); + } + } + + fn create(app: &mut App, desc: &str) -> u64 { + let id = app + .taskbook + .create_task_direct_with_tags(vec!["my board".into()], desc.into(), 1, vec![], None) + .unwrap(); + app.refresh_items().unwrap(); + id + } + + fn sid(app: &App, id: u64) -> String { + app.active_items[&id.to_string()].stable_id() + } + + fn displays(app: &App) -> Vec { + app.command_line + .suggestions + .iter() + .map(|s| s.display.clone()) + .collect() + } + + /// Covers F1. + #[test] + fn bracket_query_suggests_by_title_and_inserts_the_full_token() { + let (mut app, _) = test_app(); + let milk = create(&mut app, "Milk run"); + create(&mut app, "Other"); + press(&mut app, KeyCode::Char('/')); + type_text(&mut app, "task buy [[mil"); + + assert_eq!( + app.command_line.suggestions.len(), + 1, + "{:?}", + displays(&app) + ); + assert!(app.command_line.suggestions[0].display.contains("Milk run")); + assert_eq!(app.command_line.selected_suggestion, Some(0)); + + press(&mut app, KeyCode::Tab); + assert_eq!( + app.command_line.input, + format!("/task buy [[{}]] ", sid(&app, milk)).trim_end() + ); + } + + #[test] + fn suggestions_match_numeric_id_and_stable_prefix() { + let (mut app, _) = test_app(); + let a = create(&mut app, "Alpha"); + let b = create(&mut app, "Beta"); + press(&mut app, KeyCode::Char('/')); + type_text(&mut app, &format!("task x [[{b}")); + assert!(displays(&app).iter().any(|d| d.contains("Beta"))); + + let prefix: String = sid(&app, a).chars().take(4).collect(); + app.activate_command_line("/"); + type_text(&mut app, &format!("task x [[{prefix}")); + assert!( + displays(&app).iter().any(|d| d.contains("Alpha")), + "{:?}", + displays(&app) + ); + } + + #[test] + fn suggestions_exclude_the_edited_item_and_divergent_items() { + let (mut app, _) = test_app(); + let edited = create(&mut app, "Self"); + create(&mut app, "Kept"); + // Two items sharing a timestamp are divergent. + let mut twin = Task::new(90, "Twin".into(), vec!["my board".into()], 1); + twin.timestamp = 1; + let mut twin2 = twin.clone(); + twin2.id = 91; + app.active_items + .insert("90".into(), StorageItem::Task(twin)); + app.active_items + .insert("91".into(), StorageItem::Task(twin2)); + app.links = LinkIndex::build(app.active_items.values()); + + app.activate_command_line(&format!("/edit @{edited} x [[")); + update_suggestions(&mut app); + let shown = displays(&app); + assert!(shown.iter().any(|d| d.contains("Kept")), "{shown:?}"); + assert!( + !shown + .iter() + .any(|d| d.contains("Self") || d.contains("Twin")), + "{shown:?}" + ); + } + + #[test] + fn empty_query_lists_recent_items_with_the_first_selected() { + let (mut app, _) = test_app(); + create(&mut app, "Older"); + create(&mut app, "Newer"); + app.activate_command_line("/task x [["); + update_suggestions(&mut app); + assert!(displays(&app)[0].contains("Newer"), "{:?}", displays(&app)); + assert_eq!(app.command_line.selected_suggestion, Some(0)); + } + + #[test] + fn enter_with_an_open_dropdown_accepts_instead_of_submitting() { + let (mut app, _) = test_app(); + let milk = create(&mut app, "Milk run"); + app.activate_command_line("/task buy [[mil"); + update_suggestions(&mut app); + press(&mut app, KeyCode::Enter); + + assert!(app.command_line.focused); + assert!(app.command_line.input.contains(&sid(&app, milk))); + assert_eq!(app.active_items.len(), 1, "no task was created"); + } + + /// Covers F2. + #[test] + fn link_key_opens_an_edit_with_suggestions() { + let (mut app, _) = test_app(); + create(&mut app, "Target"); + let id = create(&mut app, "Source"); + app.selected_index = app.display_order.iter().position(|&i| i == id).unwrap(); + handle_key_event(&mut app, key('l')).unwrap(); + + assert_eq!(app.command_line.input, format!("/edit @{id} Source [[")); + assert!(displays(&app).iter().any(|d| d.contains("Target"))); + } + + #[test] + fn enter_with_no_match_does_not_save_a_stray_bracket() { + let (mut app, _) = test_app(); + let id = create(&mut app, "Source"); + app.selected_index = 0; + handle_key_event(&mut app, key('l')).unwrap(); + type_text(&mut app, "zzzz"); + assert!(app.command_line.suggestions.is_empty()); + press(&mut app, KeyCode::Enter); + + assert!(app.command_line.focused); + assert_eq!(app.active_items[&id.to_string()].description(), "Source"); + let hint = app + .status_message + .as_ref() + .map(|m| m.text.clone()) + .unwrap_or_default(); + assert!(hint.contains("Esc"), "{hint}"); + } + + #[test] + fn esc_closes_the_dropdown_then_the_command_line() { + let (mut app, _) = test_app(); + create(&mut app, "Target"); + app.activate_command_line("/task x [["); + update_suggestions(&mut app); + assert!(!app.command_line.suggestions.is_empty()); + + press(&mut app, KeyCode::Esc); + assert!(app.command_line.focused); + assert!(app.command_line.suggestions.is_empty()); + // Typing inside the same [[ keeps it closed. + type_text(&mut app, "t"); + assert!(app.command_line.suggestions.is_empty()); + + press(&mut app, KeyCode::Esc); + assert!(!app.command_line.focused); + } +} diff --git a/crates/taskbook-client/src/tui/widgets/command_line.rs b/crates/taskbook-client/src/tui/widgets/command_line.rs index 3cd4483..238a1f9 100644 --- a/crates/taskbook-client/src/tui/widgets/command_line.rs +++ b/crates/taskbook-client/src/tui/widgets/command_line.rs @@ -158,6 +158,7 @@ pub fn render_autocomplete(frame: &mut Frame, app: &App, content_area: Rect) { SuggestionKind::Command => "/", SuggestionKind::Board => "@", SuggestionKind::Item => "·", + SuggestionKind::Link => "[", }; let mut spans = vec![ diff --git a/crates/taskbook-client/src/tui/widgets/help_popup.rs b/crates/taskbook-client/src/tui/widgets/help_popup.rs index da2e69d..e8ff8f9 100644 --- a/crates/taskbook-client/src/tui/widgets/help_popup.rs +++ b/crates/taskbook-client/src/tui/widgets/help_popup.rs @@ -52,6 +52,16 @@ pub fn render_help_popup(frame: &mut Frame, app: &App, scroll: u16) { Span::styled("Filter board / Edit note", desc_style), ]), Line::from(""), + Line::from(Span::styled(" Links", section_style)), + Line::from(vec![ + Span::styled(" [[ ", key_style), + Span::styled("Link an item while typing", desc_style), + ]), + Line::from(vec![ + Span::styled(" l ", key_style), + Span::styled("Link selected item to another", desc_style), + ]), + Line::from(""), Line::from(Span::styled(" Quick Actions", section_style)), Line::from(vec![ Span::styled(" c ", key_style), From dc2451b9f01477d94394d604e936ef5913caa68f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:59:07 +0000 Subject: [PATCH 30/45] feat(tui): add a links side pane with jump-to On terminals at least 100 columns wide the content area splits to show the selected item's outgoing links ("Links to") and backlinks ("Linked from"); each entry keeps its short stable ID visible, broken and divergent entries are marked and not jumpable, empty sections say (none), and a divergent selection shows a notice instead of backlinks. An archived item in Archive view lists the active items still linking to it. v toggles the pane and o focuses it (j/k, Enter jumps, Esc or o returns); both explain themselves when the terminal is too narrow. Jumping switches Archive to Board view, clears search and board filters and reveals a hidden completed target, without changing saved settings, and names what it changed. Plan: U9 (R14-R17; F3, AE6; KTD14, KTD15, KTD17, KTD18). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/tui/actions.rs | 74 +++- crates/taskbook-client/src/tui/app.rs | 363 +++++++++++++++++- crates/taskbook-client/src/tui/ui.rs | 14 +- .../src/tui/widgets/help_popup.rs | 8 + .../src/tui/widgets/link_pane.rs | 198 ++++++++++ crates/taskbook-client/src/tui/widgets/mod.rs | 1 + 6 files changed, 647 insertions(+), 11 deletions(-) create mode 100644 crates/taskbook-client/src/tui/widgets/link_pane.rs diff --git a/crates/taskbook-client/src/tui/actions.rs b/crates/taskbook-client/src/tui/actions.rs index fbcc9c0..5d3f0de 100644 --- a/crates/taskbook-client/src/tui/actions.rs +++ b/crates/taskbook-client/src/tui/actions.rs @@ -3,9 +3,11 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::editor; use crate::error::Result; use taskbook_common::board; -use taskbook_common::links::{self, Rejection, Rewrite}; +use taskbook_common::links::{self, Rejection, Rewrite, Target}; -use super::app::{App, PendingAction, PopupState, StatusKind, SuggestionKind, ViewMode}; +use super::app::{ + App, PendingAction, PopupState, StatusKind, SuggestionKind, ViewMode, PANE_MIN_WIDTH, +}; use super::autocomplete; use super::command_parser::{self, ParsedCommand}; use super::input_handler::{handle_text_input, InputResult}; @@ -43,10 +45,51 @@ pub fn handle_key_event(app: &mut App, key: KeyEvent) -> Result<()> { return handle_command_line_key(app, key); } - // 4. Normal mode shortcuts + // 4. Link pane focused → move, jump, or return to the list. Focus goes + // back to the list if the pane was hidden (toggled off or too narrow). + if app.pane.focused { + if app.pane_shown() { + return handle_pane_key(app, key); + } + app.pane.focused = false; + } + + // 5. Normal mode shortcuts handle_shortcut_key(app, key) } +/// Handle keys while the link pane has focus +fn handle_pane_key(app: &mut App, key: KeyEvent) -> Result<()> { + let content = app.pane_content(); + let cursor = app.pane_cursor(&content); + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + app.pane.cursor = (cursor + 1).min(content.len().saturating_sub(1)); + } + KeyCode::Char('k') | KeyCode::Up => { + app.pane.cursor = cursor.saturating_sub(1); + } + KeyCode::Enter => match content.get(cursor).map(|e| (e.jump_target(), e.target)) { + Some((Some(numeric), _)) => app.jump_to(numeric)?, + Some((None, Target::Divergent)) => { + app.set_status( + "Two items share that ID; cannot jump".to_string(), + StatusKind::Info, + ); + } + Some((None, _)) => { + app.set_status("That link is broken".to_string(), StatusKind::Info); + } + None => {} + }, + KeyCode::Esc | KeyCode::Char('o') => { + app.pane.focused = false; + } + _ => {} + } + Ok(()) +} + /// Handle keys when a confirmation is pending fn handle_confirm_key(app: &mut App, key: KeyEvent) -> Result<()> { match key.code { @@ -552,6 +595,31 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { app.command_line.link_baseline = Some((id, desc)); } } + // Link pane: toggle and focus + KeyCode::Char('v') => { + app.pane.visible = !app.pane.visible; + if app.status_width < PANE_MIN_WIDTH { + let state = if app.pane.visible { "on" } else { "off" }; + app.set_status( + format!( + "Link pane needs {} columns (toggle {})", + PANE_MIN_WIDTH, state + ), + StatusKind::Info, + ); + } + } + KeyCode::Char('o') => { + if !app.pane_shown() { + app.set_status("Link pane hidden".to_string(), StatusKind::Info); + } else if app.pane_content().is_empty() { + app.set_status("No links for this item".to_string(), StatusKind::Info); + } else { + app.pane.focused = true; + app.pane.cursor = 0; + } + } + KeyCode::Char('l') if app.view != ViewMode::Archive => { if let Some(item) = app.selected_item() { let id = item.id(); diff --git a/crates/taskbook-client/src/tui/app.rs b/crates/taskbook-client/src/tui/app.rs index 8a53f6e..0a4c864 100644 --- a/crates/taskbook-client/src/tui/app.rs +++ b/crates/taskbook-client/src/tui/app.rs @@ -9,7 +9,7 @@ use crate::error::Result; use crate::render::Stats; use crate::taskbook::Taskbook; use taskbook_common::board; -use taskbook_common::links::LinkIndex; +use taskbook_common::links::{self, LinkIndex, Target}; use taskbook_common::StorageItem; /// Sort items by the given method @@ -49,6 +49,10 @@ pub fn sort_items_by(items: &mut [&StorageItem], method: SortMethod) { } use super::theme::TuiTheme; +use super::widgets::item_row::plain_text; + +/// The link pane is only shown on terminals at least this wide. +pub const PANE_MIN_WIDTH: u16 = 100; /// Main application state pub struct App { @@ -100,6 +104,8 @@ pub struct App { pub history_saved_input: String, /// Where config changes are written (`None` = the user's config file) config_path: Option, + /// Link side pane state + pub pane: PaneState, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -177,6 +183,8 @@ pub struct FilterState { pub board_filter: Option, /// Hide completed tasks pub hide_completed: bool, + /// A completed task shown despite `hide_completed`, after a jump to it + pub reveal: Option, } #[derive(Debug, Clone)] @@ -242,6 +250,11 @@ impl App { history_index: None, history_saved_input: String::new(), config_path, + pane: PaneState { + visible: true, + focused: false, + cursor: 0, + }, cached_stats: Stats { percent: 0, complete: 0, @@ -312,7 +325,7 @@ impl App { /// Check if an item should be shown based on current filters pub fn should_show_item(&self, item: &StorageItem) -> bool { - if self.filter.hide_completed { + if self.filter.hide_completed && self.filter.reveal != Some(item.id()) { if let Some(task) = item.as_task() { if task.is_complete { return false; @@ -391,7 +404,7 @@ impl App { .items .values() .filter(|item| { - if self.filter.hide_completed { + if self.filter.hide_completed && self.filter.reveal != Some(item.id()) { if let Some(task) = item.as_task() { if task.is_complete { return false; @@ -439,6 +452,7 @@ impl App { /// Toggle hide completed tasks pub fn toggle_hide_completed(&mut self) { self.filter.hide_completed = !self.filter.hide_completed; + self.filter.reveal = None; self.config.display_complete_tasks = !self.filter.hide_completed; self.save_config(); self.update_display_order(); @@ -553,13 +567,19 @@ impl App { /// Switch view mode pub fn set_view(&mut self, view: ViewMode) -> Result<()> { if self.view != view { - self.view = view; - self.selected_index = 0; - // Persist the view choice self.config.default_view = view; self.save_config(); + self.switch_view(view)?; + } + Ok(()) + } + /// Switch view without saving it as the default view. + fn switch_view(&mut self, view: ViewMode) -> Result<()> { + if self.view != view { + self.view = view; + self.selected_index = 0; self.refresh_items()?; } Ok(()) @@ -609,6 +629,172 @@ impl App { pub fn quit(&mut self) { self.running = false; } + + /// Whether the link pane is toggled on and the terminal is wide enough. + pub fn pane_shown(&self) -> bool { + self.pane.visible && self.status_width >= PANE_MIN_WIDTH + } + + /// Outgoing links and backlinks of the selected item. + pub fn pane_content(&self) -> PaneContent { + let Some(item) = self.selected_item() else { + return PaneContent::default(); + }; + + let mut targets = links::link_targets(item.description()); + if let Some(body) = item.note_body() { + for id in links::link_targets(body) { + if !targets.contains(&id) { + targets.push(id); + } + } + } + let outgoing = targets + .into_iter() + .map(|id| { + let target = self.links.target(&id); + let title = match target { + Target::Active { .. } => self.links.title(&id).unwrap_or_default(), + Target::Divergent | Target::Missing => String::new(), + }; + PaneEntry { id, title, target } + }) + .collect(); + + // Backlinks cannot be attributed to one of two items sharing an ID. + let own_id = item.stable_id(); + let divergent = self.links.resolver().is_divergent(&own_id); + let mut backlinks: Vec = if divergent { + Vec::new() + } else { + self.links + .backlinks(&own_id) + .iter() + .filter_map(|n| self.active_items.get(&n.to_string())) + .map(|source| PaneEntry { + id: source.stable_id(), + title: plain_text(self, source.description()), + target: self.links.target(&source.stable_id()), + }) + .collect() + }; + // Backlinks follow the main list's order; others keep ID order. + let position = |entry: &PaneEntry| match entry.target { + Target::Active { numeric } => self + .display_order + .iter() + .position(|&id| id == numeric) + .unwrap_or(usize::MAX), + _ => usize::MAX, + }; + backlinks.sort_by_key(position); + + PaneContent { + outgoing, + backlinks, + divergent, + } + } + + /// The pane cursor, clamped to the current entries. + pub fn pane_cursor(&self, content: &PaneContent) -> usize { + self.pane.cursor.min(content.len().saturating_sub(1)) + } + + /// Select the active item `numeric`, switching view and clearing filters + /// as needed without touching saved settings, and say what changed. + pub fn jump_to(&mut self, numeric: u64) -> Result<()> { + let mut changed = Vec::new(); + if self.view == ViewMode::Archive { + self.switch_view(ViewMode::Board)?; + changed.push("switched to board view"); + } + if self.filter.search_term.take().is_some() { + changed.push("cleared search"); + } + if self.filter.board_filter.take().is_some() { + changed.push("cleared board filter"); + } + let completed = self + .active_items + .get(&numeric.to_string()) + .and_then(|item| item.as_task()) + .is_some_and(|task| task.is_complete); + self.filter.reveal = None; + if completed && self.filter.hide_completed { + self.filter.reveal = Some(numeric); + changed.push("showing a completed task"); + } + self.update_display_order(); + if let Some(position) = self.display_order.iter().position(|&id| id == numeric) { + self.selected_index = position; + } + self.pane.focused = false; + self.pane.cursor = 0; + + let mut message = format!("Jumped to {}", numeric); + if !changed.is_empty() { + message.push_str(&format!(" ({})", changed.join(", "))); + } + self.set_status(message, StatusKind::Info); + Ok(()) + } +} + +/// Link side pane state +#[derive(Debug, Clone)] +pub struct PaneState { + /// Toggled on (it is still hidden below `PANE_MIN_WIDTH`) + pub visible: bool, + /// Keys go to the pane instead of the list + pub focused: bool, + /// Index into outgoing links followed by backlinks + pub cursor: usize, +} + +/// One entry in the link pane +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaneEntry { + pub id: String, + /// Rendered title (empty for broken and divergent targets) + pub title: String, + pub target: Target, +} + +impl PaneEntry { + /// The active item a jump would select, if this entry can be jumped to. + pub fn jump_target(&self) -> Option { + match self.target { + Target::Active { numeric } => Some(numeric), + Target::Divergent | Target::Missing => None, + } + } +} + +/// What the link pane shows for the selected item +#[derive(Debug, Clone, Default)] +pub struct PaneContent { + pub outgoing: Vec, + pub backlinks: Vec, + /// The selected item's own ID is divergent, so backlinks are not listed + pub divergent: bool, +} + +impl PaneContent { + pub fn len(&self) -> usize { + self.outgoing.len() + self.backlinks.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The entry at a cursor position over outgoing links then backlinks. + pub fn get(&self, index: usize) -> Option<&PaneEntry> { + self.outgoing + .get(index) + .or_else(|| self.backlinks.get(index.checked_sub(self.outgoing.len())?)) + } } #[cfg(test)] @@ -699,4 +885,169 @@ pub(crate) mod tests { let saved = std::fs::read_to_string(&config_path).unwrap(); assert!(saved.contains("archive"), "{saved}"); } + // ----- link pane and jump ----- + + fn select(app: &mut App, id: u64) { + app.selected_index = app.display_order.iter().position(|&i| i == id).unwrap(); + } + + fn press(app: &mut App, code: KeyCode) { + super::super::actions::handle_key_event(app, KeyEvent::new(code, KeyModifiers::NONE)) + .unwrap(); + } + + fn wide(app: &mut App) { + app.status_width = 120; + } + + /// Covers F3. + #[test] + fn jumping_from_a_backlink_selects_the_linking_item() { + let (mut app, _) = test_app(); + wide(&mut app); + let b = create_task(&mut app, "B"); + let bid = stable_id_of(&app, b); + let a = create_task(&mut app, &format!("A [[{bid}]]")); + select(&mut app, b); + + let content = app.pane_content(); + assert_eq!(content.backlinks.len(), 1); + press(&mut app, KeyCode::Char('o')); + assert!(app.pane.focused); + press(&mut app, KeyCode::Enter); + + assert_eq!(app.selected_id(), Some(a)); + assert!(!app.pane.focused, "focus returns to the list after a jump"); + } + + #[test] + fn jump_reveals_a_hidden_completed_task_without_saving_config() { + let (mut app, config_path) = test_app(); + wide(&mut app); + let done = create_task(&mut app, "Done thing"); + app.taskbook.check_tasks_silent(&[done]).unwrap(); + app.refresh_items().unwrap(); + app.filter.hide_completed = true; + app.update_display_order(); + assert!(!app.display_order.contains(&done)); + let before = std::fs::read_to_string(&config_path).ok(); + + app.jump_to(done).unwrap(); + assert_eq!(app.selected_id(), Some(done)); + assert!(app.filter.hide_completed); + assert_eq!(std::fs::read_to_string(&config_path).ok(), before); + } + + #[test] + fn jump_from_archive_view_keeps_the_saved_default_view() { + let (mut app, config_path) = test_app(); + let t = create_task(&mut app, "Target"); + app.set_view(ViewMode::Archive).unwrap(); + let saved = std::fs::read_to_string(&config_path).unwrap(); + + app.jump_to(t).unwrap(); + assert_eq!(app.view, ViewMode::Board); + assert_eq!(app.selected_id(), Some(t)); + assert_eq!(std::fs::read_to_string(&config_path).unwrap(), saved); + } + + #[test] + fn jump_clears_search_and_board_filter_and_says_so() { + let (mut app, _) = test_app(); + let t = create_task(&mut app, "Target"); + app.filter.search_term = Some("nomatch".into()); + app.filter.board_filter = Some("elsewhere".into()); + app.update_display_order(); + + app.jump_to(t).unwrap(); + assert_eq!(app.selected_id(), Some(t)); + assert!(app.filter.search_term.is_none() && app.filter.board_filter.is_none()); + let status = app.status_message.as_ref().unwrap().text.clone(); + assert!( + status.contains("search") && status.contains("board"), + "{status}" + ); + } + + #[test] + fn broken_and_divergent_entries_are_not_jumpable() { + let (mut app, _) = test_app(); + wide(&mut app); + let missing = taskbook_common::stable_id::from_timestamp(9_999); + let a = create_task(&mut app, &format!("A [[{missing}]]")); + select(&mut app, a); + + press(&mut app, KeyCode::Char('o')); + press(&mut app, KeyCode::Enter); + assert_eq!(app.selected_id(), Some(a)); + assert!(app.pane.focused); + } + + #[test] + fn narrow_terminal_keys_explain_the_hidden_pane() { + let (mut app, _) = test_app(); + app.status_width = 80; + create_task(&mut app, "x"); + + press(&mut app, KeyCode::Char('v')); + let status = app.status_message.as_ref().unwrap().text.clone(); + assert!(status.contains("100 columns"), "{status}"); + + press(&mut app, KeyCode::Char('o')); + assert_eq!( + app.status_message.as_ref().unwrap().text, + "Link pane hidden" + ); + assert!(!app.pane.focused); + } + + #[test] + fn pane_cursor_clamps_to_the_current_entries() { + let (mut app, _) = test_app(); + let b = create_task(&mut app, "B"); + let bid = stable_id_of(&app, b); + create_task(&mut app, &format!("A [[{bid}]]")); + select(&mut app, b); + app.pane.cursor = 5; + let content = app.pane_content(); + assert_eq!(app.pane_cursor(&content), 0); + } + + #[test] + fn archived_item_lists_active_backlinks() { + let (mut app, _) = test_app(); + let t = create_task(&mut app, "Target"); + let tid = stable_id_of(&app, t); + for name in ["A", "B", "C"] { + create_task(&mut app, &format!("{name} [[{tid}]]")); + } + app.taskbook.delete_items_silent(&[t]).unwrap(); + app.set_view(ViewMode::Archive).unwrap(); + app.selected_index = 0; + + let content = app.pane_content(); + let titles: Vec = content.backlinks.iter().map(|e| e.title.clone()).collect(); + assert_eq!(titles.len(), 3, "{titles:?}"); + } + + #[test] + fn divergent_selection_shows_no_backlinks() { + let (mut app, _) = test_app(); + let mut twin = taskbook_common::Task::new(90, "Twin".into(), vec!["my board".into()], 1); + twin.timestamp = 1; + let mut twin2 = twin.clone(); + twin2.id = 91; + for t in [twin, twin2] { + app.active_items + .insert(t.id.to_string(), StorageItem::Task(t)); + } + app.items = app.active_items.clone(); + app.links = LinkIndex::build(app.active_items.values()); + app.update_display_order(); + app.selected_index = 0; + + let content = app.pane_content(); + assert!(content.divergent); + assert!(content.backlinks.is_empty()); + } } diff --git a/crates/taskbook-client/src/tui/ui.rs b/crates/taskbook-client/src/tui/ui.rs index 56e2d64..98711be 100644 --- a/crates/taskbook-client/src/tui/ui.rs +++ b/crates/taskbook-client/src/tui/ui.rs @@ -11,7 +11,7 @@ use super::app::{App, PopupState, ViewMode}; use super::widgets::{ board_view::render_board_view, command_line::render_autocomplete, command_line::render_command_line, help_popup::render_help_popup, - journal_view::render_journal_view, status_bar::render_stats_line, + journal_view::render_journal_view, link_pane::render_link_pane, status_bar::render_stats_line, timeline_view::render_timeline_view, }; @@ -30,7 +30,17 @@ pub fn render(frame: &mut Frame, app: &mut App) { app.content_height = chunks[1].height; app.status_width = chunks[3].width; render_header(frame, app, chunks[0]); - render_content(frame, app, chunks[1]); + if app.pane_shown() { + let pane_width = (chunks[1].width * 30 / 100).max(32); + let parts = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(1), Constraint::Length(pane_width)]) + .split(chunks[1]); + render_content(frame, app, parts[0]); + render_link_pane(frame, app, parts[1]); + } else { + render_content(frame, app, chunks[1]); + } render_command_line(frame, app, chunks[2]); render_stats_line(frame, app, chunks[3]); diff --git a/crates/taskbook-client/src/tui/widgets/help_popup.rs b/crates/taskbook-client/src/tui/widgets/help_popup.rs index e8ff8f9..e135d08 100644 --- a/crates/taskbook-client/src/tui/widgets/help_popup.rs +++ b/crates/taskbook-client/src/tui/widgets/help_popup.rs @@ -61,6 +61,14 @@ pub fn render_help_popup(frame: &mut Frame, app: &App, scroll: u16) { Span::styled(" l ", key_style), Span::styled("Link selected item to another", desc_style), ]), + Line::from(vec![ + Span::styled(" v ", key_style), + Span::styled("Toggle link pane (100+ columns)", desc_style), + ]), + Line::from(vec![ + Span::styled(" o ", key_style), + Span::styled("Focus link pane (Enter jumps)", desc_style), + ]), Line::from(""), Line::from(Span::styled(" Quick Actions", section_style)), Line::from(vec![ diff --git a/crates/taskbook-client/src/tui/widgets/link_pane.rs b/crates/taskbook-client/src/tui/widgets/link_pane.rs new file mode 100644 index 0000000..2fc4ca5 --- /dev/null +++ b/crates/taskbook-client/src/tui/widgets/link_pane.rs @@ -0,0 +1,198 @@ +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::tui::app::{App, PaneEntry}; +use taskbook_common::links::Target; + +/// Render the link side pane for the selected item. +pub fn render_link_pane(frame: &mut Frame, app: &App, area: Rect) { + let border_style = if app.pane.focused { + app.theme.info + } else { + app.theme.border + }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(Span::styled(" Links ", app.theme.title)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let content = app.pane_content(); + let cursor = app.pane_cursor(&content); + let width = usize::from(inner.width); + let mut lines: Vec = Vec::new(); + + lines.push(Line::from(Span::styled("Links to", app.theme.header))); + if content.outgoing.is_empty() { + lines.push(Line::from(Span::styled(" (none)", app.theme.muted))); + } + for (i, entry) in content.outgoing.iter().enumerate() { + lines.push(entry_line( + app, + entry, + width, + app.pane.focused && i == cursor, + )); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("Linked from", app.theme.header))); + if content.divergent { + lines.push(Line::from(Span::styled( + " ?? divergent", + app.theme.link_divergent, + ))); + } else if content.backlinks.is_empty() { + lines.push(Line::from(Span::styled(" (none)", app.theme.muted))); + } + let offset = content.outgoing.len(); + for (i, entry) in content.backlinks.iter().enumerate() { + let selected = app.pane.focused && offset + i == cursor; + lines.push(entry_line(app, entry, width, selected)); + } + + if app.pane.focused { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "j/k move, Enter jump, Esc back", + app.theme.muted, + ))); + } + + frame.render_widget(Paragraph::new(lines), inner); +} + +/// One entry: the title cut to fit with an ellipsis, then the short stable ID, +/// which always stays visible. +fn entry_line(app: &App, entry: &PaneEntry, width: usize, selected: bool) -> Line<'static> { + let short = app.links.short_id(&entry.id).to_string(); + let (label, label_style, id_text, id_style) = match entry.target { + Target::Active { .. } => ( + entry.title.clone(), + Style::default(), + short, + app.theme.stable_id, + ), + Target::Missing => ( + String::new(), + Style::default(), + format!("✗{}", short), + app.theme.link_broken, + ), + Target::Divergent => ( + String::new(), + Style::default(), + format!("{}??", short), + app.theme.link_divergent, + ), + }; + + // " " indent + label + " " + id + let room = width.saturating_sub(2 + 1 + id_text.chars().count()); + let label = if label.chars().count() > room { + let kept: String = label.chars().take(room.saturating_sub(1)).collect(); + format!("{}…", kept) + } else { + label + }; + + let mut line = Line::from(vec![ + Span::raw(" "), + Span::styled(label, label_style), + Span::raw(" "), + Span::styled(id_text, id_style), + ]); + if selected { + line = line.style(app.theme.selected); + } + line +} + +#[cfg(test)] +mod tests { + use crate::tui::app::tests::test_app; + use crate::tui::app::App; + use crate::tui::ui; + use ratatui::{backend::TestBackend, Terminal}; + use taskbook_common::links::LinkIndex; + use taskbook_common::{StorageItem, Task}; + + fn screen(app: &mut App, width: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, 20)).unwrap(); + terminal.draw(|f| ui::render(f, app)).unwrap(); + let buffer = terminal.backend().buffer().clone(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol().to_string()) + .collect::() + }) + .collect::>() + .join("\n") + } + + fn create(app: &mut App, desc: &str) -> u64 { + let id = app + .taskbook + .create_task_direct_with_tags(vec!["my board".into()], desc.into(), 1, vec![], None) + .unwrap(); + app.refresh_items().unwrap(); + id + } + + /// Covers AE6. + #[test] + fn pane_shows_only_on_wide_terminals() { + let (mut app, _) = test_app(); + create(&mut app, "x"); + assert!(!screen(&mut app, 80).contains("Linked from")); + assert!(screen(&mut app, 120).contains("Linked from")); + } + + #[test] + fn empty_sections_say_none() { + let (mut app, _) = test_app(); + create(&mut app, "x"); + let s = screen(&mut app, 120); + assert!(s.contains("Links to"), "{s}"); + assert_eq!(s.matches("(none)").count(), 2, "{s}"); + } + + #[test] + fn long_titles_truncate_but_keep_the_short_id() { + let (mut app, _) = test_app(); + let long = create(&mut app, "An extremely long target title that cannot fit"); + let lid = app.active_items[&long.to_string()].stable_id(); + let src = create(&mut app, &format!("see [[{lid}]]")); + app.selected_index = app.display_order.iter().position(|&i| i == src).unwrap(); + + let s = screen(&mut app, 100); + let short = app.links.short_id(&lid).to_string(); + let line = s.lines().find(|l| l.contains('…')).expect(&s); + assert!(line.contains(&short), "{line}"); + } + + #[test] + fn divergent_selection_shows_a_notice() { + let (mut app, _) = test_app(); + let mut twin = Task::new(90, "Twin".into(), vec!["my board".into()], 1); + twin.timestamp = 1; + let mut twin2 = twin.clone(); + twin2.id = 91; + for t in [twin, twin2] { + app.active_items + .insert(t.id.to_string(), StorageItem::Task(t)); + } + app.items = app.active_items.clone(); + app.links = LinkIndex::build(app.active_items.values()); + app.update_display_order(); + + assert!(screen(&mut app, 120).contains("?? divergent")); + } +} diff --git a/crates/taskbook-client/src/tui/widgets/mod.rs b/crates/taskbook-client/src/tui/widgets/mod.rs index 196bcc2..1f1925f 100644 --- a/crates/taskbook-client/src/tui/widgets/mod.rs +++ b/crates/taskbook-client/src/tui/widgets/mod.rs @@ -3,6 +3,7 @@ pub mod command_line; pub mod help_popup; pub mod item_row; pub mod journal_view; +pub mod link_pane; pub mod status_bar; pub mod timeline_view; From fc637dc10c2c5473b3ab4b094fe40a7752e5e682 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:59:30 +0000 Subject: [PATCH 31/45] docs: document TUI links, backlinks and stable IDs README gains a Links and Backlinks section (the [[ picker, numeric and prefix references, the l key, the side pane, broken and divergent markers, and that other surfaces show raw tokens); docs/README lists the feature; CLAUDE.md records the new modules and the stable-ID design constraint. Plan: U10. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- CLAUDE.md | 8 ++++++-- README.md | 18 ++++++++++++++++++ docs/README.md | 1 + 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9202098..f58f3c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,8 @@ 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 +│ ├── 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 │ @@ -73,7 +75,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/ @@ -117,7 +119,9 @@ 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. **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). Links are `[[stable-id]]` tokens in item text, resolved only in the TUI. + +7. **Storage Structure (Local)**: ``` ~/.taskbook/ ├── storage/storage.json # Active items diff --git a/README.md b/README.md index d9b00e4..69554b5 100644 --- a/README.md +++ b/README.md @@ -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 `?`. diff --git a/docs/README.md b/docs/README.md index ea11408..02eec8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,6 +40,7 @@ tb --begin 2 - **Tasks & Notes**: Create tasks with priorities and notes with rich body content - **Boards**: Organize items into custom boards - **Interactive TUI**: Full-featured terminal UI with keyboard navigation +- **Links & Backlinks**: Link items inline with `[[…]]` in the TUI; a side pane shows what links where - **External Editor**: Compose and edit notes in your preferred editor (`$EDITOR`) - **Timeline View**: See items chronologically - **Search & Filter**: Find items by text or attributes From 55b69b1b28c94118faa8e3b756f6df99714ab7f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:05:49 +0000 Subject: [PATCH 32/45] refactor(backlinks): consolidate link helpers and trim per-frame work Behavior-preserving cleanup from a simplification pass: - share item_link_targets, the unique-prefix computation, the broken and divergent markers and a truncate_chars helper instead of copies; - journal view reuses should_show_item; EditBaseline, OpenLink and LinkCandidate replace anonymous tuples; the pane key handler matches on the target directly; /task and /note share rewrite_new; - refresh derives boards from the items it just loaded (one storage read instead of two), the backlink sort caches positions, the unique timestamp check only collects candidates at or after the new one, plain_text skips rendering text without [[, and the link query is only computed for Enter and Esc; - drop a plan-ID reference from a code comment. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/taskbook.rs | 6 ++ crates/taskbook-client/src/tui/actions.rs | 65 +++++++------- crates/taskbook-client/src/tui/app.rs | 80 +++++------------ .../taskbook-client/src/tui/autocomplete.rs | 90 ++++++++++++------- .../src/tui/widgets/item_row.rs | 25 ++++-- .../src/tui/widgets/link_pane.rs | 22 ++--- crates/taskbook-client/src/tui/widgets/mod.rs | 10 +++ crates/taskbook-common/src/links.rs | 45 +++++----- crates/taskbook-common/src/stable_id.rs | 20 +++-- 9 files changed, 195 insertions(+), 168 deletions(-) diff --git a/crates/taskbook-client/src/taskbook.rs b/crates/taskbook-client/src/taskbook.rs index 3774bd7..0029fe2 100644 --- a/crates/taskbook-client/src/taskbook.rs +++ b/crates/taskbook-client/src/taskbook.rs @@ -386,6 +386,11 @@ impl Taskbook { self.get_archive() } + /// Get the boards of already-loaded items (for TUI) + pub fn boards_of(&self, data: &HashMap) -> Vec { + self.get_boards(data) + } + /// Get all boards (for TUI) pub fn get_all_boards(&self) -> Result> { let data = self.get_data()?; @@ -1447,6 +1452,7 @@ fn unique_timestamp( .values() .chain(archive.values()) .map(|item| item.timestamp()) + .filter(|&ts| ts >= candidate) .collect(); let mut ts = candidate; while taken.contains(&ts) { diff --git a/crates/taskbook-client/src/tui/actions.rs b/crates/taskbook-client/src/tui/actions.rs index 5d3f0de..eefd216 100644 --- a/crates/taskbook-client/src/tui/actions.rs +++ b/crates/taskbook-client/src/tui/actions.rs @@ -6,7 +6,8 @@ use taskbook_common::board; use taskbook_common::links::{self, Rejection, Rewrite, Target}; use super::app::{ - App, PendingAction, PopupState, StatusKind, SuggestionKind, ViewMode, PANE_MIN_WIDTH, + App, EditBaseline, PendingAction, PopupState, StatusKind, SuggestionKind, ViewMode, + PANE_MIN_WIDTH, }; use super::autocomplete; use super::command_parser::{self, ParsedCommand}; @@ -69,15 +70,15 @@ fn handle_pane_key(app: &mut App, key: KeyEvent) -> Result<()> { KeyCode::Char('k') | KeyCode::Up => { app.pane.cursor = cursor.saturating_sub(1); } - KeyCode::Enter => match content.get(cursor).map(|e| (e.jump_target(), e.target)) { - Some((Some(numeric), _)) => app.jump_to(numeric)?, - Some((None, Target::Divergent)) => { + KeyCode::Enter => match content.get(cursor).map(|e| e.target) { + Some(Target::Active { numeric }) => app.jump_to(numeric)?, + Some(Target::Divergent) => { app.set_status( "Two items share that ID; cannot jump".to_string(), StatusKind::Info, ); } - Some((None, _)) => { + Some(Target::Missing) => { app.set_status("That link is broken".to_string(), StatusKind::Info); } None => {} @@ -185,7 +186,11 @@ fn handle_command_line_key(app: &mut App, key: KeyEvent) -> Result<()> { // Inside an unclosed `[[`, Enter picks a link target instead of // submitting, and Esc first closes only the dropdown. - if let Some((open, _)) = autocomplete::open_link_query(app) { + let link_query = match key.code { + KeyCode::Enter | KeyCode::Esc => autocomplete::open_link_query(app), + _ => None, + }; + if let Some(link) = link_query { let has_links = app .command_line .suggestions @@ -206,7 +211,7 @@ fn handle_command_line_key(app: &mut App, key: KeyEvent) -> Result<()> { KeyCode::Esc if has_links => { app.command_line.suggestions.clear(); app.command_line.selected_suggestion = None; - app.command_line.link_dismissed_at = Some(open); + app.command_line.link_dismissed_at = Some(link.start); return Ok(()); } _ => {} @@ -269,14 +274,22 @@ fn accept_suggestion(app: &mut App) { struct Rejected { rejections: Vec, /// The baseline the draft was diffed against, kept for the retry. - baseline: Option<(u64, String)>, + baseline: Option, +} + +/// Rewrite the description of a new item, whose baseline is empty. +fn rewrite_new(app: &App, description: &str) -> std::result::Result { + links::rewrite_on_save("", description, app.links.resolver()).map_err(|rejections| Rejected { + rejections, + baseline: None, + }) } /// Parse and execute the command line input fn execute_input( app: &mut App, input: &str, - baseline: Option<(u64, String)>, + baseline: Option, ) -> Result> { match command_parser::parse_command(input) { Ok(cmd) => execute_command(app, cmd, baseline), @@ -291,7 +304,7 @@ fn execute_input( fn execute_command( app: &mut App, cmd: ParsedCommand, - baseline: Option<(u64, String)>, + baseline: Option, ) -> Result> { match cmd { ParsedCommand::Task { @@ -301,14 +314,9 @@ fn execute_command( tags, due_date, } => { - let rewrite = match links::rewrite_on_save("", &description, app.links.resolver()) { + let rewrite = match rewrite_new(app, &description) { Ok(rewrite) => rewrite, - Err(rejections) => { - return Ok(Some(Rejected { - rejections, - baseline: None, - })) - } + Err(rejected) => return Ok(Some(rejected)), }; let board_name = board .map(|b| board::normalize_board_name(&b)) @@ -335,14 +343,9 @@ fn execute_command( description, tags, } => { - let rewrite = match links::rewrite_on_save("", &description, app.links.resolver()) { + let rewrite = match rewrite_new(app, &description) { Ok(rewrite) => rewrite, - Err(rejections) => { - return Ok(Some(Rejected { - rejections, - baseline: None, - })) - } + Err(rejected) => return Ok(Some(rejected)), }; let board_name = board .map(|b| board::normalize_board_name(&b)) @@ -373,11 +376,11 @@ fn execute_command( } } ParsedCommand::Edit { id, description } => { - // The baseline is captured once per edit session (KTD7): from the - // `e`/`l` prefill, or from storage on the first submit. + // The baseline is captured once per edit session: from the `e`/`l` + // prefill, or from storage on the first submit. let base = baseline - .filter(|(base_id, _)| *base_id == id) - .map(|(_, text)| text) + .filter(|b| b.id == id) + .map(|b| b.text) .or_else(|| { app.active_items .get(&id.to_string()) @@ -389,7 +392,7 @@ fn execute_command( Err(rejections) => { return Ok(Some(Rejected { rejections, - baseline: Some((id, base)), + baseline: Some(EditBaseline { id, text: base }), })) } } @@ -592,7 +595,7 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { let id = item.id(); let desc = item.description().to_string(); app.activate_command_line(&format!("/edit @{} {}", id, desc)); - app.command_line.link_baseline = Some((id, desc)); + app.command_line.link_baseline = Some(EditBaseline { id, text: desc }); } } // Link pane: toggle and focus @@ -625,7 +628,7 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { let id = item.id(); let desc = item.description().to_string(); app.activate_command_line(&format!("/edit @{} {} [[", id, desc)); - app.command_line.link_baseline = Some((id, desc)); + app.command_line.link_baseline = Some(EditBaseline { id, text: desc }); autocomplete::update_suggestions(app); } } diff --git a/crates/taskbook-client/src/tui/app.rs b/crates/taskbook-client/src/tui/app.rs index 0a4c864..1490c4f 100644 --- a/crates/taskbook-client/src/tui/app.rs +++ b/crates/taskbook-client/src/tui/app.rs @@ -140,11 +140,18 @@ pub struct CommandLineState { pub pending_confirm: Option, /// Stored text of the item being edited when this edit session began, /// so retries after a rejected save diff against the same baseline - pub link_baseline: Option<(u64, String)>, + pub link_baseline: Option, /// Char index of the `[[` whose link dropdown the user closed with Esc pub link_dismissed_at: Option, } +/// The stored text of an item when an edit session began +#[derive(Debug, Clone)] +pub struct EditBaseline { + pub id: u64, + pub text: String, +} + /// An autocomplete suggestion #[derive(Debug, Clone)] pub struct Suggestion { @@ -184,7 +191,7 @@ pub struct FilterState { /// Hide completed tasks pub hide_completed: bool, /// A completed task shown despite `hide_completed`, after a jump to it - pub reveal: Option, + pub revealed_completed: Option, } #[derive(Debug, Clone)] @@ -273,7 +280,7 @@ impl App { pub fn refresh_items(&mut self) -> Result<()> { self.active_items = self.taskbook.get_all_items()?; self.links = LinkIndex::build(self.active_items.values()); - self.boards = self.taskbook.get_all_boards()?; + self.boards = self.taskbook.boards_of(&self.active_items); self.items = if self.view == ViewMode::Archive { self.taskbook.get_all_archive_items()? } else { @@ -325,7 +332,7 @@ impl App { /// Check if an item should be shown based on current filters pub fn should_show_item(&self, item: &StorageItem) -> bool { - if self.filter.hide_completed && self.filter.reveal != Some(item.id()) { + if self.filter.hide_completed && self.filter.revealed_completed != Some(item.id()) { if let Some(task) = item.as_task() { if task.is_complete { return false; @@ -403,31 +410,7 @@ impl App { let mut items: Vec<_> = self .items .values() - .filter(|item| { - if self.filter.hide_completed && self.filter.reveal != Some(item.id()) { - if let Some(task) = item.as_task() { - if task.is_complete { - return false; - } - } - } - if let Some(ref term) = self.filter.search_term { - let term_lower = term.to_lowercase(); - let in_desc = item.description().to_lowercase().contains(&term_lower); - let in_body = item - .note_body() - .map(|b| b.to_lowercase().contains(&term_lower)) - .unwrap_or(false); - let in_tags = item - .tags() - .iter() - .any(|t| t.to_lowercase().contains(&term_lower)); - if !in_desc && !in_body && !in_tags { - return false; - } - } - true - }) + .filter(|item| self.should_show_item(item)) .collect(); items.sort_by(|a, b| { b.timestamp() @@ -452,7 +435,7 @@ impl App { /// Toggle hide completed tasks pub fn toggle_hide_completed(&mut self) { self.filter.hide_completed = !self.filter.hide_completed; - self.filter.reveal = None; + self.filter.revealed_completed = None; self.config.display_complete_tasks = !self.filter.hide_completed; self.save_config(); self.update_display_order(); @@ -641,15 +624,7 @@ impl App { return PaneContent::default(); }; - let mut targets = links::link_targets(item.description()); - if let Some(body) = item.note_body() { - for id in links::link_targets(body) { - if !targets.contains(&id) { - targets.push(id); - } - } - } - let outgoing = targets + let outgoing = links::item_link_targets(item) .into_iter() .map(|id| { let target = self.links.target(&id); @@ -671,10 +646,13 @@ impl App { .backlinks(&own_id) .iter() .filter_map(|n| self.active_items.get(&n.to_string())) - .map(|source| PaneEntry { - id: source.stable_id(), - title: plain_text(self, source.description()), - target: self.links.target(&source.stable_id()), + .map(|source| { + let id = source.stable_id(); + PaneEntry { + target: self.links.target(&id), + title: plain_text(self, source.description()), + id, + } }) .collect() }; @@ -687,7 +665,7 @@ impl App { .unwrap_or(usize::MAX), _ => usize::MAX, }; - backlinks.sort_by_key(position); + backlinks.sort_by_cached_key(position); PaneContent { outgoing, @@ -720,9 +698,9 @@ impl App { .get(&numeric.to_string()) .and_then(|item| item.as_task()) .is_some_and(|task| task.is_complete); - self.filter.reveal = None; + self.filter.revealed_completed = None; if completed && self.filter.hide_completed { - self.filter.reveal = Some(numeric); + self.filter.revealed_completed = Some(numeric); changed.push("showing a completed task"); } self.update_display_order(); @@ -761,16 +739,6 @@ pub struct PaneEntry { pub target: Target, } -impl PaneEntry { - /// The active item a jump would select, if this entry can be jumped to. - pub fn jump_target(&self) -> Option { - match self.target { - Target::Active { numeric } => Some(numeric), - Target::Divergent | Target::Missing => None, - } - } -} - /// What the link pane shows for the selected item #[derive(Debug, Clone, Default)] pub struct PaneContent { diff --git a/crates/taskbook-client/src/tui/autocomplete.rs b/crates/taskbook-client/src/tui/autocomplete.rs index a66cb23..1647916 100644 --- a/crates/taskbook-client/src/tui/autocomplete.rs +++ b/crates/taskbook-client/src/tui/autocomplete.rs @@ -1,5 +1,6 @@ use super::app::{App, Suggestion, SuggestionKind}; use super::widgets::item_row::plain_text; +use super::widgets::truncate_chars; use taskbook_common::board; /// Static list of all slash commands with descriptions @@ -47,10 +48,10 @@ pub fn update_suggestions(app: &mut App) { // An unclosed `[[` in a description suggests link targets, unless the // user dismissed the dropdown for this `[[` with Esc. match open_link_query(app) { - Some((open, query)) => { - if app.command_line.link_dismissed_at != Some(open) { + Some(link) => { + if app.command_line.link_dismissed_at != Some(link.start) { app.command_line.link_dismissed_at = None; - suggest_links(app, open, &query); + suggest_links(app, &link); } return; } @@ -95,9 +96,17 @@ pub fn update_suggestions(app: &mut App) { } } -/// An unclosed `[[` before the cursor in the description of `/task`, `/note` -/// or `/edit`: its char index in the input and the query typed after it. -pub fn open_link_query(app: &App) -> Option<(usize, String)> { +/// An unclosed `[[` before the cursor in the description of a command +pub struct OpenLink { + /// Char index of the `[[` in the input + pub start: usize, + /// Text typed after the `[[` + pub query: String, +} + +/// The unclosed `[[` before the cursor in the description of `/task`, +/// `/note` or `/edit`, if any. +pub fn open_link_query(app: &App) -> Option { let input = &app.command_line.input; if !input.starts_with('/') { return None; @@ -119,13 +128,25 @@ pub fn open_link_query(app: &App) -> Option<(usize, String)> { if query.contains("]]") { return None; } - Some((before[..open_byte].chars().count(), query.to_string())) + Some(OpenLink { + start: before[..open_byte].chars().count(), + query: query.to_string(), + }) +} + +/// A link target offered by `[[` autocomplete +struct LinkCandidate { + on_current_board: bool, + timestamp: i64, + id: String, + title: String, + board: Option, } /// Suggest active, non-divergent items to link to, newest first with the /// current board's items ahead; accepting one inserts `[[]]`. -fn suggest_links(app: &mut App, open: usize, query: &str) { - let query = query.to_lowercase(); +fn suggest_links(app: &mut App, link: &OpenLink) { + let query = link.query.to_lowercase(); let input_chars: Vec = app.command_line.input.chars().collect(); let cursor = app.command_line.cursor.min(input_chars.len()); @@ -147,7 +168,7 @@ fn suggest_links(app: &mut App, open: usize, query: &str) { .is_some_and(|cb| boards.iter().any(|b| board::board_eq(b, cb))) }; - let mut candidates: Vec<(bool, i64, String, String, String)> = app + let mut candidates: Vec = app .active_items .values() .filter(|item| Some(item.id()) != editing) @@ -161,31 +182,38 @@ fn suggest_links(app: &mut App, open: usize, query: &str) { || title.to_lowercase().contains(&query) || item.id().to_string().starts_with(&query) || id.starts_with(&query); - matches.then(|| { - let board = item - .boards() - .first() - .map(|b| board::display_name(b)) - .unwrap_or_default(); - (on_board(item.boards()), item.timestamp(), id, title, board) + matches.then(|| LinkCandidate { + on_current_board: on_board(item.boards()), + timestamp: item.timestamp(), + id, + title, + board: item.boards().first().cloned(), }) }) .collect(); - candidates.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1))); + candidates.sort_by(|a, b| { + b.on_current_board + .cmp(&a.on_current_board) + .then(b.timestamp.cmp(&a.timestamp)) + }); - let before_open: String = input_chars[..open].iter().collect(); + let before_open: String = input_chars[..link.start].iter().collect(); let after_cursor: String = input_chars[cursor..].iter().collect(); - for (_, _, id, title, board_name) in candidates.into_iter().take(MAX_SUGGESTIONS) { - let title = if title.chars().count() > 28 { - format!("{}…", title.chars().take(27).collect::()) - } else { - title - }; - let display = format!("{} {}", title, app.links.short_id(&id)); + for candidate in candidates.into_iter().take(MAX_SUGGESTIONS) { + let display = format!( + "{} {}", + truncate_chars(&candidate.title, 28), + app.links.short_id(&candidate.id) + ); app.command_line.suggestions.push(Suggestion { display, - completion: format!("{}[[{}]]{}", before_open, id, after_cursor), - description: Some(board_name), + completion: format!("{}[[{}]]{}", before_open, candidate.id, after_cursor), + description: Some( + candidate + .board + .map(|b| board::display_name(&b)) + .unwrap_or_default(), + ), kind: SuggestionKind::Link, }); } @@ -313,11 +341,7 @@ fn suggest_items(app: &mut App, partial: &str) { }; // Truncate description for display - let display: String = if desc.chars().count() > 35 { - format!("{}…", desc.chars().take(34).collect::()) - } else { - desc.to_string() - }; + let display = truncate_chars(&desc, 35); // Build completion: replace the partial token with @ let before_token: String = input_chars[..last_space + 1].iter().collect(); diff --git a/crates/taskbook-client/src/tui/widgets/item_row.rs b/crates/taskbook-client/src/tui/widgets/item_row.rs index 9ebd8fa..47ddeec 100644 --- a/crates/taskbook-client/src/tui/widgets/item_row.rs +++ b/crates/taskbook-client/src/tui/widgets/item_row.rs @@ -174,20 +174,29 @@ pub fn text_spans(app: &App, text: &str, base: Style) -> Vec> { .map(|segment| match segment { Segment::Text(t) => Span::styled(t, base), Segment::Link { title, .. } => Span::styled(title, app.theme.link), - Segment::Broken { id } => Span::styled( - format!("✗{}", app.links.short_id(&id)), - app.theme.link_broken, - ), - Segment::Divergent { id } => Span::styled( - format!("{}??", app.links.short_id(&id)), - app.theme.link_divergent, - ), + Segment::Broken { id } => Span::styled(broken_marker(app, &id), app.theme.link_broken), + Segment::Divergent { id } => { + Span::styled(divergent_marker(app, &id), app.theme.link_divergent) + } }) .collect() } +/// How a link to an archived or missing item reads: `✗` and its short ID. +pub fn broken_marker(app: &App, id: &str) -> String { + format!("✗{}", app.links.short_id(id)) +} + +/// How a stable ID shared by more than one item reads: short ID and `??`. +pub fn divergent_marker(app: &App, id: &str) -> String { + format!("{}??", app.links.short_id(id)) +} + /// `text` as it renders, without styling (for matching and truncation). pub fn plain_text(app: &App, text: &str) -> String { + if !text.contains("[[") { + return text.to_string(); + } text_spans(app, text, Style::default()) .iter() .map(|span| span.content.as_ref()) diff --git a/crates/taskbook-client/src/tui/widgets/link_pane.rs b/crates/taskbook-client/src/tui/widgets/link_pane.rs index 2fc4ca5..2f2d88e 100644 --- a/crates/taskbook-client/src/tui/widgets/link_pane.rs +++ b/crates/taskbook-client/src/tui/widgets/link_pane.rs @@ -6,9 +6,14 @@ use ratatui::{ Frame, }; +use super::item_row::{broken_marker, divergent_marker}; +use super::truncate_chars; use crate::tui::app::{App, PaneEntry}; use taskbook_common::links::Target; +/// Indent before each entry. +const INDENT: &str = " "; + /// Render the link side pane for the selected item. pub fn render_link_pane(frame: &mut Frame, app: &App, area: Rect) { let border_style = if app.pane.focused { @@ -82,28 +87,23 @@ fn entry_line(app: &App, entry: &PaneEntry, width: usize, selected: bool) -> Lin Target::Missing => ( String::new(), Style::default(), - format!("✗{}", short), + broken_marker(app, &entry.id), app.theme.link_broken, ), Target::Divergent => ( String::new(), Style::default(), - format!("{}??", short), + divergent_marker(app, &entry.id), app.theme.link_divergent, ), }; - // " " indent + label + " " + id - let room = width.saturating_sub(2 + 1 + id_text.chars().count()); - let label = if label.chars().count() > room { - let kept: String = label.chars().take(room.saturating_sub(1)).collect(); - format!("{}…", kept) - } else { - label - }; + // Indent, label, a space, then the ID. + let room = width.saturating_sub(INDENT.len() + 1 + id_text.chars().count()); + let label = truncate_chars(&label, room); let mut line = Line::from(vec![ - Span::raw(" "), + Span::raw(INDENT), Span::styled(label, label_style), Span::raw(" "), Span::styled(id_text, id_style), diff --git a/crates/taskbook-client/src/tui/widgets/mod.rs b/crates/taskbook-client/src/tui/widgets/mod.rs index 1f1925f..12aa028 100644 --- a/crates/taskbook-client/src/tui/widgets/mod.rs +++ b/crates/taskbook-client/src/tui/widgets/mod.rs @@ -46,3 +46,13 @@ pub(crate) fn render_scrollable_list( frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state); } } + +/// `s` cut to at most `max` characters, ending in `…` when cut. +pub(crate) fn truncate_chars(s: &str, max: usize) -> String { + if s.chars().count() > max { + let kept: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{}…", kept) + } else { + s.to_string() + } +} diff --git a/crates/taskbook-common/src/links.rs b/crates/taskbook-common/src/links.rs index a0da40f..1b3d8d9 100644 --- a/crates/taskbook-common/src/links.rs +++ b/crates/taskbook-common/src/links.rs @@ -257,6 +257,20 @@ pub fn link_targets(text: &str) -> Vec { targets } +/// Stable IDs an item links to from its description and, for a note, its +/// body: deduplicated, in order of first appearance. +pub fn item_link_targets(item: &StorageItem) -> Vec { + let mut targets = link_targets(item.description()); + if let Some(body) = item.note_body() { + for id in link_targets(body) { + if !targets.contains(&id) { + targets.push(id); + } + } + } + targets +} + /// What a link's target resolves to among the active items. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Target { @@ -312,14 +326,7 @@ impl LinkIndex { titles.insert(own_id.clone(), item.description().to_string()); numeric_by_id.insert(own_id.clone(), item.id()); - let mut targets = link_targets(item.description()); - if let Some(body) = item.note_body() { - for id in link_targets(body) { - if !targets.contains(&id) { - targets.push(id); - } - } - } + let targets = item_link_targets(item); for id in targets.iter().filter(|id| **id != own_id) { inbound.entry(id.clone()).or_default().push(item.id()); } @@ -376,18 +383,8 @@ impl LinkIndex { } let ids = &self.resolver.ids; let at = ids.partition_point(|other| other.as_str() < id); - let common = |other: &String| { - other - .bytes() - .zip(id.bytes()) - .take_while(|(a, b)| a == b) - .count() - }; - let shared = at - .checked_sub(1) - .map_or(0, |i| common(&ids[i])) - .max(ids.get(at).map_or(0, common)); - (shared + 1).min(id.len()) + let prev = at.checked_sub(1).map(|i| ids[i].as_str()); + stable_id::unique_prefix_len(prev, ids.get(at).map(String::as_str), id) } /// `id` cut to its shortest unique prefix. @@ -406,7 +403,7 @@ impl LinkIndex { let id = text[span.inner.clone()].to_string(); segments.push(match self.target(&id) { Target::Active { .. } => Segment::Link { - title: self.flat_title(&id), + title: self.title_with_short_ids(&id), id, }, Target::Divergent => Segment::Divergent { id }, @@ -423,11 +420,13 @@ impl LinkIndex { /// The current title of the active item with this stable ID, with its own /// tokens shown as short IDs. pub fn title(&self, id: &str) -> Option { - self.titles.contains_key(id).then(|| self.flat_title(id)) + self.titles + .contains_key(id) + .then(|| self.title_with_short_ids(id)) } /// A target's title with its own tokens shown as short IDs. - fn flat_title(&self, id: &str) -> String { + fn title_with_short_ids(&self, id: &str) -> String { let title = self.titles.get(id).map_or("", String::as_str); let mut flat = String::with_capacity(title.len()); let mut copied_to = 0; diff --git a/crates/taskbook-common/src/stable_id.rs b/crates/taskbook-common/src/stable_id.rs index 80aca98..0796089 100644 --- a/crates/taskbook-common/src/stable_id.rs +++ b/crates/taskbook-common/src/stable_id.rs @@ -40,6 +40,19 @@ fn mix(mut z: u64) -> u64 { z ^ (z >> 31) } +/// Shortest prefix of `id` shared with neither sorted neighbour. +pub(crate) fn unique_prefix_len(prev: Option<&str>, next: Option<&str>, id: &str) -> usize { + let common = |other: &str| { + other + .bytes() + .zip(id.bytes()) + .take_while(|(a, b)| a == b) + .count() + }; + let shared = prev.map_or(0, common).max(next.map_or(0, common)); + (shared + 1).min(id.len()) +} + fn is_alphabet_run(s: &str) -> bool { s.bytes().all(|b| ALPHABET.contains(&b)) } @@ -64,8 +77,6 @@ where let mut sorted: Vec<&str> = ids.into_iter().collect(); sorted.sort_unstable(); - let common = |a: &str, b: &str| a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count(); - let mut lengths = HashMap::with_capacity(sorted.len()); for (i, id) in sorted.iter().enumerate() { let prev = i.checked_sub(1).map(|j| sorted[j]); @@ -73,10 +84,7 @@ where let len = if prev == Some(*id) || next == Some(*id) { LEN } else { - let shared = prev - .map_or(0, |p| common(p, id)) - .max(next.map_or(0, |n| common(n, id))); - (shared + 1).min(id.len()) + unique_prefix_len(prev, next, id) }; lengths.insert((*id).to_string(), len); } From f4bcd9a8ca79ac383974718ae446441c1893c4e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:21:57 +0000 Subject: [PATCH 33/45] fix(tui): keep note edits in active storage from Archive view refresh_items now loads the archive into app.items in Archive view, so `/note` there picked the highest archived ID as "the new note" and edit_note_external read archived notes. Saving then wrote an archived note's text into the active item with the same numeric ID. Use the ID create_note_direct_with_tags returns, read the note to edit from active_items, and answer Enter on an archived note with a status instead of opening the editor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/tui/actions.rs | 36 ++++++++++++++++------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/taskbook-client/src/tui/actions.rs b/crates/taskbook-client/src/tui/actions.rs index eefd216..fc384d6 100644 --- a/crates/taskbook-client/src/tui/actions.rs +++ b/crates/taskbook-client/src/tui/actions.rs @@ -351,7 +351,7 @@ fn execute_command( .map(|b| board::normalize_board_name(&b)) .or_else(|| app.filter.board_filter.clone()) .unwrap_or_else(|| "my board".to_string()); - app.taskbook.create_note_direct_with_tags( + let new_id = app.taskbook.create_note_direct_with_tags( vec![board_name.clone()], rewrite.text.clone(), tags, @@ -366,14 +366,7 @@ fn execute_command( app.set_status(message, StatusKind::Success); // Open editor for the newly created note - // Find the highest ID note (just created) - if let Some(max_id) = app.items.values().map(|i| i.id()).max() { - if let Some(item) = app.items.get(&max_id.to_string()) { - if !item.is_task() { - edit_note_external(app, max_id)?; - } - } - } + edit_note_external(app, new_id)?; } ParsedCommand::Edit { id, description } => { // The baseline is captured once per edit session: from the `e`/`l` @@ -532,7 +525,9 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { // Enter to open note in editor or filter by board KeyCode::Enter => { if let Some(item) = app.selected_item() { - if !item.is_task() { + if !item.is_task() && app.view == ViewMode::Archive { + app.set_status("Restore the note to edit it".to_string(), StatusKind::Info); + } else if !item.is_task() { edit_note_external(app, item.id())?; } else if app.view == ViewMode::Board && app.filter.board_filter.is_none() { if let Some(board_name) = app.get_board_for_selected() { @@ -903,7 +898,8 @@ fn rename_board(app: &mut App, old_name: &str, new_name: &str) -> Result<()> { } fn edit_note_external(app: &mut App, id: u64) -> Result<()> { - let item = app.items.get(&id.to_string()); + // Edits write to active storage, so read the note from there whatever the view. + let item = app.active_items.get(&id.to_string()); let note = match item.and_then(|i| i.as_note()) { Some(n) => n, None => { @@ -1078,6 +1074,24 @@ mod tests { assert_eq!(description(&app, id), "a"); } + #[test] + fn enter_on_an_archived_note_does_not_edit_active_storage() { + let (mut app, _) = test_app(); + let kept = create(&mut app, "keep me"); + let note = app + .taskbook + .create_note_direct_with_tags(vec!["my board".into()], "old note".into(), vec![]) + .unwrap(); + app.taskbook.delete_items_silent(&[note]).unwrap(); + app.set_view(ViewMode::Archive).unwrap(); + assert!(!app.items.is_empty()); + + press(&mut app, KeyCode::Enter); + + assert_eq!(status(&app), "Restore the note to edit it"); + assert_eq!(description(&app, kept), "keep me"); + } + #[test] fn delete_still_asks_for_confirmation() { let (mut app, _) = test_app(); From 4e2f50c3c30f671dba82237fb134d16c17f8f6a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:22:31 +0000 Subject: [PATCH 34/45] test(tui): prove the edit baseline is retained across retries The existing retry test left storage unchanged between submits, so it passed even with the retained baseline or the `b.id == id` guard removed. Change storage under an open draft, and retarget an `e` prefill to another item; each new test fails under the matching mutation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/tui/actions.rs | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/taskbook-client/src/tui/actions.rs b/crates/taskbook-client/src/tui/actions.rs index fc384d6..aa53518 100644 --- a/crates/taskbook-client/src/tui/actions.rs +++ b/crates/taskbook-client/src/tui/actions.rs @@ -1074,6 +1074,48 @@ mod tests { assert_eq!(description(&app, id), "a"); } + #[test] + fn retry_keeps_the_baseline_when_storage_changes_mid_edit() { + let (mut app, _) = test_app(); + let id = create(&mut app, "a"); + submit(&mut app, &format!("/edit @{id} a [[99]]")); + assert!(app.command_line.focused); + + // Another writer adds the same span while the draft is open. Diffed + // against storage it would look pre-existing; it was added here. + app.taskbook + .edit_description_silent(id, "a [[99]]") + .unwrap(); + app.refresh_items().unwrap(); + + press(&mut app, KeyCode::Enter); + assert!(app.command_line.focused); + assert!( + status(&app).contains("[[99]] not found"), + "{}", + status(&app) + ); + } + + #[test] + fn retargeted_edit_takes_the_baseline_from_the_new_item() { + let (mut app, _) = test_app(); + create(&mut app, "other"); + let first = create(&mut app, "x"); + let second = create(&mut app, "see [[1]]"); + app.selected_index = app.display_order.iter().position(|&i| i == first).unwrap(); + + // Prefill an edit of `first`, then point it at `second` instead. + handle_key_event(&mut app, key('e')).unwrap(); + let input = format!("/edit @{second} see [[1]] more"); + app.command_line.cursor = input.chars().count(); + app.command_line.input = input; + press(&mut app, KeyCode::Enter); + + // [[1]] was already in `second`, so it stays plain text. + assert_eq!(description(&app, second), "see [[1]] more"); + } + #[test] fn enter_on_an_archived_note_does_not_edit_active_storage() { let (mut app, _) = test_app(); From 29137129d4cc33e4323534ef249eb377ce4cb659 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:23:48 +0000 Subject: [PATCH 35/45] test(tui): cover link-pane keys, divergent jump and hidden-pane fallback The pane's j/k cursor, Esc, the divergent "cannot jump" status and the fallback that hands keys back to the list once the pane is hidden had no test that could fail. Each new assertion fails under the matching mutation of handle_pane_key or handle_key_event. The broken-link test now also checks its status. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/tui/app.rs | 78 +++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/crates/taskbook-client/src/tui/app.rs b/crates/taskbook-client/src/tui/app.rs index 1490c4f..7f805e2 100644 --- a/crates/taskbook-client/src/tui/app.rs +++ b/crates/taskbook-client/src/tui/app.rs @@ -949,6 +949,84 @@ pub(crate) mod tests { press(&mut app, KeyCode::Enter); assert_eq!(app.selected_id(), Some(a)); assert!(app.pane.focused); + assert_eq!( + app.status_message.as_ref().unwrap().text, + "That link is broken" + ); + } + + #[test] + fn divergent_entries_say_why_they_cannot_be_jumped_to() { + let (mut app, _) = test_app(); + wide(&mut app); + let twin_id = taskbook_common::stable_id::from_timestamp(1); + let linker = create_task(&mut app, &format!("L [[{twin_id}]]")); + let mut twin = taskbook_common::Task::new(90, "Twin".into(), vec!["my board".into()], 1); + twin.timestamp = 1; + let mut twin2 = twin.clone(); + twin2.id = 91; + for t in [twin, twin2] { + app.active_items + .insert(t.id.to_string(), StorageItem::Task(t)); + } + app.items = app.active_items.clone(); + app.links = LinkIndex::build(app.active_items.values()); + app.update_display_order(); + select(&mut app, linker); + + press(&mut app, KeyCode::Char('o')); + press(&mut app, KeyCode::Enter); + assert_eq!(app.selected_id(), Some(linker)); + assert_eq!( + app.status_message.as_ref().unwrap().text, + "Two items share that ID; cannot jump" + ); + } + + #[test] + fn pane_keys_move_the_cursor_jump_and_leave() { + let (mut app, _) = test_app(); + wide(&mut app); + let t = create_task(&mut app, "Target"); + let tid = stable_id_of(&app, t); + create_task(&mut app, &format!("A [[{tid}]]")); + create_task(&mut app, &format!("B [[{tid}]]")); + select(&mut app, t); + let expected = match app.pane_content().get(1).map(|e| e.target) { + Some(Target::Active { numeric }) => numeric, + other => panic!("unexpected second entry: {other:?}"), + }; + + press(&mut app, KeyCode::Char('o')); + press(&mut app, KeyCode::Char('j')); + press(&mut app, KeyCode::Char('j')); + assert_eq!(app.pane.cursor, 1, "j stops at the last entry"); + press(&mut app, KeyCode::Char('k')); + assert_eq!(app.pane.cursor, 0); + press(&mut app, KeyCode::Esc); + assert!(!app.pane.focused); + + press(&mut app, KeyCode::Char('o')); + press(&mut app, KeyCode::Char('j')); + press(&mut app, KeyCode::Enter); + assert_eq!(app.selected_id(), Some(expected)); + } + + #[test] + fn hiding_the_pane_returns_keys_to_the_list() { + let (mut app, _) = test_app(); + wide(&mut app); + let first = create_task(&mut app, "first"); + let fid = stable_id_of(&app, first); + create_task(&mut app, &format!("second [[{fid}]]")); + select(&mut app, first); + press(&mut app, KeyCode::Char('o')); + assert!(app.pane.focused); + + app.status_width = 80; + press(&mut app, KeyCode::Char('j')); + assert!(!app.pane.focused); + assert_ne!(app.selected_id(), Some(first)); } #[test] From 2d47be38d831806fd23a4c19e542c8ed92e90a39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:24:13 +0000 Subject: [PATCH 36/45] refactor(links): drop LinkIndex.outgoing, which only a test read The pane gets an item's outgoing links from item_link_targets, so the per-item map the index built on every refresh was a second, unused source of the same answer. The dedupe test now asserts on item_link_targets directly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-common/src/links.rs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/crates/taskbook-common/src/links.rs b/crates/taskbook-common/src/links.rs index 1b3d8d9..805fe48 100644 --- a/crates/taskbook-common/src/links.rs +++ b/crates/taskbook-common/src/links.rs @@ -297,7 +297,7 @@ pub enum Segment { }, } -/// Outgoing links, backlinks, display prefixes and rendering over the active +/// Backlinks, targets, display prefixes and rendering over the active /// items, rebuilt whenever the item set changes. #[derive(Debug, Default)] pub struct LinkIndex { @@ -305,7 +305,6 @@ pub struct LinkIndex { titles: HashMap, numeric_by_id: HashMap, prefix_lens: HashMap, - outgoing: HashMap>, inbound: HashMap>, } @@ -319,18 +318,18 @@ impl LinkIndex { let mut titles = HashMap::new(); let mut numeric_by_id = HashMap::new(); - let mut outgoing = HashMap::new(); let mut inbound: HashMap> = HashMap::new(); for item in &items { let own_id = item.stable_id(); titles.insert(own_id.clone(), item.description().to_string()); numeric_by_id.insert(own_id.clone(), item.id()); - let targets = item_link_targets(item); - for id in targets.iter().filter(|id| **id != own_id) { - inbound.entry(id.clone()).or_default().push(item.id()); + for id in item_link_targets(item) + .into_iter() + .filter(|id| *id != own_id) + { + inbound.entry(id).or_default().push(item.id()); } - outgoing.insert(item.id(), targets); } for sources in inbound.values_mut() { sources.sort_unstable(); @@ -345,7 +344,6 @@ impl LinkIndex { titles, numeric_by_id, prefix_lens, - outgoing, inbound, } } @@ -363,11 +361,6 @@ impl LinkIndex { .map_or(Target::Missing, |&numeric| Target::Active { numeric }) } - /// Stable IDs the active item `numeric` links to. - pub fn outgoing(&self, numeric: u64) -> &[String] { - self.outgoing.get(&numeric).map_or(&[], Vec::as_slice) - } - /// Active items (numeric IDs, ascending) whose text links to `id`, /// excluding the item that holds `id` itself. Works for IDs with no active /// item, such as an archived one. @@ -699,7 +692,7 @@ mod tests { let idx = LinkIndex::build(items.iter()); let (ida, idb) = (stable_id::from_timestamp(a), stable_id::from_timestamp(b)); - assert_eq!(idx.outgoing(1), [idb.clone(), ida.clone()]); + assert_eq!(item_link_targets(&items[0]), [idb.clone(), ida.clone()]); assert_eq!(idx.backlinks(&idb), [1]); assert!(idx.backlinks(&ida).is_empty()); } From 47d67330b7179093bcadcb23d3036f013034f839 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:24:21 +0000 Subject: [PATCH 37/45] docs(links): correct scan()'s note-join comment Notes are joined with NOTE_FIELD_SEPARATOR, not `\n`; that constant's own comment explains why no reference straddles title and body. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-common/src/links.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/taskbook-common/src/links.rs b/crates/taskbook-common/src/links.rs index 805fe48..5d0f92c 100644 --- a/crates/taskbook-common/src/links.rs +++ b/crates/taskbook-common/src/links.rs @@ -36,8 +36,7 @@ pub struct Span { pub kind: SpanKind, } -/// Find every closed `[[…]]` span. Spans never contain a newline, so a note -/// title and body joined by `\n` cannot produce a span across the two. +/// Find every closed `[[…]]` span. Spans never contain a newline. pub fn scan(text: &str) -> Vec { let mut spans = Vec::new(); let mut i = 0; From 407afce68edc7fc0ad29121e3bd7476f5e8a76fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:26:22 +0000 Subject: [PATCH 38/45] docs(plan): defer saving legacy text with an unclosed `[[` Found in code review: after `e`, KTD13's Enter rule blocks saving text that already holds an unclosed `[[`, and the hint's "press Esc" cancels the edit. Recorded with a candidate fix rather than changing KTD13 now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- docs/plans/2026-10-21-2154-feat-backlinks-plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md index ef4a60f..be1362f 100644 --- a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -620,6 +620,7 @@ These mirror CI in `.github/workflows/ci.yml`. - A "jump back" key after following a link. - Making `r` on a stale archived row set a status instead of an error (pre-existing; the explicit reload in U5 removes the stale row). - Matching `/search` against rendered titles. +- Saving text that already holds an unclosed `[[` (written from the CLI or MCP) after `e`: under KTD13, Enter in that position only shows the hint, and Esc with no dropdown cancels the edit, so the hint's "press Esc" discards the draft. Candidate fix: Esc always dismisses an unclosed `[[`, Enter on a dismissed `[[` submits, and the `e` prefill pre-dismisses one from stored text. ### Considered and not built From 1ece3741b6f9a09dc6d25798a5b76c8044e1e259 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 05:15:50 +0000 Subject: [PATCH 39/45] test: define the supported store size and test at it Add taskbook-common::limits: 1,024 active and 4,096 archived items supported, 512 active recommended. Features are designed for that size, which is what makes same-millisecond stable-ID collisions acceptable. Envelope tests: - creates stay unique against a full store whose timestamps form one dense run starting now (fails if creates stop reading the archive); - unique stable-ID prefixes fit the 6-character TUI column at 1,024 active items; - an #[ignore]d create latency budget (500 ms) for `--ignored` runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/taskbook.rs | 61 +++++++++++++++++++ .../src/tui/widgets/item_row.rs | 22 +++++++ crates/taskbook-common/src/lib.rs | 1 + crates/taskbook-common/src/limits.rs | 19 ++++++ 4 files changed, 103 insertions(+) create mode 100644 crates/taskbook-common/src/limits.rs diff --git a/crates/taskbook-client/src/taskbook.rs b/crates/taskbook-client/src/taskbook.rs index 0029fe2..f30a29c 100644 --- a/crates/taskbook-client/src/taskbook.rs +++ b/crates/taskbook-client/src/taskbook.rs @@ -1504,6 +1504,67 @@ mod tests { /// Covers AE7 (creation half): rapid creates through every path get /// distinct timestamps and therefore distinct stable IDs. + /// A store filled to the supported size, with every timestamp packed into + /// one run starting now: the worst case for keeping new creates unique. + fn envelope_taskbook() -> Taskbook { + use taskbook_common::limits::{SUPPORTED_ACTIVE_ITEMS, SUPPORTED_ARCHIVED_ITEMS}; + let tb = test_taskbook(); + let start = chrono::Utc::now().timestamp_millis(); + let (active, archived) = ( + SUPPORTED_ACTIVE_ITEMS as i64, + SUPPORTED_ARCHIVED_ITEMS as i64, + ); + tb.save(&map_of( + (0..active) + .map(|i| task_at(i as u64 + 1, start + i)) + .collect(), + )) + .unwrap(); + tb.save_archive(&map_of( + (0..archived) + .map(|i| task_at(i as u64 + 1, start + active + i)) + .collect(), + )) + .unwrap(); + tb + } + + #[test] + fn creates_stay_unique_at_the_supported_size() { + let tb = envelope_taskbook(); + let board = vec![DEFAULT_BOARD.to_string()]; + for i in 0..5 { + tb.create_task_direct_with_tags(board.clone(), format!("t{i}"), 1, vec![], None) + .unwrap(); + } + + let mut timestamps: Vec = tb + .get_data() + .unwrap() + .values() + .chain(tb.get_archive().unwrap().values()) + .map(|item| item.timestamp()) + .collect(); + let total = timestamps.len(); + timestamps.sort_unstable(); + timestamps.dedup(); + assert_eq!(timestamps.len(), total); + } + + /// Loose latency budget at the supported size. Timing depends on the + /// machine, so it runs only on request: `cargo test -- --ignored`. + #[test] + #[ignore] + fn create_at_the_supported_size_is_fast() { + let tb = envelope_taskbook(); + let board = vec![DEFAULT_BOARD.to_string()]; + let started = std::time::Instant::now(); + tb.create_task_direct_with_tags(board, "timed".into(), 1, vec![], None) + .unwrap(); + let elapsed = started.elapsed(); + assert!(elapsed.as_millis() < 500, "create took {elapsed:?}"); + } + #[test] fn rapid_creates_get_distinct_stable_ids() { let tb = test_taskbook(); diff --git a/crates/taskbook-client/src/tui/widgets/item_row.rs b/crates/taskbook-client/src/tui/widgets/item_row.rs index 47ddeec..606ab18 100644 --- a/crates/taskbook-client/src/tui/widgets/item_row.rs +++ b/crates/taskbook-client/src/tui/widgets/item_row.rs @@ -352,4 +352,26 @@ mod tests { assert!(preview.chars().count() <= 53, "{preview}"); assert!(preview.ends_with('…')); } + + /// At the supported size, every active item's unique prefix fits the + /// stable-ID column, so rows stay aligned. Holds for these fixtures; past + /// the envelope a longer prefix widens the column instead. + #[test] + fn unique_prefixes_fit_the_column_at_the_supported_size() { + use taskbook_common::limits::SUPPORTED_ACTIVE_ITEMS; + let n = SUPPORTED_ACTIVE_ITEMS as i64; + // Consecutive milliseconds, and one item a minute. + for step in [1, 60_000] { + let items: Vec = (0..n) + .map(|i| task(i as u64 + 1, 1_790_000_000_000 + i * step, "x")) + .collect(); + let idx = LinkIndex::build(items.iter()); + let widest = items + .iter() + .map(|item| idx.prefix_len(&item.stable_id())) + .max() + .unwrap(); + assert!(widest <= STABLE_ID_WIDTH, "step {step}: prefix {widest}"); + } + } } diff --git a/crates/taskbook-common/src/lib.rs b/crates/taskbook-common/src/lib.rs index 4e51598..23f0913 100644 --- a/crates/taskbook-common/src/lib.rs +++ b/crates/taskbook-common/src/lib.rs @@ -3,6 +3,7 @@ pub mod board; pub mod due; pub mod encryption; pub mod error; +pub mod limits; pub mod links; pub mod models; pub mod stable_id; diff --git a/crates/taskbook-common/src/limits.rs b/crates/taskbook-common/src/limits.rs new file mode 100644 index 0000000..77503c5 --- /dev/null +++ b/crates/taskbook-common/src/limits.rs @@ -0,0 +1,19 @@ +//! The size envelope taskbook is designed for. +//! +//! Storage is a JSON file per item set: every operation reads, parses and +//! rewrites the whole file, and sync uploads the whole set. That is fine at +//! personal scale and is not meant to scale past it. Features may do O(n) +//! work per operation and may rely on these sizes (for example, accepting +//! that items created in the same millisecond on two devices share a stable +//! ID). Stores beyond the envelope are unsupported, not cases to design +//! around. + +/// Active items the design supports. +pub const SUPPORTED_ACTIVE_ITEMS: usize = 1024; + +/// Archived items the design supports. The archive grows over the years, +/// and every create reads it to keep creation timestamps unique. +pub const SUPPORTED_ARCHIVED_ITEMS: usize = 4096; + +/// Active items we recommend staying under. +pub const RECOMMENDED_ACTIVE_ITEMS: usize = 512; From 6f2c41a6b5d4c1f780ff2ebcbb5d349d23df92c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 05:15:50 +0000 Subject: [PATCH 40/45] docs: document the design envelope and how to measure it CLAUDE.md gains a Design Envelope decision (whole-file JSON, O(n) per operation is fine, larger stores are unsupported) and ties the stable-ID collision tradeoff to it. README gains a Limits section with measured numbers, reproducible with scripts/bench-envelope.py. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- CLAUDE.md | 7 ++- README.md | 24 ++++++++ scripts/bench-envelope.py | 116 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 scripts/bench-envelope.py diff --git a/CLAUDE.md b/CLAUDE.md index f58f3c3..bc99efb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,7 @@ 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 # Supported store size (the design envelope) │ ├── 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 @@ -119,9 +120,11 @@ crates/ (honoring `$XDG_CONFIG_HOME`), falling back to the legacy `~/.taskbook.json` when it exists and the XDG file does not. -6. **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). Links are `[[stable-id]]` tokens in item text, resolved only in the TUI. +6. **Design Envelope**: Storage is one JSON file per item set; every operation reads, parses and rewrites the whole file, and sync PUTs every item. The supported size is 1,024 active and 4,096 archived items, with 512 active recommended (`taskbook-common/src/limits.rs`, measured numbers in README "Limits"). Design features for that size: O(n) work per operation is fine, and indexes, caches or incremental storage are out of scope. Larger stores are unsupported, not edge cases to handle. 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. **Storage Structure (Local)**: +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 diff --git a/README.md b/README.md index 69554b5..8f326cb 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,30 @@ 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 built for a personal list, not a database. Every command reads, +parses and rewrites the whole JSON file, and sync uploads every item on each +change. The supported size is **1,024 active and 4,096 archived items**; we +recommend staying under **512 active**. Larger stores may work but are +unsupported, and features are not designed for them. For example, two items +created in the same millisecond on different devices can share a stable ID +(shown as `??`), which is acceptable at this scale. + +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) diff --git a/scripts/bench-envelope.py b/scripts/bench-envelope.py new file mode 100644 index 0000000..7d186eb --- /dev/null +++ b/scripts/bench-envelope.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Time the tb CLI against generated stores around the supported size. + +Usage: + cargo build --release -p taskbook-client + python3 scripts/bench-envelope.py target/release/tb [ACTIVE/ARCHIVED ...] + +Prints a markdown table of median board-view and create times. Stores are +synthetic: 80% tasks, notes with 200-1500 character bodies, about 385 bytes +per item. Each create is timed against a fresh copy of the store. +""" +import json +import os +import random +import shutil +import statistics +import subprocess +import sys +import tempfile +import time + +DEFAULT_SIZES = ["256/4096", "512/4096", "1024/4096", "2048/8192"] +RUNS = 7 +WORDS = ( + "alpha beta gamma review deploy invoice call email draft fix bug plan " + "meeting doc report budget tax rust ffi nix ledger sync server client " + "release notes" +).split() +BOARDS = ["my board", "work", "home", "finance", "rust"] + + +def text(rng, lo, hi): + target = rng.randint(lo, hi) + out = [] + while len(" ".join(out)) < target: + out.append(rng.choice(WORDS)) + return " ".join(out) + + +def item(rng, i, ts): + is_task = rng.random() < 0.8 + entry = { + "_id": i, + "_date": "Wed Oct 21 2026", + "_timestamp": ts, + "_isTask": is_task, + "description": text(rng, 60, 120), + "isStarred": rng.random() < 0.05, + "boards": [rng.choice(BOARDS)], + } + if is_task: + entry.update( + isComplete=rng.random() < 0.5, + inProgress=False, + priority=rng.choice([1, 1, 1, 2, 3]), + ) + elif rng.random() < 0.7: + entry["body"] = text(rng, 200, 1500) + return entry + + +def write_store(path, count, start_ts, rng): + os.makedirs(os.path.dirname(path), exist_ok=True) + data = {str(i): item(rng, i, start_ts + i * 1000) for i in range(1, count + 1)} + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +def median_ms(cmd, env, prepare=None): + times = [] + for _ in range(RUNS): + if prepare: + prepare() + started = time.perf_counter() + subprocess.run( + cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True + ) + times.append(time.perf_counter() - started) + return statistics.median(times) * 1000 + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + tb = os.path.abspath(sys.argv[1]) + sizes = sys.argv[2:] or DEFAULT_SIZES + + print("| Active / archived | Files | Board view | Create |") + print("|---|---|---|---|") + with tempfile.TemporaryDirectory(prefix="tb-bench-") as root: + for spec in sizes: + active, archived = (int(x) for x in spec.split("/")) + rng = random.Random(42) + golden = os.path.join(root, f"golden-{spec.replace('/', '-')}") + write_store(f"{golden}/.taskbook/storage/storage.json", active, 1_700_000_000_000, rng) + write_store(f"{golden}/.taskbook/archive/archive.json", archived, 1_600_000_000_000, rng) + size = sum( + os.path.getsize(f"{golden}/.taskbook/{p}") + for p in ("storage/storage.json", "archive/archive.json") + ) + + work = os.path.join(root, "work") + + def fresh_copy(): + shutil.rmtree(work, ignore_errors=True) + shutil.copytree(golden, work) + + env = dict(os.environ, TASKBOOK_DIR=work) + fresh_copy() + show = median_ms([tb, "--cli"], env) + create = median_ms([tb, "--cli", "--task", "bench task"], env, fresh_copy) + print(f"| {active} / {archived} | {size / 1e6:.1f} MB | {show:.0f} ms | {create:.0f} ms |") + + +if __name__ == "__main__": + main() From 236207b3bb572b68fc202a70a8c5f32a8dcc982b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 05:16:38 +0000 Subject: [PATCH 41/45] docs: frame the size envelope around human use, not performance taskbook is a board a person reads and keeps up with; that sets the bound. 512 active is the recommendation, 1,024/4,096 is headroom, and agent use via MCP stays within it. Features handle what a person can produce; machine-scale scenarios are unsupported rather than edge cases. Performance numbers stay, as evidence the JSON storage fits this size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- CLAUDE.md | 4 ++-- README.md | 20 +++++++++------- crates/taskbook-common/src/limits.rs | 35 +++++++++++++++++----------- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bc99efb..6940eb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ 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 # Supported store size (the design envelope) +│ ├── 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 @@ -120,7 +120,7 @@ crates/ (honoring `$XDG_CONFIG_HOME`), falling back to the legacy `~/.taskbook.json` when it exists and the XDG file does not. -6. **Design Envelope**: Storage is one JSON file per item set; every operation reads, parses and rewrites the whole file, and sync PUTs every item. The supported size is 1,024 active and 4,096 archived items, with 512 active recommended (`taskbook-common/src/limits.rs`, measured numbers in README "Limits"). Design features for that size: O(n) work per operation is fine, and indexes, caches or incremental storage are out of scope. Larger stores are unsupported, not edge cases to handle. 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`). +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. 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. diff --git a/README.md b/README.md index 8f326cb..b4cd94b 100644 --- a/README.md +++ b/README.md @@ -182,14 +182,18 @@ This implementation uses the same data format and directory (`~/.taskbook/`) as ## Limits -taskbook is built for a personal list, not a database. Every command reads, -parses and rewrites the whole JSON file, and sync uploads every item on each -change. The supported size is **1,024 active and 4,096 archived items**; we -recommend staying under **512 active**. Larger stores may work but are -unsupported, and features are not designed for them. For example, two items -created in the same millisecond on different devices can share a stable ID -(shown as `??`), which is acceptable at this scale. - +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. 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 | diff --git a/crates/taskbook-common/src/limits.rs b/crates/taskbook-common/src/limits.rs index 77503c5..1ef1207 100644 --- a/crates/taskbook-common/src/limits.rs +++ b/crates/taskbook-common/src/limits.rs @@ -1,19 +1,28 @@ //! The size envelope taskbook is designed for. //! -//! Storage is a JSON file per item set: every operation reads, parses and -//! rewrites the whole file, and sync uploads the whole set. That is fine at -//! personal scale and is not meant to scale past it. Features may do O(n) -//! work per operation and may rely on these sizes (for example, accepting -//! that items created in the same millisecond on two devices share a stable -//! ID). Stores beyond the envelope are unsupported, not cases to design -//! around. +//! taskbook is a task board for a person: someone reads it, triages it and +//! keeps up with it. That purpose, not storage performance, sets the bound. +//! A few hundred active items is already more than one person can keep up +//! with; the supported limits sit above that as headroom, and they apply to +//! agent use (MCP) too. +//! +//! Features should handle what a person using the board can produce, and no +//! more. Scenarios only reachable at machine scale (thousands of active +//! items, bulk writes within one millisecond, very large archives) are +//! unsupported, not edge cases to design for. That is why, for example, +//! two items created in the same millisecond on two devices may share a +//! stable ID. +//! +//! The JSON storage fits this: every operation reads, parses and rewrites +//! the whole file, and sync uploads every item, which is fast at this size +//! (measured numbers in the README's "Limits" section). + +/// Active items we recommend staying under: the human-scale board. +pub const RECOMMENDED_ACTIVE_ITEMS: usize = 512; -/// Active items the design supports. +/// Active items supported, as headroom above the recommendation. pub const SUPPORTED_ACTIVE_ITEMS: usize = 1024; -/// Archived items the design supports. The archive grows over the years, -/// and every create reads it to keep creation timestamps unique. +/// Archived items supported. The archive grows over the years, and every +/// create reads it to keep creation timestamps unique. pub const SUPPORTED_ARCHIVED_ITEMS: usize = 4096; - -/// Active items we recommend staying under. -pub const RECOMMENDED_ACTIVE_ITEMS: usize = 512; From 9fd0b979b6ace9fbcbe48c0d512a6b54ea547bbf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 07:55:27 +0000 Subject: [PATCH 42/45] feat(limits): add SizeNotice for stores past the envelope SizeNotice::check(active, archived) returns a notice once active items pass the 512 recommendation or the archive passes 4,096, and summary() names each limit crossed. Informational only; callers decide wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-common/src/limits.rs | 99 ++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/crates/taskbook-common/src/limits.rs b/crates/taskbook-common/src/limits.rs index 1ef1207..5ce44b5 100644 --- a/crates/taskbook-common/src/limits.rs +++ b/crates/taskbook-common/src/limits.rs @@ -26,3 +26,102 @@ pub const SUPPORTED_ACTIVE_ITEMS: usize = 1024; /// Archived items supported. The archive grows over the years, and every /// create reads it to keep creation timestamps unique. pub const SUPPORTED_ARCHIVED_ITEMS: usize = 4096; + +/// How a store compares with the envelope, once it is past the recommended +/// active count or a supported limit. Informational: nothing is blocked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SizeNotice { + pub active: usize, + pub archived: usize, +} + +impl SizeNotice { + /// A notice for a store with these counts, or `None` while it is within + /// the recommendation and the supported archive size. + pub fn check(active: usize, archived: usize) -> Option { + let notice = Self { active, archived }; + (notice.active_over_recommended() || notice.archive_over_supported()).then_some(notice) + } + + pub fn active_over_recommended(&self) -> bool { + self.active > RECOMMENDED_ACTIVE_ITEMS + } + + pub fn archive_over_supported(&self) -> bool { + self.archived > SUPPORTED_ARCHIVED_ITEMS + } + + /// One line naming each limit the store is past. + pub fn summary(&self) -> String { + let mut parts = Vec::new(); + if self.active > SUPPORTED_ACTIVE_ITEMS { + parts.push(format!( + "{} active items, over the {SUPPORTED_ACTIVE_ITEMS} supported \ + ({RECOMMENDED_ACTIVE_ITEMS} recommended)", + self.active + )); + } else if self.active_over_recommended() { + parts.push(format!( + "{} active items, over the {RECOMMENDED_ACTIVE_ITEMS} recommended", + self.active + )); + } + if self.archive_over_supported() { + parts.push(format!( + "{} archived items, over the {SUPPORTED_ARCHIVED_ITEMS} supported", + self.archived + )); + } + parts.join("; ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_notice_at_the_limits() { + assert_eq!( + SizeNotice::check(RECOMMENDED_ACTIVE_ITEMS, SUPPORTED_ARCHIVED_ITEMS), + None + ); + } + + #[test] + fn active_past_the_recommendation() { + let notice = SizeNotice::check(RECOMMENDED_ACTIVE_ITEMS + 1, 0).unwrap(); + assert_eq!( + notice.summary(), + "513 active items, over the 512 recommended" + ); + } + + #[test] + fn active_past_the_supported_size() { + let notice = SizeNotice::check(SUPPORTED_ACTIVE_ITEMS + 1, 0).unwrap(); + assert_eq!( + notice.summary(), + "1025 active items, over the 1024 supported (512 recommended)" + ); + } + + #[test] + fn archive_past_the_supported_size_alone() { + let notice = SizeNotice::check(10, SUPPORTED_ARCHIVED_ITEMS + 1).unwrap(); + assert!(!notice.active_over_recommended()); + assert_eq!( + notice.summary(), + "4097 archived items, over the 4096 supported" + ); + } + + #[test] + fn both_limits_are_named() { + let notice = SizeNotice::check(600, 5000).unwrap(); + assert_eq!( + notice.summary(), + "600 active items, over the 512 recommended; 5000 archived items, over the 4096 supported" + ); + } +} From 4382e5462f5261917615cbc1e79468295d8d7b55 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 07:55:27 +0000 Subject: [PATCH 43/45] feat(mcp): append a size notice to calls that add active items create_task, create_note and restore_items add a second text block when the store is past the envelope. It asks the agent to tell the user and not to archive or delete items itself, and mentions `tb --clear` only when there are completed tasks to archive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/mcp/mod.rs | 60 +++++++++++++++++++++++++ crates/taskbook-client/src/mcp/tools.rs | 38 ++++++++++++++-- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/crates/taskbook-client/src/mcp/mod.rs b/crates/taskbook-client/src/mcp/mod.rs index e6f644e..dc9dce9 100644 --- a/crates/taskbook-client/src/mcp/mod.rs +++ b/crates/taskbook-client/src/mcp/mod.rs @@ -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 { + 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() @@ -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(); diff --git a/crates/taskbook-client/src/mcp/tools.rs b/crates/taskbook-client/src/mcp/tools.rs index f110f9b..c8340ed 100644 --- a/crates/taskbook-client/src/mcp/tools.rs +++ b/crates/taskbook-client/src/mcp/tools.rs @@ -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. @@ -135,10 +136,16 @@ pub fn call(taskbook: &Taskbook, params: &Value) -> Result }; 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 @@ -146,6 +153,29 @@ pub fn call(taskbook: &Taskbook, params: &Value) -> Result }) } +/// 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 { + 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(), From dd930b335a1e624c763e6b3a1395bd78f7c157a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 07:55:27 +0000 Subject: [PATCH 44/45] feat(tui): show a size notice in the header past the envelope The header gains a warning-styled indicator beside the existing ones, suggesting /clear only when completed tasks exist. The archive count is read once at startup and whenever Archive view loads the archive, so it can lag deletes made in the same session. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- crates/taskbook-client/src/tui/app.rs | 110 +++++++++++++++++++++++++- crates/taskbook-client/src/tui/ui.rs | 6 ++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/crates/taskbook-client/src/tui/app.rs b/crates/taskbook-client/src/tui/app.rs index 7f805e2..6497bb8 100644 --- a/crates/taskbook-client/src/tui/app.rs +++ b/crates/taskbook-client/src/tui/app.rs @@ -9,6 +9,7 @@ use crate::error::Result; use crate::render::Stats; use crate::taskbook::Taskbook; use taskbook_common::board; +use taskbook_common::limits::SizeNotice; use taskbook_common::links::{self, LinkIndex, Target}; use taskbook_common::StorageItem; @@ -106,6 +107,9 @@ pub struct App { config_path: Option, /// Link side pane state pub pane: PaneState, + /// Archived item count for the size notice. Read at startup and whenever + /// Archive view loads the archive, so it can lag deletes made since. + pub archived_count: usize, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -262,6 +266,7 @@ impl App { focused: false, cursor: 0, }, + archived_count: 0, cached_stats: Stats { percent: 0, complete: 0, @@ -272,6 +277,9 @@ impl App { }; app.refresh_items()?; + if app.view != ViewMode::Archive { + app.archived_count = app.taskbook.get_all_archive_items()?.len(); + } Ok(app) } @@ -282,7 +290,9 @@ impl App { self.links = LinkIndex::build(self.active_items.values()); self.boards = self.taskbook.boards_of(&self.active_items); self.items = if self.view == ViewMode::Archive { - self.taskbook.get_all_archive_items()? + let archive = self.taskbook.get_all_archive_items()?; + self.archived_count = archive.len(); + archive } else { self.active_items.clone() }; @@ -297,6 +307,22 @@ impl App { Ok(()) } + /// Header text when the store is past the size taskbook is designed for, + /// suggesting `/clear` only when there are completed tasks to archive. + pub fn size_notice(&self) -> Option { + let notice = SizeNotice::check(self.active_items.len(), self.archived_count)?; + let completed = self + .active_items + .values() + .filter(|item| item.as_task().is_some_and(|task| task.is_complete)) + .count(); + let mut text = notice.summary(); + if notice.active_over_recommended() && completed > 0 { + text.push_str(&format!(" · /clear archives {completed} done")); + } + Some(text) + } + /// Recalculate cached statistics fn recalculate_stats(&mut self) { let mut complete = 0; @@ -1096,4 +1122,86 @@ pub(crate) mod tests { assert!(content.divergent); assert!(content.backlinks.is_empty()); } + + // ----- size notice ----- + + /// Put `pending` open and `done` completed tasks in the app's active set. + fn fill(app: &mut App, pending: u64, done: u64) { + for id in 1..=pending + done { + let mut task = + taskbook_common::Task::new(id, format!("t{id}"), vec!["my board".into()], 1); + task.is_complete = id > pending; + app.active_items + .insert(id.to_string(), StorageItem::Task(task)); + } + app.items = app.active_items.clone(); + } + + #[test] + fn no_size_notice_within_the_recommendation() { + let (mut app, _) = test_app(); + fill(&mut app, 512, 0); + assert_eq!(app.size_notice(), None); + } + + #[test] + fn size_notice_suggests_clear_only_with_completed_tasks() { + let (mut app, _) = test_app(); + fill(&mut app, 513, 0); + assert_eq!( + app.size_notice().as_deref(), + Some("513 active items, over the 512 recommended") + ); + + let (mut app, _) = test_app(); + fill(&mut app, 500, 13); + assert_eq!( + app.size_notice().as_deref(), + Some("513 active items, over the 512 recommended · /clear archives 13 done") + ); + } + + #[test] + fn archive_only_notice_has_no_action() { + let (mut app, _) = test_app(); + fill(&mut app, 10, 5); + app.archived_count = 5000; + assert_eq!( + app.size_notice().as_deref(), + Some("5000 archived items, over the 4096 supported") + ); + } + + #[test] + fn startup_counts_the_archive() { + let dir = std::env::temp_dir().join(format!("tb-app-test-{}", uuid::Uuid::new_v4())); + let taskbook = Taskbook::with_storage(Box::new(LocalStorage::new(&dir).unwrap())); + let id = taskbook + .create_task_direct_with_tags(vec!["my board".into()], "x".into(), 1, vec![], None) + .unwrap(); + taskbook.delete_items_silent(&[id]).unwrap(); + + let app = App::with_taskbook(taskbook, Config::default(), dir.join("config.json")).unwrap(); + assert_eq!(app.archived_count, 1); + } + + #[test] + fn header_shows_the_size_notice() { + use ratatui::{backend::TestBackend, Terminal}; + let (mut app, _) = test_app(); + fill(&mut app, 513, 0); + app.update_display_order(); + let mut terminal = Terminal::new(TestBackend::new(160, 10)).unwrap(); + terminal + .draw(|f| crate::tui::ui::render(f, &mut app)) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + let header: String = (0..buffer.area.width) + .map(|x| buffer[(x, 0)].symbol().to_string()) + .collect(); + assert!( + header.contains("[513 active items, over the 512 recommended]"), + "{header}" + ); + } } diff --git a/crates/taskbook-client/src/tui/ui.rs b/crates/taskbook-client/src/tui/ui.rs index 98711be..15fedc7 100644 --- a/crates/taskbook-client/src/tui/ui.rs +++ b/crates/taskbook-client/src/tui/ui.rs @@ -89,6 +89,12 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { spans.push(Span::styled("[Hiding completed]", app.theme.warning)); } + // Store past the size taskbook is designed for (informational) + if let Some(notice) = app.size_notice() { + spans.push(Span::raw(" ")); + spans.push(Span::styled(format!("[{notice}]"), app.theme.warning)); + } + let header = Line::from(spans); let paragraph = Paragraph::new(header); frame.render_widget(paragraph, area); From 728958220717f7658301a8f923e3eb45628aaea4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 07:55:27 +0000 Subject: [PATCH 45/45] docs: describe the size notice Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw --- CLAUDE.md | 2 +- README.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6940eb8..4f75115 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,7 +120,7 @@ crates/ (honoring `$XDG_CONFIG_HOME`), falling back to the legacy `~/.taskbook.json` when it exists and the XDG file does not. -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. 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`). +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. diff --git a/README.md b/README.md index b4cd94b..d964e69 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,10 @@ 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. For +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.