git.lucas.co / cce-list
things-to-remember checklist
git clone https://git.lucas.co/cce-list.git

commitb4996c2d1ade63822d60f7cdcc857407dbba2483
parentd75de11904
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 13:42
feat: lossless list files, and a Vault tasks view (Obsidian-on-cce M5)

Lossless: a list file is any Markdown note. Task lines keep how they were
written (indent, */+/1. bullets, custom statuses like [/]), and every
other line -- headings, prose, blanks, fenced code -- is kept verbatim
with the task below it (Item::before) or after the last (trailer). An
untouched file saves back byte for byte. Plain lines are no longer
adopted as items, which turned a heading into a checkbox on the next
save. Deleting a task (remove_item, and the sync's retain_items) hands
its kept lines to the next one. An #[ignore]d test round-trips the lists
on this machine (`cargo test -p cce-list -- --ignored`).

Vault tasks: with a vault configured, the switcher lists every open task
in it, grouped by note. A tick writes the checkbox back through
cce-vault (which re-reads the file first); a task ticked in the view
stays, struck through, until it is left. A note's name opens it in
cce-notes; typing adds a task to today's daily note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 Cargo.toml         |   1 +
 src/bin/sync.rs    |  20 ++--
 src/lib.rs         | 323 +++++++++++++++++++++++++++++++++++++++++++----------
 src/main.rs        | 187 +++++++++++++++++++++++++++----
 src/vault_tasks.rs | 165 +++++++++++++++++++++++++++
 5 files changed, 604 insertions(+), 92 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index f6a47c3..9239824 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,7 @@ serde_json = "1"
 reqwest = { version = "0.12", features = ["blocking", "json"] }
 roxmltree = "0.20"
 chrono = "0.4"
+cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "11a26a5f8e1ff1ac55a951d88afc751ab68a5aba" }
 keyring = { version = "3", features = ["sync-secret-service"] }
 
 # The desktop checklist app; src/bin/sync.rs adds the cce-list-sync helper,
diff --git a/src/bin/sync.rs b/src/bin/sync.rs
index 444e5c3..9fad50b 100644
--- a/src/bin/sync.rs
+++ b/src/bin/sync.rs
@@ -338,10 +338,11 @@ fn run_sync(backend: &Backend, dry_run: bool, force_deletes: bool) -> Result<(),
         let mut fresh = cce_list::load_list(&file.title).unwrap_or_else(|_| ListFile {
             title: file.title.clone(),
             id: Some(list_id.clone()),
-            items: Vec::new(),
+            ..Default::default()
         });
         fresh.id = Some(list_id.clone());
-        apply_local(&mut fresh.items, &plan, &todos, &created, &moved_from);
+        let orphans = apply_local(&mut fresh.items, &plan, &todos, &created, &moved_from);
+        fresh.trailer.splice(0..0, orphans);
         save_list(&fresh).map_err(|e| e.to_string())?;
     }
 
@@ -621,7 +622,7 @@ fn apply_list_plan(
     for id in &plan.pull_new {
         let r = &remote.lists[id];
         let title = unique_local_title(&r.title, local);
-        let file = ListFile { title, id: Some(id.clone()), items: Vec::new() };
+        let file = ListFile { title, id: Some(id.clone()), ..Default::default() };
         save_list(&file).map_err(|e| e.to_string())?;
         local.push(file);
         state.lists.insert(
@@ -818,8 +819,10 @@ fn apply_local(
     remote: &BTreeMap<String, &RemoteTodo>,
     created: &[(String, Option<String>, String)],
     moved_from: &[(String, String)],
-) {
-    items.retain(|i| {
+) -> Vec<String> {
+    // Lines kept above a deleted row move to the next row; any left with
+    // no row after them go back to the caller for the trailer.
+    let orphans = cce_list::retain_items(items, |i| {
         i.uid.as_deref().is_none_or(|u| !plan.pull_deletes.iter().any(|d| d == u))
     });
     for uid in &plan.pull_updates {
@@ -843,8 +846,9 @@ fn apply_local(
     }
     for uid in &plan.pull_new {
         let todo = remote[uid];
-        items.push(Item { text: todo.summary.clone(), done: todo.done, uid: Some(uid.clone()) });
+        items.push(Item { text: todo.summary.clone(), done: todo.done, uid: Some(uid.clone()), ..Default::default() });
     }
+    orphans
 }
 
 // ── Accounts ──────────────────────────────────────────────────────────────
@@ -1609,7 +1613,7 @@ mod tests {
     use super::*;
 
     fn item(text: &str, done: bool, uid: Option<&str>) -> Item {
-        Item { text: text.into(), done, uid: uid.map(String::from) }
+        Item { text: text.into(), done, uid: uid.map(String::from), ..Default::default() }
     }
 
     fn todo(summary: &str, done: bool, etag: &str) -> RemoteTodo {
@@ -1647,7 +1651,7 @@ mod tests {
     }
 
     fn lfile(title: &str, id: Option<&str>) -> ListFile {
-        ListFile { title: title.into(), id: id.map(String::from), items: Vec::new() }
+        ListFile { title: title.into(), id: id.map(String::from), ..Default::default() }
     }
 
     fn refs<'a>(m: &'a BTreeMap<String, RemoteTodo>) -> BTreeMap<String, &'a RemoteTodo> {
diff --git a/src/lib.rs b/src/lib.rs
index 65ef573..849ca9b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -14,24 +14,46 @@
 //!
 //! Before lists existed there was a single `list.md`; `load_lists` migrates
 //! it on first sight (see [`migrate_legacy`]).
+//!
+//! **Reading is lossless.** A file is any Markdown note: task lines become
+//! items, keeping how they were written (indentation, `*`/`+`/`1.` bullets,
+//! a custom status such as Obsidian's `[/]`), and every other line —
+//! headings, prose, blank lines, fenced code — is kept verbatim with the
+//! task below it ([`Item::before`]) or after the last one
+//! ([`ListFile::trailer`]). A load and save of an untouched file writes it
+//! back byte for byte (bar a missing final newline), so cce-list can open
+//! an ordinary note without eating it. (It used to adopt every non-task
+//! line as an item, turning a heading into a checkbox on the next save.)
 
 use std::collections::BTreeMap;
 use std::path::{Path, PathBuf};
 
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
 pub struct Item {
     pub text: String,
     pub done: bool,
     /// Server identity for synced items; None for purely local ones.
     pub uid: Option<String>,
-}
-
-/// One checklist: its file stem, its server identity (if mirrored), items.
-#[derive(Debug, Clone, PartialEq, Eq)]
+    /// What came before the `[` as written — `  - `, `* `, `1. ` — when it
+    /// is not the plain `- ` a new item gets.
+    pub prefix: Option<String>,
+    /// The status between the brackets when it is neither ` ` nor `x`
+    /// (`/`, `-`, `>` …). Such an item reads as done, and keeps its mark
+    /// until it is unticked.
+    pub mark: Option<char>,
+    /// Non-task lines just above this item, verbatim, written back before
+    /// it. Deleting the item hands them to the next one ([`remove_item`]).
+    pub before: Vec<String>,
+}
+
+/// One checklist: its file stem, its server identity (if mirrored), items,
+/// and whatever non-task lines follow the last item.
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
 pub struct ListFile {
     pub title: String,
     pub id: Option<String>,
     pub items: Vec<Item>,
+    pub trailer: Vec<String>,
 }
 
 pub fn data_dir() -> PathBuf {
@@ -75,29 +97,83 @@ pub fn list_path(title: &str) -> PathBuf {
 
 // ── Markdown ──────────────────────────────────────────────────────────────
 
-/// Checklist lines become items; any other non-empty line is adopted as a
-/// not-done item rather than parsed around — the next save rewrites the file,
-/// so a line this reader skipped would be a line silently deleted.
-pub fn parse_items(text: &str) -> Vec<Item> {
-    text.lines()
-        .filter_map(|line| {
-            let trimmed = line.trim();
-            if trimmed.is_empty() {
-                return None;
+/// A task line's parts: (prefix before `[`, status char, text after `] `).
+/// `- [ ] a`, `  * [x] b`, `1. [/] c`; the bracket must be followed by a
+/// space or end the line.
+fn task_line(line: &str) -> Option<(&str, char, &str)> {
+    let body = line.trim_start();
+    let indent = line.len() - body.len();
+    let bullet = if let Some(r) = body.strip_prefix(['-', '*', '+']) {
+        body.len() - r.len()
+    } else {
+        let digits = body.chars().take_while(char::is_ascii_digit).count();
+        let after = &body[digits..];
+        if digits == 0 || !(after.starts_with(". ") || after.starts_with(") ")) {
+            return None;
+        }
+        digits + 1
+    };
+    let rest = body[bullet..].strip_prefix(' ')?;
+    let mut chars = rest.chars();
+    if chars.next()? != '[' {
+        return None;
+    }
+    let status = chars.next()?;
+    if chars.next()? != ']' {
+        return None;
+    }
+    let after = chars.as_str();
+    let text = match after.strip_prefix(' ') {
+        Some(t) => t,
+        None if after.is_empty() => "",
+        None => return None,
+    };
+    let prefix_len = indent + bullet + 1;
+    Some((&line[..prefix_len], status, text))
+}
+
+/// Split text into items and the non-task lines after the last one. Lines
+/// inside fenced code blocks are never tasks.
+pub fn parse_body(text: &str) -> (Vec<Item>, Vec<String>) {
+    let mut items = Vec::new();
+    let mut pending: Vec<String> = Vec::new();
+    let mut fence: Option<&str> = None;
+    for line in text.lines() {
+        let t = line.trim_start();
+        if let Some(f) = fence {
+            if t.starts_with(f) {
+                fence = None;
             }
-            let (done, rest) = if let Some(r) = trimmed.strip_prefix("- [ ] ") {
-                (false, r)
-            } else if let Some(r) =
-                trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] "))
-            {
-                (true, r)
-            } else {
-                (false, trimmed)
-            };
-            let (text, uid) = split_uid_comment(rest);
-            Some(Item { text: text.to_string(), done, uid })
-        })
-        .collect()
+            pending.push(line.to_string());
+            continue;
+        }
+        if t.starts_with("```") || t.starts_with("~~~") {
+            fence = Some(&t[..3]);
+            pending.push(line.to_string());
+            continue;
+        }
+        match task_line(line) {
+            Some((prefix, status, rest)) => {
+                let (text, uid) = split_uid_comment(rest);
+                let done = status != ' ';
+                items.push(Item {
+                    text: text.to_string(),
+                    done,
+                    uid,
+                    prefix: (prefix != "- ").then(|| prefix.to_string()),
+                    mark: (done && status != 'x').then_some(status),
+                    before: std::mem::take(&mut pending),
+                });
+            }
+            None => pending.push(line.to_string()),
+        }
+    }
+    (items, pending)
+}
+
+/// The items of a body, its trailing lines dropped (tests, the migration).
+pub fn parse_items(text: &str) -> Vec<Item> {
+    parse_body(text).0
 }
 
 /// Peel a trailing `<!-- uid:… -->` off an item's text, if present.
@@ -115,16 +191,85 @@ fn split_uid_comment(rest: &str) -> (&str, Option<String>) {
 }
 
 pub fn serialize_items(items: &[Item]) -> String {
-    items
-        .iter()
-        .map(|i| {
-            let mark = if i.done { 'x' } else { ' ' };
-            match &i.uid {
-                Some(uid) => format!("- [{mark}] {} <!-- uid:{uid} -->\n", i.text),
-                None => format!("- [{mark}] {}\n", i.text),
+    let mut out = String::new();
+    for i in items {
+        for line in &i.before {
+            out.push_str(line);
+            out.push('\n');
+        }
+        let mark = if i.done { i.mark.unwrap_or('x') } else { ' ' };
+        let prefix = i.prefix.as_deref().unwrap_or("- ");
+        let sep = if i.text.is_empty() && i.uid.is_none() { "" } else { " " };
+        match &i.uid {
+            Some(uid) => out.push_str(&format!("{prefix}[{mark}]{sep}{} <!-- uid:{uid} -->\n", i.text)),
+            None => out.push_str(&format!("{prefix}[{mark}]{sep}{}\n", i.text)),
+        }
+    }
+    out
+}
+
+/// Remove item `i`, handing the lines kept above it to the item that
+/// follows (or the trailer), so deleting a task never deletes a heading.
+pub fn remove_item(list: &mut ListFile, i: usize) -> Item {
+    let mut item = list.items.remove(i);
+    let before = std::mem::take(&mut item.before);
+    match list.items.get_mut(i) {
+        Some(next) => {
+            let mut lines = before;
+            lines.append(&mut next.before);
+            next.before = lines;
+        }
+        None => {
+            let mut lines = before;
+            lines.append(&mut list.trailer);
+            list.trailer = lines;
+        }
+    }
+    item
+}
+
+/// `Vec::retain` for items, keeping the lines above a dropped item with
+/// the next kept one. Lines that no kept item follows are returned, for
+/// the caller to put at the front of the trailer.
+pub fn retain_items(items: &mut Vec<Item>, mut keep: impl FnMut(&Item) -> bool) -> Vec<String> {
+    let mut carried: Vec<String> = Vec::new();
+    let mut out = Vec::with_capacity(items.len());
+    for mut item in items.drain(..) {
+        if keep(&item) {
+            if !carried.is_empty() {
+                carried.append(&mut item.before);
+                item.before = std::mem::take(&mut carried);
             }
-        })
-        .collect()
+            out.push(item);
+        } else {
+            carried.append(&mut item.before);
+        }
+    }
+    *items = out;
+    carried
+}
+
+/// A whole list file: the optional `<!-- list:ID -->` header, then the
+/// body's items and trailing lines.
+pub fn parse_file(text: &str) -> (Option<String>, Vec<Item>, Vec<String>) {
+    let mut lines = text.lines();
+    let mut first = lines.next();
+    while matches!(first, Some(l) if l.trim().is_empty()) {
+        first = lines.next();
+    }
+    if let Some(id) = first
+        .map(str::trim)
+        .and_then(|l| l.strip_prefix("<!-- list:"))
+        .and_then(|l| l.strip_suffix("-->"))
+        .map(str::trim)
+        .filter(|id| !id.is_empty())
+    {
+        let rest: Vec<&str> = lines.collect();
+        let (items, trailer) = parse_body(&rest.join("\n"));
+        return (Some(id.to_string()), items, trailer);
+    }
+    let (items, trailer) = parse_body(text);
+    (None, items, trailer)
 }
 
 /// A whole list file: the optional `<!-- list:ID -->` header, then items.
@@ -156,6 +301,15 @@ pub fn serialize_list(id: Option<&str>, items: &[Item]) -> String {
     out
 }
 
+pub fn serialize_file(list: &ListFile) -> String {
+    let mut out = serialize_list(list.id.as_deref(), &list.items);
+    for line in &list.trailer {
+        out.push_str(line);
+        out.push('\n');
+    }
+    out
+}
+
 // ── Files ─────────────────────────────────────────────────────────────────
 
 /// Every list on disk, titles sorted case-insensitively. Runs the legacy
@@ -179,8 +333,8 @@ pub fn load_lists() -> std::io::Result<Vec<ListFile>> {
             continue;
         };
         let text = std::fs::read_to_string(&path)?;
-        let (id, items) = parse_list(&text);
-        out.push(ListFile { title, id, items });
+        let (id, items, trailer) = parse_file(&text);
+        out.push(ListFile { title, id, items, trailer });
     }
     out.sort_by_key(|l| l.title.to_lowercase());
     Ok(out)
@@ -188,14 +342,14 @@ pub fn load_lists() -> std::io::Result<Vec<ListFile>> {
 
 pub fn load_list(title: &str) -> std::io::Result<ListFile> {
     let text = std::fs::read_to_string(list_path(title))?;
-    let (id, items) = parse_list(&text);
-    Ok(ListFile { title: safe_title(title), id, items })
+    let (id, items, trailer) = parse_file(&text);
+    Ok(ListFile { title: safe_title(title), id, items, trailer })
 }
 
 /// Write-temp-then-rename in the same directory, so a crash mid-write never
 /// leaves a truncated list behind.
 pub fn save_list(list: &ListFile) -> std::io::Result<()> {
-    atomic_write(&list_path(&list.title), &serialize_list(list.id.as_deref(), &list.items))
+    atomic_write(&list_path(&list.title), &serialize_file(list))
 }
 
 pub fn delete_list(title: &str) -> std::io::Result<()> {
@@ -269,7 +423,7 @@ pub fn migrate_legacy() -> std::io::Result<()> {
             (Some(i), Some(m)) if i != m => safe_title(i),
             _ => "Tasks".to_string(),
         };
-        save_list(&ListFile { title, id, items })?;
+        save_list(&ListFile { title, id, items, trailer: Vec::new() })?;
     }
     save_current("Tasks")?;
     std::fs::rename(&legacy, legacy.with_extension("md.migrated"))
@@ -377,31 +531,77 @@ mod tests {
     #[test]
     fn checklist_round_trips() {
         let items = vec![
-            Item { text: "water the plants".into(), done: false, uid: None },
-            Item { text: "renew passport".into(), done: true, uid: Some("AB-12".into()) },
+            Item { text: "water the plants".into(), done: false, uid: None, ..Default::default() },
+            Item { text: "renew passport".into(), done: true, uid: Some("AB-12".into()), ..Default::default() },
         ];
         assert_eq!(parse_items(&serialize_items(&items)), items);
     }
 
-    /// A hand-edited file must survive a load/save cycle: plain lines are
-    /// adopted as items, not dropped, and `[X]` reads the same as `[x]`.
+    /// A hand-edited file survives a load/save cycle untouched: plain lines
+    /// stay lines (they are no longer adopted as items), `[X]` reads as done.
     #[test]
-    fn foreign_lines_are_adopted_not_dropped() {
-        let parsed = parse_items("buy stamps\n- [X] call mom\n\n  - [ ] indented\n");
-        assert_eq!(
-            parsed,
-            vec![
-                Item { text: "buy stamps".into(), done: false, uid: None },
-                Item { text: "call mom".into(), done: true, uid: None },
-                Item { text: "indented".into(), done: false, uid: None },
-            ]
-        );
+    fn foreign_lines_are_kept_not_adopted() {
+        let (items, trailer) = parse_body("buy stamps\n- [X] call mom\n\n  - [ ] indented\n");
+        assert_eq!(items.len(), 2);
+        assert_eq!(items[0].before, ["buy stamps"]);
+        assert!(items[0].done && items[0].mark == Some('X'));
+        assert_eq!(items[1].prefix.as_deref(), Some("  - "));
+        assert_eq!(items[1].before, [""]);
+        assert!(trailer.is_empty());
+    }
+
+    #[test]
+    fn a_note_round_trips_byte_for_byte() {
+        let note = "---\ntags: [x]\n---\n# Heading\n\nSome prose.\n- [ ] open\n  - [x] done nested\n* [/] in progress\n1. [ ] numbered\n- plain bullet\n- [ ]\n\n```\n- [ ] in code\n```\n## Tail\n";
+        let (id, items, trailer) = parse_file(note);
+        assert_eq!(id, None);
+        assert_eq!(items.len(), 5, "{items:?}");
+        assert_eq!(items[2].mark, Some('/'));
+        let list = ListFile { title: "n".into(), id, items, trailer };
+        assert_eq!(serialize_file(&list), note);
+    }
+
+    #[test]
+    fn deleting_a_task_keeps_the_lines_above_it() {
+        let (id, items, trailer) = parse_file("# A\n- [ ] one\n## B\n- [ ] two\nend\n");
+        let mut list = ListFile { title: "t".into(), id, items, trailer };
+        remove_item(&mut list, 1);
+        assert_eq!(serialize_file(&list), "# A\n- [ ] one\n## B\nend\n");
+        remove_item(&mut list, 0);
+        assert_eq!(serialize_file(&list), "# A\n## B\nend\n");
+        let (_, mut items, _) = parse_file("x\n- [ ] a\ny\n- [ ] b\nz\n- [ ] c\n");
+        let orphans = retain_items(&mut items, |i| i.text == "b");
+        assert_eq!(items[0].before, ["x", "y"]);
+        assert_eq!(orphans, ["z"]);
+    }
+
+    /// Every list on this machine reads and writes back unchanged. Reads
+    /// only; run by hand: `cargo test -p cce-list -- --ignored`.
+    #[test]
+    #[ignore]
+    fn real_lists_round_trip() {
+        let Ok(entries) = std::fs::read_dir(lists_dir()) else { return };
+        for e in entries.flatten() {
+            let text = std::fs::read_to_string(e.path()).unwrap();
+            let (id, items, trailer) = parse_file(&text);
+            let list = ListFile { title: String::new(), id, items, trailer };
+            let back = serialize_file(&list);
+            let want = if text.ends_with('\n') || text.is_empty() { text.clone() } else { format!("{text}\n") };
+            assert_eq!(back, want, "{}", e.path().display());
+        }
+    }
+
+    #[test]
+    fn unticking_drops_a_custom_mark() {
+        let mut items = parse_items("- [/] half\n");
+        items[0].done = false;
+        assert_eq!(serialize_items(&items), "- [ ] half\n");
     }
 
     #[test]
     fn uid_comment_is_identity_not_text() {
         let parsed = parse_items("- [ ] call mom <!-- uid:X-1 -->\n- [ ] literal <!-- not a uid -->\n");
-        assert_eq!(parsed[0], Item { text: "call mom".into(), done: false, uid: Some("X-1".into()) });
+        assert_eq!(parsed[0], Item { text: "call mom".into(), done: false, uid: Some("X-1".into()), ..Default::default() });
         // A comment that is not `uid:` stays part of the text.
         assert_eq!(parsed[1].uid, None);
         assert_eq!(parsed[1].text, "literal <!-- not a uid -->");
@@ -409,15 +609,16 @@ mod tests {
 
     #[test]
     fn list_header_round_trips_and_is_optional() {
-        let items = vec![Item { text: "a".into(), done: false, uid: None }];
+        let items = vec![Item { text: "a".into(), done: false, uid: None, ..Default::default() }];
         let text = serialize_list(Some("MDM5"), &items);
         assert_eq!(parse_list(&text), (Some("MDM5".into()), items.clone()));
         // No header: a hand-made file is a local-only list, first line and all.
         assert_eq!(parse_list("- [ ] a\n"), (None, items));
-        // A header that is not `list:` is just an adopted line.
-        let (id, adopted) = parse_list("<!-- note -->\n- [ ] a\n");
+        // A header that is not `list:` is just a kept line.
+        let (id, kept) = parse_list("<!-- note -->\n- [ ] a\n");
         assert_eq!(id, None);
-        assert_eq!(adopted.len(), 2);
+        assert_eq!(kept.len(), 1);
+        assert_eq!(kept[0].before, ["<!-- note -->"]);
     }
 
     #[test]
diff --git a/src/main.rs b/src/main.rs
index 2763d0f..aea792d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18,9 +18,12 @@
 //! next sync tick, and the app re-reads the directory when the sync (or a
 //! hand edit) changes it.
 
+mod vault_tasks;
+
 use cce_list::{
     delete_list, lists_dir, load_current, load_lists, save_current, save_list, Item, ListFile,
 };
+use vault_tasks::{Row, VaultTasks};
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
@@ -58,6 +61,17 @@ const WATCH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
 const NEW_LIST: &str = "New list…";
 const DELETE_LIST: &str = "Delete list…";
 const ITEM_PLACEHOLDER: &str = "Remember to…";
+/// The switcher entry (and `current` value) for every open task in the
+/// notes vault; listed only when a vault is configured.
+const VAULT_TASKS: &str = "Vault tasks";
+const VAULT_PLACEHOLDER: &str = "Add to today's note…";
+
+/// One row as drawn: a list item, a vault task, or a vault note's name.
+struct RowView {
+    text: String,
+    done: bool,
+    header: bool,
+}
 
 #[derive(Debug, Clone)]
 enum ListMessage {
@@ -166,6 +180,10 @@ struct ListApp {
     /// Outside-change detection: what the directory looked like when the
     /// lists were last read, and the countdown to the next look.
     disk_sig: Vec<(String, Option<std::time::SystemTime>)>,
+    /// The notes vault's tasks, when a vault is configured; `in_vault`
+    /// says the window shows them instead of a list.
+    vault: Option<VaultTasks>,
+    in_vault: bool,
     /// When the directory may be re-read again. A wall clock, not an
     /// accumulation of `tick`'s `dt`: `dt` is animation time, clamped to one
     /// frame after an idle sleep, and a list nobody is typing into is idle —
@@ -178,14 +196,56 @@ impl ListApp {
         self.lists.get(self.cur).map(|l| l.items.as_slice()).unwrap_or(&[])
     }
 
-    fn switcher_options(lists: &[ListFile]) -> Vec<String> {
-        lists
+    fn vault_rows(&self) -> &[Row] {
+        match (&self.vault, self.in_vault) {
+            (Some(v), true) => &v.rows,
+            _ => &[],
+        }
+    }
+
+    fn row_count(&self) -> usize {
+        if self.in_vault { self.vault_rows().len() } else { self.items().len() }
+    }
+
+    fn rows(&self) -> Vec<RowView> {
+        if self.in_vault {
+            self.vault_rows()
+                .iter()
+                .map(|r| match r {
+                    Row::Note { name, .. } => RowView { text: name.clone(), done: false, header: true },
+                    Row::Task { text, done, .. } => RowView { text: text.clone(), done: *done, header: false },
+                })
+                .collect()
+        } else {
+            self.items().iter().map(|i| RowView { text: i.text.clone(), done: i.done, header: false }).collect()
+        }
+    }
+
+    fn switcher_options(&self) -> Vec<String> {
+        self.lists
             .iter()
             .map(|l| l.title.clone())
+            .chain(self.vault.as_ref().map(|_| VAULT_TASKS.to_string()))
             .chain([NEW_LIST.to_string(), DELETE_LIST.to_string()])
             .collect()
     }
 
+    /// Show the vault's tasks.
+    fn enter_vault(&mut self) {
+        let Some(v) = self.vault.as_mut() else { return };
+        v.enter();
+        self.in_vault = true;
+        self.switcher.selected = self.lists.len();
+        if let Err(e) = save_current(VAULT_TASKS) {
+            log::error!("cce-list: saving current list: {e}");
+        }
+        self.disk_sig = disk_signature();
+        self.scroll = 0.0;
+        self.hovered_row = None;
+        self.set_mode(Mode::Items);
+        self.needs_rebuild = true;
+    }
+
     /// (Re)read every list from disk. Keeps the shown list by title where it
     /// still exists (the sync may have renamed or removed it), guarantees at
     /// least one list, and refreshes the switcher.
@@ -198,24 +258,26 @@ impl ListApp {
             }
         };
         if lists.is_empty() {
-            let first = ListFile { title: "Tasks".to_string(), id: None, items: Vec::new() };
+            let first = ListFile { title: "Tasks".to_string(), ..Default::default() };
             if let Err(e) = save_list(&first) {
                 log::error!("cce-list: creating the first list: {e}");
             }
             lists.push(first);
         }
         let wanted = load_current().or_else(|| self.lists.get(self.cur).map(|l| l.title.clone()));
+        let vault_wanted = wanted.as_deref() == Some(VAULT_TASKS) && self.vault.is_some();
         let cur = wanted
             .as_deref()
             .and_then(|t| lists.iter().position(|l| l.title == t))
-            .unwrap_or(0);
-        if wanted.as_deref() != Some(lists[cur].title.as_str()) {
+            .unwrap_or(self.cur.min(lists.len() - 1));
+        if !vault_wanted && wanted.as_deref() != Some(lists[cur].title.as_str()) {
             let _ = save_current(&lists[cur].title);
         }
         self.lists = lists;
         self.cur = cur;
-        self.switcher.options = Self::switcher_options(&self.lists);
-        self.switcher.selected = cur;
+        self.in_vault = vault_wanted;
+        self.switcher.options = self.switcher_options();
+        self.switcher.selected = if self.in_vault { self.lists.len() } else { cur };
         self.disk_sig = disk_signature();
         self.clamp_scroll();
         if let Some((px, py)) = self.pointer {
@@ -239,6 +301,7 @@ impl ListApp {
             return;
         }
         self.cur = idx;
+        self.in_vault = false;
         self.switcher.selected = idx;
         if let Err(e) = save_current(&self.lists[idx].title) {
             log::error!("cce-list: saving current list: {e}");
@@ -253,6 +316,7 @@ impl ListApp {
     fn set_mode(&mut self, mode: Mode) {
         self.mode = mode;
         let placeholder = match mode {
+            Mode::Items if self.in_vault => VAULT_PLACEHOLDER.to_string(),
             Mode::Items => ITEM_PLACEHOLDER.to_string(),
             Mode::NamingList => "Name the new list, then Enter".to_string(),
             Mode::ConfirmDelete => format!(
@@ -294,19 +358,26 @@ impl ListApp {
             self.select_list(idx);
             return;
         }
-        let list = ListFile { title: title.clone(), id: None, items: Vec::new() };
+        let list = ListFile { title: title.clone(), ..Default::default() };
         if let Err(e) = save_list(&list) {
             log::error!("cce-list: creating {title}: {e}");
             return;
         }
         self.lists.push(list);
         self.lists.sort_by_key(|l| l.title.to_lowercase());
-        self.switcher.options = Self::switcher_options(&self.lists);
+        self.switcher.options = self.switcher_options();
         let idx = self.lists.iter().position(|l| l.title == title).unwrap_or(0);
         self.select_list(idx);
     }
 
     fn begin_delete(&mut self) {
+        if self.in_vault {
+            // The vault view is not a list; there is nothing to delete.
+            self.set_mode(Mode::Items);
+            self.input_box.set_placeholder("Pick a list to delete it");
+            self.needs_rebuild = true;
+            return;
+        }
         if self.lists.len() <= 1 {
             // The server keeps a default list too; one is the floor.
             self.set_mode(Mode::Items);
@@ -328,7 +399,7 @@ impl ListApp {
             return;
         }
         self.lists.remove(self.cur);
-        self.switcher.options = Self::switcher_options(&self.lists);
+        self.switcher.options = self.switcher_options();
         let idx = self.cur.min(self.lists.len().saturating_sub(1));
         self.select_list(idx);
     }
@@ -336,14 +407,20 @@ impl ListApp {
     /// The switcher reported a pick: a list, or one of the two actions.
     fn switcher_picked(&mut self) {
         let idx = self.switcher.selected;
-        if idx < self.lists.len() {
-            if idx != self.cur {
+        let n = self.lists.len();
+        let has_vault = usize::from(self.vault.is_some());
+        if idx < n {
+            if idx != self.cur || self.in_vault {
                 self.select_list(idx);
             }
+        } else if has_vault == 1 && idx == n {
+            if !self.in_vault {
+                self.enter_vault();
+            }
         } else {
-            // A pseudo-entry: restore the trigger to the shown list.
-            self.switcher.selected = self.cur;
-            if idx == self.lists.len() {
+            // A pseudo-entry: restore the trigger to what is shown.
+            self.switcher.selected = if self.in_vault { n } else { self.cur };
+            if idx == n + has_vault {
                 self.begin_new_list();
             } else {
                 self.begin_delete();
@@ -381,7 +458,7 @@ impl ListApp {
     }
 
     fn max_scroll(&self, m: &Metrics) -> f32 {
-        (self.items().len() as f32 * ROW_H - self.list_viewport(m).height).max(0.0)
+        (self.row_count() as f32 * ROW_H - self.list_viewport(m).height).max(0.0)
     }
 
     fn clamp_scroll(&mut self) {
@@ -419,7 +496,7 @@ impl ListApp {
             return None;
         }
         let i = ((y - m.list_top + self.scroll) / ROW_H).floor();
-        let row = (i >= 0.0).then_some(i as usize).filter(|&i| i < self.items().len())?;
+        let row = (i >= 0.0).then_some(i as usize).filter(|&i| i < self.row_count())?;
         let r = self.row_rect(&m, row);
         (x >= r.x && x <= r.x + r.width).then_some(row)
     }
@@ -433,12 +510,29 @@ impl ListApp {
                     self.create_list(&text);
                 }
             }
+            Mode::Items if self.in_vault => {
+                if text.is_empty() {
+                    return;
+                }
+                if let Some(v) = self.vault.as_mut() {
+                    match v.add(&text) {
+                        Ok(path) => log::info!("cce-list: added a task to {path}"),
+                        Err(e) => {
+                            log::error!("cce-list: adding to today's note: {e}");
+                            self.input_box.set_placeholder("Could not write today's note");
+                        }
+                    }
+                }
+                self.clear_input();
+                self.clamp_scroll();
+                self.needs_rebuild = true;
+            }
             Mode::Items => {
                 if text.is_empty() {
                     return;
                 }
                 if let Some(list) = self.lists.get_mut(self.cur) {
-                    list.items.push(Item { text, done: false, uid: None });
+                    list.items.push(Item { text, ..Default::default() });
                 }
                 self.clear_input();
                 self.save_current_list();
@@ -476,6 +570,8 @@ impl Application for ListApp {
             pointer: None,
             hovered_row: None,
             disk_sig: Vec::new(),
+            vault: VaultTasks::open(),
+            in_vault: false,
             watch_at: std::time::Instant::now(),
         };
         app.load_from_disk();
@@ -515,6 +611,15 @@ impl Application for ListApp {
             *needs_rebuild = true;
             self.needs_rebuild = true;
         }
+        // Vault changes arrive from the watcher thread; applying them is
+        // cheap, so do it every tick (at least once a second when idle).
+        if let Some(v) = self.vault.as_mut() {
+            if v.poll() && self.in_vault {
+                self.clamp_scroll();
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+            }
+        }
         // Outside changes (the sync tick, a hand edit) show up without a
         // relaunch — but never while typing a name, which a reload would
         // interrupt; that waits a second.
@@ -585,12 +690,13 @@ impl Application for ListApp {
         // into the input or the plate's bottom roll.
         let (family, font_size) = cce_ui::layout::list_font_parsed();
         let vp = self.list_viewport(&m);
-        let items = self.items();
+        let items = self.rows();
+        let empty = if self.in_vault { "no open tasks in the vault" } else { "nothing to remember" };
         pc.clip(vp, |pc| {
             if items.is_empty() {
                 let r = self.row_rect(&m, 0);
                 pc.text_with(
-                    "nothing to remember".to_string(),
+                    empty.to_string(),
                     r.x,
                     cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0),
                     font_size,
@@ -605,6 +711,21 @@ impl Application for ListApp {
                     continue;
                 }
                 let hovered = self.hovered_row == Some(i);
+                if item.header {
+                    // A vault note's name: a click opens it in cce-notes.
+                    let color = if hovered { cce_ui::colors::TEXT_FG } else { cce_ui::colors::TEXT_DIM };
+                    let size = (font_size * 0.9).round();
+                    pc.text_with(
+                        item.text.clone(),
+                        r.x,
+                        cce_ui::layout::align_text_y(r.y, r.height, size, 0.0) + 2.0,
+                        size,
+                        srgb_u8(color),
+                        Some(family.clone()),
+                        Some([r.x, r.y, r.x + r.width, r.y + r.height]),
+                    );
+                    continue;
+                }
                 let (cx, cy) = (r.x + CHECK_R, r.y + r.height / 2.0);
                 cce_ui::widget::Checkbox::paint_round_mark(pc, cx, cy, CHECK_R, item.done);
                 let color = if item.done {
@@ -641,7 +762,7 @@ impl Application for ListApp {
                         Cap::Flat,
                     );
                 }
-                if hovered {
+                if hovered && !self.in_vault {
                     let d = Self::delete_rect(r);
                     let (dcx, dcy) = (d.x + d.width / 2.0, d.y + d.height / 2.0);
                     let arm = 4.0;
@@ -722,15 +843,35 @@ impl Application for ListApp {
 
         if button == MouseButton::Left && state == ElementState::Pressed {
             if let Some(i) = self.row_at(px, py) {
+                if self.in_vault {
+                    if let Some(v) = self.vault.as_mut() {
+                        match v.rows.get(i).cloned() {
+                            Some(Row::Note { path, .. }) => v.open_note(&path),
+                            Some(Row::Task { .. }) => {
+                                if let Err(e) = v.toggle(i) {
+                                    log::error!("cce-list: ticking a vault task: {e}");
+                                }
+                            }
+                            None => {}
+                        }
+                    }
+                    self.needs_rebuild = true;
+                    *needs_rebuild = true;
+                    return None;
+                }
                 let m = metrics(self.width as f32);
                 let d = Self::delete_rect(self.row_rect(&m, i));
                 if let Some(list) = self.lists.get_mut(self.cur) {
                     if px >= d.x && px <= d.x + d.width && py >= d.y && py <= d.y + d.height {
-                        list.items.remove(i);
+                        cce_list::remove_item(list, i);
                         self.clamp_scroll();
                         self.hovered_row = self.row_at(px, py);
                     } else {
-                        list.items[i].done = !list.items[i].done;
+                        let item = &mut list.items[i];
+                        item.done = !item.done;
+                        // A custom status (`[/]`, `[-]`) does not survive a
+                        // tick by hand: it reads `x` or ` ` from now on.
+                        item.mark = None;
                     }
                 }
                 self.save_current_list();
diff --git a/src/vault_tasks.rs b/src/vault_tasks.rs
new file mode 100644
index 0000000..883d328
--- /dev/null
+++ b/src/vault_tasks.rs
@@ -0,0 +1,165 @@
+//! The "Vault tasks" view: every open checkbox in the notes vault (the one
+//! cce-notes uses, from `vault { path }` in config.kdl), grouped by note.
+//! Ticking writes the checkbox back into its note through `cce-vault`,
+//! which re-reads the file first, so an edit made a second ago elsewhere is
+//! not overwritten. Typing adds a task to today's daily note.
+//!
+//! A task ticked here stays on screen, struck through, until the view is
+//! left — so a mis-tick can be undone — although the view otherwise lists
+//! only open tasks. Clicking a note's name opens it in cce-notes.
+//!
+//! The vault is watched like any cce-vault client: a watcher thread queues
+//! changed paths, and `poll` applies them on the app's loop.
+
+use std::collections::HashSet;
+use std::io::{BufRead, BufReader, Write};
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+
+use cce_vault::index::stem;
+use cce_vault::{Index, Task, VaultWatcher};
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Row {
+    Note { path: String, name: String },
+    Task { path: String, line: usize, text: String, done: bool },
+}
+
+pub struct VaultTasks {
+    index: Index,
+    _watcher: Option<VaultWatcher>,
+    pending: Arc<Mutex<Vec<PathBuf>>>,
+    /// (path, line) of tasks ticked in this view since it was entered.
+    ticked: HashSet<(String, usize)>,
+    pub rows: Vec<Row>,
+}
+
+/// The view's rows: each note with open (or just-ticked) tasks, then them.
+pub fn build_rows<'a>(tasks: impl Iterator<Item = (&'a str, &'a Task)>, ticked: &HashSet<(String, usize)>) -> Vec<Row> {
+    let mut rows = Vec::new();
+    let mut last: Option<&str> = None;
+    for (path, t) in tasks {
+        let shown = t.is_open() || ticked.contains(&(path.to_string(), t.line));
+        if !shown {
+            continue;
+        }
+        if last != Some(path) {
+            rows.push(Row::Note { path: path.to_string(), name: stem(path).to_string() });
+            last = Some(path);
+        }
+        rows.push(Row::Task { path: path.to_string(), line: t.line, text: t.text.trim().to_string(), done: !t.is_open() });
+    }
+    rows
+}
+
+impl VaultTasks {
+    /// The configured vault's tasks, or `None` when no vault is set up.
+    pub fn open() -> Option<VaultTasks> {
+        let root = cce_vault::config::vault_root(None).ok()?;
+        let index = Index::open(&root, true).map_err(|e| log::warn!("vault tasks: {e}")).ok()?;
+        let pending = Arc::new(Mutex::new(Vec::new()));
+        let queue = pending.clone();
+        let watcher = VaultWatcher::spawn(&root, move |paths| {
+            queue.lock().unwrap_or_else(|e| e.into_inner()).extend(paths);
+        })
+        .map_err(|e| log::warn!("vault watcher: {e}"))
+        .ok();
+        let mut v = VaultTasks { index, _watcher: watcher, pending, ticked: HashSet::new(), rows: Vec::new() };
+        v.rebuild();
+        Some(v)
+    }
+
+    fn rebuild(&mut self) {
+        self.rows = build_rows(self.index.tasks(), &self.ticked);
+    }
+
+    /// Apply queued vault changes; true when the rows may have changed.
+    pub fn poll(&mut self) -> bool {
+        let paths: Vec<PathBuf> = std::mem::take(&mut *self.pending.lock().unwrap_or_else(|e| e.into_inner()));
+        if paths.is_empty() {
+            return false;
+        }
+        self.index.apply_changes(&paths);
+        self.rebuild();
+        true
+    }
+
+    /// Entering the view starts a fresh session: earlier ticks drop out.
+    pub fn enter(&mut self) {
+        self.ticked.clear();
+        self.poll();
+        self.rebuild();
+    }
+
+    /// Tick or untick the task in row `i`.
+    pub fn toggle(&mut self, i: usize) -> Result<(), String> {
+        let Some(Row::Task { path, line, done, .. }) = self.rows.get(i).cloned() else { return Ok(()) };
+        let status = if done { ' ' } else { 'x' };
+        self.index.set_task(&path, line, status).map_err(|e| e.to_string())?;
+        if done {
+            self.ticked.remove(&(path, line));
+        } else {
+            self.ticked.insert((path, line));
+        }
+        self.rebuild();
+        Ok(())
+    }
+
+    /// Add `- [ ] text` to today's daily note (created from the vault's
+    /// template if missing); returns its path.
+    pub fn add(&mut self, text: &str) -> Result<String, String> {
+        let today = chrono::Local::now().date_naive();
+        let (path, _) = self.index.daily(today, true).map_err(|e| e.to_string())?;
+        self.index.append(&path, &format!("- [ ] {text}")).map_err(|e| e.to_string())?;
+        self.rebuild();
+        Ok(path)
+    }
+
+    /// Show a note in cce-notes: hand it to the running instance over its
+    /// socket, or start one.
+    pub fn open_note(&self, path: &str) {
+        let abs = self.index.abs(path);
+        let sock = cce_ui::ipc::socket_path("cce-notes");
+        if let Ok(mut s) = std::os::unix::net::UnixStream::connect(&sock) {
+            if s.write_all(format!("open {}\n", abs.display()).as_bytes()).is_ok() {
+                let mut reply = String::new();
+                let _ = BufReader::new(s).read_line(&mut reply);
+                return;
+            }
+        }
+        let _ = std::process::Command::new("cce-notes").arg("open").arg(&abs).spawn();
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn task(status: char, text: &str, line: usize) -> Task {
+        Task { status, text: text.into(), line, status_at: 0 }
+    }
+
+    #[test]
+    fn rows_group_open_tasks_and_keep_this_sessions_ticks() {
+        let a = [task(' ', "one", 1), task('x', "old done", 2)];
+        let b = [task('x', "ticked now", 4), task('x', "long done", 5)];
+        let c = [task('x', "all done", 0)];
+        let all: Vec<(&str, &Task)> = a
+            .iter()
+            .map(|t| ("A.md", t))
+            .chain(b.iter().map(|t| ("dir/B.md", t)))
+            .chain(c.iter().map(|t| ("C.md", t)))
+            .collect();
+        let ticked: HashSet<(String, usize)> = [("dir/B.md".to_string(), 4)].into_iter().collect();
+        let rows = build_rows(all.into_iter(), &ticked);
+        assert_eq!(
+            rows,
+            [
+                Row::Note { path: "A.md".into(), name: "A".into() },
+                Row::Task { path: "A.md".into(), line: 1, text: "one".into(), done: false },
+                Row::Note { path: "dir/B.md".into(), name: "B".into() },
+                Row::Task { path: "dir/B.md".into(), line: 4, text: "ticked now".into(), done: true },
+            ]
+        );
+    }
+}