Skip to main content

recursive/tui/
mod.rs

1pub mod app;
2pub mod backend;
3pub mod bash;
4pub mod commands;
5pub mod completion;
6pub mod cost;
7pub mod events;
8pub mod input_state;
9pub mod keymap;
10pub mod model;
11pub mod runtime_builder;
12pub mod skill_commands;
13pub mod ui;
14
15// Re-export types used outside the tui module.
16pub use cost::UsageStats;
17pub use input_state::{InputMode, PromptInputState};
18pub use model::{AppScreen, DiffHunk, DiffLine, DiffLineKind, TranscriptBlock};
19
20use std::io::{self, Write as _};
21use std::time::Duration;
22
23use unicode_width::UnicodeWidthStr as _;
24
25use crossterm::event::{self, Event, KeyEventKind};
26use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
27use ratatui::prelude::*;
28use ratatui::widgets::{Paragraph, Wrap};
29use ratatui::{Terminal, TerminalOptions, Viewport};
30
31use crate::tui::app::App;
32use crate::tui::backend::Backend;
33use crate::tui::events::UserAction;
34
35// ── Startup banner ────────────────────────────────────────────────────────────
36
37/// Normal inline viewport height (input + status bar + in-flight streaming).
38/// Completed messages are pushed to the terminal's native scrollback via
39/// `terminal.insert_before()` so they remain readable above this region.
40const INLINE_HEIGHT_NORMAL: u16 = 10;
41
42/// Expanded inline viewport height used while a modal or permission popup is
43/// visible.  70% of 40 ≈ 28 rows — plenty for Help, ResumePicker, etc.
44/// When the modal closes the viewport shrinks back to `INLINE_HEIGHT_NORMAL`.
45const INLINE_HEIGHT_EXPANDED: u16 = 40;
46
47/// Print the compact logo, version, model, and recent sessions to
48/// stdout before the inline TUI viewport starts. Styled after the
49/// fake-cc welcome screen: a small 3-line logo, a dot-separator, and
50/// a brief session list — all flowing into the terminal's scrollback
51/// so they remain readable above the interactive area.
52fn print_startup_banner(workspace: &std::path::Path) {
53    const CYAN: &str = "\x1b[36m";
54    const BOLD: &str = "\x1b[1m";
55    const DARK_GRAY: &str = "\x1b[90m";
56    const DIM: &str = "\x1b[2m";
57    const RESET: &str = "\x1b[0m";
58
59    // 3-line compact logo (fits in ~48 cols)
60    println!("{CYAN}{BOLD}╦═╗╔═╗╔═╗╦ ╦╦═╗╔═╗╦╦  ╦╔═╗{RESET}");
61    println!("{CYAN}{BOLD}╠╦╝║╣ ║  ║ ║╠╦╝╚═╗║╚╗╔╝║╣ {RESET}");
62    println!("{CYAN}{BOLD}╩╚═╚═╝╚═╝╚═╝╩╚═╚═╝╩ ╚╝ ╚═╝{RESET}");
63
64    // Version + model on one line (fake-cc style).
65    // Use the same detection logic as the status bar so the value is always real.
66    let model = crate::tui::cost::detect_model_name();
67    println!("{DIM}  v{}  ·  {model}{RESET}", env!("CARGO_PKG_VERSION"));
68
69    // Dot separator (fake-cc style)
70    println!("{DARK_GRAY}  ……………………………………………………………………………………………………{RESET}");
71
72    // Recent user-initiated sessions — show newest first, skip self-improve runs
73    // (those have a `goal` but no `last_prompt`; TUI sessions set `last_prompt`).
74    let mut shown = 0;
75    if let Ok(mut sessions) = crate::session::SessionReader::list_sessions(workspace) {
76        // list_sessions returns alphabetical (oldest first); reverse for newest first.
77        sessions.reverse();
78        for dir in &sessions {
79            if shown >= 3 {
80                break;
81            }
82            if let Ok(meta) = crate::session::SessionReader::load_meta(dir) {
83                // Only show sessions that have a human prompt (not internal goal runs)
84                if let Some(ref prompt) = meta.last_prompt {
85                    let short: String = prompt.chars().take(60).collect();
86                    let ellipsis = if prompt.chars().count() > 60 {
87                        "…"
88                    } else {
89                        ""
90                    };
91                    println!("{DARK_GRAY}  › {short}{ellipsis}{RESET}");
92                    shown += 1;
93                }
94            }
95        }
96    }
97
98    println!();
99    let _ = io::stdout().flush();
100}
101
102// ── RAII guard ────────────────────────────────────────────────────────────────
103
104/// Restores the terminal to cooked mode on drop.
105///
106/// Because we use `Viewport::Inline` (no alternate screen), we only
107/// need to disable raw mode and emit a final newline so the shell
108/// prompt appears on a fresh line below the last rendered frame.
109struct RawModeGuard;
110
111impl Drop for RawModeGuard {
112    fn drop(&mut self) {
113        let _ = disable_raw_mode();
114        let _ = writeln!(io::stdout());
115    }
116}
117
118// ── Terminal factory ──────────────────────────────────────────────────────────
119
120/// Create a new inline-mode terminal with the given viewport height.
121///
122/// The cursor position at call-time determines where the viewport is anchored.
123/// Passing a larger `height` makes ratatui scroll the terminal up to create
124/// room (pushing earlier content into the scrollback), matching the fake-cc
125/// behaviour of expanding the input area for popups and modals.
126fn make_inline_terminal(
127    height: u16,
128) -> io::Result<ratatui::Terminal<CrosstermBackend<io::Stdout>>> {
129    Terminal::with_options(
130        CrosstermBackend::new(io::stdout()),
131        TerminalOptions {
132            viewport: Viewport::Inline(height),
133        },
134    )
135}
136
137// ── Main entry point ──────────────────────────────────────────────────────────
138
139/// Launch the TUI and run until the user quits.
140///
141/// Uses `Viewport::Inline` so the TUI occupies a fixed-height region at
142/// the bottom of the terminal's main scrollback buffer instead of
143/// switching to an alternate screen. The startup banner (logo + recent
144/// sessions) is printed to stdout before the TUI starts and remains
145/// visible in the scrollback above the viewport.
146pub async fn run() -> io::Result<()> {
147    run_with_backend(Backend::spawn()).await
148}
149
150/// Launch the TUI with a pre-constructed [`Backend`].
151///
152/// Used by `--weixin` mode where the backend is created before the TUI
153/// starts so the WeChat channel can be wired up.
154pub async fn run_with_backend(backend: Backend) -> io::Result<()> {
155    // Suppress global tracing output for the duration of the TUI.
156    let _quiet_guard = crate::logging::suppress_tracing_for_tui();
157
158    // Determine workspace for session listing in the banner.
159    let workspace = crate::config::Config::from_env()
160        .map(|c| c.workspace)
161        .unwrap_or_else(|_| std::path::PathBuf::from("."));
162
163    // Print the startup banner before raw mode so scrollback is intact.
164    print_startup_banner(&workspace);
165
166    enable_raw_mode()?;
167    let _guard = RawModeGuard;
168
169    let mut terminal = make_inline_terminal(INLINE_HEIGHT_NORMAL)?;
170    let mut current_inline_height = INLINE_HEIGHT_NORMAL;
171
172    let mut backend = backend;
173    let mut app = App::new();
174    app.permission_hook_enabled = backend.permission_enabled.clone();
175
176    loop {
177        // ── Progressive output: flush completed blocks to scrollback ──────
178        // Advance `last_printed_idx` over any newly-finalized blocks and
179        // push their rendered lines into `print_queue`.
180        app.flush_ready_blocks();
181
182        // Drain the queue: each batch of lines is inserted *above* the
183        // inline viewport so it scrolls into the terminal's native
184        // scrollback buffer.  We render into a ratatui `Buffer` via the
185        // `Paragraph` widget so all existing ANSI styling is preserved.
186        let queued: Vec<Vec<Line<'static>>> = app.print_queue.drain(..).collect();
187        for lines in queued {
188            let h = (lines.len() as u16).max(1);
189            terminal.insert_before(h, |buf| {
190                let area = buf.area;
191                Paragraph::new(lines)
192                    .wrap(Wrap { trim: false })
193                    .render(area, buf);
194                // Fix: ratatui's `draw_lines` (used by insert_before) writes every
195                // buffer cell individually including the continuation cells that
196                // follow wide (CJK/emoji) characters.  Those cells are initialised
197                // to Cell::EMPTY whose symbol is " " (space), so crossterm prints a
198                // visible space after each wide character.  Setting them to "" makes
199                // Print("") a no-op and eliminates the inter-character gaps.
200                let mut i = 0;
201                while i < buf.content.len() {
202                    let w = buf.content[i].symbol().width();
203                    if w >= 2 {
204                        for j in 1..w {
205                            if i + j < buf.content.len() {
206                                buf.content[i + j].set_symbol("");
207                            }
208                        }
209                    }
210                    i += 1;
211                }
212            })?;
213        }
214
215        // ── Dynamic viewport height (fake-cc style) ───────────────────────
216        // Expand the viewport when modals / permission popups are open so
217        // they have room to render.  Shrink back once everything is closed.
218        let needs_expanded = !app.modals.is_empty() || app.pending_permission.is_some();
219        let desired_height = if needs_expanded {
220            INLINE_HEIGHT_EXPANDED
221        } else {
222            INLINE_HEIGHT_NORMAL
223        };
224        if desired_height != current_inline_height {
225            terminal = make_inline_terminal(desired_height)?;
226            current_inline_height = desired_height;
227        }
228
229        terminal.draw(|frame| ui::chat::render(frame, &app))?;
230        app.spinner_frame = app.spinner_frame.wrapping_add(1);
231
232        tokio::select! {
233            _ = tokio::time::sleep(Duration::from_millis(50)) => {
234                while event::poll(Duration::ZERO)? {
235                    if let Event::Key(key) = event::read()? {
236                        if key.kind == KeyEventKind::Press {
237                            if let Some(action) = keymap::dispatch(&mut app, key) {
238                                let _ = backend.action_tx.send(action);
239                            }
240                        }
241                    }
242                }
243            }
244            Some(ui_event) = backend.event_rx.recv() => {
245                app.handle_ui_event(ui_event);
246            }
247            Some(perm_req) = backend.perm_rx.recv() => {
248                app.set_pending_permission(perm_req);
249            }
250        }
251
252        if app.should_quit {
253            break;
254        }
255    }
256
257    let _ = backend.action_tx.send(UserAction::Shutdown);
258    Ok(())
259}