Files

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
01-01
01-02
src/app.rs
src/main.rs
true
SHEL-01
truths artifacts key_links
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)
path provides contains
src/app.rs App struct, run_event_loop(), double-Ctrl+C state machine, shutdown handling DOUBLE_PRESS_WINDOW
path provides contains
src/main.rs Complete startup → event loop → shutdown pipeline install_panic_hook
from to via pattern
src/app.rs src/signals.rs polls SignalFlags.should_terminate() each loop iteration should_terminate
from to via pattern
src/app.rs src/terminal.rs calls restore_terminal() on exit restore_terminal
from to via pattern
src/main.rs src/app.rs creates App and calls run_event_loop() run_event_loop
from to via pattern
src/main.rs src/config.rs loads config before terminal init load_config
from to via pattern
src/main.rs src/terminal.rs installs panic hook, inits terminal, restores on exit 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.

<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:
  1. ShutdownReason enum:

    • UserQuit — user pressed 'q' or completed double Ctrl+C
    • Signal — SIGHUP/SIGTERM received
  2. App struct:

    pub struct App {
        is_login_shell: bool,
        ctrl_c_pressed_at: Option<Instant>,
        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<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;
              }
          }
      }
      
  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:

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:

    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

<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>
After completion, create `.planning/phases/01-safety-foundation/01-03-SUMMARY.md`