---
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 reflows the layout without crashing or corrupting display"
- "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):**
- In `run_event_loop()`, add `Event::Resize(_, _) => {}` handling — ratatui handles the buffer resize automatically for `Viewport::Fullscreen`; we just need to ensure the event is consumed so it doesn't fall through
- `max_scroll()` recomputes on every draw based on `last_content_height`, so resize 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);
```
**Event loop resize re-rendering consideration:**
The initial render uses terminal width at startup. On resize, the content would ideally re-render. For Phase 2, accept the initial render width — re-rendering on resize can be added later if needed. The content still displays correctly; only horizontal rules and code block borders may not be pixel-perfect after resize.
**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