diff --git a/.planning/phases/01-safety-foundation/01-RESEARCH.md b/.planning/phases/01-safety-foundation/01-RESEARCH.md
new file mode 100644
index 0000000..b85606b
--- /dev/null
+++ b/.planning/phases/01-safety-foundation/01-RESEARCH.md
@@ -0,0 +1,699 @@
+# Phase 1: Safety Foundation - Research
+
+**Researched:** 2026-02-28
+**Domain:** Rust TUI process lifecycle, signal handling, TOML configuration, terminal state management
+**Confidence:** HIGH
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+#### Configuration shape
+- Config file lives next to the binary (same directory)
+- When bbs.toml is missing, use sensible defaults (vault defaults to ./vault/, default theme) — config file is optional
+- Phase 1 settings: `vault_path` and `theme` only — minimal config
+- Support `--config /path/to/bbs.toml` CLI flag to override config location
+- Strict TOML parsing — reject unknown keys to catch typos early
+- When vault path points to a nonexistent directory, show a friendly error and exit (don't launch the TUI)
+
+#### Startup experience
+- Straight to content on launch — no splash screen, no delay, immediately show index.md
+- Clear the terminal on launch (clean slate, not alternate screen buffer) — immersive BBS feel, no shell artifacts
+- Login shell detection: strip the leading dash from argv[0] for compatibility
+- In login-shell mode, suppress 'q' to quit — prevents accidental SSH disconnects. Only Ctrl+C works as exit.
+
+#### Failure messaging
+- On panic recovery: friendly message only — "Something went wrong. The app has exited safely." No technical details shown to user.
+- Panic details logged to stderr — captured by systemd journal or SSH output after exit, available for server admin
+- Config errors use BBS-themed tone — "SYSTEM ERROR: Config file corrupted at line 3. SysOp intervention required." style messaging
+
+#### Exit behavior
+- BBS-style goodbye message on quit — retro signoff before terminal restores
+- Ctrl+C requires double-press to confirm — first press shows "Press again to quit", second press exits
+- In login-shell mode, Ctrl+C (double-press) is the only exit method — no alternative commands
+- Goodbye message displays for ~500ms (brief flash) before process exits
+
+### Claude's Discretion
+- Exact goodbye message text and formatting
+- Panic hook implementation approach
+- Signal handler registration strategy
+- Default theme values
+- Default vault path (./vault/ or similar)
+
+### Deferred Ideas (OUT OF SCOPE)
+None — discussion stayed within phase scope
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|-----------------|
+| LIFE-01 | App installs panic hook that restores terminal state before printing error | `std::panic::set_hook` + `ratatui::restore()` pattern; crossterm `disable_raw_mode` + clear commands |
+| LIFE-02 | App handles SIGHUP/SIGTERM for clean shutdown on SSH disconnect | `signal-hook` 0.4.3 `flag::register()` with `AtomicBool`; polled in event loop |
+| LIFE-03 | App logs to file only, never writes to stderr/stdout after TUI init | Standard `std::fs::File` append logging; all TUI output through ratatui draw cycle only |
+| LIFE-04 | App handles broken pipe without crashing | Match on `ErrorKind::BrokenPipe`; use `let _ =` for write results; avoid `unwrap()` on I/O |
+| CONF-01 | App reads bbs.toml for vault path and theme configuration | `toml` 1.0.3 + `serde` 1.0.228; `#[serde(deny_unknown_fields)]` + `#[serde(default)]` |
+| SHEL-01 | App exits cleanly with q or Ctrl+C, restoring terminal state | `ratatui::restore()` in exit path; crossterm `disable_raw_mode`; double-press Ctrl+C state machine |
+| SHEL-02 | App handles being launched as a login shell gracefully | `std::env::args_os().next()` argv[0]; `strip_prefix("-")` on OsStr; suppress 'q' key when login mode |
+
+
+---
+
+## Summary
+
+Phase 1 establishes the safety envelope for the entire application: panic recovery, signal handling, config loading, and clean shutdown. The Rust ecosystem has mature, well-documented solutions for every requirement here. The primary stack is ratatui 0.30 (already a dependency) + crossterm 0.29 (already a transitive dependency) for terminal management, signal-hook 0.4.3 for UNIX signal handling, and toml 1.0.3 + serde 1.0.228 for configuration parsing. clap 4.5 handles the `--config` CLI flag.
+
+The most important architectural decision this phase must navigate is the terminal initialization strategy. The user decision specifies **main screen buffer with a clear** rather than the alternate screen buffer. This means deliberately not using `ratatui::init()` (which enters alternate screen) and instead manually calling `enable_raw_mode()` + `execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))`. This is the biggest deviation from ratatui's defaults and must be implemented carefully to ensure `restore()` still works correctly on exit and panic.
+
+The double-press Ctrl+C pattern is a small state machine: track a `last_ctrl_c: Option` — if a second SIGINT arrives within ~2 seconds of the first, exit; otherwise reset and show the prompt message. Since there is no async runtime, signal handling is done via `signal-hook` flag polling inside the crossterm event loop rather than a dedicated thread.
+
+**Primary recommendation:** Use `Terminal::with_options` with `Viewport::Fullscreen`, skip `EnterAlternateScreen`, issue a raw `Clear(ClearType::All) + MoveTo(0,0)` at startup, and build a single unified `shutdown()` function called from all exit paths (panic hook, signal handler, normal quit).
+
+---
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| ratatui | 0.30.0 | TUI framework, terminal draw cycle | Already a dependency; 0.30 has `init()`, `restore()`, `run()` convenience functions |
+| crossterm | 0.29.0 | Terminal raw mode, clear, cursor, key events | Ratatui's default backend; already a transitive dep; cross-platform |
+| signal-hook | 0.4.3 | SIGHUP/SIGTERM/SIGINT handling | Widest community support for synchronous multi-signal handling; no async needed |
+| toml | 1.0.3+spec-1.1.0 | TOML config parsing | The canonical Rust TOML crate; serde integration built-in |
+| serde | 1.0.228 | Derive-based deserialization for config struct | Required by toml crate; standard across all Rust config parsing |
+| clap | 4.5.60 | `--config` CLI flag parsing | Industry standard; derive API is concise; supports `PathBuf` natively |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| serde_derive | (bundled with serde) | `#[derive(Deserialize)]` macro | Needed for config struct; enable `features = ["derive"]` |
+| std::sync::atomic::AtomicBool | stdlib | Signal flags for SIGHUP/SIGTERM | Polling in event loop without threads |
+| std::panic | stdlib | Panic hook installation | `set_hook` + `take_hook` for custom panic handler |
+| std::time::Instant | stdlib | Double Ctrl+C timing window | Track first press timestamp |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| signal-hook | ctrlc crate | ctrlc only handles SIGINT by default; SIGHUP requires `termination` feature; signal-hook is more explicit and handles all needed signals |
+| signal-hook | raw libc signal() | Unsafe, no async-signal-safety guarantees, error-prone |
+| toml | config crate | config crate is heavier (multi-source merging); overkill for a single optional TOML file |
+| clap | std::env::args() manual parsing | clap gives `--help`, `--version`, error messages for free; minimal boilerplate with derive |
+
+**Installation:**
+```toml
+[dependencies]
+ratatui = "0.30.0" # already present
+signal-hook = "0.4.3"
+toml = "1.0.3"
+serde = { version = "1.0", features = ["derive"] }
+clap = { version = "4.5", features = ["derive"] }
+```
+
+---
+
+## Architecture Patterns
+
+### Recommended Project Structure
+```
+src/
+├── main.rs # Entry point: parse CLI, detect login shell, init terminal, run loop
+├── config.rs # Config struct, load_config(), path resolution
+├── terminal.rs # Terminal init/restore, panic hook, clear-on-start
+├── signals.rs # Signal handler setup (SIGHUP, SIGTERM, SIGINT flags)
+└── app.rs # App state, event loop, double-Ctrl+C state machine
+```
+
+### Pattern 1: Custom Terminal Initialization (No Alternate Screen)
+
+**What:** Initialize ratatui on the main screen buffer rather than alternate screen. This is the immersive BBS approach — the TUI occupies the user's actual terminal, and on exit the goodbye message is visible in scroll history.
+
+**When to use:** When the user decision specifies main screen buffer / BBS feel (which it does here).
+
+**The key insight:** `ratatui::init()` enters alternate screen. To avoid this, use `Terminal::with_options` and manually enable raw mode + clear the screen.
+
+```rust
+// Source: https://docs.rs/ratatui/latest/ratatui/struct.Terminal.html
+// Source: https://docs.rs/crossterm/latest/crossterm/terminal/index.html
+
+use std::io::stdout;
+use crossterm::{
+ execute,
+ terminal::{enable_raw_mode, disable_raw_mode, Clear, ClearType},
+ cursor::MoveTo,
+};
+use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
+
+pub fn init_terminal() -> std::io::Result>> {
+ enable_raw_mode()?;
+ execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?;
+ let backend = CrosstermBackend::new(stdout());
+ // Viewport::Fullscreen uses main screen, not alternate screen
+ let options = TerminalOptions {
+ viewport: Viewport::Fullscreen,
+ };
+ Terminal::with_options(backend, options)
+}
+
+pub fn restore_terminal() -> std::io::Result<()> {
+ disable_raw_mode()?;
+ // No LeaveAlternateScreen because we never entered it
+ // Optionally: move cursor to bottom, print newline
+ Ok(())
+}
+```
+
+**Important:** `ratatui::restore()` calls `LeaveAlternateScreen` — do NOT use it if you never entered alternate screen. Call `disable_raw_mode()` directly.
+
+### Pattern 2: Panic Hook with Friendly User Message
+
+**What:** Replace the default panic hook with one that restores the terminal, prints a friendly BBS-themed message to the user (stdout/stderr), and logs technical details for the sysop.
+
+**When to use:** Required (LIFE-01). Install before TUI init so any panic during init also triggers cleanup.
+
+```rust
+// Source: https://ratatui.rs/recipes/apps/panic-hooks/
+
+use std::panic::{set_hook, take_hook};
+use crossterm::terminal::disable_raw_mode;
+
+pub fn install_panic_hook() {
+ let original_hook = take_hook();
+ set_hook(Box::new(move |panic_info| {
+ // Restore terminal — use let _ to suppress errors (avoid double-panic)
+ let _ = disable_raw_mode();
+ // Print friendly message to terminal (user sees this)
+ eprintln!("\r\n*** SYSTEM ERROR: An unexpected fault has occurred. ***");
+ eprintln!("*** The BBS has exited safely. SysOp has been notified. ***\r");
+ // Call original hook — prints backtrace to stderr (captured by journald/SSH log)
+ original_hook(panic_info);
+ }));
+}
+```
+
+**Key principle from official docs:** "It's important to avoid panicking while restoring the terminal state, otherwise the original panic reason might be lost." — use `let _ =` for all restoration calls.
+
+### Pattern 3: Signal Handling with AtomicBool Flags
+
+**What:** Register SIGHUP and SIGTERM to set AtomicBool flags; poll those flags in the event loop. No dedicated thread needed.
+
+**When to use:** Required (LIFE-02). Synchronous, no async runtime.
+
+```rust
+// Source: https://docs.rs/signal-hook/latest/signal_hook/flag/index.html
+
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+use signal_hook::consts::signal::{SIGHUP, SIGTERM};
+use signal_hook::flag as signal_flag;
+
+pub struct Signals {
+ pub terminate: Arc,
+}
+
+pub fn register_signals() -> std::io::Result {
+ let terminate = Arc::new(AtomicBool::new(false));
+ // SIGTERM: systemd stop, kill command
+ signal_flag::register(SIGTERM, Arc::clone(&terminate))?;
+ // SIGHUP: SSH disconnect, terminal hangup
+ signal_flag::register(SIGHUP, Arc::clone(&terminate))?;
+ Ok(Signals { terminate })
+}
+
+// In event loop:
+if signals.terminate.load(Ordering::Relaxed) {
+ return Ok(ShutdownReason::Signal);
+}
+```
+
+### Pattern 4: Double-Press Ctrl+C State Machine
+
+**What:** Track the timestamp of the first Ctrl+C press. If a second arrives within a window (e.g., 2 seconds), exit. Otherwise reset and show a prompt.
+
+**When to use:** Required (SHEL-01). This is purely application logic, no external library needed.
+
+```rust
+// In-process pattern — no external library required
+use std::time::{Duration, Instant};
+
+struct App {
+ ctrl_c_pressed_at: Option,
+ is_login_shell: bool,
+}
+
+const DOUBLE_PRESS_WINDOW: Duration = Duration::from_secs(2);
+
+impl App {
+ fn handle_ctrl_c(&mut self) -> AppAction {
+ match self.ctrl_c_pressed_at {
+ Some(first) if first.elapsed() < DOUBLE_PRESS_WINDOW => {
+ // Second press within window — exit
+ AppAction::Quit
+ }
+ _ => {
+ // First press (or timeout expired) — show prompt
+ self.ctrl_c_pressed_at = Some(Instant::now());
+ AppAction::ShowQuitPrompt // "Press Ctrl+C again to disconnect."
+ }
+ }
+ }
+
+ fn handle_q_key(&mut self) -> AppAction {
+ if self.is_login_shell {
+ AppAction::Nothing // suppress 'q' in login-shell mode
+ } else {
+ AppAction::Quit
+ }
+ }
+}
+```
+
+**Note:** Crossterm sends `KeyCode::Char('c')` with `KeyModifiers::CONTROL` for Ctrl+C events when raw mode is enabled. SIGINT is also raised — but with signal-hook registered, the event loop catches it via the flag. Choose one: either handle via crossterm key events OR via signal flag. The crossterm key event approach is simpler for the double-press logic.
+
+### Pattern 5: TOML Config Loading with Strict Parsing
+
+**What:** Deserialize bbs.toml with serde; `deny_unknown_fields` rejects typos; `Default` trait provides fallbacks when file is missing.
+
+```rust
+// Source: https://docs.rs/toml/latest/toml/
+
+use serde::Deserialize;
+use std::path::PathBuf;
+
+#[derive(Deserialize, Debug)]
+#[serde(deny_unknown_fields)]
+pub struct Config {
+ #[serde(default = "default_vault_path")]
+ pub vault_path: PathBuf,
+
+ #[serde(default = "default_theme")]
+ pub theme: String,
+}
+
+fn default_vault_path() -> PathBuf {
+ PathBuf::from("./vault")
+}
+
+fn default_theme() -> String {
+ "default".to_string()
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ Config {
+ vault_path: default_vault_path(),
+ theme: default_theme(),
+ }
+ }
+}
+
+pub fn load_config(path: &PathBuf) -> Result {
+ if !path.exists() {
+ return Ok(Config::default()); // Missing file is fine
+ }
+ let text = std::fs::read_to_string(path)
+ .map_err(|e| ConfigError::ReadError(e))?;
+ let config: Config = toml::from_str(&text)
+ .map_err(|e| ConfigError::ParseError(e))?;
+ Ok(config)
+}
+```
+
+**Config path resolution order:**
+1. `--config /explicit/path` (clap CLI flag) → use that path
+2. No flag → `executable_dir() / "bbs.toml"` (same directory as binary)
+
+```rust
+// Resolve executable directory
+fn find_binary_dir() -> PathBuf {
+ std::env::current_exe()
+ .expect("Cannot determine executable path")
+ .parent()
+ .expect("Binary has no parent directory")
+ .to_path_buf()
+}
+```
+
+### Pattern 6: Login Shell Detection
+
+**What:** When launched as a login shell, the kernel sets argv[0] to have a leading dash (e.g., `-bbs-md` instead of `bbs-md`). Strip it for clap compatibility and record the mode.
+
+```rust
+// Source: POSIX convention, std::env::args_os()
+// Note: args_os() is preferred over args() for non-UTF-8 paths
+
+pub fn detect_login_shell() -> bool {
+ let argv0 = std::env::args_os().next()
+ .unwrap_or_default();
+ let argv0_str = argv0.to_string_lossy();
+ argv0_str.starts_with('-')
+}
+
+// Stripping the dash for clap: rebuild args without leading dash in argv[0]
+// Then pass to clap's Parser::parse_from()
+pub fn args_for_clap() -> Vec {
+ let mut args: Vec<_> = std::env::args_os().collect();
+ if let Some(first) = args.first_mut() {
+ let s = first.to_string_lossy();
+ if s.starts_with('-') {
+ *first = s.trim_start_matches('-').to_string().into();
+ }
+ }
+ args
+}
+```
+
+### Pattern 7: CLI Flag Parsing with clap
+
+```rust
+// Source: https://docs.rs/clap/latest/clap/
+
+use clap::Parser;
+use std::path::PathBuf;
+
+#[derive(Parser, Debug)]
+#[command(name = "bbs-md", about = "BBS-style markdown reader")]
+pub struct Cli {
+ /// Path to bbs.toml configuration file
+ #[arg(long = "config", short = 'c', value_name = "FILE")]
+ pub config: Option,
+}
+```
+
+Then call `Cli::parse_from(args_for_clap())` to handle the login shell argv[0] stripping.
+
+### Pattern 8: Goodbye Message with Timed Display
+
+**What:** Show a brief BBS-style goodbye message after terminal is restored, then `std::thread::sleep` briefly before process exits.
+
+```rust
+// Terminal is already restored (raw mode off) at this point
+// Print directly to stdout — immersive feel
+pub fn show_goodbye() {
+ println!("\r");
+ println!(" +------------------------------------------+");
+ println!(" | Thank you for calling. Goodbye! |");
+ println!(" | Carrier lost. |");
+ println!(" +------------------------------------------+");
+ println!("\r");
+ std::thread::sleep(std::time::Duration::from_millis(500));
+}
+```
+
+### Anti-Patterns to Avoid
+
+- **Using `ratatui::init()` then trying to skip alternate screen:** `ratatui::init()` enters alternate screen unconditionally. Use `Terminal::with_options` instead.
+- **Using `ratatui::restore()` when alternate screen was never entered:** This calls `LeaveAlternateScreen` unnecessarily. Build a custom `restore_terminal()` that only calls `disable_raw_mode()`.
+- **Panicking inside the panic hook:** Any error in the cleanup code that itself panics will hide the original panic message. Use `let _ =` for all I/O in the hook.
+- **Registering SIGINT via signal-hook AND handling Ctrl+C via crossterm key events:** Choose one. Crossterm key events are easier for the double-press state machine.
+- **Writing to stdout/stderr in the TUI event loop after init:** All output must go through the ratatui draw cycle. Direct writes corrupt the terminal state.
+- **`unwrap()` on broken pipe writes:** SSH connections can close mid-write. Every write after TUI init that might fail should use `let _ =` or match on `BrokenPipe`.
+- **Calling `clap::parse()` without stripping the leading dash:** Clap will fail or emit unexpected errors because `-bbs-md` looks like a flag argument.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Signal handling | Manual `libc::signal()` calls | `signal-hook` 0.4.3 | async-signal-safety, race conditions, platform differences |
+| TOML parsing | Custom string parser | `toml` + `serde` | TOML spec compliance, multi-line strings, escape sequences |
+| CLI argument parsing | Manual `args()` iteration | `clap` 4.5 | `--help`, `--version`, error messages, type coercion — all free |
+| Terminal state restoration | Track state manually | `disable_raw_mode()` from crossterm | crossterm tracks raw mode state internally |
+| Panic hook chaining | Replace the hook entirely | `take_hook()` + `set_hook()` pattern | Preserves existing hooks from other libraries (e.g., color_eyre) |
+
+**Key insight:** The signal and terminal safety domains have subtle edge cases (reentrancy, async-signal-safety, platform quirks) that make hand-rolled solutions dangerous. The libraries exist specifically because these are hard.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: ratatui::init() Enters Alternate Screen
+**What goes wrong:** Calling `ratatui::init()` silently enters the alternate screen buffer. On exit or restore, the terminal switches back to the main screen — the goodbye message and TUI output disappear from scroll history.
+**Why it happens:** `ratatui::init()` is documented to enter alternate screen; this is the TUI standard. The BBS immersive mode requires main screen.
+**How to avoid:** Use `Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Fullscreen })` and manually call `enable_raw_mode()` + `Clear`. Do not call `ratatui::restore()` — call `disable_raw_mode()` directly.
+**Warning signs:** On exit, the terminal screen flashes back to pre-TUI state; previous shell output reappears.
+
+### Pitfall 2: Double-Panic in the Panic Hook
+**What goes wrong:** The panic hook itself panics (e.g., `unwrap()` on a failed `disable_raw_mode()`), which causes an abort with no useful output.
+**Why it happens:** Terminal state restoration can fail if the fd is already closed (e.g., SSH disconnect).
+**How to avoid:** Every line in the panic hook that can fail must use `let _ =`. Never `unwrap()` or `?` inside a panic hook.
+**Warning signs:** App crashes with "panicked while panicking" message.
+
+### Pitfall 3: Ctrl+C Handled Both by Signal Hook and Crossterm Key Events
+**What goes wrong:** SIGINT is registered via signal-hook AND crossterm delivers a `Ctrl+C` key event. The application handles it twice, or the state machine gets confused.
+**Why it happens:** In raw mode, crossterm delivers Ctrl+C as a key event. signal-hook also fires if registered. Both can be active simultaneously.
+**How to avoid:** Pick ONE mechanism. For the double-press pattern, crossterm key events are simpler because you get the event directly in your event loop without threading. Register SIGHUP and SIGTERM via signal-hook (for SSH disconnect), but handle Ctrl+C / SIGINT purely as a crossterm key event.
+**Warning signs:** App exits on first Ctrl+C despite double-press requirement, or shows "press again" message twice.
+
+### Pitfall 4: Config Error Exits Before Terminal Init
+**What goes wrong:** Config validation (vault path doesn't exist) exits the process with `eprintln!()` + `process::exit(1)` after the terminal is in raw mode — leaving the terminal broken.
+**Why it happens:** Config loading happens early, but if terminal init precedes it, error output is garbled.
+**How to avoid:** Load and validate config BEFORE initializing the terminal. Any error at that stage uses normal `eprintln!()` because raw mode is not yet active.
+**Warning signs:** Terminal is stuck in raw mode after config error; user has to run `reset`.
+
+### Pitfall 5: SIGHUP Not Handled, SSH Disconnect Orphans Process
+**What goes wrong:** User disconnects SSH; the process keeps running because SIGHUP is not handled, leaving a zombie BBS session.
+**Why it happens:** Rust programs do not handle SIGHUP by default.
+**How to avoid:** Register SIGHUP with signal-hook → same `terminate` AtomicBool as SIGTERM. Check the flag at the top of every event loop iteration.
+**Warning signs:** After SSH disconnect, the process shows in `ps aux`; terminal state on reconnect is corrupted.
+
+### Pitfall 6: Broken Pipe Panic on SSH Close
+**What goes wrong:** Mid-write, the SSH connection drops. Writing to stdout returns `EPIPE`. Rust converts this to `BrokenPipe` error. If the write is `unwrap()`-ed, the app panics.
+**Why it happens:** Rust by default ignores SIGPIPE and propagates as `ErrorKind::BrokenPipe`. With `unwrap()`, any such error becomes a panic.
+**How to avoid:** All writes to stdout/stderr after TUI init use `let _ =` or match on `ErrorKind::BrokenPipe`. Also check the `terminate` signal flag in the event loop — the process should be exiting cleanly before the next write attempt.
+**Warning signs:** Panic messages in logs containing "Broken pipe" or "os error 32".
+
+### Pitfall 7: Login Shell argv[0] Passed to clap
+**What goes wrong:** When launched as `-bbs-md`, clap sees `-bbs-md` as the program name (argv[0]) and may fail or produce confusing errors because the name starts with a dash.
+**Why it happens:** clap uses argv[0] for usage messages and may interpret it as a flag prefix.
+**How to avoid:** Strip the leading dash from argv[0] before passing args to `Cli::parse_from()`. Detect login shell separately by checking the original argv[0] before stripping.
+**Warning signs:** clap prints usage with `-bbs-md` as the program name, or fails to parse.
+
+---
+
+## Code Examples
+
+Verified patterns from official sources:
+
+### Complete Terminal Initialization (Main Screen, No Alternate Buffer)
+```rust
+// Source: https://docs.rs/ratatui/latest/ratatui/struct.Terminal.html
+// Source: https://docs.rs/crossterm/latest/crossterm/terminal/index.html
+
+use std::io::stdout;
+use crossterm::{
+ execute,
+ terminal::{enable_raw_mode, disable_raw_mode, Clear, ClearType},
+ cursor::MoveTo,
+};
+use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
+
+pub fn init_terminal() -> std::io::Result>> {
+ enable_raw_mode()?;
+ execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?;
+ let backend = CrosstermBackend::new(stdout());
+ Terminal::with_options(backend, TerminalOptions {
+ viewport: Viewport::Fullscreen,
+ })
+}
+
+pub fn restore_terminal() {
+ let _ = disable_raw_mode();
+ // No LeaveAlternateScreen — we never entered it
+ let _ = execute!(stdout(), crossterm::cursor::Show);
+}
+```
+
+### Panic Hook
+```rust
+// Source: https://ratatui.rs/recipes/apps/panic-hooks/
+
+use std::panic::{set_hook, take_hook};
+
+pub fn install_panic_hook() {
+ let original_hook = take_hook();
+ set_hook(Box::new(move |panic_info| {
+ let _ = crossterm::terminal::disable_raw_mode();
+ let _ = crossterm::execute!(
+ std::io::stdout(),
+ crossterm::cursor::Show
+ );
+ // Friendly user message (visible in terminal)
+ eprintln!("\r\n+--------------------------------------------+");
+ eprintln!("| SYSTEM ERROR: An unexpected fault occurred.|");
+ eprintln!("| The BBS has exited safely. |");
+ eprintln!("| SysOp has been notified. |");
+ eprintln!("+--------------------------------------------+\r");
+ // Original hook prints panic details to stderr (captured by journald)
+ original_hook(panic_info);
+ }));
+}
+```
+
+### Signal Registration
+```rust
+// Source: https://docs.rs/signal-hook/latest/signal_hook/flag/index.html
+
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+use signal_hook::consts::signal::{SIGHUP, SIGTERM};
+use signal_hook::flag as signal_flag;
+
+pub struct SignalFlags {
+ pub terminate: Arc,
+}
+
+pub fn register_signals() -> std::io::Result {
+ let terminate = Arc::new(AtomicBool::new(false));
+ signal_flag::register(SIGTERM, Arc::clone(&terminate))?;
+ signal_flag::register(SIGHUP, Arc::clone(&terminate))?;
+ Ok(SignalFlags { terminate })
+}
+```
+
+### Config Struct
+```rust
+// Source: https://docs.rs/toml/latest/toml/
+// Source: https://docs.rs/serde/latest/serde/
+
+use serde::Deserialize;
+use std::path::PathBuf;
+
+#[derive(Deserialize, Debug)]
+#[serde(deny_unknown_fields)]
+pub struct Config {
+ #[serde(default = "default_vault_path")]
+ pub vault_path: PathBuf,
+
+ #[serde(default = "default_theme")]
+ pub theme: String,
+}
+
+fn default_vault_path() -> PathBuf { PathBuf::from("./vault") }
+fn default_theme() -> String { "default".to_string() }
+
+impl Default for Config {
+ fn default() -> Self {
+ Config { vault_path: default_vault_path(), theme: default_theme() }
+ }
+}
+
+pub enum ConfigError {
+ ReadError(std::io::Error),
+ ParseError(toml::de::Error),
+ VaultNotFound(PathBuf),
+}
+
+pub fn load_config(path: &PathBuf) -> Result {
+ if !path.exists() {
+ return Ok(Config::default());
+ }
+ let text = std::fs::read_to_string(path).map_err(ConfigError::ReadError)?;
+ let config: Config = toml::from_str(&text).map_err(ConfigError::ParseError)?;
+ if !config.vault_path.exists() {
+ return Err(ConfigError::VaultNotFound(config.vault_path));
+ }
+ Ok(config)
+}
+```
+
+### CLI Parsing with Login Shell Support
+```rust
+// Source: https://docs.rs/clap/latest/clap/
+
+use clap::Parser;
+use std::path::PathBuf;
+
+#[derive(Parser, Debug)]
+#[command(name = "bbs-md")]
+pub struct Cli {
+ #[arg(long = "config", short = 'c', value_name = "FILE")]
+ pub config: Option,
+}
+
+pub fn detect_login_shell() -> bool {
+ std::env::args_os()
+ .next()
+ .map(|a| a.to_string_lossy().starts_with('-'))
+ .unwrap_or(false)
+}
+
+pub fn parse_cli() -> Cli {
+ // Strip leading dash from argv[0] before clap sees it
+ let mut args: Vec = std::env::args_os().collect();
+ if let Some(first) = args.first_mut() {
+ let stripped = first.to_string_lossy()
+ .trim_start_matches('-')
+ .to_string();
+ *first = stripped.into();
+ }
+ Cli::parse_from(args)
+}
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Manual `Terminal::new()` + manual raw mode | `ratatui::init()` / `ratatui::restore()` | ratatui 0.28.1 (2024) | Simpler for standard use; but we need `Terminal::with_options` for main-screen mode |
+| `ratatui::init()` + `ratatui::restore()` | `ratatui::run()` closure | ratatui 0.30.0 (2025) | Even simpler for alternate-screen apps; not applicable here (we skip alternate screen) |
+| `ctrlc` crate for signal handling | `signal-hook` for multi-signal | Ongoing | signal-hook handles SIGHUP + SIGTERM without separate crates |
+
+**Deprecated/outdated:**
+- `crossterm 0.28` and earlier: Ratatui 0.30 uses crossterm 0.29; there are breaking changes in the API between majors. The Cargo.lock already has 0.29.0 — do not pin to 0.28.
+- `tui-rs`: Predecessor to ratatui; fully deprecated. Not relevant here.
+
+---
+
+## Open Questions
+
+1. **Ctrl+C vs SIGINT: Which to use for double-press?**
+ - What we know: Crossterm delivers `Ctrl+C` as a key event in raw mode. signal-hook can also intercept SIGINT. Both work.
+ - What's unclear: Whether to use the crossterm key event path or register SIGINT via signal-hook and check the flag.
+ - Recommendation: Use crossterm key event (`KeyCode::Char('c') + KeyModifiers::CONTROL`) for the double-press state machine. Register SIGHUP + SIGTERM via signal-hook for SSH disconnect handling. Do NOT register SIGINT with signal-hook to avoid double handling.
+
+2. **Viewport::Fullscreen on resize**
+ - What we know: ratatui handles terminal resize events via `crossterm::event::Event::Resize`. `Viewport::Fullscreen` should redraw on resize.
+ - What's unclear: Whether `Terminal::with_options(Viewport::Fullscreen)` without alternate screen has any edge cases on terminal resize.
+ - Recommendation: Handle `Event::Resize` explicitly in the event loop and call `terminal.clear()` + redraw. Test on resize during Phase 2 (content rendering).
+
+3. **BBS-themed error message formatting**
+ - What we know: User wants "SYSTEM ERROR: Config file corrupted at line 3. SysOp intervention required." style.
+ - What's unclear: Exact text for goodbye message, panic message, and config error variants (left to Claude's discretion).
+ - Recommendation: Keep messages short, in ALL-CAPS for system-level errors, and use box-drawing ASCII art for the goodbye screen. Plan task should include the exact strings.
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+- `https://docs.rs/ratatui/latest/ratatui/fn.init.html` — ratatui::init() enters alternate screen; `init_with_options` does not
+- `https://docs.rs/ratatui/latest/ratatui/struct.Terminal.html` — Terminal::with_options(), TerminalOptions, Viewport
+- `https://ratatui.rs/recipes/apps/panic-hooks/` — panic hook pattern with take_hook/set_hook and restore
+- `https://docs.rs/signal-hook/latest/signal_hook/flag/index.html` — flag::register() with AtomicBool for SIGHUP/SIGTERM
+- `https://docs.rs/crossterm/latest/crossterm/terminal/index.html` — enable_raw_mode, disable_raw_mode, Clear, ClearType
+- `https://docs.rs/toml/latest/toml/` — toml::from_str(), current version 1.0.3+spec-1.1.0
+- `cargo search` results — verified crate versions: signal-hook 0.4.3, toml 1.0.3, clap 4.5.60, serde 1.0.228
+
+### Secondary (MEDIUM confidence)
+- `https://docs.rs/ratatui/latest/ratatui/fn.init_with_options.html` — init_with_options does not enter alternate screen; verified with official docs
+- `https://docs.rs/crossterm/latest/crossterm/cursor/struct.MoveTo.html` — MoveTo(0,0) for cursor home position; cursor module is separate from terminal module
+
+### Tertiary (LOW confidence)
+- WebSearch result: double-press Ctrl+C pattern from GitHub issue on opencode CLI — not in official docs, pattern is idiomatic but implementation details left to discretion
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — versions verified via `cargo search` and official docs.rs
+- Architecture: HIGH — ratatui docs explicitly document Terminal::with_options, Viewport, and panic hook patterns; signal-hook flag API verified
+- Pitfalls: HIGH for terminal/panic pitfalls (from official docs); MEDIUM for signal + crossterm interaction (from ecosystem knowledge)
+
+**Research date:** 2026-02-28
+**Valid until:** 2026-03-30 (stable libraries, 30-day window reasonable)