Skip to main content

claude_code_rust/app/
mod.rs

1// Claude Code Rust - A native Rust terminal interface for Claude Code
2// Copyright (C) 2025  Simon Peter Rothgang
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as
6// published by the Free Software Foundation, either version 3 of the
7// License, or (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17mod connect;
18mod dialog;
19mod events;
20mod focus;
21pub(crate) mod input;
22mod input_submit;
23mod keys;
24pub(crate) mod mention;
25pub(crate) mod paste_burst;
26mod permissions;
27mod selection;
28pub(crate) mod slash;
29mod state;
30mod terminal;
31mod todos;
32
33// Re-export all public types so `crate::app::App`, `crate::app::BlockCache`, etc. still work.
34pub use connect::{create_app, start_connection};
35pub use events::{handle_acp_event, handle_terminal_event};
36pub use focus::{FocusManager, FocusOwner, FocusTarget};
37pub use input::InputState;
38pub(crate) use selection::normalize_selection;
39pub use state::{
40    App, AppStatus, BlockCache, ChatMessage, ChatViewport, HelpView, IncrementalMarkdown,
41    InlinePermission, InputWrapCache, LoginHint, MessageBlock, MessageRole, ModeInfo, ModeState,
42    SelectionKind, SelectionPoint, SelectionState, TodoItem, TodoStatus, ToolCallInfo,
43    WelcomeBlock,
44};
45
46use agent_client_protocol::{self as acp, Agent as _};
47use crossterm::event::{
48    EventStream, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
49    PushKeyboardEnhancementFlags,
50};
51use futures::{FutureExt as _, StreamExt};
52use std::time::{Duration, Instant};
53
54// ---------------------------------------------------------------------------
55// TUI event loop
56// ---------------------------------------------------------------------------
57
58#[allow(clippy::too_many_lines, clippy::cast_precision_loss)]
59pub async fn run_tui(app: &mut App) -> anyhow::Result<()> {
60    let mut terminal = ratatui::init();
61    let mut os_shutdown = Box::pin(wait_for_shutdown_signal());
62
63    // Enable bracketed paste and mouse capture (ignore error on unsupported terminals)
64    let _ = crossterm::execute!(
65        std::io::stdout(),
66        crossterm::event::EnableBracketedPaste,
67        crossterm::event::EnableMouseCapture,
68        crossterm::event::EnableFocusChange,
69        // Enable enhanced keyboard protocol for reliable modifier detection (e.g. Shift+Enter)
70        PushKeyboardEnhancementFlags(
71            KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
72                | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
73                | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
74        )
75    );
76
77    let mut events = EventStream::new();
78    let tick_duration = Duration::from_millis(16);
79    let mut last_render = Instant::now();
80
81    loop {
82        // Phase 1: wait for at least one event or the next frame tick
83        let time_to_next = tick_duration.saturating_sub(last_render.elapsed());
84        tokio::select! {
85            Some(Ok(event)) = events.next() => {
86                events::handle_terminal_event(app, event);
87            }
88            Some(event) = app.event_rx.recv() => {
89                events::handle_acp_event(app, event);
90            }
91            shutdown = &mut os_shutdown => {
92                if let Err(err) = shutdown {
93                    tracing::warn!(%err, "OS shutdown signal listener failed");
94                }
95                app.should_quit = true;
96            }
97            () = tokio::time::sleep(time_to_next) => {}
98        }
99
100        // Phase 2: drain all remaining queued events (non-blocking)
101        loop {
102            // Try terminal events first (keeps typing responsive)
103            if let Some(Some(Ok(event))) = events.next().now_or_never() {
104                events::handle_terminal_event(app, event);
105                continue;
106            }
107            // Then ACP events
108            match app.event_rx.try_recv() {
109                Ok(event) => {
110                    events::handle_acp_event(app, event);
111                }
112                Err(_) => break,
113            }
114        }
115
116        // Merge and process `Event::Paste` chunks as one paste action.
117        if !app.pending_paste_text.is_empty() {
118            finalize_pending_paste_event(app);
119        }
120
121        // Post-drain paste handling:
122        // - while a detected paste burst is still active, defer rendering to avoid
123        //   showing raw pasted text before placeholder collapse.
124        // - once the burst settles, collapse large paste content to placeholder.
125        let suppress_render_for_active_paste =
126            app.paste_burst.is_paste() && app.paste_burst.is_active();
127        if app.paste_burst.is_paste() {
128            app.pending_submit = false;
129            if app.paste_burst.is_settled() {
130                finalize_paste_burst(app);
131                app.paste_burst.reset();
132            }
133        }
134
135        // Deferred submit: if Enter was pressed and no rapid keys followed
136        // (not a paste), strip the trailing newline and submit.
137        if app.pending_submit {
138            app.pending_submit = false;
139            finalize_deferred_submit(app);
140        }
141        app.drain_key_count = 0;
142
143        if app.should_quit {
144            break;
145        }
146        if suppress_render_for_active_paste {
147            continue;
148        }
149
150        // Phase 3: render once (only when something changed)
151        let is_animating =
152            matches!(app.status, AppStatus::Connecting | AppStatus::Thinking | AppStatus::Running);
153        if is_animating {
154            app.spinner_frame = app.spinner_frame.wrapping_add(1);
155            app.needs_redraw = true;
156        }
157        // Smooth scroll still settling
158        let scroll_delta = (app.viewport.scroll_target as f32 - app.viewport.scroll_pos).abs();
159        if scroll_delta >= 0.01 {
160            app.needs_redraw = true;
161        }
162        if terminal::update_terminal_outputs(app) {
163            app.needs_redraw = true;
164        }
165        if app.force_redraw {
166            terminal.clear()?;
167            app.force_redraw = false;
168            app.needs_redraw = true;
169        }
170        if app.needs_redraw {
171            if let Some(ref mut perf) = app.perf {
172                perf.next_frame();
173            }
174            if app.perf.is_some() {
175                app.mark_frame_presented(Instant::now());
176            }
177            #[allow(clippy::drop_non_drop)]
178            {
179                let timer = app.perf.as_ref().map(|p| p.start("frame_total"));
180                let draw_timer = app.perf.as_ref().map(|p| p.start("frame::terminal_draw"));
181                terminal.draw(|f| crate::ui::render(f, app))?;
182                drop(draw_timer);
183                drop(timer);
184            }
185            app.needs_redraw = false;
186            last_render = Instant::now();
187        }
188    }
189
190    // --- Graceful shutdown ---
191
192    // Dismiss all pending inline permissions (reject via last option)
193    for tool_id in std::mem::take(&mut app.pending_permission_ids) {
194        if let Some((mi, bi)) = app.tool_call_index.get(&tool_id).copied()
195            && let Some(MessageBlock::ToolCall(tc)) =
196                app.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi))
197        {
198            let tc = tc.as_mut();
199            if let Some(pending) = tc.pending_permission.take()
200                && let Some(last_opt) = pending.options.last()
201            {
202                let _ = pending.response_tx.send(acp::RequestPermissionResponse::new(
203                    acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
204                        last_opt.option_id.clone(),
205                    )),
206                ));
207            }
208        }
209    }
210
211    // Cancel any active turn and give the adapter a moment to clean up
212    if matches!(app.status, AppStatus::Thinking | AppStatus::Running)
213        && let Some(ref conn) = app.conn
214        && let Some(sid) = app.session_id.clone()
215    {
216        let _ = conn.cancel(acp::CancelNotification::new(sid)).await;
217    }
218
219    // Restore terminal
220    let _ = crossterm::execute!(
221        std::io::stdout(),
222        crossterm::event::DisableBracketedPaste,
223        crossterm::event::DisableMouseCapture,
224        crossterm::event::DisableFocusChange,
225        PopKeyboardEnhancementFlags
226    );
227    ratatui::restore();
228
229    Ok(())
230}
231
232async fn wait_for_shutdown_signal() -> std::io::Result<()> {
233    #[cfg(unix)]
234    {
235        let mut sigterm =
236            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
237        tokio::select! {
238            sigint = tokio::signal::ctrl_c() => {
239                sigint?;
240            }
241            _ = sigterm.recv() => {}
242        }
243        Ok(())
244    }
245    #[cfg(not(unix))]
246    {
247        tokio::signal::ctrl_c().await
248    }
249}
250
251/// Finalize queued `Event::Paste` chunks for this drain cycle.
252fn finalize_pending_paste_event(app: &mut App) {
253    let pasted = std::mem::take(&mut app.pending_paste_text);
254    if pasted.is_empty() {
255        return;
256    }
257
258    // Continuation chunk of an already collapsed placeholder.
259    if app.input.append_to_active_paste_block(&pasted) {
260        return;
261    }
262
263    let line_count = input::count_text_lines(&pasted);
264    if line_count > input::PASTE_PLACEHOLDER_LINE_THRESHOLD {
265        app.input.insert_paste_block(&pasted);
266    } else {
267        app.input.insert_str(&pasted);
268    }
269}
270
271/// After a paste burst is detected (rapid key events), clean up the pasted
272/// content: strip trailing empty lines and convert large pastes (>10 lines)
273/// into a compact placeholder.
274fn finalize_paste_burst(app: &mut App) {
275    // Work on the fully expanded text so placeholders + trailing chunk artifacts
276    // are normalized back into a single coherent paste block.
277    let full_text = app.input.text();
278    let full_text = input::trim_trailing_line_breaks(&full_text);
279
280    if full_text.is_empty() {
281        app.input.clear();
282        return;
283    }
284
285    let line_count = input::count_text_lines(full_text);
286    if line_count > input::PASTE_PLACEHOLDER_LINE_THRESHOLD {
287        app.input.clear();
288        app.input.insert_paste_block(full_text);
289    } else {
290        app.input.set_text(full_text);
291    }
292}
293
294/// Finalize a deferred Enter: strip trailing empty lines that were optimistically
295/// inserted by the deferred-submit Enter handler, then submit the input.
296fn finalize_deferred_submit(app: &mut App) {
297    // Remove trailing empty lines added by deferred Enter presses.
298    while app.input.lines.len() > 1 && app.input.lines.last().is_some_and(String::is_empty) {
299        app.input.lines.pop();
300    }
301    // Place cursor at end of last line
302    app.input.cursor_row = app.input.lines.len().saturating_sub(1);
303    app.input.cursor_col = app.input.lines.last().map_or(0, |l| l.chars().count());
304    app.input.version += 1;
305    app.input.sync_textarea_engine();
306
307    input_submit::submit_input(app);
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crossterm::event::Event;
314
315    #[test]
316    fn pending_paste_chunks_are_merged_before_threshold_check() {
317        let mut app = App::test_default();
318        events::handle_terminal_event(&mut app, Event::Paste("a\nb\nc\nd\ne\nf".to_owned()));
319        events::handle_terminal_event(&mut app, Event::Paste("\ng\nh\ni\nj\nk".to_owned()));
320
321        // Not applied until post-drain finalization.
322        assert!(app.input.is_empty());
323        assert!(!app.pending_paste_text.is_empty());
324
325        finalize_pending_paste_event(&mut app);
326
327        assert_eq!(app.input.lines, vec!["[Pasted Text 1 - 11 lines]"]);
328        assert_eq!(app.input.text(), "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk");
329    }
330
331    #[test]
332    fn pending_paste_chunk_appends_to_existing_placeholder() {
333        let mut app = App::test_default();
334        app.input.insert_paste_block("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk");
335        app.pending_paste_text = "\nl\nm".to_owned();
336
337        finalize_pending_paste_event(&mut app);
338
339        assert_eq!(app.input.lines, vec!["[Pasted Text 1 - 13 lines]"]);
340        assert_eq!(app.input.text(), "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm");
341    }
342}