opencrabs 0.3.47

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
//! TUI Runner
//!
//! Main event loop and terminal setup for the TUI.

use super::app::App;
use super::events::EventHandler;
use super::render;
use anyhow::Result;
use crossterm::{
    event::{
        DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
        EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags,
        PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
    },
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex};

/// Captures location + filtered backtrace of the last panic so the
/// render loop can correlate a caught panic with its source. `catch_unwind`
/// only hands back the payload message, not the location — so we stash
/// both via the panic hook (which runs before `catch_unwind` swallows it)
/// and read them out after.
///
/// The `file:line:col` alone usually points at ratatui internals
/// (e.g. `ratatui-core/.../buffer.rs:250`) — useless without knowing
/// which of *our* widgets called in. The filtered backtrace picks the
/// first frame under `opencrabs::tui::` so the caller widget is named.
static LAST_PANIC_LOCATION: LazyLock<Mutex<Option<PanicInfo>>> = LazyLock::new(|| Mutex::new(None));

#[derive(Clone)]
struct PanicInfo {
    file: String,
    line: u32,
    column: u32,
    /// First OpenCrabs frame pulled out of the backtrace, if any.
    /// Format `module::fn at path:line` — typically the widget render call
    /// that fed ratatui an out-of-bounds coord.
    opencrabs_frame: Option<String>,
}

/// Walk a captured `Backtrace` and return the first frame whose symbol
/// name starts with `opencrabs::`. `Backtrace`'s `Display` impl renders
/// one frame per two lines:
///
/// ```text
///    N: symbol_name
///              at src/path.rs:LINE:COL
/// ```
///
/// We scan for any line containing `opencrabs::` (skipping the panic
/// hook itself), pull the symbol + `at ...` pair, and return them so
/// the render-panic log points at the actual widget that called
/// ratatui with an out-of-bounds coord, not ratatui's internal bounds
/// check.
fn first_opencrabs_frame(bt: &std::backtrace::Backtrace) -> Option<String> {
    let text = format!("{}", bt);
    let mut lines = text.lines().peekable();
    while let Some(line) = lines.next() {
        let trimmed = line.trim();
        if trimmed.contains("opencrabs::")
            && !trimmed.contains("first_opencrabs_frame")
            && !trimmed.contains("runner::")
        {
            let symbol = trimmed
                .trim_start_matches(|c: char| c.is_ascii_digit() || c == ':' || c == ' ')
                .to_string();
            let at = lines
                .peek()
                .filter(|l| l.trim().starts_with("at "))
                .map(|l| l.trim().to_string())
                .unwrap_or_default();
            return Some(if at.is_empty() {
                symbol
            } else {
                format!("{} {}", symbol, at)
            });
        }
    }
    None
}

/// Force-restore terminal state. Safe to call from signal handlers and panic hooks.
fn force_restore_terminal() {
    let _ = disable_raw_mode();
    let _ = execute!(
        io::stdout(),
        PopKeyboardEnhancementFlags,
        LeaveAlternateScreen,
        DisableBracketedPaste,
        DisableFocusChange,
        DisableMouseCapture
    );
    let _ = execute!(io::stdout(), crossterm::cursor::Show);
}

/// Run the TUI application
pub async fn run(mut app: App) -> Result<()> {
    // Install panic hook that restores terminal before printing the panic.
    // Without this, a panic leaves the terminal in raw mode with no cursor.
    // Also stash the panic location so the render `catch_unwind` path can
    // log WHERE a caught render panic happened (the payload only carries
    // the message, not the source location).
    let default_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        if let Some(loc) = info.location() {
            // Force-capture unconditionally — render panics are rare,
            // and RUST_BACKTRACE=1 is usually unset in TUI sessions.
            let bt = std::backtrace::Backtrace::force_capture();
            let opencrabs_frame = first_opencrabs_frame(&bt);
            if let Ok(mut slot) = LAST_PANIC_LOCATION.lock() {
                *slot = Some(PanicInfo {
                    file: loc.file().to_string(),
                    line: loc.line(),
                    column: loc.column(),
                    opencrabs_frame,
                });
            }
        }
        force_restore_terminal();
        default_hook(info);
    }));

    // SIGINT (Ctrl+C) handler — forces terminal restoration and exits.
    // In raw mode, Ctrl+C is just byte 0x03 and crossterm may eat it if
    // stuck mid-escape-sequence. This handler bypasses the event loop.
    let sigint_flag = Arc::new(AtomicBool::new(false));
    let sigint_clone = sigint_flag.clone();
    tokio::spawn(async move {
        if let Ok(()) = tokio::signal::ctrl_c().await {
            sigint_clone.store(true, Ordering::SeqCst);
            force_restore_terminal();
            // Give a moment for terminal to restore, then hard-exit
            std::process::exit(130); // 128 + SIGINT(2)
        }
    });

    // Setup terminal
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(
        stdout,
        EnterAlternateScreen,
        EnableBracketedPaste,
        EnableFocusChange,
        EnableMouseCapture
    )?;
    // Kitty keyboard protocol: enables unambiguous modifier reporting
    // so Shift+Enter is distinguishable from plain Enter. Silently
    // ignored by terminals that don't support it (no-op, no errors).
    // On Windows, the legacy Console API errors instead of ignoring, so
    // we deliberately discard the result.
    let _ = execute!(
        stdout,
        PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
    );

    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // Signal that the TUI owns stdout — suppress_stdio() will skip fd 1
    // redirection to avoid racing with ratatui's escape-sequence writes.
    crate::utils::fd_suppress::set_tui_active(true);

    // Force a full clear so stale content from a previous exec() restart
    // is wiped.
    terminal.clear()?;

    // Drain any stale terminal events (e.g. mouse events queued after a crash
    // where DisableMouseCapture never ran). Without this, queued escape
    // sequences leak into the input buffer as raw characters.
    while crossterm::event::poll(std::time::Duration::from_millis(10))? {
        let _ = crossterm::event::read();
    }

    // Fast sync init: decide mode and arm the header card. If we're going
    // to Chat, draw a first frame immediately so the user sees the header
    // card + empty chat + input *before* the blocking session load runs.
    // Onboarding skips the first frame so the wizard renders on first paint.
    let draw_first_frame = app.initialize_sync();
    if draw_first_frame {
        let app_ref: &mut App = &mut app;
        terminal.draw(move |f| render::render(f, app_ref))?;
    }

    // Now do the slow async init: load last session, sessions list, pane
    // preload, update check, DB integrity warnings. The header card stays
    // visible during this and vanishes on the 500ms timer from its arming
    // point inside `initialize_sync`.
    app.initialize().await?;

    // Start terminal event listener
    let event_sender = app.event_sender();
    EventHandler::start_terminal_listener(event_sender);

    // Run main loop
    let result = run_loop(&mut terminal, &mut app, &sigint_flag).await;

    // Restore terminal
    crate::utils::fd_suppress::set_tui_active(false);
    force_restore_terminal();

    result
}

/// Main event loop
async fn run_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
    sigint_flag: &AtomicBool,
) -> Result<()> {
    use super::events::TuiEvent;

    // Tracks the last applied mouse-capture state so we only call
    // Enable/DisableMouseCapture when the app's desired state changes.
    let mut mouse_capture_applied = true;

    loop {
        // Check SIGINT flag (redundant safety — the handler already exits,
        // but this catches the race where the flag is set before exit)
        if sigint_flag.load(Ordering::SeqCst) {
            break;
        }

        // Sync mouse-capture state if the user toggled it via F12.
        if app.mouse_capture_enabled != mouse_capture_applied {
            if app.mouse_capture_enabled {
                execute!(terminal.backend_mut(), EnableMouseCapture)?;
            } else {
                execute!(terminal.backend_mut(), DisableMouseCapture)?;
            }
            mouse_capture_applied = app.mouse_capture_enabled;
        }

        // Flush debounced session refresh from remote channels.
        // Only fires after 500ms of quiet to avoid blocking the loop with
        // rapid DB queries during multi-tool Telegram runs.
        if let Some((session_id, queued_at)) = app.pending_session_refresh
            && queued_at.elapsed() >= std::time::Duration::from_millis(500)
        {
            app.pending_session_refresh = None;
            if app.is_current_session(session_id)
                && !app.processing_sessions.contains(&session_id)
                && let Err(e) = app.load_session(session_id).await
            {
                tracing::warn!("Debounced session refresh failed: {}", e);
            }
        }

        // Consume pending resize (just clears the flag; ratatui's
        // autoresize inside draw() handles the actual buffer resize).
        app.pending_resize.take();

        // Wrap every frame in synchronized output (DEC private mode
        // 2026) so the terminal buffers all escape sequences and
        // displays the complete frame atomically.  This eliminates
        // flicker on resize because the clear + full redraw appear as
        // a single update to the terminal.
        let _ = execute!(
            terminal.backend_mut(),
            crossterm::terminal::BeginSynchronizedUpdate
        );

        // Render — wrap in catch_unwind so a render-time panic (e.g. a
        // ratatui buffer OOB from some edge-case layout) is caught, logged,
        // and the loop continues instead of crashing the whole TUI.
        // Reborrow terminal/app as fresh mutable references for this
        // iteration so moving them into the catch_unwind closure doesn't
        // consume the outer references permanently.
        let term_ref: &mut Terminal<CrosstermBackend<io::Stdout>> = &mut *terminal;
        let app_ref: &mut App = &mut *app;
        // Clear prior location before each draw so we only see what THIS
        // frame hit (panic hook overwrites the slot unconditionally, but
        // being explicit is cheap insurance).
        if let Ok(mut slot) = LAST_PANIC_LOCATION.lock() {
            *slot = None;
        }
        let draw_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
            term_ref.draw(move |f| render::render(f, app_ref))
        }));
        match draw_result {
            Ok(Ok(_)) => {}
            Ok(Err(e)) => return Err(e.into()),
            Err(panic_payload) => {
                let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
                    (*s).to_string()
                } else if let Some(s) = panic_payload.downcast_ref::<String>() {
                    s.clone()
                } else {
                    "unknown render panic".to_string()
                };
                let (loc, caller) = LAST_PANIC_LOCATION
                    .lock()
                    .ok()
                    .and_then(|slot| slot.clone())
                    .map(|info| {
                        let loc = format!(" at {}:{}:{}", info.file, info.line, info.column);
                        let caller = info
                            .opencrabs_frame
                            .map(|f| format!(" — caller: {}", f))
                            .unwrap_or_default();
                        (loc, caller)
                    })
                    .unwrap_or_default();
                tracing::error!("[TUI] render panic caught{}{}: {}", loc, caller, msg);
                app.error_message = Some(format!("render panic{}{}: {}", loc, caller, msg));
                // Try to recover the terminal state for the next frame.
                let _ = terminal.clear();
            }
        }

        let _ = execute!(
            terminal.backend_mut(),
            crossterm::terminal::EndSynchronizedUpdate
        );

        // Check for quit
        if app.should_quit {
            break;
        }

        // Wait for at least one event (with timeout for animation refresh)
        let event =
            tokio::time::timeout(tokio::time::Duration::from_millis(100), app.next_event()).await;

        if let Ok(Some(event)) = event {
            // Disable mouse capture when losing focus so the terminal stops
            // queuing SGR mouse sequences that pile up while unfocused.
            // Re-enable on focus regain and clear any garbage from input.
            match &event {
                TuiEvent::FocusLost => {
                    execute!(terminal.backend_mut(), DisableMouseCapture)?;
                    mouse_capture_applied = false;
                }
                TuiEvent::FocusGained => {
                    // Only re-enable mouse capture if the user hasn't
                    // explicitly turned it off via F12 (selection mode).
                    if app.mouse_capture_enabled {
                        execute!(terminal.backend_mut(), EnableMouseCapture)?;
                        mouse_capture_applied = true;
                    }
                    // Clear any garbage that leaked into the input buffer
                    // while mouse capture was active in another tmux pane.
                    app.clear_escape_garbage();
                }
                _ => {}
            }

            // Intercept MouseScroll events — don't process through state handler.
            // Instead, add to pending_scroll for coalesced processing to avoid
            // double-counting and excessive load_more_history() calls.
            let mut pending_scroll: i32 = 0;
            if let TuiEvent::MouseScroll(dir) = &event {
                pending_scroll += *dir as i32;
            } else if let Err(e) = app.handle_event(event).await {
                app.error_message = Some(e.to_string());
            }

            // Drain all remaining queued events before re-rendering.
            // Coalesce Ticks, Scrolls, and streaming chunks to avoid redundant
            // re-renders. Streaming chunks are batched with a time budget so the
            // TUI stays responsive to keyboard/mouse input during long streams.
            let drain_start = std::time::Instant::now();
            loop {
                match app.try_next_event() {
                    Some(TuiEvent::Tick) => continue,
                    Some(TuiEvent::MouseScroll(dir)) => {
                        pending_scroll += dir as i32;
                    }
                    Some(event) => {
                        let is_chunk = matches!(
                            event,
                            TuiEvent::ResponseChunk { .. } | TuiEvent::ReasoningChunk { .. }
                        );
                        if let Err(e) = app.handle_event(event).await {
                            app.error_message = Some(e.to_string());
                        }
                        // Batch streaming chunks for up to 30ms before yielding
                        // to render. This lets multiple chunks coalesce into one
                        // re-render instead of O(n) renders per second.
                        if is_chunk && drain_start.elapsed() >= std::time::Duration::from_millis(30)
                        {
                            break;
                        }
                    }
                    None => break,
                }
            }
            // Apply coalesced scroll as a single operation.
            // Cap to prevent macOS smooth-scroll flicks (20-50 events) from
            // launching the user into orbit. Keep it small for smooth scrolling.
            const MAX_SCROLL_PER_FRAME: i32 = 3;
            let capped = pending_scroll.clamp(-MAX_SCROLL_PER_FRAME, MAX_SCROLL_PER_FRAME);
            if capped > 0 {
                app.scroll_offset = app.scroll_offset.saturating_add(capped as usize);
                app.auto_scroll = false;
                // History loading removed — causes overshoot. User can manually
                // load more history via keyboard shortcut if needed.
            } else if capped < 0 {
                app.scroll_offset = app
                    .scroll_offset
                    .saturating_sub(capped.unsigned_abs() as usize);
                app.auto_scroll = false;
                if app.scroll_offset == 0 {
                    app.auto_scroll = true;
                }
            }
        }
    }

    Ok(())
}