--- phase: 03-navigation-and-links plan: 02 type: execute wave: 2 depends_on: - "03-01" files_modified: - src/app.rs - src/main.rs autonomous: true requirements: - NAV-01 - NAV-02 - NAV-03 - NAV-04 - NAV-10 - NAV-11 must_haves: truths: - "User can press Tab to cycle forward through links on a page, with wrap-around from last to first" - "User can press Shift+Tab to cycle backward through links" - "When Tab-cycling to an off-screen link, the view auto-scrolls to center the link" - "User can press Enter on a selected link to navigate to that document" - "User can press Backspace or Alt+Left to go back to the previous document with restored scroll and link selection" - "User can press Alt+Right to go forward after going back" - "Forward stack is cleared when following a new link after going back (browser-style fork)" - "Status bar shows breadcrumb trail (e.g. docs > guides > getting-started) with .md stripped" - "Status bar shows back/forward indicators only when history exists in that direction" - "Status bar shows Link 3/7 counter when a link is selected" - "Selected link is rendered with inverted colors (REVERSED modifier) at draw time" artifacts: - path: "src/app.rs" provides: "Navigation history, link cycling, draw-time selection, breadcrumb status bar" contains: "struct HistoryEntry" - path: "src/main.rs" provides: "Updated startup wiring for new render_markdown signature" contains: "render_markdown" key_links: - from: "src/app.rs" to: "src/renderer.rs" via: "App stores Vec from render_markdown and uses it for Tab cycling and Enter follow" pattern: "link_records" - from: "src/app.rs" to: "src/vault.rs" via: "navigate_to calls vault::load_document and vault::resolve_wiki_link" pattern: "resolve_wiki_link" - from: "src/app.rs" to: "src/renderer.rs" via: "navigate_to re-renders loaded document via render_markdown" pattern: "render_markdown" - from: "src/main.rs" to: "src/renderer.rs" via: "Initial document load destructures (lines, link_records) tuple" pattern: "render_markdown" --- Wire link navigation, history stack, Tab-cycling, and breadcrumb status bar into the app event loop. Purpose: This plan turns the static document viewer into an interactive vault browser. Users can follow links, navigate back/forward, Tab-cycle between links, and see where they are via breadcrumbs — the core browsing experience. Output: - HistoryEntry struct with path, scroll_offset, selected_link for full state restoration - Tab/Shift-Tab link cycling with wrap-around and auto-scroll - Enter to follow selected link (wiki-link resolution + standard link resolution) - Backspace and Alt+Left/Right for back/forward navigation - Draw-time REVERSED modifier on selected link - Breadcrumb trail, back/forward indicators, and link counter in status bar - main.rs updated for new render_markdown return type @/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md @/Users/ruohki/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-navigation-and-links/03-RESEARCH.md @.planning/phases/03-navigation-and-links/03-01-SUMMARY.md @src/app.rs @src/main.rs @src/renderer.rs @src/vault.rs Task 1: Add navigation state, history, link cycling, and navigate_to to App src/app.rs, src/main.rs Modify `src/app.rs` and `src/main.rs` to support full link navigation: **1. Add HistoryEntry struct** (in app.rs, before App): ```rust /// A snapshot of navigation state for back/forward history. struct HistoryEntry { /// Vault-relative path (e.g. "guides/getting-started.md") path: String, /// Scroll offset at time of navigation away from this page scroll_offset: u16, /// Selected link index at time of navigation (None if no link was selected) selected_link: Option, } ``` **2. Add navigation fields to App struct**: ```rust // ── Phase 3 additions ───────────────────────────────────────────────────── /// Browser-style navigation history. Vec of visited pages with state. history: Vec, /// Current position in history (index into history Vec). history_index: usize, /// Link records from the current rendered document. link_records: Vec, /// Index of the currently selected link (None = no link selected). selected_link: Option, /// Current document's vault-relative path (e.g. "index.md", "guides/page.md"). current_path: String, ``` **3. Update App::new()** to accept `link_records` and `current_path`: ```rust pub fn new( is_login_shell: bool, config: Config, document: DocumentState, raw_content: Option, link_records: Vec, current_path: String, ) -> Self ``` Initialize `history` with one entry for the initial page (scroll 0, no selected link), `history_index: 0`, `link_records`, `selected_link: None`, `current_path`. **4. Add navigate_to() method**: ```rust /// Navigate to a new document by vault-relative path. /// /// Saves current state to history, loads the new document, renders it, /// and updates all navigation state. If history_index is not at the end, /// truncates forward history (browser-style fork). fn navigate_to(&mut self, vault_relative: &str) { let vault_path = &self.config.vault_path; // 1. Save current state to history at current position // Update the entry at history_index with current scroll + selection if let Some(entry) = self.history.get_mut(self.history_index) { entry.scroll_offset = self.scroll_offset; entry.selected_link = self.selected_link; } // 2. Truncate forward history if we navigated back then follow a new link self.history.truncate(self.history_index + 1); // 3. Load new document match crate::vault::load_document(vault_path, vault_relative) { crate::vault::VaultDocument::Loaded { path, content } => { let filename = path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| vault_relative.to_string()); let width = // get current terminal width from last draw or crossterm::terminal::size() ratatui::crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80); let (lines, link_records) = crate::renderer::render_markdown(&content, width, Some(vault_path)); self.document = DocumentState::Loaded { filename, lines }; self.raw_content = Some(content); self.link_records = link_records; self.selected_link = None; self.scroll_offset = 0; self.current_path = vault_relative.to_string(); self.filename = // extract from vault_relative vault_relative.to_string(); // 4. Push new history entry self.history.push(HistoryEntry { path: vault_relative.to_string(), scroll_offset: 0, selected_link: None, }); self.history_index = self.history.len() - 1; } crate::vault::VaultDocument::Missing { path } => { // Show error screen for missing link target — do NOT push to history // (user stays on current page conceptually, just sees the error) self.document = DocumentState::Missing { path }; self.raw_content = None; self.link_records = Vec::new(); self.selected_link = None; } crate::vault::VaultDocument::ReadError { path, reason } => { self.document = DocumentState::Error { path, reason }; self.raw_content = None; self.link_records = Vec::new(); self.selected_link = None; } } } ``` **5. Add navigate_back() and navigate_forward() methods**: ```rust fn navigate_back(&mut self) { if self.history_index == 0 { return; } // Save current state if let Some(entry) = self.history.get_mut(self.history_index) { entry.scroll_offset = self.scroll_offset; entry.selected_link = self.selected_link; } self.history_index -= 1; let entry = &self.history[self.history_index]; let target_path = entry.path.clone(); let target_scroll = entry.scroll_offset; let target_link = entry.selected_link; // Re-load and re-render the document (per research: don't cache rendered output) let vault_path = &self.config.vault_path; if let crate::vault::VaultDocument::Loaded { path, content } = crate::vault::load_document(vault_path, &target_path) { let filename = path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| target_path.clone()); let width = ratatui::crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80); let (lines, link_records) = crate::renderer::render_markdown(&content, width, Some(vault_path)); self.document = DocumentState::Loaded { filename, lines }; self.raw_content = Some(content); self.link_records = link_records; self.selected_link = target_link; self.scroll_offset = target_scroll; self.current_path = target_path; self.filename = self.current_path.clone(); } // If file was deleted since last visit, leave current doc unchanged } fn navigate_forward(&mut self) { if self.history_index >= self.history.len().saturating_sub(1) { return; } // Save current state if let Some(entry) = self.history.get_mut(self.history_index) { entry.scroll_offset = self.scroll_offset; entry.selected_link = self.selected_link; } self.history_index += 1; // Same re-load logic as navigate_back let entry = &self.history[self.history_index]; let target_path = entry.path.clone(); let target_scroll = entry.scroll_offset; let target_link = entry.selected_link; let vault_path = &self.config.vault_path; if let crate::vault::VaultDocument::Loaded { path, content } = crate::vault::load_document(vault_path, &target_path) { let filename = path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| target_path.clone()); let width = ratatui::crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80); let (lines, link_records) = crate::renderer::render_markdown(&content, width, Some(vault_path)); self.document = DocumentState::Loaded { filename, lines }; self.raw_content = Some(content); self.link_records = link_records; self.selected_link = target_link; self.scroll_offset = target_scroll; self.current_path = target_path; self.filename = self.current_path.clone(); } } ``` **6. Add follow_selected_link() method**: ```rust fn follow_selected_link(&mut self) { let link_index = match self.selected_link { Some(i) if i < self.link_records.len() => i, _ => return, // No link selected or index out of bounds }; let record = &self.link_records[link_index]; let dest = record.dest.clone(); let is_wiki = record.is_wiki; let vault_path = self.config.vault_path.clone(); if is_wiki { // Resolve wiki-link to vault-relative path match crate::vault::resolve_wiki_link(&vault_path, &dest) { Some(resolved) => { let rel = resolved.to_string_lossy().to_string(); self.navigate_to(&rel); } None => { // Broken wiki-link — already shown as red/strikethrough in render. // Optionally: could show an error flash. For now, do nothing. } } } else { // Standard markdown link — resolve relative to current document's directory match crate::vault::resolve_standard_link(&vault_path, &self.current_path, &dest) { Some(resolved) => { let rel = resolved.to_string_lossy().to_string(); self.navigate_to(&rel); } None => { // Broken link — show error page let full_path = vault_path.join(&dest); self.document = DocumentState::Missing { path: full_path }; self.link_records = Vec::new(); self.selected_link = None; } } } } ``` **7. Add link cycling helpers**: ```rust fn select_next_link(&mut self) { if self.link_records.is_empty() { return; } let next = match self.selected_link { Some(i) => (i + 1) % self.link_records.len(), // Wrap around per locked decision None => 0, // First Tab press selects the first link }; self.selected_link = Some(next); self.scroll_to_selected_link(); } fn select_prev_link(&mut self) { if self.link_records.is_empty() { return; } let prev = match self.selected_link { Some(0) => self.link_records.len() - 1, // Wrap to last Some(i) => i - 1, None => self.link_records.len() - 1, // First Shift+Tab selects last link }; self.selected_link = Some(prev); self.scroll_to_selected_link(); } /// Auto-scroll to center the selected link on screen if it's off-screen. fn scroll_to_selected_link(&mut self) { if let Some(i) = self.selected_link { if let Some(record) = self.link_records.get(i) { let link_line = record.line_index as u16; let viewport_start = self.scroll_offset; let viewport_end = viewport_start + self.last_content_height; if link_line < viewport_start || link_line >= viewport_end { // Center the link on screen let half = self.last_content_height / 2; self.scroll_offset = link_line.saturating_sub(half); // Clamp to max scroll let max = self.max_scroll(); if self.scroll_offset > max { self.scroll_offset = max; } } } } } ``` **8. Update handle_key()** — add new key bindings BEFORE the existing scroll keys (but after Ctrl+C and 'q'): ```rust // ── Navigation keys — add after 'q' handler, before scroll keys ────── KeyCode::Tab => { self.select_next_link(); } KeyCode::BackTab => { self.select_prev_link(); } KeyCode::Enter => { self.follow_selected_link(); } KeyCode::Backspace => { self.navigate_back(); } // Alt+Left = back, Alt+Right = forward KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => { self.navigate_back(); } KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => { self.navigate_forward(); } ``` **IMPORTANT**: The Alt+Left/Right bindings must come BEFORE the existing Down/Up handlers. Since the existing handlers are `KeyCode::Down` and `KeyCode::Up`, there's no conflict. But ensure the pattern matching order is: Ctrl+C > q > Tab > BackTab > Enter > Backspace > Alt+Left > Alt+Right > j/k/Down/Up > PgDn/PgUp > _ catch-all. **9. Update handle_resize()** to use new render_markdown signature: ```rust let (lines, link_records) = crate::renderer::render_markdown(content, new_width, Some(&self.config.vault_path)); // ... update self.link_records = link_records; // Preserve selected_link if still valid if let Some(i) = self.selected_link { if i >= self.link_records.len() { self.selected_link = None; } } ``` **10. Update main.rs** for new render_markdown signature: - Change `renderer::render_markdown(&content, initial_width)` to `renderer::render_markdown(&content, initial_width, Some(&app_config.vault_path))` - Destructure: `let (lines, link_records) = renderer::render_markdown(...)` - Pass `link_records` and `"index.md".to_string()` to `App::new()` - Update `App::new()` call to include new parameters `cargo build` compiles. Tab/Shift-Tab cycle through links. Enter follows a link. Backspace goes back. Alt+Right goes forward. History truncation works (navigate back, then follow new link = forward stack cleared). App has full navigation: HistoryEntry with scroll+link restoration, navigate_to/back/forward, Tab/Shift-Tab cycling with wrap-around and auto-scroll, Enter to follow links (wiki via resolve_wiki_link, standard via resolve_standard_link), Backspace and Alt+Left/Right for back/forward. main.rs passes link_records and current_path to App::new. Task 2: Draw-time link selection and breadcrumb status bar src/app.rs Modify `src/app.rs` draw methods for link selection display and breadcrumb navigation status: **1. Update draw() content rendering** for selected link highlight: In the `DocumentState::Loaded` branch of `draw()`, instead of directly creating a Paragraph from `lines.clone()`, apply the REVERSED modifier to the selected link's spans at draw time: ```rust DocumentState::Loaded { lines, .. } => { let display_lines = if let Some(selected_idx) = self.selected_link { if let Some(record) = self.link_records.get(selected_idx) { let mut cloned = lines.clone(); // Find the line containing the selected link if let Some(line) = cloned.get_mut(record.line_index) { // Find and modify spans at the link's column offset // Walk spans, summing character widths until we reach col_offset let mut col = 0usize; for span in line.spans.iter_mut() { let span_chars = span.content.chars().count(); if col >= record.col_offset && col < record.col_offset + record.span_len { // This span is part of the selected link — add REVERSED span.style = span.style.add_modifier(Modifier::REVERSED); } col += span_chars; // Also catch spans that start within the link range // (the link brackets + text are multiple spans) } } cloned } else { lines.clone() } } else { lines.clone() }; let para = Paragraph::new(display_lines) .scroll((self.scroll_offset, 0)); frame.render_widget(para, content_area); } ``` Note: The link's spans include `[`, the text content spans, and `]` — all consecutive in the line at the recorded col_offset. The REVERSED modifier inverts fg/bg per the locked decision for selected link appearance. **2. Add build_breadcrumb() function** (private helper in app.rs): ```rust /// Build a breadcrumb trail from a vault-relative path. /// "guides/getting-started.md" -> "guides > getting-started" fn build_breadcrumb(vault_relative: &str) -> String { std::path::Path::new(vault_relative) .components() .map(|c| { let s = c.as_os_str().to_string_lossy(); s.strip_suffix(".md").unwrap_or(&s).to_string() }) .collect::>() .join(" > ") } ``` **3. Rewrite draw_status_bar()** to include breadcrumb, back/forward indicators, and link counter: The status bar layout (left to right): - Left: ` {breadcrumb} ` (e.g. ` guides > getting-started `) - Center/Right: `< Back ` (if history_index > 0, else hidden) + `Link 3/7 ` (if link selected, else hidden) + `Forward >` (if history_index < history.len()-1, else hidden) - Far right: keyboard hints (existing, but updated) ```rust fn draw_status_bar(&self, frame: &mut Frame, area: Rect) { let width = area.width as usize; if self.show_quit_prompt { // Quit prompt takes over entire bar (existing behavior) let left = format!(" {} ", build_breadcrumb(&self.current_path)); let right = " Press Ctrl+C again to disconnect... ".to_string(); let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len()); let padding = " ".repeat(pad_len); let bar = Paragraph::new(Line::from(vec![ Span::styled( format!("{}{}{}", left, padding, right), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD | Modifier::REVERSED), ), ])); frame.render_widget(bar, area); return; } // Normal status bar let breadcrumb = build_breadcrumb(&self.current_path); let left = format!(" {} ", breadcrumb); // Build right side: nav indicators + link counter + hints let mut right_parts: Vec = Vec::new(); // Back indicator (per locked decision: shown only when history exists) if self.history_index > 0 { right_parts.push("< Back".to_string()); } // Link counter (per locked decision: shown when link selected) if let Some(i) = self.selected_link { right_parts.push(format!("Link {}/{}", i + 1, self.link_records.len())); } // Forward indicator if self.history_index < self.history.len().saturating_sub(1) { right_parts.push("Forward >".to_string()); } // Keyboard hints let hints = if self.is_login_shell { "Tab:Links Enter:Go Bksp:Back Ctrl+C\u{00D7}2:Quit" } else { "Tab:Links Enter:Go Bksp:Back q:Quit" }; right_parts.push(hints.to_string()); let right = format!(" {} ", right_parts.join(" ")); let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len()); let padding = " ".repeat(pad_len); let bar = Paragraph::new(Line::from(vec![ Span::raw(format!("{}{}{}", left, padding, right)), ])) .style(Style::default().add_modifier(Modifier::REVERSED)); frame.render_widget(bar, area); } ``` **4. Remove the `#[allow(dead_code)]` on the config field** in App struct — it's now actively used by navigate_to/back/forward for vault_path access. `cargo build` compiles with no new warnings. Status bar shows breadcrumb for current page. When Tab is pressed, selected link gets REVERSED modifier visually. Back/forward indicators appear/hide correctly based on history state. Link counter shows `Link N/M` when a link is selected. Selected link displays with inverted colors (REVERSED) at draw time without mutating stored lines. Status bar shows breadcrumb trail with .md stripped and > separator. Back/forward indicators conditionally visible. Link counter shows position when a link is selected. Keyboard hints updated with navigation commands. 1. `cargo build` succeeds with zero errors 2. Launch with vault containing index.md with wiki-links and standard links: - Links render as `[Link Text]` in cyan color - Broken wiki-links render as red strikethrough 3. Press Tab — first link gets REVERSED (inverted) styling; press Tab again — next link selected 4. Press Shift+Tab — previous link selected; wrap-around works at both ends 5. Press Enter on a link — navigates to target document; status bar updates breadcrumb 6. Press Backspace — returns to previous document with scroll position and link selection restored 7. Press Alt+Right — navigates forward to where you were 8. Navigate back, then follow a new link — forward history is cleared (browser fork) 9. Status bar shows: breadcrumb left, `< Back` when history exists, `Link N/M` when selected, `Forward >` when forward exists, hints right 10. Off-screen link selected via Tab auto-scrolls to center it The app is a functional vault browser: users can Tab-cycle links, Enter to follow them, Backspace/Alt+arrows for history, and see their location via breadcrumbs. All locked decisions (bracket-wrapped links, inverted selection, breadcrumb format, back/forward indicators, link counter, wrap-around cycling, auto-scroll, browser-style fork, scroll+link restoration) are implemented. After completion, create `.planning/phases/03-navigation-and-links/03-02-SUMMARY.md`