--- phase: 02-vault-core-and-rendering plan: 03 type: execute wave: 3 depends_on: - "02-01" - "02-02" files_modified: - src/app.rs - src/main.rs autonomous: true requirements: - NAV-05 - NAV-06 - NAV-07 - NAV-08 - NAV-09 must_haves: truths: - "App starts and displays index.md content from the configured vault_path" - "When index.md is missing, user sees a BBS-style error screen with box-drawing border and helpful message" - "User can scroll content with j/k (one line), arrow keys (one line), PgUp/PgDn (full page)" - "Status bar at the bottom shows filename on the left and keyboard hints on the right in reverse video" - "Terminal resize re-renders content at new width and reflows layout without crashing" - "Quit behavior from Phase 1 (q key, double Ctrl+C) is preserved" artifacts: - path: "src/app.rs" provides: "Document display, scrolling, status bar, error screen" contains: "DocumentState" - path: "src/main.rs" provides: "Wiring: mod declarations, highlighter init, vault loading" contains: "mod renderer" key_links: - from: "src/main.rs" to: "src/highlighter.rs" via: "init_highlighter() called before App::new()" pattern: "init_highlighter" - from: "src/app.rs" to: "src/vault.rs" via: "load_document() called to get markdown content" pattern: "load_document" - from: "src/app.rs" to: "src/renderer.rs" via: "render_markdown() converts content to styled lines for display" pattern: "render_markdown" - from: "src/app.rs" to: "ratatui::widgets::Paragraph" via: "Paragraph::new(lines).scroll((offset, 0)) for scrollable content" pattern: "scroll" --- Wire everything together: rework app.rs to display rendered markdown content with scrolling, a status bar, and error screens; update main.rs to register all new modules, initialize the highlighter, and load index.md on startup. Purpose: This is the integration plan that turns the separate modules (vault, renderer, highlighter) into a working content viewer. Output: Running `cargo run` displays index.md from the vault with full markdown styling, scrolling, and a status bar. @/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/02-vault-core-and-rendering/02-RESEARCH.md @.planning/phases/02-vault-core-and-rendering/02-CONTEXT.md @.planning/phases/02-vault-core-and-rendering/02-01-SUMMARY.md @.planning/phases/02-vault-core-and-rendering/02-02-SUMMARY.md @src/app.rs @src/main.rs Task 1: Rework app.rs with document state, scrolling, status bar, and error screen src/app.rs **Extend the App struct** — add new fields while preserving ALL Phase 1 fields and behavior: ```rust pub struct App { // Phase 1 fields (keep exactly as-is) is_login_shell: bool, ctrl_c_pressed_at: Option, show_quit_prompt: bool, should_quit: bool, config: Config, // Phase 2 additions document: DocumentState, scroll_offset: u16, } ``` **Add `DocumentState` enum:** ```rust pub enum DocumentState { Loaded { filename: String, lines: Vec>, }, Missing { path: PathBuf, }, Error { path: PathBuf, reason: String, }, } ``` **Update `App::new()`** to accept the initial document state: Change signature to `pub fn new(is_login_shell: bool, config: Config, document: DocumentState) -> Self` with `scroll_offset: 0`. **Rework `draw()`** — replace the Phase 1 placeholder UI entirely: 1. Split frame into two areas using Layout: ```rust let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(1)]) .split(frame.area()); let content_area = chunks[0]; let status_area = chunks[1]; ``` 2. **Content area rendering** — match on `self.document`: - `DocumentState::Loaded { filename, lines }`: - Create `Paragraph::new(lines.clone()).scroll((self.scroll_offset, 0))` - Do NOT enable Wrap — lines are pre-rendered - Render into `content_area` - `DocumentState::Missing { path }`: - Call `draw_error_screen(frame, content_area, path)` (see below) - `DocumentState::Error { path, reason }`: - Call `draw_error_screen_with_reason(frame, content_area, path, reason)` 3. **Status bar rendering (NAV-08):** - Determine filename text: from DocumentState::Loaded filename, or "ERROR" for missing/error states - Determine hints: `"q:Quit j/k:Scroll PgUp/PgDn:Page"` (if not login shell), or `"Ctrl+C×2:Quit j/k:Scroll PgUp/PgDn:Page"` (if login shell) - Build the status bar as a single Line with the filename left-aligned and hints right-aligned: - Calculate padding to fill the full status_area.width between filename and hints - Style: `Style::default().add_modifier(Modifier::REVERSED)` on the entire Paragraph - Render `Paragraph::new(status_line).style(Style::default().add_modifier(Modifier::REVERSED))` into `status_area` 4. **Quit prompt overlay** — if `self.show_quit_prompt` is true, render the "Press Ctrl+C again..." message. Options: - Render it as part of the status bar (replace hints with the warning), OR - Overlay it on the bottom of content_area - Recommended: replace the status bar content with the quit prompt in yellow bold reverse video **Error screen widget (NAV-07):** Create a private method `draw_error_screen(frame: &mut Frame, area: Rect, path: &Path)`: Use ratatui's `Block` widget with `BorderType::Plain` (┌─┐│└─┘) borders: ``` ┌─────────────────────────────────────────┐ │ *** SYSTEM ERROR *** │ │ │ │ No index.md found in vault: │ │ /path/to/vault │ │ │ │ Create index.md to begin. │ └─────────────────────────────────────────┘ ``` - Block border: `Color::Red` - "SYSTEM ERROR" text: `Color::LightRed + Modifier::BOLD` - Path: `Color::Yellow` - Hint: `Color::DarkGray` - Center the block in the content area (calculate a centered Rect) Also create `draw_error_screen_with_reason()` for ReadError states — same layout but shows the error reason instead of "No index.md found". **Extend `handle_key()` for scrolling (NAV-05):** Add new key bindings BEFORE the existing `_` catch-all: ```rust KeyCode::Char('j') | KeyCode::Down => { self.scroll_down(1); } KeyCode::Char('k') | KeyCode::Up => { self.scroll_up(1); } KeyCode::PageDown => { self.scroll_down(self.page_height()); } KeyCode::PageUp => { self.scroll_up(self.page_height()); } ``` Helper methods: - `scroll_down(n: u16)`: `self.scroll_offset = self.scroll_offset.saturating_add(n).min(self.max_scroll())` - `scroll_up(n: u16)`: `self.scroll_offset = self.scroll_offset.saturating_sub(n)` - `max_scroll() -> u16`: if Loaded, `lines.len() as u16 - page_height`, else 0. Use `saturating_sub`. - `page_height() -> u16`: store the content area height from the last draw, or default to 24. Add a `last_content_height: u16` field to App, update it in draw(). **IMPORTANT:** The scroll keys (j/k/arrows/PgUp/PgDn) must NOT trigger the quit prompt dismissal. Currently the `_ =>` branch dismisses the prompt. The scroll keys should be handled before the `_` catch-all, and only dismiss the prompt for truly unrelated keys. **Resize handling (NAV-09):** - App needs to store the raw markdown content so it can re-render on resize. Add a `raw_content: Option` field to App (populated when document is loaded). - In `run_event_loop()`, handle `Event::Resize(w, _h)`: - If `raw_content` is Some, re-render: `let lines = renderer::render_markdown(&content, w); self.document = DocumentState::Loaded { filename, lines };` - Clamp `scroll_offset` to new `max_scroll()` after re-render - ratatui handles buffer resize automatically for `Viewport::Fullscreen` - This ensures horizontal rules, code block borders, and table widths adapt to the new terminal width - `max_scroll()` recomputes on every draw based on `last_content_height`, so vertical scroll is handled naturally **Preserve ALL Phase 1 behavior:** - Double Ctrl+C quit mechanism - Login shell mode suppressing 'q' key - `show_goodbye()` function unchanged - `ShutdownReason` enum unchanged - `DOUBLE_PRESS_WINDOW` unchanged `cargo check` passes. Mentally trace: App starts with DocumentState::Loaded, draw() shows content with status bar, j/k adjust scroll_offset, q still quits, Ctrl+C double-press still works. app.rs has DocumentState enum, scroll_offset field, status bar with filename+hints in reverse video, BBS error screen for missing files, j/k/arrow/PgUp/PgDn scroll keys, resize handling. All Phase 1 quit behavior preserved. Task 2: Wire main.rs with module declarations, highlighter init, and index.md loading src/main.rs **Add module declarations** at the top of main.rs (add to the existing mod block): ```rust mod app; mod config; mod highlighter; mod renderer; mod signals; mod terminal; mod vault; ``` **Update the startup sequence** in `fn main()`: After step 3 (config loading) and before step 4 (panic hook), add: ```rust // 3a. Initialize syntax highlighting (one-time, ~23ms) highlighter::init_highlighter(); // 3b. Load initial document (index.md from vault) let initial_doc = match vault::load_document(&app_config.vault_path, "index.md") { vault::VaultDocument::Loaded { path, content } => { // Get terminal width for rendering — use a reasonable default before terminal init // We'll re-render if needed, but 80 is safe for initial parse let width = ratatui::crossterm::terminal::size() .map(|(w, _)| w) .unwrap_or(80); let filename = path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "index.md".to_string()); let lines = renderer::render_markdown(&content, width); app::DocumentState::Loaded { filename, lines } } vault::VaultDocument::Missing { path } => { app::DocumentState::Missing { path } } vault::VaultDocument::ReadError { path, reason } => { app::DocumentState::Error { path, reason } } }; ``` **Update App::new() call** in step 7: ```rust let mut app_state = app::App::new(is_login_shell, app_config, initial_doc); ``` **Pass raw content to App for resize re-rendering:** When loading a Loaded document, also pass the raw markdown string to App so it can re-render on resize. Update `App::new()` signature to accept an optional raw content string, or store it alongside the DocumentState. **Remove the `#[allow(dead_code)]` on config field** in app.rs if it's now used (vault_path is accessed). Actually, config is passed at construction but vault loading happens in main.rs, so config may still appear unused inside App. Keep the allow if needed, or add a method to access vault_path for future use. 1. `cargo build` succeeds 2. Create a test vault: `mkdir -p /tmp/bbs-test-vault && echo '# Welcome\n\nHello **world**!\n\n- Item one\n- Item two\n\n> A blockquote\n\n---\n\n```rust\nlet x = 42;\n```' > /tmp/bbs-test-vault/index.md` 3. Run: `cargo run -- --config /dev/null` with a bbs.toml pointing to the test vault (or modify the default path for testing) 4. Verify: content displays with colored headings, styled text, and a status bar at the bottom 5. Verify: j/k scroll content, q exits cleanly 6. Verify: without index.md, the BBS error screen appears main.rs declares all 7 modules (app, config, highlighter, renderer, signals, terminal, vault). Startup sequence initializes highlighter, loads index.md via vault, renders via renderer, passes DocumentState to App. Running `cargo run` displays styled markdown content with scrolling and status bar. Missing index.md shows error screen. 1. `cargo build` succeeds with zero warnings on new code 2. With a vault containing index.md: app displays rendered markdown content 3. Without index.md in vault: app displays BBS error screen with box-drawing border 4. j/k keys scroll one line, PgUp/PgDn scroll one page 5. Status bar shows filename on left, keyboard hints on right, in reverse video 6. q key exits (non-login-shell), double Ctrl+C exits (always) 7. Terminal resize does not crash — content area adjusts 8. All Phase 1 safety features (panic hook, signal handling, terminal restore) still work - Running `cargo run` displays index.md from the configured vault with full markdown styling - Missing index.md shows BBS error screen instead of crashing - Content scrolls smoothly with j/k/arrows/PgUp/PgDn - Status bar visible at bottom with filename and keyboard hints - All Phase 1 quit/safety behavior preserved - Terminal resize handled gracefully After completion, create `.planning/phases/02-vault-core-and-rendering/02-03-SUMMARY.md`