git.lucas.co / cce-notes
notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git

commitb0e0c6e9da6e8eb0836b2f42830a249ca80f0d9e
parent56e1a5f200
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 13:04
cce-notes: backlinks, outline, search, rename, [[ completion, MCP (milestone 3)

- right pane: Backlinks (linked mentions grouped by note with their lines,
  then unlinked mentions) and Outline (headings; a click scrolls the
  reading view or moves the source caret)
- left pane gains a Search tab: full text, every word required, grouped by
  note; `#tag` / `tag:x` lists tagged notes. A tag click in the reading
  view opens it; Enter opens the first hit; `cce-notes search` too
- rename (F2 or a click on the title): previews the link count from
  plan_rename, then Index::rename rewrites every link across the vault
- `[[` completion in source mode under the caret: shortest link text that
  still resolves, closed with `]]`, one undo step. Painted as a hole the
  editor is drawn around, since text draws over every plate
- MCP on 127.0.0.1:3002 (CCE_NOTES_MCP_PORT): search, find_notes,
  read_note, backlinks, open_note, append_daily, current_note
- fix: load_text clears the TextBox undo history, which let Ctrl+Z after
  switching notes in source mode restore the previous note's text
- the reading view maps list items to lines, so a search hit or task
  lands on its item rather than its list
- status bar counts linking notes, as the backlinks pane does

Verified in scale-1 and scale-2 shadows against a copy of the vault.

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

 CLAUDE.md       |  64 +++--
 src/complete.rs | 118 ++++++++++
 src/instance.rs |  11 +-
 src/main.rs     | 715 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/mcp.rs      | 210 +++++++++++++++++
 src/panel.rs    | 163 +++++++++++++
 src/reading.rs  |   9 +-
 src/side.rs     | 180 ++++++++++++++
 8 files changed, 1426 insertions(+), 44 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7102585..b1d7163 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,9 +1,11 @@
 # cce-notes
 
-The vault's notes editor — milestone 2 of the Obsidian-on-cce plan, built
-on `cce-vault` (milestone 1). A file tree on the left and one note on the
-right, shown as rendered Markdown (**reading view**) or edited as text
-(**source mode**), toggled with Ctrl+E as in Obsidian.
+The vault's notes editor — milestones 2 and 3 of the Obsidian-on-cce plan,
+built on `cce-vault` (milestone 1). Three panes: files or search on the
+left, one note in the middle — shown as rendered Markdown (**reading
+view**) or edited as text (**source mode**), toggled with Ctrl+E as in
+Obsidian — and backlinks or the outline on the right. Agents reach the
+same vault through its MCP tools.
 
 Read `../cce-vault/CLAUDE.md` first: the index, watcher, writes and the
 `markdown` document model this app draws all live there, along with their
@@ -14,9 +16,13 @@ proposal".
 
 | File | What it owns |
 | --- | --- |
-| `main.rs` | The `Application`: panes, modes, history, quick switcher, autosave, conflicts, input |
+| `main.rs` | The `Application`: panes, modes, history, switcher + rename prompt, completion, autosave, conflicts, input |
 | `reading.rs` | Lays out `cce_vault::markdown::Block`s at a width into draw items + click targets |
 | `tree.rs` | The file tree's rows (folders first, case-insensitive, collapsible) |
+| `panel.rs` | The scrollable header/title/line list both side panes draw through |
+| `side.rs` | What the panes list: backlinks + unlinked mentions, outline, text and tag search |
+| `complete.rs` | `[[` completion: the open link at the caret, the shortest link text, the splice |
+| `mcp.rs` | MCP tools (`search`, `find_notes`, `read_note`, `backlinks`, `open_note`, `append_daily`, `current_note`) |
 | `instance.rs` | Single instance on `/tmp/cce-notes-<WAYLAND_DISPLAY>.sock`; the CLI's commands |
 
 ## Behaviour worth knowing before changing it
@@ -33,6 +39,26 @@ proposal".
 - **Source mode autosaves** 1.5 s after typing stops (`AUTOSAVE_AFTER`,
   polled through `idle_poll_interval` only while dirty), on leaving a note,
   on switching to reading, and in `on_exit`.
+- **Rename (F2, or a click on the title in the band)** goes through
+  `Index::rename`, which rewrites every link to the note across the vault;
+  the prompt previews the count from `plan_rename` before Enter. History
+  entries follow the rename.
+- **`[[` completion** opens while the caret sits in an unclosed `[[` on
+  its line (`complete::open_link`; a `|` or `#` ends it). Enter/Tab
+  inserts the shortest link text that still resolves (`link_text`) and
+  `]]`, recording one undo step (`TextBox::history.record`); Escape shuts
+  it for that link. It needs the caret's pixel position, which TextBox
+  does not expose: `completion_rect` recomputes it from the public
+  `wrap_text`/`char_width`/`line_height`/`scroll_*` the way TextBox's own
+  `selection_quads` does (monospace, 8 px inner pad) — change both if
+  TextBox's padding changes.
+- **Popups over text:** text draws after every plate, so the completion
+  popup is a *hole*: the editor is painted four times, clipped to the
+  bands around the popup (clips intersect), and the popup fills the gap.
+  The quick switcher instead stops painting what it covers.
+- **`load_text` clears the TextBox undo history** and re-syncs
+  `editor_state`; without that, Ctrl+Z after switching notes in source
+  mode stepped back into the previous note's text.
 - **Links:** click in reading, Ctrl+click in source (the caret's byte
   offset is matched against `cce_vault::parse` link spans). An unresolved
   link creates the note at the vault root and opens it in source mode, as
@@ -75,14 +101,23 @@ today, which a long note can churn).
 ```sh
 cce-notes [--vault <dir>] [open] <note>[#heading] [line]   # line is 1-based
 cce-notes daily [YYYY-MM-DD]
-cce-notes search <query>         # opens the quick switcher holding it
+cce-notes search <query>         # the search pane holding it (#tag works)
 ```
 
 A second launch forwards its command to the running instance and exits.
 Keys come from input.kdl's `cce-notes` domain: `quick_switcher` (ctrl+o),
 `toggle_mode` (ctrl+e), `save` (ctrl+s), `reload` (ctrl+r), `back`
 (alt+arrowleft), `forward` (alt+arrowright), `toggle_tree` (ctrl+\\),
-`daily` (alt+d), `quit` (ctrl+q). Mouse back/forward walk the history too.
+`toggle_side` (ctrl+]), `search` (ctrl+shift+f), `rename` (f2), `daily`
+(alt+d), `quit` (ctrl+q). Mouse back/forward walk the history too.
+
+## MCP
+
+`claude mcp add --transport http cce-notes http://127.0.0.1:3002/mcp`.
+Only the single instance serves it, and only with a vault. Tools run on
+the app's event loop against the window's own index, so a write to a note
+open and dirty in the window raises the usual conflict. A shadow instance
+must move the port (`CCE_NOTES_MCP_PORT=3902`) — the live one holds 3002.
 
 ## Testing
 
@@ -91,11 +126,14 @@ tree and the socket commands. Anything visual goes through a shadow at
 `--scale 2` with `CCE_FONTS_DIR` set, against a **copy** of the vault
 (`CCE_VAULT=<copy>`) — never the live vault, which syncs to other devices.
 Fullscreen the window first (`ctl set-mode fullscreen cce-notes`): the
-scale-2 shadow output is only 640×360 logical.
+scale-2 shadow output is only 640×360 logical, too narrow for the right
+pane (it yields below a 360 px note) — check three-pane layout in a
+scale-1 shadow (1280×720) and pointer/caret maths at scale 2.
 
-## Not done yet (milestone 2 → 3)
+## Not done yet
 
-Backlinks and outline panes, `[[` completion, rename, the search pane, tag
-search (a tag click does nothing yet), MCP tools, embedded images, the
-icon (`Icon=cce-notes` has no SVG in cce-icons yet), per-note scroll in
-the history, and the move of the reading view into cce-ui.
+Heading completion (`[[Note#`), rendered snippets in the panes (they show
+raw lines), search debounce for large vaults (it scans every note per
+keystroke), embedded images, the icon (`Icon=cce-notes` has no SVG in
+cce-icons yet), per-note scroll in the history, and the move of the
+reading view into cce-ui.
diff --git a/src/complete.rs b/src/complete.rs
new file mode 100644
index 0000000..7eedd17
--- /dev/null
+++ b/src/complete.rs
@@ -0,0 +1,118 @@
+//! `[[` completion in source mode: find the link being typed at the caret,
+//! and write the chosen note back in the shortest form that still reaches
+//! it, as Obsidian does. Positions are char indices (the TextBox's own
+//! `cursor_idx` unit), not bytes.
+
+use cce_vault::index::stem;
+use cce_vault::{FileKind, Index};
+
+/// How many choices the popup offers.
+pub const LIMIT: usize = 8;
+
+/// An open `[[` before `cursor` on the caret's line, with nothing closing
+/// it yet: the char index just past the `[[`, and what has been typed
+/// since. A `|` (display text) or `#` (a heading) ends completion — the
+/// note part is already chosen by then.
+pub fn open_link(text: &str, cursor: usize) -> Option<(usize, String)> {
+    let chars: Vec<char> = text.chars().collect();
+    let cursor = cursor.min(chars.len());
+    let mut i = cursor;
+    while i >= 2 {
+        let c = chars[i - 1];
+        if c == '\n' || c == ']' || c == '|' || c == '#' {
+            return None;
+        }
+        if c == '[' && chars[i - 2] == '[' {
+            let query: String = chars[i..cursor].iter().collect();
+            return Some((i, query));
+        }
+        i -= 1;
+    }
+    None
+}
+
+/// What to write between the brackets for `path`: its bare name when that
+/// resolves back to it from `from`, else its vault path; `.md` dropped,
+/// other extensions kept.
+pub fn link_text(ix: &Index, from: Option<&str>, path: &str) -> String {
+    let is_note = FileKind::of(path) == FileKind::Note;
+    let file = path.rsplit('/').next().unwrap_or(path);
+    let short = if is_note { stem(path).to_string() } else { file.to_string() };
+    if ix.resolve_text(from, &short).as_deref() == Some(path) {
+        short
+    } else if is_note {
+        path.strip_suffix(".md").unwrap_or(path).to_string()
+    } else {
+        path.to_string()
+    }
+}
+
+/// Choices for a query: fuzzy over names, aliases and paths.
+pub fn choices(ix: &Index, query: &str) -> Vec<String> {
+    if query.trim().is_empty() {
+        let mut notes: Vec<(&String, u64)> =
+            ix.files().iter().filter(|(_, e)| e.kind == FileKind::Note).map(|(p, e)| (p, e.mtime)).collect();
+        notes.sort_by(|a, b| b.1.cmp(&a.1));
+        return notes.into_iter().take(LIMIT).map(|(p, _)| p.clone()).collect();
+    }
+    ix.find(query, LIMIT).into_iter().map(|m| m.path).collect()
+}
+
+/// Replace chars `start..cursor` with `link`, closing it with `]]` unless
+/// the text already closes it there. Returns the new text and the caret
+/// just past the closing brackets.
+pub fn apply(text: &str, start: usize, cursor: usize, link: &str) -> (String, usize) {
+    let chars: Vec<char> = text.chars().collect();
+    let cursor = cursor.min(chars.len());
+    let closed = chars.get(cursor) == Some(&']') && chars.get(cursor + 1) == Some(&']');
+    let mut out: String = chars[..start].iter().collect();
+    out.push_str(link);
+    if !closed {
+        out.push_str("]]");
+    }
+    let caret = start + link.chars().count() + 2;
+    out.extend(&chars[cursor..]);
+    (out, caret)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn finds_the_open_link_at_the_caret() {
+        let t = "see [[Pro";
+        assert_eq!(open_link(t, 9), Some((6, "Pro".into())));
+        assert_eq!(open_link("x [[", 4), Some((4, String::new())));
+        assert_eq!(open_link("[[A]] b", 7), None);
+        assert_eq!(open_link("[[A|sh", 6), None);
+        assert_eq!(open_link("[[A#h", 5), None);
+        assert_eq!(open_link("[[A\nb", 5), None);
+        assert_eq!(open_link("[x", 2), None);
+        // Non-ASCII before the link: indices are chars.
+        assert_eq!(open_link("é [[ab", 6), Some((4, "ab".into())));
+    }
+
+    #[test]
+    fn applies_and_closes_once() {
+        assert_eq!(apply("see [[Pro", 6, 9, "Projects"), ("see [[Projects]]".into(), 16));
+        assert_eq!(apply("[[Pro]] x", 2, 5, "Projects"), ("[[Projects]] x".into(), 12));
+        assert_eq!(apply("é [[a tail", 4, 5, "Alpha"), ("é [[Alpha]] tail".into(), 11));
+    }
+
+    #[test]
+    fn link_text_is_shortest_that_resolves() {
+        let dir = tempfile::tempdir().unwrap();
+        for p in ["a/Note.md", "b/Note.md", "Solo.md", "img/pic.png"] {
+            let abs = dir.path().join(p);
+            std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
+            std::fs::write(abs, "x").unwrap();
+        }
+        let ix = Index::open(dir.path(), false).unwrap();
+        assert_eq!(link_text(&ix, None, "Solo.md"), "Solo");
+        assert_eq!(link_text(&ix, None, "img/pic.png"), "pic.png");
+        // Two notes share the name: from a/, the bare name reaches a/Note.
+        assert_eq!(link_text(&ix, Some("a/Other.md"), "a/Note.md"), "Note");
+        assert_eq!(link_text(&ix, Some("a/Other.md"), "b/Note.md"), "b/Note");
+    }
+}
diff --git a/src/instance.rs b/src/instance.rs
index f0d6f64..89c942c 100644
--- a/src/instance.rs
+++ b/src/instance.rs
@@ -9,7 +9,7 @@
 //! - `open <path>[#heading] [line]` — a vault path, an absolute path inside
 //!   the vault, or a note name; `line` is 1-based.
 //! - `daily [YYYY-MM-DD]` — open (creating it from the template) a day's note.
-//! - `search <query>` — open the quick switcher holding `query`.
+//! - `search <query>` — show the search pane holding `query` (`#tag` too).
 //! - `show` — nothing but bringing the instance up.
 //!
 //! Connect before binding, as cce-browser does: a refused connect means a
@@ -143,10 +143,12 @@ fn try_forward(path: &str, cmd: &Command) -> bool {
     BufReader::new(stream).read_line(&mut reply).is_ok()
 }
 
-/// Serve the claimed listener on a thread, feeding the app's loop.
-pub fn spawn_listener(sender: calloop::channel::Sender<Message>) {
+/// Serve the claimed listener on a thread, feeding the app's loop. False
+/// when this process holds no listener (single-instance handling failed
+/// and it runs standalone).
+pub fn spawn_listener(sender: calloop::channel::Sender<Message>) -> bool {
     let Some(listener) = CLAIMED.lock().unwrap().take() else {
-        return;
+        return false;
     };
     std::thread::spawn(move || {
         for conn in listener.incoming() {
@@ -168,6 +170,7 @@ pub fn spawn_listener(sender: calloop::channel::Sender<Message>) {
             let _ = reader.get_mut().write_all(reply);
         }
     });
+    true
 }
 
 pub fn cleanup() {
diff --git a/src/main.rs b/src/main.rs
index 0373464..8d18209 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -20,6 +20,10 @@
 mod instance;
 mod reading;
 mod tree;
+mod complete;
+mod mcp;
+mod panel;
+mod side;
 
 use std::collections::HashMap;
 use std::path::{Path, PathBuf};
@@ -38,6 +42,7 @@ use cce_vault::{FileKind, Index, VaultWatcher};
 use wayland_client::QueueHandle;
 
 use instance::Command;
+use panel::{Action, Panel};
 use reading::{srgb_u8, Hit, Layout, Measure, Theme};
 
 const BAND_H: f32 = 40.0;
@@ -51,6 +56,13 @@ const READ_SIZE: f32 = 15.0;
 const SWITCHER_W: f32 = 560.0;
 const SWITCHER_ROW_H: f32 = 28.0;
 const SWITCHER_ROWS: usize = 10;
+const SIDE_W: f32 = 260.0;
+/// The note pane's narrowest before the side pane gives way to it.
+const NOTE_MIN_W: f32 = 360.0;
+const TAB_H: f32 = 30.0;
+const SEARCH_H: f32 = 30.0;
+const COMPLETE_W: f32 = 320.0;
+const COMPLETE_ROW_H: f32 = 24.0;
 /// Source mode saves once typing has paused this long.
 const AUTOSAVE_AFTER: Duration = Duration::from_millis(1500);
 
@@ -64,6 +76,7 @@ const CONFLICT: [f32; 4] = [0.95, 0.45, 0.35, 1.0];
 pub enum Message {
     Command(Command),
     VaultChanged(Vec<PathBuf>),
+    Mcp(cce_ui::mcp::McpToolCall),
     Exit,
 }
 
@@ -85,6 +98,9 @@ struct Keys {
     back: String,
     forward: String,
     toggle_tree: String,
+    toggle_side: String,
+    search: String,
+    rename: String,
     daily: String,
     quit: String,
 }
@@ -100,6 +116,9 @@ impl Keys {
             back: get("back", "alt+arrowleft"),
             forward: get("forward", "alt+arrowright"),
             toggle_tree: get("toggle_tree", "ctrl+\\"),
+            toggle_side: get("toggle_side", "ctrl+]"),
+            search: get("search", "ctrl+shift+f"),
+            rename: get("rename", "f2"),
             daily: get("daily", "alt+d"),
             quit: get("quit", "ctrl+q"),
         }
@@ -112,6 +131,30 @@ enum Mode {
     Source,
 }
 
+/// The left pane's tabs.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+enum LeftTab {
+    Files,
+    Search,
+}
+
+/// The right pane's tabs.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+enum SideTab {
+    Backlinks,
+    Outline,
+}
+
+/// `[[` completion in source mode: the link being typed and its choices.
+struct Completion {
+    /// Char index just past the `[[`.
+    start: usize,
+    query: String,
+    /// Vault paths, best first.
+    choices: Vec<String>,
+    selected: usize,
+}
+
 /// One quick-switcher result: an existing note, or "create this".
 #[derive(Clone, Debug)]
 enum Choice {
@@ -124,6 +167,10 @@ struct Switcher {
     selected: usize,
     /// The query the choices were computed for.
     query: String,
+    /// A rename prompt for this note rather than a switcher.
+    rename: Option<String>,
+    /// A line under the input: what a rename will do, or why it cannot.
+    hint: Option<(String, bool)>,
 }
 
 /// Widths through the renderer's own shaping entry, cached per run.
@@ -188,6 +235,24 @@ struct NotesApp {
     switcher: Option<Switcher>,
     switcher_input: Adapted<TextBox>,
 
+    left_tab: LeftTab,
+    search_input: Adapted<TextBox>,
+    search_panel: Panel,
+    /// The query the search panel shows results for.
+    search_seen: String,
+
+    show_side: bool,
+    side_tab: SideTab,
+    side_panel: Panel,
+    /// What the side panel was built for; a change resets its scroll.
+    side_key: Option<(String, SideTab)>,
+    side_dirty: bool,
+
+    completion: Option<Completion>,
+    /// A `[[` whose completion was dismissed with Escape: it stays shut
+    /// until the caret leaves that link.
+    completion_dismissed: Option<usize>,
+
     conflict: bool,
     status: Option<(String, bool)>,
 
@@ -202,8 +267,19 @@ struct NotesApp {
 
 /// Where everything sits for a window size.
 struct Metrics {
+    /// The whole left pane: tabs, then the tree or the search.
     tree: Rect,
+    left_tabs: Rect,
+    /// The search box, when the search tab shows.
+    search_box: Rect,
+    /// Below the tabs (and search box): the tree's rows or the results.
+    tree_body: Rect,
     note: Rect,
+    side: Rect,
+    side_tabs: Rect,
+    side_body: Rect,
+    /// The note's name in the band; a click renames it.
+    title: Rect,
     mode_chip: Rect,
 }
 
@@ -212,11 +288,29 @@ impl NotesApp {
         let (w, h) = (self.width as f32, self.height as f32);
         let body_h = (h - BAND_H - STATUS_H).max(0.0);
         let tree_w = if self.show_tree && self.index.is_some() { TREE_W.min(w * 0.4) } else { 0.0 };
+        let side_w = if self.show_side && self.current.is_some() && w - tree_w - SIDE_W >= NOTE_MIN_W {
+            SIDE_W
+        } else {
+            0.0
+        };
         let inset = cce_ui::layout::root_plate_inset();
+        let tree = Rect { x: 0.0, y: BAND_H, width: tree_w, height: body_h };
+        let left_tabs = Rect { x: 0.0, y: BAND_H, width: tree_w, height: TAB_H };
+        let search_box = Rect { x: 8.0, y: BAND_H + TAB_H + 4.0, width: (tree_w - 16.0).max(0.0), height: SEARCH_H };
+        let body_top = if self.left_tab == LeftTab::Search { search_box.y + SEARCH_H + 4.0 } else { BAND_H + TAB_H };
+        let side = Rect { x: w - side_w, y: BAND_H, width: side_w, height: body_h };
+        let mode_chip = Rect { x: w - inset - 84.0, y: (BAND_H - 24.0) / 2.0, width: 84.0, height: 24.0 };
         Metrics {
-            tree: Rect { x: 0.0, y: BAND_H, width: tree_w, height: body_h },
-            note: Rect { x: tree_w, y: BAND_H, width: w - tree_w, height: body_h },
-            mode_chip: Rect { x: w - inset - 84.0, y: (BAND_H - 24.0) / 2.0, width: 84.0, height: 24.0 },
+            tree,
+            left_tabs,
+            search_box,
+            tree_body: Rect { x: 0.0, y: body_top, width: tree_w, height: (BAND_H + body_h - body_top).max(0.0) },
+            note: Rect { x: tree_w, y: BAND_H, width: w - tree_w - side_w, height: body_h },
+            side,
+            side_tabs: Rect { x: side.x, y: BAND_H, width: side_w, height: TAB_H },
+            side_body: Rect { x: side.x, y: BAND_H + TAB_H, width: side_w, height: (body_h - TAB_H).max(0.0) },
+            title: Rect { x: inset, y: 0.0, width: (mode_chip.x - 12.0 - inset).max(0.0), height: BAND_H },
+            mode_chip,
         }
     }
 
@@ -241,6 +335,7 @@ impl NotesApp {
         let Some(ix) = &self.index else { return };
         let notes = ix.files().iter().filter(|(_, e)| e.kind == FileKind::Note).map(|(p, _)| p.as_str());
         self.rows = self.tree.rows(notes);
+        self.tree_hover = None;
     }
 
     fn editor_text(&self) -> &str {
@@ -273,9 +368,16 @@ impl NotesApp {
         self.editor.edit_buffer = text.clone();
         self.editor.cursor_idx = 0;
         self.editor.select_anchor = None;
+        self.editor.all_selected = false;
+        self.editor.sync_editor_state();
+        // Undo must not step back into another note's (or the pre-reload)
+        // text.
+        self.editor.history.clear();
+        self.completion = None;
         self.edit_seen = text.clone();
         self.saved = text;
         self.conflict = false;
+        self.side_dirty = true;
         self.invalidate();
     }
 
@@ -341,7 +443,9 @@ impl NotesApp {
             self.read_scroll = 0.0;
             self.read_motion = ScrollMotion::new();
         }
-        self.pending_line = line;
+        if let Some(l) = line {
+            self.goto_line(l);
+        }
         self.tree.reveal(path);
         self.rebuild_rows();
         if record {
@@ -409,8 +513,7 @@ impl NotesApp {
             SpanLink::Url(url) => {
                 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
             }
-            // Tag search comes with the search pane.
-            SpanLink::Tag(_) => {}
+            SpanLink::Tag(tag) => self.open_search(&format!("#{tag}")),
         }
     }
 
@@ -494,7 +597,7 @@ impl NotesApp {
         match cmd {
             Command::Open { target, line } => self.open_target(&target, line),
             Command::Daily(d) => self.open_daily(d),
-            Command::Search(q) => self.open_switcher(&q),
+            Command::Search(q) => self.open_search(&q),
             Command::Show => {}
         }
         self.needs_rebuild = true;
@@ -506,12 +609,18 @@ impl NotesApp {
         let Some(ix) = self.index.as_mut() else { return };
         ix.apply_changes(&paths);
         self.rebuild_rows();
-        // Link colours depend on what resolves.
+        // Link colours, backlinks and search hits depend on every file.
         self.layout = None;
+        self.side_dirty = true;
+        if self.left_tab == LeftTab::Search {
+            self.refresh_search(true);
+        }
         let Some(cur) = self.current.clone() else { return };
         let Some(ix) = self.index.as_ref() else { return };
         let abs = ix.abs(&cur);
-        if !paths.iter().any(|p| p == &abs || p.canonicalize().ok().as_deref() == abs.canonicalize().ok().as_deref()) {
+        let canon = abs.canonicalize().ok();
+        let touches = |p: &PathBuf| p == &abs || (canon.is_some() && p.canonicalize().ok() == canon);
+        if !paths.iter().any(touches) {
             return;
         }
         match ix.read_text(&cur) {
@@ -536,7 +645,8 @@ impl NotesApp {
         self.switcher_input.edit_buffer = query.to_string();
         self.switcher_input.cursor_idx = query.chars().count();
         self.switcher_input.select_anchor = None;
-        self.switcher = Some(Switcher { choices: Vec::new(), selected: 0, query: "\u{0}".into() });
+        self.switcher =
+            Some(Switcher { choices: Vec::new(), selected: 0, query: "\u{0}".into(), rename: None, hint: None });
         self.ui_context.set_focused(&mut self.switcher_input);
         WidgetHost::focus(&mut self.switcher_input);
         self.refresh_switcher();
@@ -567,6 +677,28 @@ impl NotesApp {
         if sw.query == query {
             return;
         }
+        if let Some(from) = sw.rename.clone() {
+            sw.choices = Vec::new();
+            sw.hint = Some(match rename_target(&from, &query) {
+                None => ("Type the note's new name or path".into(), false),
+                Some(to) if to == from => ("Unchanged".into(), false),
+                Some(to) => match ix.plan_rename(&from, &to) {
+                    Ok(plan) => {
+                        let notes: std::collections::BTreeSet<&str> = plan.edits.iter().map(|e| e.path.as_str()).collect();
+                        let what = if plan.edits.is_empty() {
+                            "no links to update".to_string()
+                        } else {
+                            format!("updates {} in {}", side::plural(plan.edits.len(), "link"), side::plural(notes.len(), "note"))
+                        };
+                        (format!("Enter renames to {to} — {what}"), false)
+                    }
+                    Err(e) => (e.to_string(), true),
+                },
+            });
+            sw.query = query;
+            self.needs_rebuild = true;
+            return;
+        }
         sw.choices = if query.trim().is_empty() {
             // Recent first: the history, then the newest files.
             let mut seen = std::collections::HashSet::new();
@@ -615,6 +747,20 @@ impl NotesApp {
     fn switcher_pick(&mut self, create: bool) {
         let Some(sw) = &self.switcher else { return };
         let query = self.switcher_query();
+        if let Some(from) = sw.rename.clone() {
+            match rename_target(&from, &query) {
+                Some(to) if to != from => {
+                    if sw.hint.as_ref().is_some_and(|h| h.1) {
+                        return; // the plan already said why it cannot
+                    }
+                    self.close_switcher();
+                    self.rename(&from, &to);
+                }
+                Some(_) => self.close_switcher(),
+                None => {}
+            }
+            return;
+        }
         let choice = if create { Some(Choice::Create(query.trim().to_string())) } else { sw.choices.get(sw.selected).cloned() };
         self.close_switcher();
         match choice {
@@ -626,7 +772,7 @@ impl NotesApp {
 
     fn switcher_rect(&self) -> Rect {
         let w = SWITCHER_W.min(self.width as f32 - 32.0);
-        let n = self.switcher.as_ref().map_or(0, |s| s.choices.len());
+        let n = self.switcher.as_ref().map_or(0, |s| s.choices.len() + usize::from(s.hint.is_some()));
         Rect {
             x: (self.width as f32 - w) / 2.0,
             y: BAND_H + 24.0,
@@ -645,6 +791,240 @@ impl NotesApp {
         (i < self.switcher.as_ref()?.choices.len()).then_some(i)
     }
 
+    // ---- rename ----------------------------------------------------------
+
+    fn open_rename(&mut self) {
+        let Some(cur) = self.current.clone() else { return };
+        let shown = cur.strip_suffix(".md").unwrap_or(&cur).to_string();
+        self.open_switcher(&shown);
+        if let Some(sw) = self.switcher.as_mut() {
+            sw.rename = Some(cur);
+            sw.query = "\u{0}".into();
+        }
+        self.refresh_switcher();
+    }
+
+    /// Rename (or move) a note and rewrite every link to it, as Obsidian
+    /// does with "automatically update internal links" on.
+    fn rename(&mut self, from: &str, to: &str) {
+        if self.current.as_deref() == Some(from) && !self.save() {
+            return;
+        }
+        let Some(ix) = self.index.as_mut() else { return };
+        match ix.rename(from, to) {
+            Ok(plan) => {
+                for h in &mut self.history {
+                    if h == from {
+                        *h = plan.to.clone();
+                    }
+                }
+                let notes: std::collections::BTreeSet<&str> = plan.edits.iter().map(|e| e.path.as_str()).collect();
+                let msg = format!(
+                    "Renamed to {} · updated {} in {}",
+                    plan.to,
+                    side::plural(plan.edits.len(), "link"),
+                    side::plural(notes.len(), "note")
+                );
+                if self.current.as_deref() == Some(from) {
+                    // Its own self-links may have been rewritten too.
+                    let to = plan.to.clone();
+                    self.current = Some(to.clone());
+                    if let Ok(text) = self.index.as_ref().map(|ix| ix.read_text(&to)).transpose().map(Option::unwrap_or_default) {
+                        let scroll = self.read_scroll;
+                        self.load_text(text);
+                        self.read_scroll = scroll;
+                    }
+                    self.tree.reveal(&to);
+                }
+                self.rebuild_rows();
+                self.side_dirty = true;
+                self.layout = None;
+                self.set_status(msg, false);
+            }
+            Err(e) => self.set_status(format!("Could not rename: {e}"), true),
+        }
+    }
+
+    // ---- side panes ------------------------------------------------------
+
+    fn refresh_side(&mut self) {
+        self.side_dirty = false;
+        let (Some(ix), Some(cur)) = (&self.index, self.current.clone()) else {
+            self.side_panel.set(Vec::new(), true);
+            self.side_key = None;
+            return;
+        };
+        let items = match self.side_tab {
+            SideTab::Backlinks => side::backlinks(ix, &cur),
+            SideTab::Outline => side::outline(ix, &cur),
+        };
+        let key = (cur, self.side_tab);
+        let reset = self.side_key.as_ref() != Some(&key);
+        self.side_panel.set(items, reset);
+        self.side_key = Some(key);
+    }
+
+    fn search_query(&self) -> String {
+        if self.search_input.editing {
+            self.search_input.edit_buffer.clone()
+        } else {
+            self.search_input.text.clone()
+        }
+    }
+
+    /// Rerun the search if the query changed, or always with `force`.
+    fn refresh_search(&mut self, force: bool) {
+        let q = self.search_query();
+        if !force && q == self.search_seen {
+            return;
+        }
+        let Some(ix) = &self.index else { return };
+        let reset = q != self.search_seen;
+        self.search_panel.set(side::search(ix, &q), reset);
+        self.search_seen = q;
+        self.needs_rebuild = true;
+    }
+
+    /// Show the search tab holding `query` (a tag click, Ctrl+Shift+F).
+    fn open_search(&mut self, query: &str) {
+        self.left_tab = LeftTab::Search;
+        self.show_tree = true;
+        self.search_input.text = query.to_string();
+        self.search_input.edit_buffer = query.to_string();
+        self.search_input.cursor_idx = query.chars().count();
+        self.search_input.select_anchor = None;
+        self.search_input.sync_editor_state();
+        self.ui_context.set_focused(&mut self.search_input);
+        WidgetHost::focus(&mut self.search_input);
+        self.refresh_search(true);
+        self.invalidate();
+    }
+
+    fn run_action(&mut self, action: Action) {
+        match action {
+            Action::Open { path, line } => self.open_path(&path, line, true),
+            Action::Line(line) => self.goto_line(line),
+        }
+    }
+
+    /// Bring a 0-based source line of the open note into view: scroll the
+    /// reading view to its block, or put the source caret at its start.
+    fn goto_line(&mut self, line: usize) {
+        match self.mode {
+            Mode::Reading => self.pending_line = Some(line),
+            Mode::Source => {
+                let text = self.editor_text().to_string();
+                let idx: usize = text.split('\n').take(line).map(|l| l.chars().count() + 1).sum();
+                self.ui_context.set_focused(&mut self.editor);
+                WidgetHost::focus(&mut self.editor);
+                self.editor.edit_buffer = text;
+                self.editor.cursor_idx = idx.min(self.editor.edit_buffer.chars().count());
+                self.editor.select_anchor = None;
+                self.editor.all_selected = false;
+                self.editor.sync_editor_state();
+                self.editor.scroll_to_cursor();
+            }
+        }
+        self.needs_rebuild = true;
+    }
+
+    // ---- [[ completion -----------------------------------------------------
+
+    /// Open, update or close the completion for the caret's position.
+    fn update_completion(&mut self) {
+        if self.mode != Mode::Source || !self.editor.editing {
+            self.completion = None;
+            return;
+        }
+        let found = complete::open_link(&self.editor.edit_buffer, self.editor.cursor_idx);
+        let Some((start, query)) = found else {
+            self.completion = None;
+            self.completion_dismissed = None;
+            return;
+        };
+        if self.completion_dismissed == Some(start) {
+            return;
+        }
+        if self.completion.as_ref().is_some_and(|c| c.start == start && c.query == query) {
+            return;
+        }
+        let Some(ix) = &self.index else { return };
+        let choices = complete::choices(ix, &query);
+        self.completion = Some(Completion { start, query, choices, selected: 0 });
+        self.needs_rebuild = true;
+    }
+
+    /// Keys the open completion takes; true when it took this one.
+    fn completion_key(&mut self, key: &Key) -> bool {
+        let Some(c) = self.completion.as_mut() else { return false };
+        if c.choices.is_empty() {
+            return false;
+        }
+        let n = c.choices.len();
+        match key {
+            Key::Named(NamedKey::ArrowDown) => c.selected = (c.selected + 1) % n,
+            Key::Named(NamedKey::ArrowUp) => c.selected = (c.selected + n - 1) % n,
+            Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Tab) => self.accept_completion(),
+            Key::Named(NamedKey::Escape) => {
+                self.completion_dismissed = Some(c.start);
+                self.completion = None;
+            }
+            _ => return false,
+        }
+        self.needs_rebuild = true;
+        true
+    }
+
+    fn accept_completion(&mut self) {
+        let Some(c) = self.completion.take() else { return };
+        let (Some(ix), Some(path)) = (&self.index, c.choices.get(c.selected)) else { return };
+        let link = complete::link_text(ix, self.current.as_deref(), path);
+        let e = &mut self.editor;
+        let before = cce_ui::widget::editor::TextEditorState {
+            buffer: e.edit_buffer.clone(),
+            cursor_idx: e.cursor_idx,
+            select_anchor: e.select_anchor,
+            all_selected: e.all_selected,
+        };
+        let (text, caret) = complete::apply(&e.edit_buffer, c.start, e.cursor_idx, &link);
+        e.history.record(before);
+        e.edit_buffer = text;
+        e.cursor_idx = caret;
+        e.select_anchor = None;
+        e.all_selected = false;
+        e.sync_editor_state();
+        e.scroll_to_cursor();
+    }
+
+    /// The completion popup's rect, under the caret and inside the note pane.
+    fn completion_rect(&self, m: &Metrics) -> Option<Rect> {
+        let c = self.completion.as_ref().filter(|c| !c.choices.is_empty())?;
+        let e = &self.editor;
+        let r = self.editor_rect(m);
+        let (cw, lh) = (e.char_width(), e.line_height());
+        let max_chars = (((r.width - 16.0) / cw).floor() as usize).max(1);
+        let (_, map) = e.wrap_text(max_chars);
+        let (line, col) = *map.get(e.cursor_idx.min(map.len().saturating_sub(1)))?;
+        let caret_x = r.x + 8.0 + col as f32 * cw - e.scroll_x;
+        let caret_y = r.y + 8.0 + line as f32 * lh - e.scroll_y;
+        let h = c.choices.len() as f32 * COMPLETE_ROW_H + 8.0;
+        let w = COMPLETE_W.min(r.width);
+        let x = caret_x.min(r.x + r.width - w).max(r.x);
+        // Below the caret line, or above it when there is no room below.
+        let below = caret_y + lh + 2.0;
+        let y = if below + h <= r.y + r.height { below } else { (caret_y - h - 2.0).max(r.y) };
+        Some(Rect { x, y, width: w, height: h })
+    }
+
+    fn completion_row_at(&self, m: &Metrics, x: f32, y: f32) -> Option<usize> {
+        let r = self.completion_rect(m)?;
+        if !contains(r, x, y) {
+            return None;
+        }
+        let i = ((y - r.y - 4.0) / COMPLETE_ROW_H).floor();
+        (i >= 0.0 && (i as usize) < self.completion.as_ref()?.choices.len()).then_some(i as usize)
+    }
+
     // ---- layout and scrolling -------------------------------------------
 
     fn ensure_layout(&mut self, width: f32) {
@@ -671,14 +1051,14 @@ impl NotesApp {
     }
 
     fn tree_max_scroll(&self, m: &Metrics) -> f32 {
-        (self.rows.len() as f32 * TREE_ROW_H + 8.0 - m.tree.height).max(0.0)
+        (self.rows.len() as f32 * TREE_ROW_H + 8.0 - m.tree_body.height).max(0.0)
     }
 
     fn tree_row_at(&self, m: &Metrics, x: f32, y: f32) -> Option<usize> {
-        if x < m.tree.x || x > m.tree.x + m.tree.width || y < m.tree.y || y > m.tree.y + m.tree.height {
+        if self.left_tab != LeftTab::Files || !contains(m.tree_body, x, y) {
             return None;
         }
-        let i = ((y - m.tree.y - 4.0 + self.tree_scroll) / TREE_ROW_H).floor();
+        let i = ((y - m.tree_body.y - 4.0 + self.tree_scroll) / TREE_ROW_H).floor();
         (i >= 0.0 && (i as usize) < self.rows.len()).then_some(i as usize)
     }
 
@@ -778,7 +1158,8 @@ impl NotesApp {
         } else if let (Some(cur), Some(ix)) = (&self.current, &self.index) {
             let text = if self.mode == Mode::Source { self.editor_text() } else { self.saved.as_str() };
             let words = text.split_whitespace().count();
-            let back = ix.backlinks(cur).len();
+            // Notes linking here, as the backlinks pane counts them.
+            let back = ix.backlinks(cur).iter().filter(|b| b.source != cur).map(|b| b.source).collect::<std::collections::HashSet<_>>().len();
             (format!("{} · {}", plural(words, "word"), plural(back, "backlink")), DIM)
         } else {
             (String::new(), DIM)
@@ -795,8 +1176,28 @@ impl NotesApp {
         if m.tree.width <= 0.0 {
             return;
         }
-        let t = m.tree;
-        pc.recess_edges(t, (0.0, 0.0, 0.0, 0.0), cce_ui::layout::bar_wall_width(), (false, true, false, false));
+        pc.recess_edges(m.tree, (0.0, 0.0, 0.0, 0.0), cce_ui::layout::bar_wall_width(), (false, true, false, false));
+        let active = if self.left_tab == LeftTab::Files { 0 } else { 1 };
+        paint_tabs(pc, m.left_tabs, &["Files", "Search"], active);
+        if self.left_tab == LeftTab::Search {
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.search_input, pc);
+            if self.search_panel.items.is_empty() {
+                let (family, size) = cce_ui::layout::tree_font_parsed();
+                let b = m.tree_body;
+                pc.text_with(
+                    "Words, or #tag",
+                    b.x + 12.0,
+                    b.y + 10.0,
+                    (size * 0.9).round(),
+                    srgb_u8(DIM),
+                    Some(family),
+                    Some([b.x, b.y, b.x + b.width, b.y + b.height]),
+                );
+            }
+            self.search_panel.paint(pc, m.tree_body);
+            return;
+        }
+        let t = m.tree_body;
         let (family, size) = cce_ui::layout::tree_font_parsed();
         pc.clip(t, |pc| {
             for (i, row) in self.rows.iter().enumerate() {
@@ -869,7 +1270,26 @@ impl NotesApp {
             return;
         }
         match self.mode {
-            Mode::Source => cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.editor, pc),
+            Mode::Source => match self.completion_rect(m) {
+                // Text draws over every plate, so the popup cannot simply
+                // cover the editor: paint the editor four times, clipped to
+                // the bands around the popup, and the popup into the hole.
+                Some(hole) => {
+                    let e = self.editor_rect(m);
+                    let (l, t, r, b) = (e.x - 4.0, e.y - 4.0, e.x + e.width + 4.0, e.y + e.height + 4.0);
+                    let bands = [
+                        Rect { x: l, y: t, width: r - l, height: hole.y - t },
+                        Rect { x: l, y: hole.y + hole.height, width: r - l, height: b - hole.y - hole.height },
+                        Rect { x: l, y: hole.y, width: hole.x - l, height: hole.height },
+                        Rect { x: hole.x + hole.width, y: hole.y, width: r - hole.x - hole.width, height: hole.height },
+                    ];
+                    for band in bands.into_iter().filter(|b| b.width > 0.0 && b.height > 0.0) {
+                        pc.clip(band, |pc| cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.editor, pc));
+                    }
+                    self.paint_completion(pc, hole);
+                }
+                None => cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.editor, pc),
+            },
             Mode::Reading => {
                 let (ox, oy, width) = self.reading_frame(m);
                 self.ensure_layout(width);
@@ -913,6 +1333,102 @@ impl NotesApp {
                 Some([row.x, row.y, row.x + row.width - 6.0, row.y + row.height]),
             );
         }
+        if let Some((hint, err)) = &sw.hint {
+            let y = top + sw.choices.len() as f32 * SWITCHER_ROW_H;
+            let row = Rect { x: r.x + 6.0, y, width: r.width - 12.0, height: SWITCHER_ROW_H };
+            pc.text_with(
+                hint.clone(),
+                row.x + 10.0,
+                cce_ui::layout::align_text_y(row.y, row.height, size, 0.0),
+                size,
+                srgb_u8(if *err { CONFLICT } else { DIM }),
+                Some(family.clone()),
+                Some([row.x, row.y, row.x + row.width - 6.0, row.y + row.height]),
+            );
+        }
+    }
+
+    fn paint_side(&self, pc: &mut PaintCtx, m: &Metrics) {
+        if m.side.width <= 0.0 {
+            return;
+        }
+        pc.recess_edges(m.side, (0.0, 0.0, 0.0, 0.0), cce_ui::layout::bar_wall_width(), (false, false, false, true));
+        let active = if self.side_tab == SideTab::Backlinks { 0 } else { 1 };
+        paint_tabs(pc, m.side_tabs, &["Backlinks", "Outline"], active);
+        self.side_panel.paint(pc, m.side_body);
+    }
+
+    fn paint_completion(&self, pc: &mut PaintCtx, r: Rect) {
+        let Some(c) = &self.completion else { return };
+        pc.rounded_rect(r, 8.0, (true, true, true, true), cce_ui::colors::PANEL_MENU_BG);
+        let (family, size) = cce_ui::layout::list_font_parsed();
+        for (i, path) in c.choices.iter().enumerate() {
+            let row = Rect { x: r.x + 4.0, y: r.y + 4.0 + i as f32 * COMPLETE_ROW_H, width: r.width - 8.0, height: COMPLETE_ROW_H };
+            if i == c.selected {
+                pc.rounded_rect(row, 5.0, (true, true, true, true), cce_ui::colors::PANEL_MENU_HOVER);
+            }
+            let (dir, file) = match path.rsplit_once('/') {
+                Some((d, f)) => (Some(d), f),
+                None => (None, path.as_str()),
+            };
+            let name = file.strip_suffix(".md").unwrap_or(file);
+            let ty = cce_ui::layout::align_text_y(row.y, row.height, size, 0.0);
+            let bounds = Some([row.x, row.y, row.x + row.width - 6.0, row.y + row.height]);
+            pc.text_with(name.to_string(), row.x + 8.0, ty, size, srgb_u8(FG), Some(family.clone()), bounds);
+            if let Some(d) = dir {
+                let nx = row.x + 8.0 + (name.chars().count() as f32 + 2.0) * size * 0.6;
+                pc.text_with(d.to_string(), nx, ty, size, srgb_u8(DIM), Some(family.clone()), bounds);
+            }
+        }
+    }
+}
+
+/// Equal-width text tabs across `r`, the active one underlined.
+fn paint_tabs(pc: &mut PaintCtx, r: Rect, labels: &[&str], active: usize) {
+    if r.width <= 0.0 {
+        return;
+    }
+    let (family, size) = cce_ui::layout::tree_font_parsed();
+    let w = r.width / labels.len() as f32;
+    for (i, label) in labels.iter().enumerate() {
+        let cell = Rect { x: r.x + i as f32 * w, y: r.y, width: w, height: r.height };
+        let lw = label.chars().count() as f32 * size * 0.6;
+        let color = if i == active { FG } else { DIM };
+        pc.text_with(
+            label.to_string(),
+            cell.x + ((cell.width - lw) / 2.0).max(4.0),
+            cce_ui::layout::align_text_y(cell.y, cell.height, size, 0.0),
+            size,
+            srgb_u8(color),
+            Some(family.clone()),
+            Some([cell.x, cell.y, cell.x + cell.width, cell.y + cell.height]),
+        );
+        if i == active {
+            let u = Rect { x: cell.x + 14.0, y: cell.y + cell.height - 3.0, width: (cell.width - 28.0).max(4.0), height: 2.0 };
+            pc.rounded_rect(u, 1.0, (true, true, true, true), cce_ui::colors::to_linear([0.66, 0.55, 0.98, 1.0]));
+        }
+    }
+}
+
+fn tab_at(r: Rect, n: usize, x: f32, y: f32) -> Option<usize> {
+    if r.width <= 0.0 || !contains(r, x, y) {
+        return None;
+    }
+    Some((((x - r.x) / (r.width / n as f32)) as usize).min(n - 1))
+}
+
+/// The vault path a rename prompt's text names: `.md` added unless it
+/// names another extension the note already had. `None` while empty.
+fn rename_target(from: &str, query: &str) -> Option<String> {
+    let q = query.trim().trim_start_matches('/').trim_end_matches('/');
+    if q.is_empty() {
+        return None;
+    }
+    let keeps_ext = !from.to_lowercase().ends_with(".md") && from.rsplit('.').next() == q.rsplit('.').next();
+    if q.to_lowercase().ends_with(".md") || keeps_ext {
+        Some(q.to_string())
+    } else {
+        Some(format!("{q}.md"))
     }
 }
 
@@ -920,9 +1436,7 @@ fn contains(r: Rect, x: f32, y: f32) -> bool {
     x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height
 }
 
-fn plural(n: usize, what: &str) -> String {
-    format!("{n} {what}{}", if n == 1 { "" } else { "s" })
-}
+use side::plural;
 
 fn vault_name(root: &Path) -> String {
     root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| root.display().to_string())
@@ -959,13 +1473,18 @@ impl Application for NotesApp {
             },
             Err(e) => (None, Some(e.clone()), None),
         };
-        instance::spawn_listener(sender.clone());
+        // Only the instance serves MCP: a second copy running standalone
+        // would fight it for the port.
+        if instance::spawn_listener(sender.clone()) && index.is_some() {
+            mcp::start(sender.clone());
+        }
         let _ = sender.send(Message::Command(startup.command.clone()));
 
         let mut editor = TextBox::new(String::new()).with_multiline(true).with_draw_bg_border(true).with_max_width(None);
         editor.font_family = "monospace".to_string();
         editor.font_size = 13.0;
         let switcher_input = TextBox::new(String::new()).with_placeholder("Find or create a note…");
+        let search_input = TextBox::new(String::new()).with_placeholder("Search");
 
         let mut app = NotesApp {
             keys: Keys::load(),
@@ -997,6 +1516,17 @@ impl Application for NotesApp {
             hist_pos: 0,
             switcher: None,
             switcher_input,
+            left_tab: LeftTab::Files,
+            search_input,
+            search_panel: Panel::default(),
+            search_seen: String::new(),
+            show_side: true,
+            side_tab: SideTab::Backlinks,
+            side_panel: Panel::default(),
+            side_key: None,
+            side_dirty: true,
+            completion: None,
+            completion_dismissed: None,
             conflict: false,
             status: None,
             width: 1000,
@@ -1026,6 +1556,7 @@ impl Application for NotesApp {
         match msg {
             Message::Command(cmd) => self.run_command(cmd),
             Message::VaultChanged(paths) => self.vault_changed(paths),
+            Message::Mcp(call) => self.mcp_call(call),
             Message::Exit => *_exit = true,
         }
         *needs_rebuild = true;
@@ -1053,6 +1584,9 @@ impl Application for NotesApp {
             self.tree_scroll = self.tree_motion.y.pos();
             *needs_rebuild = true;
         }
+        if self.search_panel.tick(dt, m.tree_body) | self.side_panel.tick(dt, m.side_body) {
+            *needs_rebuild = true;
+        }
         if self.dirty() && !self.conflict {
             let now = Instant::now();
             if self.editor_text() != self.edit_seen {
@@ -1072,6 +1606,8 @@ impl Application for NotesApp {
             self.ui_context.register_widget(id, ptr);
             let (id, ptr) = (self.switcher_input.id(), self.switcher_input.as_ptr_mut());
             self.ui_context.register_widget(id, ptr);
+            let (id, ptr) = (self.search_input.id(), self.search_input.as_ptr_mut());
+            self.ui_context.register_widget(id, ptr);
         }
         let size_changed =
             self.width != size.width as u32 || self.height != size.height as u32 || self.scale != scale;
@@ -1091,10 +1627,17 @@ impl Application for NotesApp {
             self.editor.set_rect(e.x, e.y, e.width, e.height);
             let r = self.switcher_rect();
             self.switcher_input.set_rect(r.x + 12.0, r.y + 12.0, r.width - 24.0, 32.0);
+            let sb = m.search_box;
+            self.search_input.set_rect(sb.x, sb.y, sb.width, sb.height);
             self.needs_rebuild = false;
             self.ui_context.rebuild_spatial_grid();
             // Keep scroll offsets in range after a resize or a new layout.
             self.tree_scroll = self.tree_scroll.min(self.tree_max_scroll(&m));
+            self.search_panel.clamp(m.tree_body);
+            self.side_panel.clamp(m.side_body);
+        }
+        if self.side_dirty && m.side.width > 0.0 {
+            self.refresh_side();
         }
 
         let (w, h) = (self.width as f32, self.height as f32);
@@ -1103,6 +1646,7 @@ impl Application for NotesApp {
         self.paint_band(&mut pc, &m);
         if self.switcher.is_none() {
             self.paint_tree(&mut pc, &m);
+            self.paint_side(&mut pc, &m);
         }
         self.paint_note(&mut pc, &m);
         let max = self.read_max_scroll(&m);
@@ -1128,10 +1672,19 @@ impl Application for NotesApp {
     }
 
     fn cursor_icon(&self, x: f32, y: f32) -> Option<CursorIcon> {
-        if self.hover_hit || self.tree_hover.is_some() {
+        let m = self.metrics();
+        let over_title = self.current.is_some() && self.switcher.is_none() && contains(m.title, x, y);
+        if self.hover_hit
+            || self.tree_hover.is_some()
+            || self.search_panel.hover.is_some()
+            || self.side_panel.hover.is_some()
+            || over_title
+        {
             return Some(CursorIcon::Pointer);
         }
-        let m = self.metrics();
+        if self.left_tab == LeftTab::Search && contains(m.search_box, x, y) {
+            return Some(CursorIcon::Text);
+        }
         (self.mode == Mode::Source && self.switcher.is_none() && contains(self.editor_rect(&m), x, y))
             .then_some(CursorIcon::Text)
     }
@@ -1168,6 +1721,13 @@ impl Application for NotesApp {
             self.tree_hover = hover;
             *needs_rebuild = true;
         }
+        let search_area = if self.left_tab == LeftTab::Search { m.tree_body } else { Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } };
+        if self.search_panel.hover_at(search_area, x, y) | self.side_panel.hover_at(m.side_body, x, y) {
+            *needs_rebuild = true;
+        }
+        if self.left_tab == LeftTab::Search && self.ui_context.propagate_event(&ev, self.search_input.id()) {
+            *needs_rebuild = true;
+        }
         let hit = self.reading_hit(x, y).is_some();
         if hit != self.hover_hit {
             self.hover_hit = hit;
@@ -1209,6 +1769,52 @@ impl Application for NotesApp {
         }
 
         let m = self.metrics();
+        // The search box keeps the keyboard only while it is clicked into.
+        let in_search = self.left_tab == LeftTab::Search && contains(m.search_box, x, y);
+        if pressed && !in_search && self.search_input.editing {
+            self.search_input.unfocus();
+        }
+        if in_search {
+            if pressed && !self.search_input.editing {
+                self.editor.unfocus();
+                self.ui_context.set_focused(&mut self.search_input);
+                WidgetHost::focus(&mut self.search_input);
+            }
+            self.ui_context.propagate_event(&ev, self.search_input.id());
+            return None;
+        }
+        if pressed {
+            if let Some(i) = self.completion_row_at(&m, x, y) {
+                if let Some(c) = self.completion.as_mut() {
+                    c.selected = i;
+                }
+                self.accept_completion();
+                return None;
+            }
+            if let Some(t) = tab_at(m.left_tabs, 2, x, y) {
+                self.left_tab = if t == 0 { LeftTab::Files } else { LeftTab::Search };
+                if self.left_tab == LeftTab::Search {
+                    let q = self.search_query();
+                    self.open_search(&q);
+                }
+                self.needs_rebuild = true;
+                return None;
+            }
+            if let Some(t) = tab_at(m.side_tabs, 2, x, y) {
+                self.side_tab = if t == 0 { SideTab::Backlinks } else { SideTab::Outline };
+                self.side_dirty = true;
+                return None;
+            }
+            let search_area = if self.left_tab == LeftTab::Search { m.tree_body } else { Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } };
+            if let Some(a) = self.search_panel.click(search_area, x, y).or_else(|| self.side_panel.click(m.side_body, x, y)) {
+                self.run_action(a);
+                return None;
+            }
+            if self.current.is_some() && contains(m.title, x, y) {
+                self.open_rename();
+                return None;
+            }
+        }
         if pressed && contains(m.mode_chip, x, y) && self.current.is_some() {
             let next = if self.mode == Mode::Reading { Mode::Source } else { Mode::Reading };
             self.set_mode(next);
@@ -1254,8 +1860,10 @@ impl Application for NotesApp {
             if ctrl_click && contains(self.editor_rect(&m), x, y) {
                 if let Some(link) = self.link_at_caret() {
                     self.follow(link);
+                    return None;
                 }
             }
+            self.update_completion();
         }
         None
     }
@@ -1266,7 +1874,19 @@ impl Application for NotesApp {
             return;
         }
         let m = self.metrics();
-        if contains(m.tree, x, y) {
+        if contains(m.side_body, x, y) {
+            if self.side_panel.wheel(delta, m.side_body) {
+                *needs_rebuild = true;
+            }
+            return;
+        }
+        if self.left_tab == LeftTab::Search && contains(m.tree_body, x, y) {
+            if self.search_panel.wheel(delta, m.tree_body) {
+                *needs_rebuild = true;
+            }
+            return;
+        }
+        if contains(m.tree_body, x, y) && self.left_tab == LeftTab::Files {
             let max = self.tree_max_scroll(&m);
             self.tree_motion.reconcile(0.0, self.tree_scroll);
             if self.tree_motion.apply(delta, (TREE_ROW_H, TREE_ROW_H), Bounds::max(0.0), Bounds::max(max)) {
@@ -1376,11 +1996,56 @@ impl Application for NotesApp {
                 self.open_daily(None);
                 return None;
             }
+            if k(&self.keys.toggle_side) {
+                self.show_side = !self.show_side;
+                self.side_dirty = true;
+                self.invalidate();
+                return None;
+            }
+            if k(&self.keys.search) && self.index.is_some() {
+                let q = self.search_query();
+                self.open_search(&q);
+                self.search_input.select_all();
+                return None;
+            }
+            if k(&self.keys.rename) && self.current.is_some() {
+                self.open_rename();
+                return None;
+            }
+        }
+
+        // The search box, while it holds the keyboard.
+        if self.search_input.editing {
+            if pressed {
+                match &event.logical_key {
+                    Key::Named(NamedKey::Escape) => {
+                        self.search_input.unfocus();
+                        return None;
+                    }
+                    Key::Named(NamedKey::Enter) => {
+                        // Enter opens the first result.
+                        let first = self.search_panel.items.iter().find_map(|i| i.action.clone());
+                        if let Some(a) = first {
+                            self.search_input.unfocus();
+                            self.run_action(a);
+                        }
+                        return None;
+                    }
+                    _ => {}
+                }
+            }
+            self.ui_context.propagate_event(&ev, self.search_input.id());
+            self.refresh_search(false);
+            return None;
         }
 
         match self.mode {
             Mode::Source => {
+                if pressed && self.completion_key(&event.logical_key) {
+                    return None;
+                }
                 self.ui_context.propagate_event(&ev, self.editor.id());
+                self.update_completion();
             }
             Mode::Reading if pressed => {
                 let m = self.metrics();
diff --git a/src/mcp.rs b/src/mcp.rs
new file mode 100644
index 0000000..f23d340
--- /dev/null
+++ b/src/mcp.rs
@@ -0,0 +1,210 @@
+//! MCP tools, so agents can read and write the vault through the running
+//! app (cce-ui's tools-only server, as cce-designer uses it):
+//!
+//! ```sh
+//! claude mcp add --transport http cce-notes http://127.0.0.1:3002/mcp
+//! ```
+//!
+//! Only the single instance serves it (a forwarding launch exits first).
+//! `CCE_NOTES_MCP_PORT` moves the port, for a second instance in a shadow.
+//! Calls run on the app's event loop, so they see the same index as the
+//! window, and writes go through the same paths (a note open and dirty in
+//! the window raises the usual conflict rather than being overwritten).
+
+use cce_ui::mcp::{McpTool, McpToolCall};
+use cce_vault::index::stem;
+use cce_vault::FileKind;
+use serde_json::{json, Value};
+
+use crate::{Message, NotesApp};
+
+const DEFAULT_PORT: u16 = 3002;
+
+pub fn start(sender: calloop::channel::Sender<Message>) {
+    let port = std::env::var("CCE_NOTES_MCP_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(DEFAULT_PORT);
+    cce_ui::mcp::start_mcp_server("cce-notes", port, tools(), sender, Message::Mcp);
+}
+
+fn tools() -> Vec<McpTool> {
+    let tool = |name: &str, description: &str, input_schema: Value| McpTool {
+        name: name.into(),
+        description: description.into(),
+        input_schema,
+    };
+    let note_arg = json!({ "type": "string", "description": "A note name, vault path, or path without .md" });
+    vec![
+        tool(
+            "search",
+            "Full-text search over the vault's notes (every word must match), or `#tag` / `tag:name` for the notes carrying a tag. Returns matching notes with up to 5 matching lines each (1-based line numbers).",
+            json!({ "type": "object", "properties": {
+                "query": { "type": "string" },
+                "limit": { "type": "integer", "description": "Most notes to return (default 20)" }
+            }, "required": ["query"] }),
+        ),
+        tool(
+            "find_notes",
+            "Fuzzy-match note names, aliases and paths, best first.",
+            json!({ "type": "object", "properties": {
+                "query": { "type": "string" },
+                "limit": { "type": "integer", "description": "Default 10" }
+            }, "required": ["query"] }),
+        ),
+        tool(
+            "read_note",
+            "A note's full Markdown text, with its vault path.",
+            json!({ "type": "object", "properties": { "note": note_arg }, "required": ["note"] }),
+        ),
+        tool(
+            "backlinks",
+            "Notes linking to a note (with the linking lines, 1-based), and unlinked mentions of its name.",
+            json!({ "type": "object", "properties": { "note": note_arg }, "required": ["note"] }),
+        ),
+        tool(
+            "open_note",
+            "Show a note in the cce-notes window, optionally at a 1-based line.",
+            json!({ "type": "object", "properties": {
+                "note": note_arg,
+                "line": { "type": "integer" }
+            }, "required": ["note"] }),
+        ),
+        tool(
+            "append_daily",
+            "Append a paragraph to a day's daily note (today by default), creating the note from the vault's daily template if needed. Returns the note's path.",
+            json!({ "type": "object", "properties": {
+                "text": { "type": "string" },
+                "date": { "type": "string", "description": "YYYY-MM-DD; default today" }
+            }, "required": ["text"] }),
+        ),
+        tool(
+            "current_note",
+            "What the window shows: the open note's path, the mode (reading or source) and whether it has unsaved edits.",
+            json!({ "type": "object", "properties": {} }),
+        ),
+    ]
+}
+
+fn str_arg<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
+    args.get(key).and_then(Value::as_str).ok_or_else(|| format!("missing string argument `{key}`"))
+}
+
+fn limit_arg(args: &Value, default: usize) -> usize {
+    args.get("limit").and_then(Value::as_u64).map(|n| n.clamp(1, 200) as usize).unwrap_or(default)
+}
+
+impl NotesApp {
+    pub(crate) fn mcp_call(&mut self, call: McpToolCall) {
+        let result = self.mcp_dispatch(&call.name, &call.arguments);
+        let _ = call.reply.send(result);
+    }
+
+    fn resolve_note(&self, note: &str) -> Result<String, String> {
+        let ix = self.index.as_ref().ok_or("no vault is configured")?;
+        let path = ix.lookup(note).ok_or_else(|| format!("no note named `{note}`"))?;
+        match ix.entry(&path).map(|e| e.kind) {
+            Some(FileKind::Note) => Ok(path),
+            _ => Err(format!("`{path}` is not a note")),
+        }
+    }
+
+    fn mcp_dispatch(&mut self, name: &str, args: &Value) -> Result<Value, String> {
+        match name {
+            "current_note" => Ok(json!({
+                "path": self.current,
+                "mode": if self.mode == crate::Mode::Source { "source" } else { "reading" },
+                "unsaved": self.dirty(),
+                "conflict": self.conflict,
+            })),
+            "open_note" => {
+                let path = self.resolve_note(str_arg(args, "note")?)?;
+                let line = args.get("line").and_then(Value::as_u64).map(|l| (l as usize).saturating_sub(1));
+                self.open_path(&path, line, true);
+                if self.current.as_deref() != Some(path.as_str()) {
+                    return Err(self.status.as_ref().map(|s| s.0.clone()).unwrap_or_else(|| "could not open".into()));
+                }
+                self.needs_rebuild = true;
+                Ok(json!({ "opened": path }))
+            }
+            "append_daily" => {
+                let text = str_arg(args, "text")?.to_string();
+                let date = match args.get("date").and_then(Value::as_str) {
+                    Some(d) => Some(chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").map_err(|_| format!("not a date: {d}"))?),
+                    None => None,
+                };
+                let ix = self.index.as_mut().ok_or("no vault is configured")?;
+                let date = date.unwrap_or_else(|| chrono::Local::now().date_naive());
+                let (path, _) = ix.daily(date, true).map_err(|e| e.to_string())?;
+                ix.append(&path, &text).map_err(|e| e.to_string())?;
+                // The window, if it shows that note, catches up as it does
+                // for any outside edit.
+                self.vault_changed(vec![ix_abs(self, &path)]);
+                Ok(json!({ "appended_to": path }))
+            }
+            _ => {
+                let ix = self.index.as_ref().ok_or("no vault is configured")?;
+                match name {
+                    "search" => {
+                        let q = str_arg(args, "query")?;
+                        let limit = limit_arg(args, 20);
+                        if let Some(tag) = crate::side::tag_query(q) {
+                            let mut notes = ix.tagged(tag);
+                            notes.truncate(limit);
+                            return Ok(json!({ "tag": tag, "notes": notes }));
+                        }
+                        let hits: Vec<Value> = ix
+                            .search(q, limit)
+                            .into_iter()
+                            .map(|h| {
+                                json!({
+                                    "path": h.path,
+                                    "matching_lines": h.total,
+                                    "lines": h.lines.iter().map(|l| json!({ "line": l.line + 1, "text": l.text })).collect::<Vec<_>>(),
+                                })
+                            })
+                            .collect();
+                        Ok(json!(hits))
+                    }
+                    "find_notes" => {
+                        let q = str_arg(args, "query")?;
+                        let found: Vec<Value> = ix
+                            .find(q, limit_arg(args, 10))
+                            .into_iter()
+                            .map(|m| json!({ "path": m.path, "matched": m.matched }))
+                            .collect();
+                        Ok(json!(found))
+                    }
+                    "read_note" => {
+                        let path = self.resolve_note(str_arg(args, "note")?)?;
+                        let text = ix.read_text(&path).map_err(|e| e.to_string())?;
+                        Ok(json!({ "path": path, "text": text }))
+                    }
+                    "backlinks" => {
+                        let path = self.resolve_note(str_arg(args, "note")?)?;
+                        let mut linked = Vec::new();
+                        let mut texts: std::collections::HashMap<&str, String> = Default::default();
+                        for b in ix.backlinks(&path) {
+                            let text = texts.entry(b.source).or_insert_with(|| ix.read_text(b.source).unwrap_or_default());
+                            let line_text = text.lines().nth(b.link.line).unwrap_or("").trim().to_string();
+                            linked.push(json!({ "source": b.source, "line": b.link.line + 1, "text": line_text }));
+                        }
+                        let unlinked: Vec<Value> = ix
+                            .unlinked_mentions(&path)
+                            .into_iter()
+                            .map(|h| {
+                                json!({
+                                    "source": h.path,
+                                    "lines": h.lines.iter().map(|l| json!({ "line": l.line + 1, "text": l.text })).collect::<Vec<_>>(),
+                                })
+                            })
+                            .collect();
+                        Ok(json!({ "note": path, "name": stem(&path), "linked": linked, "unlinked": unlinked }))
+                    }
+                    other => Err(format!("unknown tool `{other}`")),
+                }
+            }
+        }
+    }
+}
+
+fn ix_abs(app: &NotesApp, path: &str) -> std::path::PathBuf {
+    app.index.as_ref().map(|ix| ix.abs(path)).unwrap_or_default()
+}
diff --git a/src/panel.rs b/src/panel.rs
new file mode 100644
index 0000000..e4a0f29
--- /dev/null
+++ b/src/panel.rs
@@ -0,0 +1,163 @@
+//! A scrollable list of rows for the side panes: section headers, note
+//! titles and indented line snippets, each optionally clickable. The
+//! backlinks and outline pane on the right and the search pane on the left
+//! all draw through this, so they hover, scroll and click alike.
+
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{PaintCtx, TextAttrs};
+use cce_ui::widget::{Bounds, MouseScrollDelta, ScrollMotion};
+
+use crate::reading::srgb_u8;
+
+pub const ROW_H: f32 = 22.0;
+const PAD: f32 = 6.0;
+const INDENT: f32 = 14.0;
+
+/// What a click on a row does.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Action {
+    /// Open a note, optionally at a 0-based source line.
+    Open { path: String, line: Option<usize> },
+    /// Bring a 0-based line of the open note into view.
+    Line(usize),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Kind {
+    /// A section label ("Linked mentions (3)").
+    Header,
+    /// A note's name.
+    Title,
+    /// A line of text under a title, or an outline heading.
+    Line,
+    /// An explanatory line ("No backlinks").
+    Note,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Item {
+    pub depth: usize,
+    pub text: String,
+    pub kind: Kind,
+    pub action: Option<Action>,
+}
+
+impl Item {
+    pub fn header(text: impl Into<String>) -> Item {
+        Item { depth: 0, text: text.into(), kind: Kind::Header, action: None }
+    }
+    pub fn note(text: impl Into<String>) -> Item {
+        Item { depth: 0, text: text.into(), kind: Kind::Note, action: None }
+    }
+    pub fn title(text: impl Into<String>, action: Action) -> Item {
+        Item { depth: 0, text: text.into(), kind: Kind::Title, action: Some(action) }
+    }
+    pub fn line(depth: usize, text: impl Into<String>, action: Action) -> Item {
+        Item { depth, text: text.into(), kind: Kind::Line, action: Some(action) }
+    }
+}
+
+#[derive(Default)]
+pub struct Panel {
+    pub items: Vec<Item>,
+    pub scroll: f32,
+    motion: ScrollMotion,
+    pub hover: Option<usize>,
+}
+
+impl Panel {
+    /// Replace the rows. The scroll stays when the rows are an update of
+    /// the same content (a refresh), and resets when `reset` says the
+    /// content is for something else (another note, another query).
+    pub fn set(&mut self, items: Vec<Item>, reset: bool) {
+        self.items = items;
+        self.hover = None;
+        if reset {
+            self.scroll = 0.0;
+            self.motion = ScrollMotion::new();
+        }
+    }
+
+    fn max_scroll(&self, area: Rect) -> f32 {
+        (self.items.len() as f32 * ROW_H + 2.0 * PAD - area.height).max(0.0)
+    }
+
+    pub fn item_at(&self, area: Rect, x: f32, y: f32) -> Option<usize> {
+        if x < area.x || x > area.x + area.width || y < area.y || y > area.y + area.height {
+            return None;
+        }
+        let i = ((y - area.y - PAD + self.scroll) / ROW_H).floor();
+        if i < 0.0 {
+            return None;
+        }
+        let i = i as usize;
+        (i < self.items.len() && self.items[i].action.is_some()).then_some(i)
+    }
+
+    /// Update the hovered row; true when it changed.
+    pub fn hover_at(&mut self, area: Rect, x: f32, y: f32) -> bool {
+        let h = self.item_at(area, x, y);
+        let changed = h != self.hover;
+        self.hover = h;
+        changed
+    }
+
+    pub fn click(&self, area: Rect, x: f32, y: f32) -> Option<Action> {
+        self.item_at(area, x, y).and_then(|i| self.items[i].action.clone())
+    }
+
+    pub fn wheel(&mut self, delta: &MouseScrollDelta, area: Rect) -> bool {
+        let max = self.max_scroll(area);
+        self.motion.reconcile(0.0, self.scroll);
+        let moved = self.motion.apply(delta, (ROW_H, ROW_H), Bounds::max(0.0), Bounds::max(max));
+        self.scroll = self.motion.y.pos();
+        moved
+    }
+
+    /// Advance a wheel glide; true while it moves.
+    pub fn tick(&mut self, dt: f32, area: Rect) -> bool {
+        if !self.motion.is_animating() {
+            return false;
+        }
+        let max = self.max_scroll(area);
+        self.motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
+        self.scroll = self.motion.y.pos();
+        true
+    }
+
+    pub fn clamp(&mut self, area: Rect) {
+        let max = self.max_scroll(area);
+        if self.scroll > max {
+            self.scroll = max;
+            self.motion.y.jump_to(max);
+        }
+    }
+
+    pub fn paint(&self, pc: &mut PaintCtx, area: Rect) {
+        let (family, size) = cce_ui::layout::tree_font_parsed();
+        let small = (size * 0.9).round();
+        let body = "sans-serif".to_string();
+        pc.clip(area, |pc| {
+            for (i, item) in self.items.iter().enumerate() {
+                let y = area.y + PAD + i as f32 * ROW_H - self.scroll;
+                if y + ROW_H < area.y || y > area.y + area.height {
+                    continue;
+                }
+                let r = Rect { x: area.x + 4.0, y, width: area.width - 8.0, height: ROW_H };
+                if self.hover == Some(i) {
+                    pc.rounded_rect(r, 4.0, (true, true, true, true), [1.0, 1.0, 1.0, 0.05]);
+                }
+                let x = r.x + 6.0 + item.depth as f32 * INDENT;
+                let bounds = Some([x, r.y.max(area.y), r.x + r.width - 4.0, (r.y + r.height).min(area.y + area.height)]);
+                let (font, fsize, color, attrs) = match item.kind {
+                    Kind::Header => (family.clone(), small, cce_ui::colors::TEXT_DIM, TextAttrs::default()),
+                    Kind::Title => (family.clone(), size, cce_ui::colors::TEXT_FG, TextAttrs::default()),
+                    Kind::Line => (body.clone(), small, cce_ui::colors::TEXT_FG, TextAttrs::default()),
+                    Kind::Note => (body.clone(), small, cce_ui::colors::TEXT_DIM, TextAttrs { italic: true, weight: None }),
+                };
+                let ty = cce_ui::layout::align_text_y(r.y, r.height, fsize, 0.0);
+                pc.text_attrs(item.text.clone(), x, ty, fsize, srgb_u8(color), Some(font), bounds, attrs);
+            }
+        });
+    }
+}
diff --git a/src/reading.rs b/src/reading.rs
index 336811c..e182c2b 100644
--- a/src/reading.rs
+++ b/src/reading.rs
@@ -89,7 +89,8 @@ impl Draw {
 pub struct Layout {
     pub draws: Vec<Draw>,
     pub hits: Vec<(Rect, Hit)>,
-    /// Each block's first source line and the y it starts at, in order.
+    /// Each block's (and list item's) first source line and the y it
+    /// starts at, in source order.
     pub lines: Vec<(usize, f32)>,
     pub height: f32,
 }
@@ -278,6 +279,10 @@ impl<'a> Layouter<'a> {
             if i > 0 {
                 y += 2.0;
             }
+            // Items are where a search hit or a task usually points.
+            if self.out.lines.last().is_none_or(|(l, _)| *l < item.line) {
+                self.out.lines.push((item.line, y));
+            }
             let body_x = x + INDENT;
             let mid = y + lh / 2.0;
             match (item.task, start) {
@@ -803,7 +808,7 @@ mod tests {
             _ => None,
         }).collect();
         assert_eq!(tasks, [(2, ' '), (3, 'x')]);
-        assert_eq!(l.lines.iter().map(|(l, _)| *l).collect::<Vec<_>>(), [0, 2, 5]);
+        assert_eq!(l.lines.iter().map(|(l, _)| *l).collect::<Vec<_>>(), [0, 2, 3, 5]);
         assert!(l.y_of_line(5) > l.y_of_line(2));
     }
 
diff --git a/src/side.rs b/src/side.rs
new file mode 100644
index 0000000..84dadf8
--- /dev/null
+++ b/src/side.rs
@@ -0,0 +1,180 @@
+//! What the side panes list, built from the index: a note's backlinks and
+//! unlinked mentions, its outline, and full-text or tag search results.
+//! Pure functions over `Index`, so they test without a window.
+
+use std::collections::BTreeMap;
+
+use cce_vault::index::stem;
+use cce_vault::Index;
+
+use crate::panel::{Action, Item};
+
+/// Search results kept per query; the pane is for finding, not listing.
+const SEARCH_LIMIT: usize = 50;
+
+/// Linked mentions (grouped by note, each with its lines), then unlinked
+/// mentions — Obsidian's backlinks pane.
+pub fn backlinks(ix: &Index, path: &str) -> Vec<Item> {
+    let mut out = Vec::new();
+    let mut by_source: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
+    for b in ix.backlinks(path) {
+        if b.source == path {
+            continue;
+        }
+        by_source.entry(b.source).or_default().push(b.link.line);
+    }
+    let count: usize = by_source.len();
+    out.push(Item::header(format!("Linked mentions ({count})")));
+    if by_source.is_empty() {
+        out.push(Item::note("No backlinks"));
+    }
+    for (source, mut lines) in by_source {
+        lines.sort_unstable();
+        lines.dedup();
+        out.push(Item::title(stem(source), Action::Open { path: source.to_string(), line: lines.first().copied() }));
+        let text = ix.read_text(source).unwrap_or_default();
+        let all: Vec<&str> = text.lines().collect();
+        for line in lines {
+            // A canvas link has no line of its own worth showing.
+            let Some(t) = all.get(line).map(|t| t.trim()).filter(|t| !t.is_empty()) else { continue };
+            out.push(Item::line(1, t.to_string(), Action::Open { path: source.to_string(), line: Some(line) }));
+        }
+    }
+    let mentions = ix.unlinked_mentions(path);
+    out.push(Item::header(format!("Unlinked mentions ({})", mentions.len())));
+    for hits in mentions {
+        let first = hits.lines.first().map(|l| l.line);
+        out.push(Item::title(stem(&hits.path), Action::Open { path: hits.path.clone(), line: first }));
+        for l in hits.lines {
+            out.push(Item::line(1, l.text, Action::Open { path: hits.path.clone(), line: Some(l.line) }));
+        }
+    }
+    out
+}
+
+/// The note's headings, indented by level.
+pub fn outline(ix: &Index, path: &str) -> Vec<Item> {
+    let Some(note) = ix.note(path) else { return vec![Item::note("Not a note")] };
+    if note.headings.is_empty() {
+        return vec![Item::note("No headings")];
+    }
+    let top = note.headings.iter().map(|h| h.level).min().unwrap_or(1);
+    note.headings
+        .iter()
+        .map(|h| Item::line((h.level - top) as usize, h.text.clone(), Action::Line(h.line)))
+        .collect()
+}
+
+/// A search query's results: `#tag` or `tag:tag` lists the notes carrying
+/// it (nested tags included); anything else is full text, every word
+/// required, grouped by note with the matching lines.
+pub fn search(ix: &Index, query: &str) -> Vec<Item> {
+    let q = query.trim();
+    if q.is_empty() {
+        return Vec::new();
+    }
+    if let Some(tag) = tag_query(q) {
+        let mut notes = ix.tagged(tag);
+        notes.sort_by_key(|p| p.to_lowercase());
+        let mut out = vec![Item::header(format!("#{tag} — {}", plural(notes.len(), "note")))];
+        out.extend(notes.into_iter().map(|p| Item::title(stem(p), Action::Open { path: p.to_string(), line: None })));
+        return out;
+    }
+    let hits = ix.search(q, SEARCH_LIMIT);
+    let mut out = vec![Item::header(plural(hits.len(), "note"))];
+    for h in hits {
+        let first = h.lines.first().map(|l| l.line);
+        let title = if h.total > h.lines.len() {
+            format!("{}  ({} lines)", stem(&h.path), h.total)
+        } else {
+            stem(&h.path).to_string()
+        };
+        out.push(Item::title(title, Action::Open { path: h.path.clone(), line: first }));
+        for l in h.lines {
+            out.push(Item::line(1, l.text, Action::Open { path: h.path.clone(), line: Some(l.line) }));
+        }
+    }
+    out
+}
+
+/// The tag a query asks for, if it is a tag query.
+pub fn tag_query(q: &str) -> Option<&str> {
+    let t = q.strip_prefix("tag:").or_else(|| q.strip_prefix('#'))?.trim().trim_start_matches('#');
+    (!t.is_empty() && !t.contains(char::is_whitespace)).then_some(t)
+}
+
+pub fn plural(n: usize, what: &str) -> String {
+    format!("{n} {what}{}", if n == 1 { "" } else { "s" })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::panel::Kind;
+
+    fn vault(files: &[(&str, &str)]) -> (tempfile::TempDir, Index) {
+        let dir = tempfile::tempdir().unwrap();
+        for (p, t) in files {
+            let abs = dir.path().join(p);
+            std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
+            std::fs::write(abs, t).unwrap();
+        }
+        let ix = Index::open(dir.path(), false).unwrap();
+        (dir, ix)
+    }
+
+    fn texts(items: &[Item]) -> Vec<String> {
+        items.iter().map(|i| format!("{}{}", "  ".repeat(i.depth), i.text)).collect()
+    }
+
+    #[test]
+    fn backlinks_group_by_note_with_lines_then_mentions() {
+        let (_d, ix) = vault(&[
+            ("Target.md", "# T\n"),
+            ("a/One.md", "intro\nsee [[Target]] here\nand [[Target#T]] again\n"),
+            ("Two.md", "about Target, unlinked\n"),
+        ]);
+        let items = backlinks(&ix, "Target.md");
+        assert_eq!(
+            texts(&items),
+            [
+                "Linked mentions (1)",
+                "One",
+                "  see [[Target]] here",
+                "  and [[Target#T]] again",
+                "Unlinked mentions (1)",
+                "Two",
+                "  about Target, unlinked",
+            ]
+        );
+        assert_eq!(items[2].action, Some(Action::Open { path: "a/One.md".into(), line: Some(1) }));
+        assert_eq!(items[0].kind, Kind::Header);
+    }
+
+    #[test]
+    fn outline_indents_from_the_top_level() {
+        let (_d, ix) = vault(&[("N.md", "## A\ntext\n### B\n## C\n")]);
+        let items = outline(&ix, "N.md");
+        assert_eq!(texts(&items), ["A", "  B", "C"]);
+        assert_eq!(items[1].action, Some(Action::Line(2)));
+        let (_d, ix) = vault(&[("E.md", "no headings\n")]);
+        assert_eq!(outline(&ix, "E.md")[0].kind, Kind::Note);
+    }
+
+    #[test]
+    fn search_text_and_tags() {
+        let (_d, ix) = vault(&[
+            ("A.md", "apple pie\nbanana\n#fruit/red\n"),
+            ("B.md", "apple tart #fruit\n"),
+            ("C.md", "nothing\n"),
+        ]);
+        let items = search(&ix, "apple");
+        assert_eq!(items[0].text, "2 notes");
+        assert!(texts(&items).contains(&"  apple pie".to_string()));
+        let tags = search(&ix, "#fruit");
+        assert_eq!(texts(&tags), ["#fruit — 2 notes", "A", "B"]);
+        assert_eq!(texts(&search(&ix, "tag:fruit/red")), ["#fruit/red — 1 note", "A"]);
+        assert!(search(&ix, "  ").is_empty());
+        assert_eq!(tag_query("#a b"), None);
+    }
+}