328 lines
13 KiB
Markdown
328 lines
13 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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`.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Implement App struct and event loop with exit behavior</name>
|
|
<files>src/app.rs</files>
|
|
<action>
|
|
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<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:
|
|
```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;
|
|
```
|
|
</action>
|
|
<verify>
|
|
`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.
|
|
</verify>
|
|
<done>
|
|
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.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Wire complete startup-to-shutdown pipeline in main.rs</name>
|
|
<files>src/main.rs</files>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
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.
|
|
</verify>
|
|
<done>
|
|
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.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/01-safety-foundation/01-03-SUMMARY.md`
|
|
</output>
|