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;
32
33pub 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#[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 let _ = crossterm::execute!(
65 std::io::stdout(),
66 crossterm::event::EnableBracketedPaste,
67 crossterm::event::EnableMouseCapture,
68 crossterm::event::EnableFocusChange,
69 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 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 loop {
102 if let Some(Some(Ok(event))) = events.next().now_or_never() {
104 events::handle_terminal_event(app, event);
105 continue;
106 }
107 match app.event_rx.try_recv() {
109 Ok(event) => {
110 events::handle_acp_event(app, event);
111 }
112 Err(_) => break,
113 }
114 }
115
116 if !app.pending_paste_text.is_empty() {
118 finalize_pending_paste_event(app);
119 }
120
121 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 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 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 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 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 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 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
251fn 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 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
271fn finalize_paste_burst(app: &mut App) {
275 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
294fn finalize_deferred_submit(app: &mut App) {
297 while app.input.lines.len() > 1 && app.input.lines.last().is_some_and(String::is_empty) {
299 app.input.lines.pop();
300 }
301 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 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}