13 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-safety-foundation | 03 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md @/Users/ruohki/.claude/get-shit-done/templates/summary.md </execution_context>
@.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:-
ShutdownReasonenum:UserQuit— user pressed 'q' or completed double Ctrl+CSignal— SIGHUP/SIGTERM received
-
Appstruct:pub struct App { is_login_shell: bool, ctrl_c_pressed_at: Option<Instant>, show_quit_prompt: bool, should_quit: bool, } -
const DOUBLE_PRESS_WINDOW: Duration = Duration::from_secs(2); -
App::new(is_login_shell: bool) -> Self— initialize with defaults -
App::run_event_loop(&mut self, terminal: &mut Term, signals: &SignalFlags) -> std::io::Result<ShutdownReason>:- 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; } } }
- Main loop structure:
-
App::handle_key(&mut self, key: KeyEvent):- Match on
key.code:KeyCode::Char('c')withKeyModifiers::CONTROL→ handle Ctrl+C:- If
ctrl_c_pressed_atisSome(t)andt.elapsed() < DOUBLE_PRESS_WINDOW→ setshould_quit = true - Otherwise → set
ctrl_c_pressed_at = Some(Instant::now()),show_quit_prompt = true
- If
KeyCode::Char('q')→ if NOTis_login_shell, setshould_quit = true- Any other key → if
show_quit_prompt, clear it (resetctrl_c_pressed_at = None,show_quit_prompt = false)
- Match on
-
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 basicStyle— nothing fancy, just enough to prove the TUI works - Keep imports minimal:
ratatui::prelude::*,ratatui::widgets::{Block, Borders, Paragraph}
- Phase 1 placeholder UI. Render a simple centered block with:
-
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 ofprintln!()if you want to handle broken pipe gracefully here too, butprintln!()is acceptable since terminal is already restored
- Called AFTER
Use these imports at the top:
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;
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.
-
Declare all modules:
mod config;,mod terminal;,mod signals;,mod app; -
fn main()flow: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_configis 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.
<success_criteria>
- 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 </success_criteria>