diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8134446..e99a940 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -29,7 +29,11 @@ Decimal phases appear between their surrounding integers in numeric order. 3. The app reads vault path and theme settings from bbs.toml and applies them on launch 4. The app exits cleanly when the user presses q or Ctrl+C, restoring the terminal 5. The app starts correctly when invoked as a login shell (argv[0] may have a leading dash) -**Plans**: TBD +**Plans:** 3 plans +Plans: +- [ ] 01-01-PLAN.md — Config loading, CLI parsing, and login shell detection +- [ ] 01-02-PLAN.md — Terminal init/restore, panic hook, and signal handling +- [ ] 01-03-PLAN.md — App event loop, exit behavior, and main.rs wiring ### Phase 2: Vault Core and Rendering **Goal**: Users can read a markdown document — all standard constructs render correctly and content is scrollable @@ -73,7 +77,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| -| 1. Safety Foundation | 0/TBD | Not started | - | +| 1. Safety Foundation | 0/3 | Planned | - | | 2. Vault Core and Rendering | 0/TBD | Not started | - | | 3. Navigation and Links | 0/TBD | Not started | - | | 4. BBS Polish and Live Content | 0/TBD | Not started | - | diff --git a/.planning/phases/01-safety-foundation/01-01-PLAN.md b/.planning/phases/01-safety-foundation/01-01-PLAN.md new file mode 100644 index 0000000..bdf4659 --- /dev/null +++ b/.planning/phases/01-safety-foundation/01-01-PLAN.md @@ -0,0 +1,184 @@ +--- +phase: 01-safety-foundation +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - Cargo.toml + - src/config.rs + - src/main.rs +autonomous: true +requirements: + - CONF-01 + - SHEL-02 + +must_haves: + truths: + - "App reads vault_path and theme from bbs.toml when the file exists" + - "App uses sensible defaults (vault=./vault/, theme=default) when bbs.toml is missing" + - "App rejects unknown keys in bbs.toml with a BBS-themed error message" + - "App shows a friendly error and exits when vault path does not exist" + - "App accepts --config flag to specify an alternate config path" + - "App detects login shell mode when argv[0] starts with a dash" + - "App strips the leading dash from argv[0] before clap parses arguments" + artifacts: + - path: "src/config.rs" + provides: "Config struct, load_config(), config path resolution, BBS error formatting" + contains: "deny_unknown_fields" + - path: "Cargo.toml" + provides: "Dependencies: toml, serde, clap, signal-hook" + contains: "signal-hook" + key_links: + - from: "src/main.rs" + to: "src/config.rs" + via: "load_config() call before terminal init" + pattern: "load_config" + - from: "src/main.rs" + to: "clap::Parser" + via: "Cli::parse_from with stripped argv[0]" + pattern: "parse_from" +--- + + +Add all Phase 1 dependencies to Cargo.toml, implement config loading from bbs.toml with strict TOML parsing and BBS-themed error messages, implement CLI argument parsing with --config flag, and detect login shell mode from argv[0]. Wire the early startup path in main.rs (everything before terminal initialization). + +Purpose: Config and CLI must work before the terminal enters raw mode, so errors can be printed normally. Login shell detection determines whether 'q' to quit is suppressed later. + +Output: `src/config.rs` with Config struct and load_config(), updated `Cargo.toml` with all Phase 1 deps, and early startup wiring in `src/main.rs`. + + + +@/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/01-safety-foundation/01-RESEARCH.md + + + + + + Task 1: Add Phase 1 dependencies and implement config + CLI + Cargo.toml, src/config.rs, src/main.rs + +**Cargo.toml** — Add all Phase 1 dependencies alongside the existing `ratatui = "0.30.0"`: +```toml +signal-hook = "0.4.3" +toml = "1.0.3" +serde = { version = "1.0", features = ["derive"] } +clap = { version = "4.5", features = ["derive"] } +``` + +**src/config.rs** — Create with: + +1. `Config` struct with `#[derive(Deserialize, Debug)]` and `#[serde(deny_unknown_fields)]`: + - `vault_path: PathBuf` with `#[serde(default = "default_vault_path")]` — defaults to `PathBuf::from("./vault")` + - `theme: String` with `#[serde(default = "default_theme")]` — defaults to `"default".to_string()` + - Implement `Default` for `Config` + +2. `ConfigError` enum with variants: + - `ReadError(std::io::Error)` — file exists but can't be read + - `ParseError(toml::de::Error)` — TOML syntax or unknown fields + - `VaultNotFound(PathBuf)` — vault_path directory doesn't exist + +3. `load_config(path: &std::path::Path) -> Result`: + - If file doesn't exist, return `Ok(Config::default())` + - Read file, parse with `toml::from_str()` + - Validate vault_path exists as a directory (resolve relative paths against the config file's parent directory, NOT the cwd — this is important because the config lives next to the binary) + - Return `Err(ConfigError::VaultNotFound(...))` if vault dir missing + +4. `resolve_config_path(cli_config: Option<&std::path::Path>) -> PathBuf`: + - If CLI provided a path, use it + - Otherwise, use `std::env::current_exe().parent() / "bbs.toml"` + +5. `print_config_error(err: &ConfigError)`: + - Format BBS-themed error messages to stderr. Examples: + - ParseError: `"SYSTEM ERROR: Configuration file corrupted. SysOp intervention required.\nDetail: {toml error message}"` + - VaultNotFound: `"SYSTEM ERROR: Vault directory not found at '{path}'. SysOp must verify vault_path in bbs.toml."` + - ReadError: `"SYSTEM ERROR: Cannot read configuration file. Check file permissions."` + - These print with `eprintln!()` since terminal is not yet in raw mode + +6. `Cli` struct with clap derive: + - `#[derive(clap::Parser, Debug)]` + - `#[command(name = "bbs-md", about = "BBS-style markdown vault reader")]` + - `config: Option` field with `#[arg(long = "config", short = 'c', value_name = "FILE")]` + +7. `detect_login_shell() -> bool`: + - Check `std::env::args_os().next()` — if it starts with '-', return true + +8. `parse_cli() -> Cli`: + - Collect `std::env::args_os()`, strip leading dash from first element if present + - Call `Cli::parse_from(args)` with the cleaned args + +**src/main.rs** — Replace the hello world with the early startup path: +```rust +mod config; + +fn main() { + // 1. Detect login shell BEFORE stripping argv[0] + let is_login_shell = config::detect_login_shell(); + + // 2. Parse CLI (strips dash from argv[0] internally) + let cli = config::parse_cli(); + + // 3. Resolve config path and load config + let config_path = config::resolve_config_path(cli.config.as_deref()); + let app_config = match config::load_config(&config_path) { + Ok(c) => c, + Err(e) => { + config::print_config_error(&e); + std::process::exit(1); + } + }; + + // Phase 1 placeholder: print loaded config for verification + // This will be replaced by terminal init + event loop in Plan 03 + println!("Config loaded: {:?}", app_config); + println!("Login shell: {}", is_login_shell); +} +``` + +Declare `mod config;` in main.rs. The placeholder `println!` lines will be replaced in Plan 03 when the event loop is wired. + + +Run `cargo build` from the project root — must compile without errors. + +Run `cargo run` — should print "Config loaded: Config { vault_path: ... }" with defaults (since no bbs.toml exists yet). + +Create a test bbs.toml next to the binary with `vault_path = "./vault"` and `theme = "retro"`, create a `./vault` directory, run the binary — config should load successfully. + +Create a bbs.toml with an unknown key like `bogus = true` — app should print BBS-themed error and exit with code 1. + + +`cargo build` succeeds. Config loads from bbs.toml with strict parsing, defaults work when file is missing, unknown keys produce BBS-themed errors, missing vault directory produces a friendly error, --config flag overrides config path, and login shell detection works. + + + + + + +1. `cargo build` compiles without errors or warnings +2. Running without bbs.toml uses defaults (vault=./vault, theme=default) +3. Running with valid bbs.toml loads specified values +4. Unknown TOML keys produce BBS-themed error output +5. Nonexistent vault_path produces friendly error and exit(1) +6. `--config /path/to/custom.toml` overrides default config location +7. Login shell detection returns true when argv[0] starts with dash + + + +- Config struct deserializes bbs.toml with deny_unknown_fields +- Missing config file gracefully falls back to defaults +- All error messages use BBS-themed tone +- CLI parsing works with and without login shell dash prefix +- All Phase 1 dependencies are in Cargo.toml + + + +After completion, create `.planning/phases/01-safety-foundation/01-01-SUMMARY.md` + diff --git a/.planning/phases/01-safety-foundation/01-02-PLAN.md b/.planning/phases/01-safety-foundation/01-02-PLAN.md new file mode 100644 index 0000000..091095b --- /dev/null +++ b/.planning/phases/01-safety-foundation/01-02-PLAN.md @@ -0,0 +1,198 @@ +--- +phase: 01-safety-foundation +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/terminal.rs + - src/signals.rs +autonomous: true +requirements: + - LIFE-01 + - LIFE-02 + - LIFE-03 + - LIFE-04 + +must_haves: + truths: + - "When the app panics, raw mode is disabled and a friendly BBS message is printed before exiting" + - "Panic details go to stderr for sysop; user sees only the friendly message" + - "SIGHUP sets a terminate flag that the event loop can poll" + - "SIGTERM sets a terminate flag that the event loop can poll" + - "Writing to stdout after SSH disconnect does not panic (broken pipe handled)" + - "Terminal init uses main screen buffer (no alternate screen) with raw mode and clear" + - "Terminal restore disables raw mode and shows cursor without calling LeaveAlternateScreen" + artifacts: + - path: "src/terminal.rs" + provides: "init_terminal(), restore_terminal(), install_panic_hook()" + contains: "disable_raw_mode" + - path: "src/signals.rs" + provides: "SignalFlags, register_signals()" + contains: "AtomicBool" + key_links: + - from: "src/terminal.rs" + to: "crossterm" + via: "enable_raw_mode, disable_raw_mode, Clear, cursor::Show" + pattern: "enable_raw_mode" + - from: "src/terminal.rs" + to: "ratatui" + via: "Terminal::with_options(Viewport::Fullscreen)" + pattern: "Viewport::Fullscreen" + - from: "src/signals.rs" + to: "signal-hook" + via: "flag::register for SIGHUP and SIGTERM" + pattern: "flag::register" +--- + + +Implement terminal initialization (main screen, no alternate buffer), terminal restoration, panic hook with friendly BBS messaging, UNIX signal handling for SIGHUP/SIGTERM, and broken pipe safety. These are the safety primitives that all other code depends on. + +Purpose: The login-shell deployment context means any terminal corruption locks out SSH users. This plan establishes the safety envelope: every exit path (panic, signal, normal quit) restores the terminal correctly. + +Output: `src/terminal.rs` with init/restore/panic-hook, `src/signals.rs` with signal flag registration. + + + +@/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/01-safety-foundation/01-RESEARCH.md + + + + + + Task 1: Implement terminal init/restore and panic hook + src/terminal.rs + +Create `src/terminal.rs` with: + +1. **`init_terminal() -> std::io::Result>>`** + - Call `crossterm::terminal::enable_raw_mode()?` + - Call `crossterm::execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?` — clears main screen buffer for immersive BBS feel + - Create `CrosstermBackend::new(stdout())` + - Return `Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Fullscreen })` + - IMPORTANT: Do NOT enter alternate screen. Do NOT use `ratatui::init()`. + +2. **`restore_terminal()`** (no return value — must never panic) + - `let _ = crossterm::terminal::disable_raw_mode();` + - `let _ = crossterm::execute!(std::io::stdout(), crossterm::cursor::Show);` + - Do NOT call `ratatui::restore()` (it calls LeaveAlternateScreen which we never entered) + - Every operation uses `let _ =` to suppress errors — this runs in cleanup paths including panic hook + +3. **`install_panic_hook()`** + - Call `std::panic::take_hook()` to capture the original hook + - Call `std::panic::set_hook(Box::new(move |panic_info| { ... }))` with a closure that: + a. Calls `restore_terminal()` to clean up the terminal + b. Prints friendly BBS-themed message to stderr with `eprintln!()`: + ``` + \r\n+----------------------------------------------+ + | SYSTEM ERROR: An unexpected fault occurred. | + | The BBS has exited safely. | + | SysOp has been notified. | + +----------------------------------------------+\r + ``` + (Use `\r\n` and trailing `\r` for proper display after raw mode restoration) + c. Calls the original hook with `panic_info` — this outputs the technical backtrace to stderr (captured by systemd journal / SSH log) + - All I/O in the hook uses `let _ =` or `eprintln!()` (which silently ignores errors). Never `unwrap()` inside the hook. + +4. **Broken pipe safety (LIFE-04):** At the module level, add a doc comment noting that all write operations in the TUI must use `let _ =` or handle `ErrorKind::BrokenPipe`. The actual enforcement happens in the event loop (Plan 03), but restore_terminal() already follows this pattern with `let _ =` on every call. + +Use these imports: +```rust +use std::io::stdout; +use crossterm::{ + execute, + terminal::{enable_raw_mode, disable_raw_mode, Clear, ClearType}, + cursor::{MoveTo, Show}, +}; +use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport}; +``` + +Export the Terminal type alias for use in app.rs: +```rust +pub type Term = Terminal>; +``` + + +`cargo build` succeeds (after adding `mod terminal;` temporarily to main.rs or just checking compilation). + +Review: `init_terminal` does NOT contain `EnterAlternateScreen`. `restore_terminal` does NOT contain `LeaveAlternateScreen`. Panic hook does NOT contain any `unwrap()` or `?` operator. + + +Terminal init enters raw mode and clears main screen without alternate screen buffer. Restore disables raw mode and shows cursor with `let _ =` on every call. Panic hook restores terminal, prints friendly BBS message, then delegates to original hook for technical details. + + + + + Task 2: Implement signal handling and file logging stub + src/signals.rs + +Create `src/signals.rs` with: + +1. **`SignalFlags` struct:** + ```rust + pub struct SignalFlags { + pub terminate: Arc, + } + ``` + +2. **`register_signals() -> std::io::Result`:** + - Create `terminate = Arc::new(AtomicBool::new(false))` + - Register SIGTERM: `signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&terminate))?` + - Register SIGHUP: `signal_hook::flag::register(signal_hook::consts::SIGHUP, Arc::clone(&terminate))?` + - Do NOT register SIGINT — Ctrl+C is handled as a crossterm key event in the event loop (avoids double-handling per research findings) + - Return `Ok(SignalFlags { terminate })` + +3. **`SignalFlags::should_terminate(&self) -> bool`:** + - Returns `self.terminate.load(Ordering::Relaxed)` + - Convenience method for polling in the event loop + +4. **LIFE-03 (logging):** Add a simple `init_logging()` function stub. For Phase 1, logging is minimal — the requirement is that after TUI init, nothing writes to stdout/stderr directly. The panic hook is the exception (it restores terminal first). For now, `init_logging()` is a no-op that returns `()`. A doc comment should note: "Phase 1: no file logging needed yet. When LIFE-03 becomes relevant in Phase 2+, replace this with file-based logging. For now, the rule is simply: do not write to stdout/stderr after terminal init except through ratatui's draw cycle or after calling restore_terminal()." + +Use these imports: +```rust +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use signal_hook::consts::signal::{SIGHUP, SIGTERM}; +use signal_hook::flag as signal_flag; +``` + + +`cargo build` succeeds (after adding `mod signals;` temporarily to main.rs). + +Review: SIGINT is NOT registered (only SIGHUP and SIGTERM). `should_terminate()` uses `Ordering::Relaxed`. + + +SIGHUP and SIGTERM set a shared AtomicBool flag. SignalFlags provides a `should_terminate()` method for polling. SIGINT is deliberately not registered (Ctrl+C handled via crossterm key events). Logging stub documents the stdout/stderr rule for LIFE-03. + + + + + + +1. `cargo build` compiles with both new modules +2. `init_terminal()` uses `Viewport::Fullscreen` and never enters alternate screen +3. `restore_terminal()` uses `let _ =` for every operation +4. Panic hook contains no `unwrap()` or `?` — only `let _ =` and `eprintln!()` +5. Signal registration covers SIGHUP and SIGTERM but NOT SIGINT +6. `should_terminate()` polls the AtomicBool + + + +- Terminal initializes on main screen buffer with raw mode +- Panic hook restores terminal and prints BBS-friendly message +- Signals set a pollable flag for clean shutdown +- No alternate screen buffer is used anywhere +- All cleanup paths are panic-safe (no unwrap in error paths) + + + +After completion, create `.planning/phases/01-safety-foundation/01-02-SUMMARY.md` + diff --git a/.planning/phases/01-safety-foundation/01-03-PLAN.md b/.planning/phases/01-safety-foundation/01-03-PLAN.md new file mode 100644 index 0000000..fa64770 --- /dev/null +++ b/.planning/phases/01-safety-foundation/01-03-PLAN.md @@ -0,0 +1,327 @@ +--- +phase: 01-safety-foundation +plan: 03 +type: execute +wave: 2 +depends_on: + - "01-01" + - "01-02" +files_modified: + - src/app.rs + - src/main.rs +autonomous: true +requirements: + - SHEL-01 + +must_haves: + truths: + - "User can exit by pressing q (only when NOT in login shell mode)" + - "User can exit by pressing Ctrl+C twice within 2 seconds" + - "First Ctrl+C shows 'Press Ctrl+C again to disconnect' prompt in the TUI" + - "In login shell mode, q key is suppressed — only double Ctrl+C exits" + - "On exit, terminal is restored to a usable state" + - "A BBS-style goodbye message displays for ~500ms before process exits" + - "Signal-triggered shutdown (SIGHUP/SIGTERM) restores terminal and exits without goodbye message" + - "App launches straight to a placeholder screen (ready for Phase 2 content)" + artifacts: + - path: "src/app.rs" + provides: "App struct, run_event_loop(), double-Ctrl+C state machine, shutdown handling" + contains: "DOUBLE_PRESS_WINDOW" + - path: "src/main.rs" + provides: "Complete startup → event loop → shutdown pipeline" + contains: "install_panic_hook" + key_links: + - from: "src/app.rs" + to: "src/signals.rs" + via: "polls SignalFlags.should_terminate() each loop iteration" + pattern: "should_terminate" + - from: "src/app.rs" + to: "src/terminal.rs" + via: "calls restore_terminal() on exit" + pattern: "restore_terminal" + - from: "src/main.rs" + to: "src/app.rs" + via: "creates App and calls run_event_loop()" + pattern: "run_event_loop" + - from: "src/main.rs" + to: "src/config.rs" + via: "loads config before terminal init" + pattern: "load_config" + - from: "src/main.rs" + to: "src/terminal.rs" + via: "installs panic hook, inits terminal, restores on exit" + pattern: "init_terminal" +--- + + +Implement the App state struct with the event loop, double-press Ctrl+C state machine, 'q' key handling with login-shell suppression, goodbye message, and wire everything together in main.rs. This is the final plan that produces a complete, runnable Phase 1 application. + +Purpose: The event loop is where all safety mechanisms converge — signal polling, key handling, and clean exit. This plan wires config, terminal, and signals into a working application that can be safely used as a login shell. + +Output: `src/app.rs` with event loop and exit behavior, fully wired `src/main.rs`. + + + +@/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/01-safety-foundation/01-RESEARCH.md +@.planning/phases/01-safety-foundation/01-01-SUMMARY.md +@.planning/phases/01-safety-foundation/01-02-SUMMARY.md + + + + + + Task 1: Implement App struct and event loop with exit behavior + src/app.rs + +Create `src/app.rs` with: + +1. **`ShutdownReason` enum:** + - `UserQuit` — user pressed 'q' or completed double Ctrl+C + - `Signal` — SIGHUP/SIGTERM received + +2. **`App` struct:** + ```rust + pub struct App { + is_login_shell: bool, + ctrl_c_pressed_at: Option, + show_quit_prompt: bool, + should_quit: bool, + } + ``` + +3. **`const DOUBLE_PRESS_WINDOW: Duration = Duration::from_secs(2);`** + +4. **`App::new(is_login_shell: bool) -> Self`** — initialize with defaults + +5. **`App::run_event_loop(&mut self, terminal: &mut Term, signals: &SignalFlags) -> std::io::Result`:** + - Main loop structure: + ``` + loop { + // 1. Check signal flags FIRST (fast path for SSH disconnect) + if signals.should_terminate() { + return Ok(ShutdownReason::Signal); + } + + // 2. Draw the UI + terminal.draw(|frame| self.draw(frame))?; + // Note: if draw() returns BrokenPipe, the ? propagates it up. + // main.rs will catch BrokenPipe and exit cleanly (LIFE-04). + + // 3. Poll for events with a 250ms timeout + if crossterm::event::poll(Duration::from_millis(250))? { + if let Event::Key(key) = crossterm::event::read()? { + self.handle_key(key); + } + // Handle Event::Resize by clearing — ratatui redraws on next loop + } + + // 4. Check if we should quit + if self.should_quit { + return Ok(ShutdownReason::UserQuit); + } + + // 5. Clear quit prompt if double-press window expired + if let Some(pressed_at) = self.ctrl_c_pressed_at { + if pressed_at.elapsed() >= DOUBLE_PRESS_WINDOW { + self.ctrl_c_pressed_at = None; + self.show_quit_prompt = false; + } + } + } + ``` + +6. **`App::handle_key(&mut self, key: KeyEvent)`:** + - Match on `key.code`: + - `KeyCode::Char('c')` with `KeyModifiers::CONTROL` → handle Ctrl+C: + - If `ctrl_c_pressed_at` is `Some(t)` and `t.elapsed() < DOUBLE_PRESS_WINDOW` → set `should_quit = true` + - Otherwise → set `ctrl_c_pressed_at = Some(Instant::now())`, `show_quit_prompt = true` + - `KeyCode::Char('q')` → if NOT `is_login_shell`, set `should_quit = true` + - Any other key → if `show_quit_prompt`, clear it (reset `ctrl_c_pressed_at = None`, `show_quit_prompt = false`) + +7. **`App::draw(&self, frame: &mut Frame)`:** + - Phase 1 placeholder UI. Render a simple centered block with: + - Title: "BBS-MD" in the top border + - Body text: "Welcome to BBS-MD. Content loading will be available in Phase 2." + - If `show_quit_prompt`: render a line at the bottom saying "Press Ctrl+C again to disconnect..." (styled with a warning color like yellow) + - If `is_login_shell`: show "Login Shell Mode" indicator somewhere visible + - Use `ratatui::widgets::Block`, `Paragraph`, and basic `Style` — nothing fancy, just enough to prove the TUI works + - Keep imports minimal: `ratatui::prelude::*`, `ratatui::widgets::{Block, Borders, Paragraph}` + +8. **`show_goodbye()`** (standalone function, not on App): + - Called AFTER `restore_terminal()` has been called (terminal is in normal mode) + - Print to stdout (not stderr — user should see this in their terminal): + ``` + println!("\r"); + println!(" +------------------------------------------+"); + println!(" | Thank you for calling the BBS! |"); + println!(" | *** CARRIER LOST *** |"); + println!(" +------------------------------------------+"); + println!("\r"); + ``` + - Call `std::thread::sleep(Duration::from_millis(500))` for the brief display per user decision + - Use `let _ = writeln!(...)` instead of `println!()` if you want to handle broken pipe gracefully here too, but `println!()` is acceptable since terminal is already restored + +Use these imports at the top: +```rust +use std::io; +use std::time::{Duration, Instant}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph}; +use crate::terminal::Term; +use crate::signals::SignalFlags; +``` + + +`cargo build` succeeds with the new app module (after adding `mod app;` to main.rs). + +Review: `handle_key` suppresses 'q' when `is_login_shell` is true. Double Ctrl+C window is 2 seconds. `show_goodbye()` sleeps for 500ms. Event loop polls signals before draw. + + +App struct implements the event loop with signal polling, double-press Ctrl+C state machine, login-shell 'q' suppression, placeholder TUI rendering, quit prompt display, and BBS-style goodbye message with 500ms delay. + + + + + Task 2: Wire complete startup-to-shutdown pipeline in main.rs + src/main.rs + +Replace the placeholder main.rs from Plan 01 with the complete pipeline. The final main.rs should: + +1. Declare all modules: `mod config;`, `mod terminal;`, `mod signals;`, `mod app;` + +2. `fn main()` flow: + ```rust + fn main() { + // --- PRE-TERMINAL PHASE (errors use normal eprintln) --- + + // 1. Detect login shell + let is_login_shell = config::detect_login_shell(); + + // 2. Parse CLI + let cli = config::parse_cli(); + + // 3. Load config + let config_path = config::resolve_config_path(cli.config.as_deref()); + let app_config = match config::load_config(&config_path) { + Ok(c) => c, + Err(e) => { + config::print_config_error(&e); + std::process::exit(1); + } + }; + + // --- TERMINAL PHASE (safety envelope active) --- + + // 4. Install panic hook BEFORE terminal init + terminal::install_panic_hook(); + + // 5. Register signal handlers + let signal_flags = match signals::register_signals() { + Ok(s) => s, + Err(e) => { + eprintln!("SYSTEM ERROR: Cannot register signal handlers: {}", e); + std::process::exit(1); + } + }; + + // 6. Initialize terminal + let mut term = match terminal::init_terminal() { + Ok(t) => t, + Err(e) => { + eprintln!("SYSTEM ERROR: Cannot initialize terminal: {}", e); + std::process::exit(1); + } + }; + + // --- EVENT LOOP PHASE --- + + // 7. Create app and run + let mut app_state = app::App::new(is_login_shell); + let shutdown_reason = app_state.run_event_loop(&mut term, &signal_flags); + + // --- SHUTDOWN PHASE --- + + // 8. Restore terminal (always, regardless of how we got here) + terminal::restore_terminal(); + + // 9. Handle shutdown reason + match shutdown_reason { + Ok(app::ShutdownReason::UserQuit) => { + app::show_goodbye(); + } + Ok(app::ShutdownReason::Signal) => { + // Signal shutdown — exit silently, no goodbye + // (SSH disconnect — nobody to show goodbye to) + } + Err(e) => { + // Handle broken pipe gracefully (LIFE-04) + if e.kind() == std::io::ErrorKind::BrokenPipe { + // Silent exit — SSH connection already gone + } else { + eprintln!("SYSTEM ERROR: {}", e); + std::process::exit(1); + } + } + } + } + ``` + +Key points: +- Config loads BEFORE terminal init — errors print to stderr normally +- Panic hook installed BEFORE terminal init — covers panics during init +- Signal handlers registered BEFORE terminal init — covers early SIGHUP +- Terminal restored in ALL exit paths (the match arms all fall through after restore) +- Broken pipe error from event loop is caught and silently exits (LIFE-04) +- Goodbye message ONLY on user-initiated quit (not on signal shutdown) +- `app_config` is passed to App::new if needed (currently not used beyond loading, but the config is validated — Phase 2 will use vault_path) + +Note: `app_config` may appear unused in Phase 1 since we only display a placeholder. That's fine — the config is validated and ready. Suppress the warning with `let _app_config = ...` or pass it to App::new for future use. Prefer passing it: `App::new(is_login_shell, app_config)` and store it in App even if unused yet. + + +Run `cargo build` — must compile with all four modules wired together. + +Run `cargo run` — TUI should display the placeholder screen with "BBS-MD" title. Press 'q' — should show goodbye message and exit. Press Ctrl+C once — should show "Press Ctrl+C again to disconnect". Press Ctrl+C twice quickly — should show goodbye and exit. Wait 2+ seconds after first Ctrl+C, then press any key — quit prompt should disappear. + +Run `cargo run` and send SIGTERM from another terminal (`kill $(pgrep bbs-md)`) — should exit cleanly without goodbye message, terminal should be restored. + +Verify no `unwrap()` exists in terminal.rs panic hook. Verify no `LeaveAlternateScreen` anywhere in the codebase. + + +Complete Phase 1 application runs: config loads before terminal init, panic hook protects against crashes, signals trigger clean shutdown, double Ctrl+C exits with goodbye message, 'q' exits (suppressed in login shell mode), broken pipe exits silently, terminal is restored in all exit paths. + + + + + + +1. `cargo build` compiles the complete application with all modules +2. TUI launches and displays placeholder content +3. Pressing 'q' exits with goodbye message (normal mode) +4. Pressing 'q' does nothing in login shell mode +5. Single Ctrl+C shows quit prompt, second within 2s exits +6. SIGTERM/SIGHUP causes clean exit without goodbye message +7. Terminal is restored after any exit path +8. No `unwrap()` in panic hook, no alternate screen usage +9. Goodbye message displays for ~500ms + + + +- Complete runnable application from `cargo run` +- All 7 Phase 1 requirements (LIFE-01 through LIFE-04, CONF-01, SHEL-01, SHEL-02) are implemented and wired +- Every exit path restores the terminal +- Double Ctrl+C state machine works with 2-second window +- Login shell mode suppresses 'q' key + + + +After completion, create `.planning/phases/01-safety-foundation/01-03-SUMMARY.md` +