claude_code_rust/app/
mod.rs1mod 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
34pub 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#[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 let _ = crossterm::execute!(
66 std::io::stdout(),
67 crossterm::event::EnableBracketedPaste,
68 crossterm::event::EnableMouseCapture,
69 crossterm::event::EnableFocusChange,
70 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 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 loop {
103 if let Some(Some(Ok(event))) = events.next().now_or_never() {
105 events::handle_terminal_event(app, event);
106 continue;
107 }
108 match app.event_rx.try_recv() {
110 Ok(event) => {
111 events::handle_acp_event(app, event);
112 }
113 Err(_) => break,
114 }
115 }
116
117 if !app.pending_paste_text.is_empty() {
119 finalize_pending_paste_event(app);
120 }
121
122 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 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 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 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 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 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 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
252fn 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 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
272fn finalize_paste_burst(app: &mut App) {
276 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
295fn finalize_deferred_submit(app: &mut App) {
298 while app.input.lines.len() > 1 && app.input.lines.last().is_some_and(String::is_empty) {
300 app.input.lines.pop();
301 }
302 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 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}