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}