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