1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::sync::OnceLock;
5use std::time::Duration;
6use std::{fs, io::Cursor};
7
8use base64::Engine;
9use crossterm::event::{
10 self, Event, KeyCode, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind,
11};
12use ratatui::layout::Rect;
13use tokio::sync::mpsc;
14use tokio::sync::oneshot;
15
16use crate::agent::{AgentLoader, AgentMode, AgentRegistry};
17use crate::cli::agent_init;
18use crate::cli::render;
19use crate::cli::tui::{
20 self, ChatApp, DebugRenderer, ModelOptionView, QuestionKeyResult, ScopedTuiEvent,
21 SubmittedInput, TuiEvent, TuiEventSender,
22};
23use crate::config::Settings;
24use crate::core::agent::subagent_manager::{
25 SubagentExecutionRequest, SubagentExecutionResult, SubagentExecutor, SubagentManager,
26 SubagentStatus,
27};
28use crate::core::agent::{AgentEvents, AgentLoop, NoopEvents};
29use crate::core::{Message, MessageAttachment, Role};
30use crate::permission::PermissionMatcher;
31use crate::provider::openai_compatible::OpenAiCompatibleProvider;
32use crate::session::types::SubAgentFailureReason;
33use crate::session::{SessionEvent, SessionStore, event_id};
34use crate::tool::registry::{ToolRegistry, ToolRegistryContext};
35use crate::tool::task::TaskToolRuntimeContext;
36use uuid::Uuid;
37
38static GLOBAL_SUBAGENT_MANAGER: OnceLock<Arc<SubagentManager>> = OnceLock::new();
39
40pub async fn run_chat(settings: Settings, cwd: &std::path::Path) -> anyhow::Result<()> {
41 let terminal = tui::setup_terminal()?;
43 let mut tui_guard = tui::TuiGuard::new(terminal);
44
45 let mut app = ChatApp::new(build_session_name(cwd), cwd);
47 app.configure_models(
48 settings.selected_model_ref().to_string(),
49 build_model_options(&settings),
50 );
51
52 let (agent_views, selected_agent) = agent_init::initialize_agents(&settings)?;
54 app.set_agents(agent_views, selected_agent);
55
56 let (event_tx, mut event_rx) = mpsc::unbounded_channel::<ScopedTuiEvent>();
57 let event_sender = TuiEventSender::new(event_tx);
58 initialize_subagent_manager(settings.clone(), cwd.to_path_buf());
59
60 run_interactive_chat_loop(
61 &mut tui_guard,
62 &mut app,
63 InteractiveChatRunner {
64 settings: &settings,
65 cwd,
66 event_sender: &event_sender,
67 event_rx: &mut event_rx,
68 debug_renderer: None,
69 scroll_down_lines: 3,
70 },
71 )
72 .await?;
73
74 Ok(())
75}
76
77pub async fn run_chat_with_debug(
79 settings: Settings,
80 cwd: &std::path::Path,
81 debug_dir: PathBuf,
82) -> anyhow::Result<()> {
83 let terminal = tui::setup_terminal()?;
85 let mut tui_guard = tui::TuiGuard::new(terminal);
86
87 let mut debug_renderer = DebugRenderer::new(debug_dir.clone())?;
89
90 let mut app = ChatApp::new(build_session_name(cwd), cwd);
92 app.configure_models(
93 settings.selected_model_ref().to_string(),
94 build_model_options(&settings),
95 );
96
97 let (agent_views, selected_agent) = agent_init::initialize_agents(&settings)?;
99 app.set_agents(agent_views, selected_agent);
100
101 let (event_tx, mut event_rx) = mpsc::unbounded_channel::<ScopedTuiEvent>();
102 let event_sender = TuiEventSender::new(event_tx);
103 initialize_subagent_manager(settings.clone(), cwd.to_path_buf());
104
105 run_interactive_chat_loop(
106 &mut tui_guard,
107 &mut app,
108 InteractiveChatRunner {
109 settings: &settings,
110 cwd,
111 event_sender: &event_sender,
112 event_rx: &mut event_rx,
113 debug_renderer: Some(&mut debug_renderer),
114 scroll_down_lines: 1,
115 },
116 )
117 .await?;
118
119 eprintln!(
120 "Debug: {} frames written to {}",
121 debug_renderer.frame_count(),
122 debug_dir.display()
123 );
124
125 Ok(())
126}
127
128pub async fn run_prompt_with_debug(
130 settings: Settings,
131 cwd: &std::path::Path,
132 output_dir: PathBuf,
133 prompt: String,
134) -> anyhow::Result<()> {
135 let mut renderer = DebugRenderer::new(output_dir.clone())?;
137
138 let mut app = ChatApp::new(build_session_name(cwd), cwd);
140 app.configure_models(
141 settings.selected_model_ref().to_string(),
142 build_model_options(&settings),
143 );
144
145 let (agent_views, selected_agent) = agent_init::initialize_agents(&settings)?;
147 app.set_agents(agent_views, selected_agent);
148
149 let (event_tx, mut event_rx) = mpsc::unbounded_channel::<ScopedTuiEvent>();
150 let event_sender = TuiEventSender::new(event_tx);
151 let subagent_manager = current_subagent_manager(&settings, cwd);
152
153 app.messages.push(tui::ChatMessage::User(prompt.clone()));
155 app.set_processing(true);
156
157 renderer.render(&app)?;
159
160 println!(
161 "Debug mode: writing screen dumps to {}",
162 output_dir.display()
163 );
164
165 let settings_clone = settings.clone();
167 let model_ref = settings.selected_model_ref().to_string();
168 let cwd_clone = cwd.to_path_buf();
169 let sender_clone = event_sender.clone();
170 let prompt_clone = prompt.clone();
171 let session_id = Uuid::new_v4().to_string();
172
173 let title = fallback_session_title(&prompt);
174 let title_clone = title.clone();
175
176 {
177 let settings = settings.clone();
178 let cwd = cwd.to_path_buf();
179 let session_id = session_id.clone();
180 let model_ref = model_ref.clone();
181 let prompt = prompt.clone();
182 tokio::spawn(async move {
183 let generated = match generate_session_title(&settings, &model_ref, &prompt).await {
184 Ok(title) => title,
185 Err(_) => return,
186 };
187
188 let store =
189 match SessionStore::new(&settings.session.root, &cwd, Some(&session_id), None) {
190 Ok(store) => store,
191 Err(_) => return,
192 };
193
194 let _ = store.update_title(generated);
195 });
196 }
197
198 let agent_handle = tokio::spawn(async move {
199 let result = run_agent(
200 settings_clone,
201 &cwd_clone,
202 Message {
203 role: crate::core::Role::User,
204 content: prompt_clone,
205 attachments: Vec::new(),
206 tool_call_id: None,
207 },
208 model_ref,
209 sender_clone.clone(),
210 Arc::clone(&subagent_manager),
211 AgentRunOptions {
212 session_id: Some(session_id),
213 session_title: Some(title_clone),
214 allow_questions: false,
215 },
216 )
217 .await;
218 if let Err(ref e) = result {
219 sender_clone.send(TuiEvent::Error(e.to_string()));
220 }
221 result
222 });
223 drop(event_sender); loop {
227 tokio::select! {
228 event = event_rx.recv() => {
229 if let Some(event) = event {
230 let is_done_or_error =
231 matches!(&event.event, TuiEvent::AssistantDone | TuiEvent::Error(_));
232 if event.session_epoch == app.session_epoch()
233 && event.run_epoch == app.run_epoch()
234 {
235 app.handle_event(&event.event);
236 }
237
238 renderer.render(&app)?;
240
241 if is_done_or_error {
243 renderer.render(&app)?;
245 break;
246 }
247 } else {
248 break;
250 }
251 }
252 }
253 }
254
255 if let Err(e) = agent_handle.await? {
256 eprintln!("Agent task error: {}", e);
257 return Err(e);
258 }
259
260 println!(
261 "Debug complete: {} frames written to {}",
262 renderer.frame_count(),
263 output_dir.display()
264 );
265
266 Ok(())
267}
268
269enum InputEvent {
271 Key(event::KeyEvent),
272 Paste(String),
273 ScrollUp { x: u16, y: u16 },
274 ScrollDown { x: u16, y: u16 },
275 Refresh,
276 MouseClick { x: u16, y: u16 },
277 MouseDrag { x: u16, y: u16 },
278 MouseRelease { x: u16, y: u16 },
279}
280
281const INPUT_POLL_TIMEOUT: Duration = Duration::from_millis(16);
282const INPUT_BATCH_MAX: usize = 64;
283
284async fn handle_input_batch() -> anyhow::Result<Vec<InputEvent>> {
285 if !event::poll(INPUT_POLL_TIMEOUT)? {
286 return Ok(Vec::new());
287 }
288
289 let mut events = Vec::with_capacity(INPUT_BATCH_MAX.min(8));
290 if let Some(input_event) = translate_terminal_event(event::read()?) {
291 events.push(input_event);
292 }
293
294 while events.len() < INPUT_BATCH_MAX && event::poll(Duration::ZERO)? {
295 if let Some(input_event) = translate_terminal_event(event::read()?) {
296 events.push(input_event);
297 }
298 }
299
300 Ok(events)
301}
302
303fn translate_terminal_event(event: Event) -> Option<InputEvent> {
304 match event {
305 Event::Key(key) => Some(InputEvent::Key(key)),
306 Event::Paste(text) => Some(InputEvent::Paste(text)),
307 Event::Mouse(mouse) => handle_mouse_event(mouse),
308 Event::Resize(_, _) | Event::FocusGained => Some(InputEvent::Refresh),
309 _ => None,
310 }
311}
312
313fn handle_key_event<F>(
314 key_event: event::KeyEvent,
315 app: &mut ChatApp,
316 settings: &Settings,
317 cwd: &Path,
318 event_sender: &TuiEventSender,
319 mut terminal_size: F,
320) -> anyhow::Result<()>
321where
322 F: FnMut() -> anyhow::Result<(u16, u16)>,
323{
324 if key_event.kind == KeyEventKind::Release {
325 return Ok(());
326 }
327
328 if app.is_processing && key_event.code != KeyCode::Esc {
329 app.clear_pending_esc_interrupt();
330 }
331
332 if app.has_pending_question() {
333 let handled = app.handle_question_key(key_event);
334 if handled == QuestionKeyResult::Dismissed && app.is_processing {
335 if app.should_interrupt_on_esc() {
336 app.cancel_agent_task();
337 app.set_processing(false);
338 } else {
339 app.arm_esc_interrupt();
340 }
341 }
342 if handled != QuestionKeyResult::NotHandled {
343 return Ok(());
344 }
345 }
346
347 if key_event.code == KeyCode::Char('c') && key_event.modifiers.contains(KeyModifiers::CONTROL) {
348 if app.input.is_empty() {
349 app.should_quit = true;
350 } else {
351 mutate_input(app, ChatApp::clear_input);
352 }
353 return Ok(());
354 }
355
356 if maybe_handle_paste_shortcut(key_event, app) {
357 return Ok(());
358 }
359
360 match key_event.code {
361 KeyCode::Char(c) => {
362 if key_event.modifiers.contains(KeyModifiers::CONTROL) {
363 match c {
364 'a' | 'A' => app.move_to_line_start(),
365 'e' | 'E' => app.move_to_line_end(),
366 _ => {}
367 }
368 } else {
369 mutate_input(app, |app| app.insert_char(c));
370 }
371 }
372 KeyCode::Backspace => {
373 mutate_input(app, ChatApp::backspace);
374 }
375 KeyCode::Enter if key_event.modifiers.contains(KeyModifiers::SHIFT) => {
376 mutate_input(app, |app| app.insert_char('\n'));
377 }
378 KeyCode::Enter => {
379 handle_enter_key(app, settings, cwd, event_sender);
380 }
381 KeyCode::Tab => {
382 app.cycle_agent();
383 }
384 KeyCode::Esc => {
385 if app.is_processing {
386 if app.should_interrupt_on_esc() {
387 app.cancel_agent_task();
388 app.set_processing(false);
389 } else {
390 app.arm_esc_interrupt();
391 }
392 } else {
393 mutate_input(app, ChatApp::clear_input);
395 }
396 }
397 KeyCode::Up => {
398 if !app.filtered_commands.is_empty() {
399 if app.selected_command_index > 0 {
400 app.selected_command_index -= 1;
401 } else {
402 app.selected_command_index = app.filtered_commands.len().saturating_sub(1);
403 }
404 } else if !app.input.is_empty() {
405 app.move_cursor_up();
406 } else {
407 let (width, height) = terminal_size()?;
408 scroll_up_steps(app, width, height, 1);
409 }
410 }
411 KeyCode::Left => {
412 app.move_cursor_left();
413 }
414 KeyCode::Right => {
415 app.move_cursor_right();
416 }
417 KeyCode::Down => {
418 if !app.filtered_commands.is_empty() {
419 if app.selected_command_index < app.filtered_commands.len().saturating_sub(1) {
420 app.selected_command_index += 1;
421 } else {
422 app.selected_command_index = 0;
423 }
424 } else if !app.input.is_empty() {
425 app.move_cursor_down();
426 } else {
427 let (width, height) = terminal_size()?;
428 scroll_down_once(app, width, height);
429 }
430 }
431 KeyCode::PageUp => {
432 let (width, height) = terminal_size()?;
433 scroll_up_steps(
434 app,
435 width,
436 height,
437 app.message_viewport_height(height).saturating_sub(1),
438 );
439 }
440 KeyCode::PageDown => {
441 let (width, height) = terminal_size()?;
442 scroll_page_down(app, width, height);
443 }
444 _ => {}
445 }
446
447 Ok(())
448}
449
450fn scroll_down_once(app: &mut ChatApp, width: u16, height: u16) {
451 scroll_down_steps(app, width, height, 1);
452}
453
454fn scroll_up_steps(app: &mut ChatApp, width: u16, height: u16, steps: usize) {
455 if steps == 0 {
456 return;
457 }
458
459 let (total_lines, visible_height) = scroll_bounds(app, width, height);
460 app.message_scroll
461 .scroll_up_steps(total_lines, visible_height, steps);
462}
463
464fn scroll_down_steps(app: &mut ChatApp, width: u16, height: u16, steps: usize) {
465 if steps == 0 {
466 return;
467 }
468
469 let (total_lines, visible_height) = scroll_bounds(app, width, height);
470 app.message_scroll
471 .scroll_down_steps(total_lines, visible_height, steps);
472}
473
474fn mutate_input(app: &mut ChatApp, mutator: impl FnOnce(&mut ChatApp)) {
475 mutator(app);
476 app.update_command_filtering();
477}
478
479fn apply_paste(app: &mut ChatApp, pasted: String) {
480 let mut prepared = prepare_paste(&pasted);
481 if prepared.attachments.is_empty()
482 && let Some(clipboard_image) = prepare_clipboard_image_paste()
483 {
484 prepared = clipboard_image;
485 }
486 apply_prepared_paste(app, prepared);
487}
488
489fn apply_prepared_paste(app: &mut ChatApp, prepared: PreparedPaste) {
490 mutate_input(app, |app| {
491 app.insert_str(&prepared.insert_text);
492 for attachment in prepared.attachments {
493 app.add_pending_attachment(attachment);
494 }
495 });
496}
497
498struct PreparedPaste {
499 insert_text: String,
500 attachments: Vec<MessageAttachment>,
501}
502
503fn prepare_paste(pasted: &str) -> PreparedPaste {
504 if let Some(image_paste) = prepare_image_file_paste(pasted) {
505 return image_paste;
506 }
507
508 PreparedPaste {
509 insert_text: pasted.to_string(),
510 attachments: Vec::new(),
511 }
512}
513
514fn prepare_image_file_paste(pasted: &str) -> Option<PreparedPaste> {
515 let non_empty_lines: Vec<&str> = pasted
516 .lines()
517 .filter(|line| !line.trim().is_empty())
518 .collect();
519 if non_empty_lines.is_empty() {
520 return None;
521 }
522
523 let mut image_paths = Vec::with_capacity(non_empty_lines.len());
524 let mut attachments = Vec::with_capacity(non_empty_lines.len());
525 for line in &non_empty_lines {
526 let path = extract_image_path(line)?;
527 let attachment = read_image_file_attachment(&path)?;
528 image_paths.push(path);
529 attachments.push(attachment);
530 }
531
532 let insert_text = image_paths
533 .iter()
534 .enumerate()
535 .map(|(idx, path)| {
536 let name = Path::new(path)
537 .file_name()
538 .and_then(|value| value.to_str())
539 .unwrap_or("image");
540 if image_paths.len() == 1 {
541 format!("[pasted image: {name}]")
542 } else {
543 format!("[pasted image {}: {name}]", idx + 1)
544 }
545 })
546 .collect::<Vec<_>>()
547 .join("\n");
548
549 Some(PreparedPaste {
550 insert_text,
551 attachments,
552 })
553}
554
555fn maybe_handle_paste_shortcut(key_event: event::KeyEvent, app: &mut ChatApp) -> bool {
556 if !is_paste_shortcut(key_event) {
557 return false;
558 }
559
560 if let Some(prepared) = prepare_clipboard_image_paste() {
561 apply_prepared_paste(app, prepared);
562 return true;
563 }
564
565 if let Some(text) = read_clipboard_text() {
566 apply_paste(app, text);
567 }
568
569 true
570}
571
572fn is_paste_shortcut(key_event: event::KeyEvent) -> bool {
573 (key_event.code == KeyCode::Char('v')
574 && (key_event.modifiers.contains(KeyModifiers::CONTROL)
575 || key_event.modifiers.contains(KeyModifiers::SUPER)))
576 || (key_event.code == KeyCode::Insert && key_event.modifiers.contains(KeyModifiers::SHIFT))
577}
578
579fn prepare_clipboard_image_paste() -> Option<PreparedPaste> {
580 let mut clipboard = arboard::Clipboard::new().ok()?;
581 let image = clipboard.get_image().ok()?;
582 let png_data = encode_rgba_to_png(image.width, image.height, image.bytes.as_ref())?;
583 let data_base64 = base64::engine::general_purpose::STANDARD.encode(png_data);
584
585 Some(PreparedPaste {
586 insert_text: "[pasted image from clipboard]".to_string(),
587 attachments: vec![MessageAttachment::Image {
588 media_type: "image/png".to_string(),
589 data_base64,
590 }],
591 })
592}
593
594fn read_clipboard_text() -> Option<String> {
595 let mut clipboard = arboard::Clipboard::new().ok()?;
596 let text = clipboard.get_text().ok()?;
597 if text.is_empty() { None } else { Some(text) }
598}
599
600fn encode_rgba_to_png(width: usize, height: usize, rgba_bytes: &[u8]) -> Option<Vec<u8>> {
601 let mut output = Vec::new();
602 {
603 let mut cursor = Cursor::new(&mut output);
604 let mut encoder = png::Encoder::new(&mut cursor, width as u32, height as u32);
605 encoder.set_color(png::ColorType::Rgba);
606 encoder.set_depth(png::BitDepth::Eight);
607 let mut writer = encoder.write_header().ok()?;
608 writer.write_image_data(rgba_bytes).ok()?;
609 }
610 Some(output)
611}
612
613fn extract_image_path(raw: &str) -> Option<String> {
614 let trimmed = strip_surrounding_quotes(raw.trim());
615 if trimmed.is_empty() {
616 return None;
617 }
618
619 let normalized = if let Some(rest) = trimmed.strip_prefix("file://") {
620 let path = if rest.starts_with('/') {
621 rest
622 } else {
623 return None;
624 };
625 match urlencoding::decode(path) {
626 Ok(decoded) => decoded.into_owned(),
627 Err(_) => return None,
628 }
629 } else {
630 trimmed.to_string()
631 };
632
633 resolve_image_path(&normalized)
634}
635
636fn resolve_image_path(path: &str) -> Option<String> {
637 let unescaped = unescape_shell_escaped_path(path);
638 let mut candidates = vec![path.to_string()];
639 if unescaped != path {
640 candidates.push(unescaped);
641 }
642
643 for candidate in &candidates {
644 if is_image_path(candidate) && Path::new(candidate).exists() {
645 return Some(candidate.clone());
646 }
647 }
648
649 candidates
650 .into_iter()
651 .find(|candidate| is_image_path(candidate))
652}
653
654fn unescape_shell_escaped_path(path: &str) -> String {
655 let mut out = String::with_capacity(path.len());
656 let mut chars = path.chars();
657 while let Some(ch) = chars.next() {
658 if ch == '\\' {
659 if let Some(next) = chars.next() {
660 out.push(next);
661 } else {
662 out.push('\\');
663 }
664 } else {
665 out.push(ch);
666 }
667 }
668 out
669}
670
671fn read_image_file_attachment(path: &str) -> Option<MessageAttachment> {
672 let media_type = image_media_type(path)?;
673 let bytes = fs::read(path).ok()?;
674 let data_base64 = base64::engine::general_purpose::STANDARD.encode(bytes);
675 Some(MessageAttachment::Image {
676 media_type: media_type.to_string(),
677 data_base64,
678 })
679}
680
681fn image_media_type(path: &str) -> Option<&'static str> {
682 let lower = path.to_ascii_lowercase();
683 if lower.ends_with(".png") {
684 Some("image/png")
685 } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
686 Some("image/jpeg")
687 } else if lower.ends_with(".gif") {
688 Some("image/gif")
689 } else if lower.ends_with(".webp") {
690 Some("image/webp")
691 } else if lower.ends_with(".bmp") {
692 Some("image/bmp")
693 } else if lower.ends_with(".tiff") || lower.ends_with(".tif") {
694 Some("image/tiff")
695 } else if lower.ends_with(".heic") {
696 Some("image/heic")
697 } else if lower.ends_with(".heif") {
698 Some("image/heif")
699 } else if lower.ends_with(".avif") {
700 Some("image/avif")
701 } else {
702 None
703 }
704}
705
706fn strip_surrounding_quotes(value: &str) -> &str {
707 if value.len() < 2 {
708 return value;
709 }
710 let bytes = value.as_bytes();
711 let first = bytes[0];
712 let last = bytes[value.len() - 1];
713 if (first == b'\'' && last == b'\'') || (first == b'"' && last == b'"') {
714 &value[1..value.len() - 1]
715 } else {
716 value
717 }
718}
719
720fn is_image_path(path: &str) -> bool {
721 let lower = path.to_ascii_lowercase();
722 [
723 ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".heic", ".heif",
724 ".avif",
725 ]
726 .iter()
727 .any(|ext| lower.ends_with(ext))
728}
729
730fn selected_command_name(app: &ChatApp) -> Option<String> {
731 app.filtered_commands
732 .get(app.selected_command_index)
733 .map(|command| command.name.clone())
734}
735
736fn submit_and_handle(
737 app: &mut ChatApp,
738 settings: &Settings,
739 cwd: &Path,
740 event_sender: &TuiEventSender,
741) {
742 let input = app.submit_input();
743 app.update_command_filtering();
744 handle_submitted_input(input, app, settings, cwd, event_sender);
745}
746
747fn handle_enter_key(
748 app: &mut ChatApp,
749 settings: &Settings,
750 cwd: &Path,
751 event_sender: &TuiEventSender,
752) {
753 if let Some(name) = selected_command_name(app)
754 && app.input != name
755 {
756 mutate_input(app, |app| app.set_input(name));
757 return;
758 }
759
760 submit_and_handle(app, settings, cwd, event_sender);
761}
762
763fn scroll_page_down(app: &mut ChatApp, width: u16, height: u16) {
764 let (total_lines, visible_height) = scroll_bounds(app, width, height);
765 app.message_scroll.scroll_down_steps(
766 total_lines,
767 visible_height,
768 visible_height.saturating_sub(1),
769 );
770}
771
772fn scroll_bounds(app: &ChatApp, width: u16, height: u16) -> (usize, usize) {
773 let visible_height = app.message_viewport_height(height);
774 let wrap_width = app.message_wrap_width(width);
775 let lines = app.get_lines(wrap_width);
776 let total_lines = lines.len();
777 drop(lines);
778 (total_lines, visible_height)
779}
780
781fn copy_selection_to_clipboard(app: &ChatApp, terminal_width: u16) -> bool {
783 let wrap_width = app.message_wrap_width(terminal_width);
784 let lines = app.get_lines(wrap_width);
785 let selected_text = app.get_selected_text(&lines);
786
787 if !selected_text.is_empty()
788 && let Ok(mut clipboard) = arboard::Clipboard::new()
789 && clipboard.set_text(&selected_text).is_ok()
790 {
791 return true;
792 }
793
794 false
795}
796
797fn handle_mouse_click(app: &mut ChatApp, x: u16, y: u16, terminal: &tui::Tui) {
799 if let Some((line, column)) = screen_to_message_coords(app, x, y, terminal) {
800 app.start_selection(line, column);
801 }
802}
803
804fn handle_mouse_drag(app: &mut ChatApp, x: u16, y: u16, terminal: &tui::Tui) {
806 if let Some((line, column)) = screen_to_message_coords(app, x, y, terminal) {
807 app.update_selection(line, column);
808 }
809}
810
811fn handle_mouse_release(app: &mut ChatApp, _x: u16, _y: u16, _terminal: &tui::Tui) {
813 if let Some((line, column)) = screen_to_message_coords(app, _x, _y, _terminal) {
814 app.update_selection(line, column);
815 }
816 if app.text_selection.is_active()
817 && let Ok(size) = _terminal.size()
818 {
819 if copy_selection_to_clipboard(app, size.width) {
820 app.show_clipboard_notice(_x, _y);
821 }
822 app.clear_selection();
823 }
824 app.end_selection();
825}
826
827fn screen_to_message_coords(
829 app: &ChatApp,
830 x: u16,
831 y: u16,
832 terminal: &tui::Tui,
833) -> Option<(usize, usize)> {
834 const MAIN_OUTER_PADDING_X: u16 = 1;
835 const MAIN_OUTER_PADDING_Y: u16 = 1;
836
837 let size = terminal.size().ok()?;
838
839 let input_area_height = 6; if y < MAIN_OUTER_PADDING_Y || y >= size.height.saturating_sub(input_area_height) {
843 return None;
844 }
845
846 let relative_y = (y - MAIN_OUTER_PADDING_Y) as usize;
847 let relative_x = x.saturating_sub(MAIN_OUTER_PADDING_X) as usize;
848
849 let wrap_width = app.message_wrap_width(size.width);
850 let total_lines = app.get_lines(wrap_width).len();
851 let visible_height = app.message_viewport_height(size.height);
852 let scroll_offset = app
853 .message_scroll
854 .effective_offset(total_lines, visible_height);
855
856 let line = scroll_offset.saturating_add(relative_y);
857 let column = relative_x;
858
859 Some((line, column))
860}
861
862fn handle_area_scroll(
863 app: &mut ChatApp,
864 terminal_size: Rect,
865 x: u16,
866 y: u16,
867 up_steps: usize,
868 down_steps: usize,
869) -> bool {
870 let layout_rects = tui::compute_layout_rects(terminal_size, app);
871
872 if let Some(sidebar_content) = layout_rects.sidebar_content
874 && point_in_rect(x, y, sidebar_content)
875 {
876 let total_lines = tui::build_sidebar_lines(app, sidebar_content.width).len();
877 let visible_height = sidebar_content.height as usize;
878
879 if total_lines > visible_height {
881 if up_steps > 0 {
882 app.sidebar_scroll
883 .scroll_up_steps(total_lines, visible_height, up_steps);
884 }
885 if down_steps > 0 {
886 app.sidebar_scroll
887 .scroll_down_steps(total_lines, visible_height, down_steps);
888 }
889 return true;
890 }
891 return true;
893 }
894
895 if let Some(main_messages) = layout_rects.main_messages
897 && point_in_rect(x, y, main_messages)
898 {
899 let (total_lines, visible_height) =
900 scroll_bounds(app, terminal_size.width, terminal_size.height);
901 if up_steps > 0 {
902 app.message_scroll
903 .scroll_up_steps(total_lines, visible_height, up_steps);
904 }
905 if down_steps > 0 {
906 app.message_scroll
907 .scroll_down_steps(total_lines, visible_height, down_steps);
908 }
909 return true;
910 }
911
912 false
914}
915
916fn point_in_rect(x: u16, y: u16, rect: Rect) -> bool {
917 x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
918}
919
920fn spawn_agent_task(
921 settings: &Settings,
922 cwd: &Path,
923 input: Message,
924 model_ref: String,
925 event_sender: &TuiEventSender,
926 subagent_manager: Arc<SubagentManager>,
927 run_options: AgentRunOptions,
928) -> tokio::task::JoinHandle<()> {
929 let settings = settings.clone();
930 let cwd = cwd.to_path_buf();
931 let sender = event_sender.clone();
932 tokio::spawn(async move {
933 if let Err(e) = run_agent(
934 settings,
935 &cwd,
936 input,
937 model_ref,
938 sender.clone(),
939 subagent_manager,
940 run_options,
941 )
942 .await
943 {
944 sender.send(TuiEvent::Error(e.to_string()));
945 }
946 })
947}
948
949fn handle_mouse_event(mouse: MouseEvent) -> Option<InputEvent> {
950 match mouse.kind {
951 MouseEventKind::ScrollUp => Some(InputEvent::ScrollUp {
952 x: mouse.column,
953 y: mouse.row,
954 }),
955 MouseEventKind::ScrollDown => Some(InputEvent::ScrollDown {
956 x: mouse.column,
957 y: mouse.row,
958 }),
959 MouseEventKind::Down(crossterm::event::MouseButton::Left) => Some(InputEvent::MouseClick {
960 x: mouse.column,
961 y: mouse.row,
962 }),
963 MouseEventKind::Drag(crossterm::event::MouseButton::Left) => Some(InputEvent::MouseDrag {
964 x: mouse.column,
965 y: mouse.row,
966 }),
967 MouseEventKind::Up(crossterm::event::MouseButton::Left) => Some(InputEvent::MouseRelease {
968 x: mouse.column,
969 y: mouse.row,
970 }),
971 _ => None,
972 }
973}
974
975async fn run_interactive_chat_loop(
976 tui_guard: &mut tui::TuiGuard,
977 app: &mut ChatApp,
978 mut runner: InteractiveChatRunner<'_>,
979) -> anyhow::Result<()> {
980 if let Some(renderer) = runner.debug_renderer.as_deref_mut() {
981 renderer.render(app)?;
982 }
983
984 let mut render_tick = tokio::time::interval(Duration::from_secs(1));
985
986 loop {
987 tui_guard.get().draw(|f| tui::render_app(f, app))?;
988 if let Some(renderer) = runner.debug_renderer.as_deref_mut() {
989 renderer.render(app)?;
990 }
991
992 tokio::select! {
993 input_result = handle_input_batch() => {
994 for input_event in input_result? {
995 match input_event {
996 InputEvent::Key(key_event) => {
997 handle_key_event(
998 key_event,
999 app,
1000 runner.settings,
1001 runner.cwd,
1002 runner.event_sender,
1003 || {
1004 let size = tui_guard.get().size()?;
1005 Ok((size.width, size.height))
1006 },
1007 )?;
1008 }
1009 InputEvent::Paste(text) => {
1010 apply_paste(app, text);
1011 }
1012 InputEvent::ScrollUp { x, y } => {
1013 let terminal_size = tui_guard.get().size()?;
1014 let terminal_rect = Rect {
1015 x: 0,
1016 y: 0,
1017 width: terminal_size.width,
1018 height: terminal_size.height,
1019 };
1020 handle_area_scroll(app, terminal_rect, x, y, 3, 0);
1021 }
1022 InputEvent::ScrollDown { x, y } => {
1023 let terminal_size = tui_guard.get().size()?;
1024 let terminal_rect = Rect {
1025 x: 0,
1026 y: 0,
1027 width: terminal_size.width,
1028 height: terminal_size.height,
1029 };
1030 handle_area_scroll(
1031 app,
1032 terminal_rect,
1033 x,
1034 y,
1035 0,
1036 runner.scroll_down_lines,
1037 );
1038 }
1039 InputEvent::Refresh => {
1040 tui_guard.get().autoresize()?;
1041 tui_guard.get().clear()?;
1042 }
1043 InputEvent::MouseClick { x, y } => {
1044 handle_mouse_click(app, x, y, tui_guard.get());
1045 }
1046 InputEvent::MouseDrag { x, y } => {
1047 handle_mouse_drag(app, x, y, tui_guard.get());
1048 }
1049 InputEvent::MouseRelease { x, y } => {
1050 handle_mouse_release(app, x, y, tui_guard.get());
1051 }
1052 }
1053 }
1054 }
1055 event = runner.event_rx.recv() => {
1056 if let Some(event) = event
1057 && event.session_epoch == app.session_epoch()
1058 && event.run_epoch == app.run_epoch()
1059 {
1060 app.handle_event(&event.event);
1061 }
1062 }
1063 _ = render_tick.tick() => {
1064 app.mark_dirty();
1065 }
1066 }
1067
1068 if app.should_quit {
1069 break;
1070 }
1071 }
1072
1073 Ok(())
1074}
1075
1076struct InteractiveChatRunner<'a> {
1077 settings: &'a Settings,
1078 cwd: &'a Path,
1079 event_sender: &'a TuiEventSender,
1080 event_rx: &'a mut mpsc::UnboundedReceiver<ScopedTuiEvent>,
1081 debug_renderer: Option<&'a mut DebugRenderer>,
1082 scroll_down_lines: usize,
1083}
1084
1085#[derive(Clone)]
1086struct AgentRunOptions {
1087 session_id: Option<String>,
1088 session_title: Option<String>,
1089 allow_questions: bool,
1090}
1091
1092struct AgentLoopOptions {
1093 subagent_manager: Option<Arc<SubagentManager>>,
1094 parent_task_id: Option<String>,
1095 depth: usize,
1096 session_id: Option<String>,
1097 session_title: Option<String>,
1098 session_parent_id: Option<String>,
1099}
1100
1101fn build_session_name(cwd: &std::path::Path) -> String {
1102 let _ = cwd;
1103 "New Session".to_string()
1104}
1105
1106fn build_model_options(settings: &Settings) -> Vec<ModelOptionView> {
1107 settings
1108 .model_refs()
1109 .into_iter()
1110 .filter_map(|model_ref| {
1111 settings
1112 .resolve_model_ref(&model_ref)
1113 .map(|resolved| ModelOptionView {
1114 full_id: model_ref,
1115 provider_name: if resolved.provider.display_name.trim().is_empty() {
1116 resolved.provider_id.clone()
1117 } else {
1118 resolved.provider.display_name.clone()
1119 },
1120 model_name: if resolved.model.display_name.trim().is_empty() {
1121 resolved.model_id.clone()
1122 } else {
1123 resolved.model.display_name.clone()
1124 },
1125 modality: format!(
1126 "{} -> {}",
1127 format_modalities(&resolved.model.modalities.input),
1128 format_modalities(&resolved.model.modalities.output)
1129 ),
1130 max_context_size: resolved.model.limits.context,
1131 })
1132 })
1133 .collect()
1134}
1135
1136fn initialize_subagent_manager(settings: Settings, cwd: PathBuf) {
1137 let _ = GLOBAL_SUBAGENT_MANAGER.get_or_init(|| Arc::new(build_subagent_manager(settings, cwd)));
1138}
1139
1140fn current_subagent_manager(settings: &Settings, cwd: &Path) -> Arc<SubagentManager> {
1141 Arc::clone(
1142 GLOBAL_SUBAGENT_MANAGER
1143 .get_or_init(|| Arc::new(build_subagent_manager(settings.clone(), cwd.to_path_buf()))),
1144 )
1145}
1146
1147fn build_subagent_manager(settings: Settings, cwd: PathBuf) -> SubagentManager {
1148 let enabled = settings.agent.parallel_subagents;
1149 let max_parallel = settings.agent.max_parallel_subagents;
1150 let max_depth = settings.agent.sub_agent_max_depth;
1151 let executor_settings = settings.clone();
1152 let executor: SubagentExecutor = Arc::new(move |request| {
1153 let settings = executor_settings.clone();
1154 let cwd = cwd.clone();
1155 Box::pin(async move {
1156 if !enabled {
1157 return SubagentExecutionResult {
1158 status: SubagentStatus::Failed,
1159 summary: "parallel sub-agents are disabled by configuration".to_string(),
1160 error: Some("agent.parallel_subagents=false".to_string()),
1161 failure_reason: Some(SubAgentFailureReason::RuntimeError),
1162 };
1163 }
1164 run_subagent_execution(settings, cwd, request).await
1165 })
1166 });
1167
1168 SubagentManager::new(max_parallel, max_depth, executor)
1169}
1170
1171async fn run_subagent_execution(
1172 settings: Settings,
1173 cwd: PathBuf,
1174 request: SubagentExecutionRequest,
1175) -> SubagentExecutionResult {
1176 let loader = match AgentLoader::new() {
1177 Ok(loader) => loader,
1178 Err(err) => {
1179 return SubagentExecutionResult {
1180 status: SubagentStatus::Failed,
1181 summary: "failed to initialize agent loader".to_string(),
1182 error: Some(err.to_string()),
1183 failure_reason: Some(SubAgentFailureReason::RuntimeError),
1184 };
1185 }
1186 };
1187 let registry = match loader.load_agents() {
1188 Ok(agents) => AgentRegistry::new(agents),
1189 Err(err) => {
1190 return SubagentExecutionResult {
1191 status: SubagentStatus::Failed,
1192 summary: "failed to load agents".to_string(),
1193 error: Some(err.to_string()),
1194 failure_reason: Some(SubAgentFailureReason::RuntimeError),
1195 };
1196 }
1197 };
1198
1199 let Some(agent) = registry.get_agent(&request.subagent_type).cloned() else {
1200 return SubagentExecutionResult {
1201 status: SubagentStatus::Failed,
1202 summary: format!("unknown subagent_type: {}", request.subagent_type),
1203 error: None,
1204 failure_reason: Some(SubAgentFailureReason::RuntimeError),
1205 };
1206 };
1207 if agent.mode != AgentMode::Subagent {
1208 return SubagentExecutionResult {
1209 status: SubagentStatus::Failed,
1210 summary: format!("agent '{}' is not a subagent", agent.name),
1211 error: None,
1212 failure_reason: Some(SubAgentFailureReason::RuntimeError),
1213 };
1214 }
1215
1216 let mut child_settings = settings.clone();
1217 child_settings.apply_agent_settings(&agent);
1218 child_settings.selected_agent = Some(agent.name.clone());
1219 let model_ref = child_settings.selected_model_ref().to_string();
1220
1221 let loop_runner = match create_agent_loop(
1222 child_settings,
1223 &cwd,
1224 &model_ref,
1225 NoopEvents,
1226 AgentLoopOptions {
1227 subagent_manager: Some(current_subagent_manager(&settings, &cwd)),
1228 parent_task_id: Some(request.task_id.clone()),
1229 depth: request.depth,
1230 session_id: Some(request.child_session_id),
1231 session_title: Some(request.description),
1232 session_parent_id: Some(request.parent_session_id),
1233 },
1234 ) {
1235 Ok(loop_runner) => loop_runner,
1236 Err(err) => {
1237 return SubagentExecutionResult {
1238 status: SubagentStatus::Failed,
1239 summary: "failed to initialize sub-agent runtime".to_string(),
1240 error: Some(err.to_string()),
1241 failure_reason: Some(SubAgentFailureReason::RuntimeError),
1242 };
1243 }
1244 };
1245
1246 match loop_runner
1247 .run_with_question_tool(
1248 Message {
1249 role: Role::User,
1250 content: request.prompt,
1251 attachments: Vec::new(),
1252 tool_call_id: None,
1253 },
1254 |_tool_name| Ok(true),
1255 |_questions| async {
1256 anyhow::bail!("question tool is not available in sub-agent mode")
1257 },
1258 )
1259 .await
1260 {
1261 Ok(output) => SubagentExecutionResult {
1262 status: SubagentStatus::Completed,
1263 summary: output,
1264 error: None,
1265 failure_reason: None,
1266 },
1267 Err(err) => SubagentExecutionResult {
1268 status: SubagentStatus::Failed,
1269 summary: "sub-agent execution failed".to_string(),
1270 error: Some(err.to_string()),
1271 failure_reason: Some(SubAgentFailureReason::RuntimeError),
1272 },
1273 }
1274}
1275
1276fn format_modalities(modalities: &[crate::config::settings::ModelModalityType]) -> String {
1277 modalities
1278 .iter()
1279 .map(std::string::ToString::to_string)
1280 .collect::<Vec<_>>()
1281 .join(",")
1282}
1283
1284async fn run_agent(
1285 settings: Settings,
1286 cwd: &std::path::Path,
1287 prompt: Message,
1288 model_ref: String,
1289 events: TuiEventSender,
1290 subagent_manager: Arc<SubagentManager>,
1291 options: AgentRunOptions,
1292) -> anyhow::Result<()> {
1293 validate_image_input_model_support(&settings, &model_ref, &prompt)?;
1294
1295 let event_sender = events.clone();
1296 let question_event_sender = event_sender.clone();
1297 let allow_questions = options.allow_questions;
1298 let parent_session_id = options.session_id.clone();
1299 let loop_runner = create_agent_loop(
1300 settings,
1301 cwd,
1302 &model_ref,
1303 events,
1304 AgentLoopOptions {
1305 subagent_manager: Some(Arc::clone(&subagent_manager)),
1306 parent_task_id: None,
1307 depth: 0,
1308 session_id: options.session_id,
1309 session_title: options.session_title,
1310 session_parent_id: None,
1311 },
1312 )?;
1313 loop_runner
1314 .run_with_question_tool(
1315 prompt,
1316 |_tool_name| {
1317 Ok(true)
1319 },
1320 move |questions| {
1321 let event_sender = question_event_sender.clone();
1322 async move {
1323 if !allow_questions {
1324 anyhow::bail!("question tool is not available in headless debug mode")
1325 }
1326 let (tx, rx) = oneshot::channel();
1327 event_sender.send(TuiEvent::QuestionPrompt {
1328 questions,
1329 responder: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))),
1330 });
1331 rx.await
1332 .unwrap_or_else(|_| Err(anyhow::anyhow!("question prompt was cancelled")))
1333 }
1334 },
1335 )
1336 .await?;
1337
1338 if let Some(parent_session_id) = parent_session_id.as_deref() {
1339 loop {
1340 let nodes = subagent_manager.list_for_parent(parent_session_id).await;
1341 event_sender.send(TuiEvent::SubagentsChanged(
1342 nodes.iter().map(map_subagent_node_event).collect(),
1343 ));
1344
1345 if nodes.iter().all(|node| node.status.is_terminal()) {
1346 break;
1347 }
1348
1349 tokio::time::sleep(Duration::from_millis(50)).await;
1350 }
1351 }
1352
1353 Ok(())
1354}
1355
1356fn map_subagent_node_event(
1357 node: &crate::core::agent::subagent_manager::SubagentNode,
1358) -> tui::SubagentEventItem {
1359 let status = node.status.label().to_string();
1360
1361 let finished_at = if node.status.is_terminal() {
1362 Some(node.updated_at)
1363 } else {
1364 None
1365 };
1366
1367 tui::SubagentEventItem {
1368 task_id: node.task_id.clone(),
1369 name: node.name.clone(),
1370 agent_name: node.agent_name.clone(),
1371 status,
1372 prompt: node.prompt.clone(),
1373 depth: node.depth,
1374 parent_task_id: node.parent_task_id.clone(),
1375 started_at: node.started_at,
1376 finished_at,
1377 summary: node.summary.clone(),
1378 error: node.error.clone(),
1379 }
1380}
1381
1382fn validate_image_input_model_support(
1383 settings: &Settings,
1384 model_ref: &str,
1385 prompt: &Message,
1386) -> anyhow::Result<()> {
1387 if prompt.attachments.is_empty() {
1388 return Ok(());
1389 }
1390
1391 let selected = settings
1392 .resolve_model_ref(model_ref)
1393 .with_context(|| format!("unknown model reference: {model_ref}"))?;
1394 let supports_image_input = selected
1395 .model
1396 .modalities
1397 .input
1398 .contains(&crate::config::settings::ModelModalityType::Image);
1399
1400 if supports_image_input {
1401 return Ok(());
1402 }
1403
1404 anyhow::bail!(
1405 "Model `{model_ref}` does not support image input (input modalities: {}).",
1406 format_modalities(&selected.model.modalities.input)
1407 )
1408}
1409
1410pub async fn run_single_prompt(
1411 settings: Settings,
1412 cwd: &std::path::Path,
1413 prompt: String,
1414) -> anyhow::Result<String> {
1415 run_single_prompt_with_events(settings, cwd, prompt, NoopEvents).await
1416}
1417
1418pub async fn run_single_prompt_with_events<E>(
1419 settings: Settings,
1420 cwd: &std::path::Path,
1421 prompt: String,
1422 events: E,
1423) -> anyhow::Result<String>
1424where
1425 E: AgentEvents,
1426{
1427 let default_model_ref = settings.selected_model_ref().to_string();
1428 let session_id = Uuid::new_v4().to_string();
1429 let fallback_title = fallback_session_title(&prompt);
1430
1431 {
1432 let settings = settings.clone();
1433 let cwd = cwd.to_path_buf();
1434 let session_id = session_id.clone();
1435 let model_ref = default_model_ref.clone();
1436 let prompt = prompt.clone();
1437 tokio::spawn(async move {
1438 let generated = match generate_session_title(&settings, &model_ref, &prompt).await {
1439 Ok(title) => title,
1440 Err(_) => return,
1441 };
1442
1443 let store =
1444 match SessionStore::new(&settings.session.root, &cwd, Some(&session_id), None) {
1445 Ok(store) => store,
1446 Err(_) => return,
1447 };
1448
1449 let _ = store.update_title(generated);
1450 });
1451 }
1452
1453 let loop_runner = create_agent_loop(
1454 settings.clone(),
1455 cwd,
1456 &default_model_ref,
1457 events,
1458 AgentLoopOptions {
1459 subagent_manager: Some(current_subagent_manager(&settings, cwd)),
1460 parent_task_id: None,
1461 depth: 0,
1462 session_id: Some(session_id),
1463 session_title: Some(fallback_title),
1464 session_parent_id: None,
1465 },
1466 )?;
1467
1468 loop_runner
1469 .run_with_question_tool(
1470 Message {
1471 role: Role::User,
1472 content: prompt,
1473 attachments: Vec::new(),
1474 tool_call_id: None,
1475 },
1476 |tool_name| {
1477 Ok(render::confirm(&format!(
1478 "Allow tool '{}' execution?",
1479 tool_name
1480 ))?)
1481 },
1482 |questions| async move { Ok(render::ask_questions(&questions)?) },
1483 )
1484 .await
1485}
1486
1487fn create_agent_loop<E>(
1488 settings: Settings,
1489 cwd: &std::path::Path,
1490 model_ref: &str,
1491 events: E,
1492 options: AgentLoopOptions,
1493) -> anyhow::Result<
1494 AgentLoop<OpenAiCompatibleProvider, E, ToolRegistry, PermissionMatcher, SessionStore>,
1495>
1496where
1497 E: AgentEvents,
1498{
1499 let AgentLoopOptions {
1500 subagent_manager,
1501 parent_task_id,
1502 depth,
1503 session_id,
1504 session_title,
1505 session_parent_id,
1506 } = options;
1507
1508 let selected = settings
1509 .resolve_model_ref(model_ref)
1510 .with_context(|| format!("unknown model reference: {model_ref}"))?;
1511 let provider = OpenAiCompatibleProvider::new(
1512 selected.provider.base_url.clone(),
1513 selected.model.id.clone(),
1514 selected.provider.api_key_env.clone(),
1515 );
1516
1517 let session = match session_parent_id {
1518 Some(parent_session_id) => SessionStore::new_with_parent(
1519 &settings.session.root,
1520 cwd,
1521 session_id.as_deref(),
1522 session_title,
1523 Some(parent_session_id),
1524 )?,
1525 None => SessionStore::new(
1526 &settings.session.root,
1527 cwd,
1528 session_id.as_deref(),
1529 session_title,
1530 )?,
1531 };
1532
1533 let tool_context = if let Some(manager) = subagent_manager {
1534 ToolRegistryContext {
1535 task: Some(TaskToolRuntimeContext {
1536 manager,
1537 settings: settings.clone(),
1538 workspace_root: cwd.to_path_buf(),
1539 parent_session_id: session.id.clone(),
1540 parent_task_id,
1541 depth,
1542 }),
1543 }
1544 } else {
1545 ToolRegistryContext::default()
1546 };
1547
1548 let tool_registry = ToolRegistry::new_with_context(&settings, cwd, tool_context);
1549 let tool_schemas = tool_registry.schemas();
1550 let permissions = PermissionMatcher::new(settings.clone(), &tool_schemas);
1551
1552 Ok(AgentLoop {
1553 provider,
1554 tools: tool_registry,
1555 approvals: permissions,
1556 max_steps: settings.agent.max_steps,
1557 model: selected.model.id.clone(),
1558 system_prompt: settings.agent.resolved_system_prompt(),
1559 session,
1560 events,
1561 })
1562}
1563
1564use anyhow::Context;
1565
1566fn handle_submitted_input(
1567 input: SubmittedInput,
1568 app: &mut ChatApp,
1569 settings: &Settings,
1570 cwd: &Path,
1571 event_sender: &TuiEventSender,
1572) {
1573 if input.text.starts_with('/') && input.attachments.is_empty() {
1574 if let Some(tui::ChatMessage::User(last)) = app.messages.last()
1575 && last == &input.text
1576 {
1577 app.messages.pop();
1578 app.mark_dirty();
1579 }
1580 handle_slash_command(input.text, app, settings, cwd, event_sender);
1581 } else if app.is_picking_session {
1582 if let Err(e) = handle_session_selection(input.text, app, settings, cwd) {
1583 app.messages
1584 .push(tui::ChatMessage::Assistant(e.to_string()));
1585 app.mark_dirty();
1586 }
1587 app.set_processing(false);
1588 } else {
1589 handle_chat_message(input, app, settings, cwd, event_sender);
1590 }
1591}
1592
1593fn handle_slash_command(
1594 input: String,
1595 app: &mut ChatApp,
1596 settings: &Settings,
1597 cwd: &Path,
1598 event_sender: &TuiEventSender,
1599) {
1600 let scoped_sender = event_sender.scoped(app.session_epoch(), app.run_epoch());
1601 let mut parts = input.split_whitespace();
1602 let command = parts.next().unwrap_or_default();
1603
1604 match command {
1605 "/new" => {
1606 app.start_new_session(build_session_name(cwd));
1607 finish_idle(app);
1608 }
1609 "/model" => {
1610 if let Some(model_ref) = parts.next() {
1611 if let Some(model) = settings.resolve_model_ref(model_ref) {
1612 app.set_selected_model(model_ref);
1613 finish_with_assistant(
1614 app,
1615 format!(
1616 "Switched to {} ({} -> {}, context: {}, output: {})",
1617 model_ref,
1618 format_modalities(&model.model.modalities.input),
1619 format_modalities(&model.model.modalities.output),
1620 model.model.limits.context,
1621 model.model.limits.output
1622 ),
1623 );
1624 } else {
1625 finish_with_assistant(app, format!("Unknown model: {model_ref}"));
1626 }
1627 } else {
1628 let mut text = format!(
1629 "Current model: {}\n\nAvailable models:\n",
1630 app.selected_model_ref()
1631 );
1632 for option in &app.available_models {
1633 text.push_str(&format!(
1634 "- {} ({}, context: {} tokens)\n",
1635 option.full_id, option.modality, option.max_context_size
1636 ));
1637 }
1638 text.push_str("\nUse /model <provider-id/model-id> to switch.");
1639 finish_with_assistant(app, text);
1640 }
1641 }
1642 "/compact" => {
1643 let Some(session_id) = app.session_id.clone() else {
1644 finish_with_assistant(app, "No active session to compact yet.");
1645 return;
1646 };
1647 let model_ref = app.selected_model_ref().to_string();
1648
1649 app.handle_event(&TuiEvent::CompactionStart);
1650
1651 if let Ok(handle) = tokio::runtime::Handle::try_current() {
1652 let settings = settings.clone();
1653 let cwd = cwd.to_path_buf();
1654 let sender = scoped_sender.clone();
1655 handle.spawn(async move {
1656 match compact_session_with_llm(settings, &cwd, &session_id, &model_ref).await {
1657 Ok(summary) => sender.send(TuiEvent::CompactionDone(summary)),
1658 Err(e) => sender.send(TuiEvent::Error(format!("Failed to compact: {e}"))),
1659 }
1660 });
1661 } else {
1662 let result = tokio::runtime::Builder::new_current_thread()
1663 .enable_all()
1664 .build()
1665 .context("Failed to create runtime for compaction")
1666 .and_then(|rt| {
1667 rt.block_on(compact_session_with_llm(
1668 settings.clone(),
1669 cwd,
1670 &session_id,
1671 &model_ref,
1672 ))
1673 });
1674
1675 match result {
1676 Ok(summary) => {
1677 app.handle_event(&TuiEvent::CompactionDone(summary));
1678 }
1679 Err(e) => {
1680 app.handle_event(&TuiEvent::Error(format!("Failed to compact: {e}")));
1681 }
1682 }
1683 }
1684 }
1685 "/quit" => {
1686 app.should_quit = true;
1687 }
1688 "/resume" => {
1689 let sessions = SessionStore::list(&settings.session.root, cwd).unwrap_or_default();
1690 if sessions.is_empty() {
1691 finish_with_assistant(app, "No previous sessions found.");
1692 } else {
1693 app.available_sessions = sessions;
1694 app.is_picking_session = true;
1695
1696 let mut msg = String::from("Available sessions:\n");
1697 for (i, s) in app.available_sessions.iter().enumerate() {
1698 msg.push_str(&format!("[{}] {}\n", i + 1, s.title));
1699 }
1700 msg.push_str("\nEnter number to resume:");
1701 finish_with_assistant(app, msg);
1702 }
1703 }
1704 _ => {
1705 finish_with_assistant(app, format!("Unknown command: {}", input));
1706 }
1707 }
1708}
1709
1710fn finish_with_assistant(app: &mut ChatApp, message: impl Into<String>) {
1711 app.messages
1712 .push(tui::ChatMessage::Assistant(message.into()));
1713 finish_idle(app);
1714}
1715
1716fn finish_idle(app: &mut ChatApp) {
1717 app.mark_dirty();
1718 app.set_processing(false);
1719}
1720
1721async fn compact_session_with_llm(
1722 settings: Settings,
1723 cwd: &Path,
1724 session_id: &str,
1725 model_ref: &str,
1726) -> anyhow::Result<String> {
1727 let store = SessionStore::new(&settings.session.root, cwd, Some(session_id), None)
1728 .context("Failed to load session store")?;
1729 let messages = store
1730 .replay_messages()
1731 .context("Failed to replay session for compaction")?;
1732
1733 if messages.is_empty() {
1734 return Ok("No prior context to compact yet.".to_string());
1735 }
1736
1737 let summary = generate_compaction_summary(&settings, messages, model_ref).await?;
1738 store
1739 .append(&SessionEvent::Compact {
1740 id: event_id(),
1741 summary: summary.clone(),
1742 })
1743 .context("Failed to append compact marker")?;
1744
1745 Ok(summary)
1746}
1747
1748async fn generate_compaction_summary(
1749 settings: &Settings,
1750 messages: Vec<Message>,
1751 model_ref: &str,
1752) -> anyhow::Result<String> {
1753 #[cfg(test)]
1754 {
1755 let _ = settings;
1756 let _ = messages;
1757 let _ = model_ref;
1758 Ok("Compacted context summary for tests.".to_string())
1759 }
1760
1761 #[cfg(not(test))]
1762 {
1763 let mut prompt_messages = Vec::with_capacity(messages.len() + 2);
1764 prompt_messages.push(Message {
1765 role: crate::core::Role::System,
1766 content: "You compact conversation history for an engineering assistant. Produce a concise summary that preserves requirements, decisions, constraints, open questions, and pending work items. Prefer bullet points. Do not invent details.".to_string(),
1767 attachments: Vec::new(),
1768 tool_call_id: None,
1769 });
1770 prompt_messages.extend(messages);
1771 prompt_messages.push(Message {
1772 role: crate::core::Role::User,
1773 content: "Compact the conversation so future turns can continue from this summary with minimal context loss.".to_string(),
1774 attachments: Vec::new(),
1775 tool_call_id: None,
1776 });
1777
1778 let selected = settings
1779 .resolve_model_ref(model_ref)
1780 .with_context(|| format!("model is not configured: {model_ref}"))?;
1781
1782 let provider = OpenAiCompatibleProvider::new(
1783 selected.provider.base_url.clone(),
1784 selected.model.id.clone(),
1785 selected.provider.api_key_env.clone(),
1786 );
1787
1788 let response = crate::core::Provider::complete(
1789 &provider,
1790 crate::core::ProviderRequest {
1791 model: selected.model.id.clone(),
1792 messages: prompt_messages,
1793 tools: Vec::new(),
1794 },
1795 )
1796 .await
1797 .context("Compaction request failed")?;
1798
1799 if !response.tool_calls.is_empty() {
1800 anyhow::bail!("Compaction response unexpectedly requested tools");
1801 }
1802
1803 let summary = response.assistant_message.content.trim().to_string();
1804 if summary.is_empty() {
1805 anyhow::bail!("Compaction response was empty");
1806 }
1807
1808 Ok(summary)
1809 }
1810}
1811
1812fn handle_session_selection(
1813 input: String,
1814 app: &mut ChatApp,
1815 settings: &Settings,
1816 cwd: &Path,
1817) -> anyhow::Result<()> {
1818 let idx = input.trim().parse::<usize>().context("Invalid number.")?;
1819
1820 if idx == 0 || idx > app.available_sessions.len() {
1821 anyhow::bail!("Invalid session index.");
1822 }
1823
1824 let session = app.available_sessions[idx - 1].clone();
1825 app.bump_session_epoch();
1826 app.session_id = Some(session.id.clone());
1827 app.session_name = session.title.clone();
1828 app.last_context_tokens = None;
1829 app.is_picking_session = false;
1830
1831 let store = SessionStore::new(&settings.session.root, cwd, Some(&session.id), None)
1832 .context("Failed to load session store")?;
1833
1834 let events = store.replay_events().context("Failed to replay session")?;
1835
1836 app.messages.clear();
1837 app.todo_items.clear();
1838 app.subagent_items.clear();
1839 let mut subagent_items_by_task: HashMap<String, tui::SubagentItemView> = HashMap::new();
1840 for event in events {
1841 match event {
1842 SessionEvent::Message { message, .. } => {
1843 let chat_msg = match message.role {
1844 crate::core::Role::User => tui::ChatMessage::User(message.content),
1845 crate::core::Role::Assistant => tui::ChatMessage::Assistant(message.content),
1846 _ => continue,
1847 };
1848 app.messages.push(chat_msg);
1849 }
1850 SessionEvent::ToolCall { call } => {
1851 app.messages.push(tui::ChatMessage::ToolCall {
1852 name: call.name,
1853 args: call.arguments.to_string(),
1854 output: None,
1855 is_error: None,
1856 });
1857 }
1858 SessionEvent::ToolResult {
1859 id: _,
1860 is_error,
1861 output,
1862 result,
1863 } => {
1864 let pending_tool_name = app.messages.iter().rev().find_map(|msg| match msg {
1865 tui::ChatMessage::ToolCall { name, output, .. } if output.is_none() => {
1866 Some(name.clone())
1867 }
1868 _ => None,
1869 });
1870 if let Some(name) = pending_tool_name {
1871 let replayed_result = result.unwrap_or_else(|| {
1872 if is_error {
1873 crate::tool::ToolResult::err_text("error", output)
1874 } else {
1875 crate::tool::ToolResult::ok_text("ok", output)
1876 }
1877 });
1878 app.handle_event(&tui::TuiEvent::ToolEnd {
1879 name,
1880 result: replayed_result,
1881 });
1882 }
1883 }
1884 SessionEvent::Thinking { content, .. } => {
1885 app.messages.push(tui::ChatMessage::Thinking(content));
1886 }
1887 SessionEvent::Compact { summary, .. } => {
1888 app.messages.push(tui::ChatMessage::Compaction(summary));
1889 }
1890 SessionEvent::SubAgentStart {
1891 id,
1892 task_id,
1893 name,
1894 parent_id,
1895 agent_name,
1896 prompt,
1897 depth,
1898 created_at,
1899 status,
1900 ..
1901 } => {
1902 let task_id = task_id.unwrap_or(id);
1903 subagent_items_by_task.insert(
1904 task_id.clone(),
1905 tui::SubagentItemView {
1906 task_id,
1907 name: name
1908 .or_else(|| agent_name.clone())
1909 .unwrap_or_else(|| "subagent".to_string()),
1910 parent_task_id: parent_id,
1911 agent_name: agent_name.unwrap_or_else(|| "subagent".to_string()),
1912 prompt,
1913 summary: None,
1914 depth,
1915 started_at: created_at,
1916 finished_at: None,
1917 status: tui::SubagentStatusView::from_lifecycle(status),
1918 },
1919 );
1920 }
1921 SessionEvent::SubAgentResult {
1922 id,
1923 task_id,
1924 status,
1925 summary,
1926 output,
1927 ..
1928 } => {
1929 let task_id = task_id.unwrap_or(id);
1930 let entry = subagent_items_by_task
1931 .entry(task_id.clone())
1932 .or_insert_with(|| tui::SubagentItemView {
1933 task_id,
1934 name: "subagent".to_string(),
1935 parent_task_id: None,
1936 agent_name: "subagent".to_string(),
1937 prompt: String::new(),
1938 summary: None,
1939 depth: 0,
1940 started_at: 0,
1941 finished_at: None,
1942 status: tui::SubagentStatusView::Running,
1943 });
1944 entry.status = tui::SubagentStatusView::from_lifecycle(status);
1945 if entry.status.is_terminal() {
1946 entry.finished_at = Some(entry.started_at);
1947 }
1948 entry.summary = if let Some(summary) = summary {
1949 Some(summary)
1950 } else if output.trim().is_empty() {
1951 None
1952 } else {
1953 Some(output)
1954 };
1955 }
1956 _ => {}
1957 }
1958 }
1959 app.subagent_items = subagent_items_by_task.into_values().collect();
1960 for item in &mut app.subagent_items {
1961 if item.status.is_active() {
1962 item.status = tui::SubagentStatusView::Failed;
1963 if item.summary.is_none() {
1964 item.summary = Some("interrupted_by_restart".to_string());
1965 }
1966 }
1967 }
1968 app.mark_dirty();
1969
1970 Ok(())
1971}
1972
1973fn handle_chat_message(
1974 input: SubmittedInput,
1975 app: &mut ChatApp,
1976 settings: &Settings,
1977 cwd: &Path,
1978 event_sender: &TuiEventSender,
1979) {
1980 if !input.text.is_empty() || !input.attachments.is_empty() {
1981 app.cancel_agent_task();
1984
1985 let scoped_sender = event_sender.scoped(app.session_epoch(), app.run_epoch());
1986 let session_id = app.session_id.clone();
1987 let session_title = if session_id.is_none() {
1988 Some(fallback_session_title(&input.text))
1989 } else {
1990 None
1991 };
1992
1993 let current_session_id = session_id.unwrap_or_else(|| Uuid::new_v4().to_string());
1994 if app.session_id.is_none() {
1995 app.session_id = Some(current_session_id.clone());
1996 if let Some(t) = &session_title {
1997 app.session_name = t.clone();
1998 }
1999 if !input.text.trim().is_empty() {
2000 spawn_session_title_generation_task(
2001 settings,
2002 cwd,
2003 current_session_id.clone(),
2004 app.selected_model_ref().to_string(),
2005 input.text.clone(),
2006 &scoped_sender,
2007 );
2008 }
2009 }
2010
2011 let message = Message {
2012 role: crate::core::Role::User,
2013 content: input.text,
2014 attachments: input.attachments,
2015 tool_call_id: None,
2016 };
2017
2018 let subagent_manager = current_subagent_manager(settings, cwd);
2019 let handle = spawn_agent_task(
2020 settings,
2021 cwd,
2022 message,
2023 app.selected_model_ref().to_string(),
2024 &scoped_sender,
2025 subagent_manager,
2026 AgentRunOptions {
2027 session_id: Some(current_session_id),
2028 session_title,
2029 allow_questions: true,
2030 },
2031 );
2032 app.set_agent_task(handle);
2033 } else {
2034 app.set_processing(false);
2035 }
2036}
2037
2038fn fallback_session_title(prompt: &str) -> String {
2039 let trimmed = prompt.trim();
2040 if trimmed.is_empty() {
2041 return "Image input".to_string();
2042 }
2043
2044 trimmed
2045 .split_whitespace()
2046 .take(12)
2047 .collect::<Vec<_>>()
2048 .join(" ")
2049}
2050
2051fn normalize_session_title(raw: &str, fallback: &str) -> String {
2052 let cleaned = raw
2053 .lines()
2054 .next()
2055 .unwrap_or_default()
2056 .trim()
2057 .trim_matches('"')
2058 .trim_matches('`')
2059 .split_whitespace()
2060 .take(12)
2061 .collect::<Vec<_>>()
2062 .join(" ");
2063
2064 if cleaned.is_empty() {
2065 fallback.to_string()
2066 } else {
2067 cleaned
2068 }
2069}
2070
2071fn spawn_session_title_generation_task(
2072 settings: &Settings,
2073 cwd: &Path,
2074 session_id: String,
2075 model_ref: String,
2076 prompt: String,
2077 event_sender: &TuiEventSender,
2078) {
2079 let settings = settings.clone();
2080 let cwd = cwd.to_path_buf();
2081 let event_sender = event_sender.clone();
2082 tokio::spawn(async move {
2083 let fallback = fallback_session_title(&prompt);
2084 let generated = match generate_session_title(&settings, &model_ref, &prompt).await {
2085 Ok(title) => title,
2086 Err(_) => return,
2087 };
2088
2089 let store = match SessionStore::new(&settings.session.root, &cwd, Some(&session_id), None) {
2090 Ok(store) => store,
2091 Err(_) => return,
2092 };
2093
2094 let title = normalize_session_title(&generated, &fallback);
2095 if store.update_title(title.clone()).is_ok() {
2096 event_sender.send(TuiEvent::SessionTitle(title));
2097 }
2098 });
2099}
2100
2101async fn generate_session_title(
2102 settings: &Settings,
2103 model_ref: &str,
2104 prompt: &str,
2105) -> anyhow::Result<String> {
2106 #[cfg(test)]
2107 {
2108 let _ = settings;
2109 let _ = model_ref;
2110 Ok(normalize_session_title(
2111 "Generated test title",
2112 &fallback_session_title(prompt),
2113 ))
2114 }
2115
2116 #[cfg(not(test))]
2117 {
2118 let selected = settings
2119 .resolve_model_ref(model_ref)
2120 .with_context(|| format!("model is not configured: {model_ref}"))?;
2121
2122 let provider = OpenAiCompatibleProvider::new(
2123 selected.provider.base_url.clone(),
2124 selected.model.id.clone(),
2125 selected.provider.api_key_env.clone(),
2126 );
2127
2128 let request = crate::core::ProviderRequest {
2129 model: selected.model.id.clone(),
2130 messages: vec![
2131 Message {
2132 role: crate::core::Role::System,
2133 content: "Generate a concise session title for this prompt. Return only the title, no punctuation wrappers, and keep it to 12 words or fewer.".to_string(),
2134 attachments: Vec::new(),
2135 tool_call_id: None,
2136 },
2137 Message {
2138 role: crate::core::Role::User,
2139 content: prompt.to_string(),
2140 attachments: Vec::new(),
2141 tool_call_id: None,
2142 },
2143 ],
2144 tools: Vec::new(),
2145 };
2146
2147 let mut last_error: Option<anyhow::Error> = None;
2148 for attempt in 1..=3 {
2149 if attempt > 1 {
2150 tokio::time::sleep(Duration::from_millis(350 * attempt as u64)).await;
2151 }
2152
2153 match crate::core::Provider::complete_stream(&provider, request.clone(), |_| {}).await {
2154 Ok(response) => {
2155 if !response.tool_calls.is_empty() {
2156 anyhow::bail!("Session title response unexpectedly requested tools");
2157 }
2158
2159 let fallback = fallback_session_title(prompt);
2160 return Ok(normalize_session_title(
2161 &response.assistant_message.content,
2162 &fallback,
2163 ));
2164 }
2165 Err(err) => {
2166 last_error =
2167 Some(err.context(format!("title generation attempt {attempt}/3 failed")));
2168 }
2169 }
2170 }
2171
2172 let err = last_error.unwrap_or_else(|| anyhow::anyhow!("unknown title request failure"));
2173 Err(err).context("Session title request failed")
2174 }
2175}
2176
2177#[cfg(test)]
2178mod tests {
2179 use super::*;
2180 use crate::config::settings::{
2181 AgentSettings, ModelLimits, ModelMetadata, ModelModalities, ModelModalityType,
2182 ModelSettings, ProviderConfig, SessionSettings,
2183 };
2184 use crate::core::{Message, Role};
2185 use crossterm::event::{KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
2186 use std::collections::BTreeMap;
2187 use tempfile::tempdir;
2188
2189 fn create_dummy_settings(root: &Path) -> Settings {
2190 Settings {
2191 models: ModelSettings {
2192 default: "test/test-model".to_string(),
2193 },
2194 providers: BTreeMap::from([(
2195 "test".to_string(),
2196 ProviderConfig {
2197 display_name: "Test Provider".to_string(),
2198 base_url: "http://localhost:1234".to_string(),
2199 api_key_env: "TEST_KEY".to_string(),
2200 models: BTreeMap::from([(
2201 "test-model".to_string(),
2202 ModelMetadata {
2203 id: "provider-test-model".to_string(),
2204 display_name: "Test Model".to_string(),
2205 modalities: ModelModalities {
2206 input: vec![ModelModalityType::Text],
2207 output: vec![ModelModalityType::Text],
2208 },
2209 limits: ModelLimits {
2210 context: 64_000,
2211 output: 8_000,
2212 },
2213 },
2214 )]),
2215 },
2216 )]),
2217 agent: AgentSettings {
2218 max_steps: 10,
2219 sub_agent_max_depth: 2,
2220 parallel_subagents: false,
2221 max_parallel_subagents: 2,
2222 system_prompt: None,
2223 },
2224 session: SessionSettings {
2225 root: root.to_path_buf(),
2226 },
2227 tools: Default::default(),
2228 permission: Default::default(),
2229 selected_agent: None,
2230 agents: BTreeMap::new(),
2231 }
2232 }
2233
2234 #[test]
2235 fn test_resume_clears_processing() {
2236 let temp_dir = tempdir().unwrap();
2237 let settings = create_dummy_settings(temp_dir.path());
2238 let cwd = temp_dir.path();
2239
2240 let session_id = "test-session-id";
2242 let _store = SessionStore::new(
2243 &settings.session.root,
2244 cwd,
2245 Some(session_id),
2246 Some("Test Session".to_string()),
2247 )
2248 .unwrap();
2249
2250 let mut app = ChatApp::new("Session".to_string(), cwd);
2252 let (tx, _rx) = mpsc::unbounded_channel();
2253 let event_sender = TuiEventSender::new(tx);
2254
2255 app.set_input("/resume".to_string());
2257 let input = app.submit_input();
2259 assert!(app.is_processing);
2260
2261 handle_submitted_input(input, &mut app, &settings, cwd, &event_sender);
2262
2263 assert!(
2265 !app.is_processing,
2266 "Processing should be cleared after /resume lists sessions"
2267 );
2268 assert!(app.is_picking_session);
2269
2270 app.set_input("1".to_string());
2272 let input = app.submit_input();
2273 assert!(app.is_processing);
2274
2275 handle_submitted_input(input, &mut app, &settings, cwd, &event_sender);
2276
2277 assert!(
2279 !app.is_processing,
2280 "Processing should be cleared after picking session"
2281 );
2282 assert!(!app.is_picking_session);
2283 assert_eq!(app.session_name, "Test Session");
2287 }
2288
2289 #[test]
2290 fn test_session_selection_restores_todos_from_todo_write_and_replaces_stale_items() {
2291 let temp_dir = tempdir().unwrap();
2292 let settings = create_dummy_settings(temp_dir.path());
2293 let cwd = temp_dir.path();
2294
2295 let session_id = "todo-session-id";
2296 let store = SessionStore::new(
2297 &settings.session.root,
2298 cwd,
2299 Some(session_id),
2300 Some("Todo Session".to_string()),
2301 )
2302 .unwrap();
2303
2304 store
2305 .append(&SessionEvent::ToolCall {
2306 call: crate::core::ToolCall {
2307 id: "call-1".to_string(),
2308 name: "todo_write".to_string(),
2309 arguments: serde_json::json!({"todos": []}),
2310 },
2311 })
2312 .unwrap();
2313 store
2314 .append(&SessionEvent::ToolResult {
2315 id: "call-1".to_string(),
2316 is_error: false,
2317 output: "".to_string(),
2318 result: Some(crate::tool::ToolResult::ok_json_typed(
2319 "todo list updated",
2320 "application/vnd.hh.todo+json",
2321 serde_json::json!({
2322 "todos": [
2323 {"content": "Resume pending", "status": "pending", "priority": "medium"},
2324 {"content": "Resume done", "status": "completed", "priority": "high"}
2325 ],
2326 "counts": {"total": 2, "pending": 1, "in_progress": 0, "completed": 1, "cancelled": 0}
2327 }),
2328 )),
2329 })
2330 .unwrap();
2331
2332 let mut app = ChatApp::new("Session".to_string(), cwd);
2333 app.handle_event(&TuiEvent::ToolStart {
2334 name: "todo_write".to_string(),
2335 args: serde_json::json!({"todos": []}),
2336 });
2337 app.handle_event(&TuiEvent::ToolEnd {
2338 name: "todo_write".to_string(),
2339 result: crate::tool::ToolResult::ok_json_typed(
2340 "todo list updated",
2341 "application/vnd.hh.todo+json",
2342 serde_json::json!({
2343 "todos": [
2344 {"content": "Stale item", "status": "pending", "priority": "low"}
2345 ],
2346 "counts": {"total": 1, "pending": 1, "in_progress": 0, "completed": 0, "cancelled": 0}
2347 }),
2348 ),
2349 });
2350
2351 app.available_sessions = vec![crate::session::SessionMetadata {
2352 id: session_id.to_string(),
2353 title: "Todo Session".to_string(),
2354 created_at: 0,
2355 last_updated_at: 0,
2356 parent_session_id: None,
2357 }];
2358 app.is_picking_session = true;
2359
2360 handle_session_selection("1".to_string(), &mut app, &settings, cwd).unwrap();
2361
2362 let backend = ratatui::backend::TestBackend::new(120, 25);
2363 let mut terminal = ratatui::Terminal::new(backend).expect("terminal");
2364 terminal
2365 .draw(|frame| tui::render_app(frame, &app))
2366 .expect("draw app");
2367 let full_text = terminal
2368 .backend()
2369 .buffer()
2370 .content()
2371 .iter()
2372 .map(|cell| cell.symbol())
2373 .collect::<String>();
2374
2375 assert!(full_text.contains("TODO"));
2376 assert!(full_text.contains("1 / 2 done"));
2377 assert!(full_text.contains("[ ] Resume pending"));
2378 assert!(full_text.contains("[x] Resume done"));
2379 assert!(!full_text.contains("Stale item"));
2380 }
2381
2382 #[test]
2383 fn test_new_starts_fresh_session() {
2384 let temp_dir = tempdir().unwrap();
2385 let settings = create_dummy_settings(temp_dir.path());
2386 let cwd = temp_dir.path();
2387 let (tx, _rx) = mpsc::unbounded_channel();
2388 let event_sender = TuiEventSender::new(tx);
2389
2390 let mut app = ChatApp::new("Session".to_string(), cwd);
2391 app.session_id = Some("existing-session".to_string());
2392 app.session_name = "Existing Session".to_string();
2393 app.messages
2394 .push(tui::ChatMessage::Assistant("previous context".to_string()));
2395
2396 app.set_input("/new".to_string());
2397 let input = app.submit_input();
2398 handle_submitted_input(input, &mut app, &settings, cwd, &event_sender);
2399
2400 assert!(!app.is_processing);
2401 assert!(app.session_id.is_none());
2402 assert_eq!(app.session_name, build_session_name(cwd));
2403 assert!(app.messages.is_empty());
2404 }
2405
2406 #[test]
2407 fn test_new_session_ignores_stale_scoped_events() {
2408 let temp_dir = tempdir().unwrap();
2409 let cwd = temp_dir.path();
2410 let mut app = ChatApp::new("Session".to_string(), cwd);
2411 let (tx, mut rx) = mpsc::unbounded_channel();
2412 let event_sender = TuiEventSender::new(tx);
2413
2414 let old_scope_sender = event_sender.scoped(app.session_epoch(), app.run_epoch());
2415 app.start_new_session("New Session".to_string());
2416
2417 old_scope_sender.send(TuiEvent::AssistantDelta("stale".to_string()));
2418 let stale_event = rx.blocking_recv().unwrap();
2419 if stale_event.session_epoch == app.session_epoch()
2420 && stale_event.run_epoch == app.run_epoch()
2421 {
2422 app.handle_event(&stale_event.event);
2423 }
2424 assert!(app.messages.is_empty());
2425
2426 let current_scope_sender = event_sender.scoped(app.session_epoch(), app.run_epoch());
2427 current_scope_sender.send(TuiEvent::AssistantDelta("fresh".to_string()));
2428 let fresh_event = rx.blocking_recv().unwrap();
2429 if fresh_event.session_epoch == app.session_epoch()
2430 && fresh_event.run_epoch == app.run_epoch()
2431 {
2432 app.handle_event(&fresh_event.event);
2433 }
2434
2435 assert!(matches!(
2436 app.messages.first(),
2437 Some(tui::ChatMessage::Assistant(text)) if text == "fresh"
2438 ));
2439 }
2440
2441 #[test]
2442 fn test_set_agent_task_without_existing_task_keeps_run_epoch_and_allows_events() {
2443 let temp_dir = tempdir().unwrap();
2444 let cwd = temp_dir.path();
2445 let mut app = ChatApp::new("Session".to_string(), cwd);
2446 let (tx, mut rx) = mpsc::unbounded_channel();
2447 let event_sender = TuiEventSender::new(tx);
2448 app.set_processing(true);
2449
2450 let initial_run_epoch = app.run_epoch();
2451 let scoped_sender = event_sender.scoped(app.session_epoch(), app.run_epoch());
2452
2453 let runtime = tokio::runtime::Builder::new_current_thread()
2454 .enable_all()
2455 .build()
2456 .expect("runtime");
2457 let handle = runtime.block_on(async { tokio::spawn(async {}) });
2458 app.set_agent_task(handle);
2459
2460 assert_eq!(app.run_epoch(), initial_run_epoch);
2461
2462 scoped_sender.send(TuiEvent::AssistantDone);
2463 let event = rx.blocking_recv().expect("event");
2464 if event.session_epoch == app.session_epoch() && event.run_epoch == app.run_epoch() {
2465 app.handle_event(&event.event);
2466 }
2467
2468 assert!(!app.is_processing);
2469 app.cancel_agent_task();
2470 }
2471
2472 #[test]
2473 fn test_compact_appends_marker_and_clears_replayed_context() {
2474 let temp_dir = tempdir().unwrap();
2475 let settings = create_dummy_settings(temp_dir.path());
2476 let cwd = temp_dir.path();
2477 let (tx, _rx) = mpsc::unbounded_channel();
2478 let event_sender = TuiEventSender::new(tx);
2479
2480 let session_id = "compact-session-id";
2481 let store = SessionStore::new(
2482 &settings.session.root,
2483 cwd,
2484 Some(session_id),
2485 Some("Compact Session".to_string()),
2486 )
2487 .unwrap();
2488 store
2489 .append(&SessionEvent::Message {
2490 id: event_id(),
2491 message: Message {
2492 role: Role::User,
2493 content: "hello".to_string(),
2494 attachments: Vec::new(),
2495 tool_call_id: None,
2496 },
2497 })
2498 .unwrap();
2499
2500 let mut app = ChatApp::new("Session".to_string(), cwd);
2501 app.session_id = Some(session_id.to_string());
2502 app.session_name = "Compact Session".to_string();
2503 app.messages
2504 .push(tui::ChatMessage::Assistant("previous context".to_string()));
2505
2506 app.set_input("/compact".to_string());
2507 let input = app.submit_input();
2508 handle_submitted_input(input, &mut app, &settings, cwd, &event_sender);
2509
2510 assert!(!app.is_processing);
2511 assert_eq!(app.messages.len(), 2);
2512 assert!(matches!(
2513 app.messages[0],
2514 tui::ChatMessage::Assistant(ref text) if text == "previous context"
2515 ));
2516 assert!(matches!(
2517 app.messages[1],
2518 tui::ChatMessage::Compaction(ref text)
2519 if text == "Compacted context summary for tests."
2520 ));
2521
2522 let store = SessionStore::new(&settings.session.root, cwd, Some(session_id), None).unwrap();
2523 let replayed_events = store.replay_events().unwrap();
2524 assert_eq!(replayed_events.len(), 2);
2525 assert!(matches!(
2526 replayed_events[1],
2527 SessionEvent::Compact { ref summary, .. } if summary == "Compacted context summary for tests."
2528 ));
2529
2530 let replayed_messages = store.replay_messages().unwrap();
2531 assert_eq!(replayed_messages.len(), 1);
2532 assert_eq!(
2533 replayed_messages[0].content,
2534 "Compacted context summary for tests."
2535 );
2536 }
2537
2538 #[test]
2539 fn test_esc_requires_two_presses_to_interrupt_processing() {
2540 let temp_dir = tempdir().unwrap();
2541 let settings = create_dummy_settings(temp_dir.path());
2542 let cwd = temp_dir.path();
2543 let (tx, _rx) = mpsc::unbounded_channel();
2544 let event_sender = TuiEventSender::new(tx);
2545 let mut app = ChatApp::new("Session".to_string(), cwd);
2546 app.set_processing(true);
2547
2548 handle_key_event(
2549 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
2550 &mut app,
2551 &settings,
2552 cwd,
2553 &event_sender,
2554 || Ok((120, 40)),
2555 )
2556 .unwrap();
2557
2558 assert!(app.is_processing);
2559 assert!(app.should_interrupt_on_esc());
2560 assert_eq!(app.processing_interrupt_hint(), "esc again to interrupt");
2561
2562 handle_key_event(
2563 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
2564 &mut app,
2565 &settings,
2566 cwd,
2567 &event_sender,
2568 || Ok((120, 40)),
2569 )
2570 .unwrap();
2571
2572 assert!(!app.is_processing);
2573 assert!(!app.should_interrupt_on_esc());
2574 assert_eq!(app.processing_interrupt_hint(), "esc interrupt");
2575 }
2576
2577 #[test]
2578 fn test_non_esc_key_clears_pending_interrupt_confirmation() {
2579 let temp_dir = tempdir().unwrap();
2580 let settings = create_dummy_settings(temp_dir.path());
2581 let cwd = temp_dir.path();
2582 let (tx, _rx) = mpsc::unbounded_channel();
2583 let event_sender = TuiEventSender::new(tx);
2584 let mut app = ChatApp::new("Session".to_string(), cwd);
2585 app.set_processing(true);
2586
2587 handle_key_event(
2588 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
2589 &mut app,
2590 &settings,
2591 cwd,
2592 &event_sender,
2593 || Ok((120, 40)),
2594 )
2595 .unwrap();
2596 assert!(app.should_interrupt_on_esc());
2597
2598 handle_key_event(
2599 KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
2600 &mut app,
2601 &settings,
2602 cwd,
2603 &event_sender,
2604 || Ok((120, 40)),
2605 )
2606 .unwrap();
2607
2608 assert!(app.is_processing);
2609 assert!(!app.should_interrupt_on_esc());
2610 assert_eq!(app.processing_interrupt_hint(), "esc interrupt");
2611
2612 handle_key_event(
2613 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
2614 &mut app,
2615 &settings,
2616 cwd,
2617 &event_sender,
2618 || Ok((120, 40)),
2619 )
2620 .unwrap();
2621
2622 assert!(app.is_processing);
2623 assert!(app.should_interrupt_on_esc());
2624 assert_eq!(app.processing_interrupt_hint(), "esc again to interrupt");
2625 }
2626
2627 #[test]
2628 fn test_cancelled_run_ignores_queued_events_from_previous_run_epoch() {
2629 let temp_dir = tempdir().unwrap();
2630 let settings = create_dummy_settings(temp_dir.path());
2631 let cwd = temp_dir.path();
2632 let (tx, mut rx) = mpsc::unbounded_channel();
2633 let event_sender = TuiEventSender::new(tx);
2634 let mut app = ChatApp::new("Session".to_string(), cwd);
2635 app.set_processing(true);
2636
2637 let runtime = tokio::runtime::Builder::new_current_thread()
2638 .enable_all()
2639 .build()
2640 .expect("runtime");
2641 let handle = runtime.block_on(async {
2642 tokio::spawn(async {
2643 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2644 })
2645 });
2646 app.set_agent_task(handle);
2647
2648 let old_scope_sender = event_sender.scoped(app.session_epoch(), app.run_epoch());
2649
2650 handle_key_event(
2651 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
2652 &mut app,
2653 &settings,
2654 cwd,
2655 &event_sender,
2656 || Ok((120, 40)),
2657 )
2658 .unwrap();
2659 handle_key_event(
2660 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
2661 &mut app,
2662 &settings,
2663 cwd,
2664 &event_sender,
2665 || Ok((120, 40)),
2666 )
2667 .unwrap();
2668
2669 assert!(!app.is_processing);
2670
2671 old_scope_sender.send(TuiEvent::AssistantDelta("stale-stream".to_string()));
2672 let stale_event = rx.blocking_recv().unwrap();
2673 if stale_event.session_epoch == app.session_epoch()
2674 && stale_event.run_epoch == app.run_epoch()
2675 {
2676 app.handle_event(&stale_event.event);
2677 }
2678
2679 assert!(!app.messages.iter().any(
2680 |message| matches!(message, tui::ChatMessage::Assistant(text) if text.contains("stale-stream"))
2681 ));
2682
2683 app.cancel_agent_task();
2684 }
2685
2686 #[test]
2687 fn test_replacing_finished_task_scopes_events_to_new_run_epoch() {
2688 let temp_dir = tempdir().unwrap();
2689 let settings = create_dummy_settings(temp_dir.path());
2690 let cwd = temp_dir.path();
2691 let (tx, mut rx) = mpsc::unbounded_channel();
2692 let event_sender = TuiEventSender::new(tx);
2693 let mut app = ChatApp::new("Session".to_string(), cwd);
2694
2695 let runtime = tokio::runtime::Builder::new_current_thread()
2696 .enable_all()
2697 .build()
2698 .expect("runtime");
2699
2700 let first_handle = runtime.block_on(async { tokio::spawn(async {}) });
2701 app.set_agent_task(first_handle);
2702 app.set_processing(true);
2703
2704 let submitted = SubmittedInput {
2705 text: "follow-up".to_string(),
2706 attachments: vec![crate::core::MessageAttachment::Image {
2707 media_type: "image/png".to_string(),
2708 data_base64: "aGVsbG8=".to_string(),
2709 }],
2710 };
2711
2712 let _enter = runtime.enter();
2713 handle_chat_message(submitted, &mut app, &settings, cwd, &event_sender);
2714 drop(_enter);
2715
2716 runtime.block_on(async {
2717 tokio::time::sleep(std::time::Duration::from_millis(60)).await;
2718 });
2719
2720 while let Ok(event) = rx.try_recv() {
2721 if event.session_epoch == app.session_epoch() && event.run_epoch == app.run_epoch() {
2722 app.handle_event(&event.event);
2723 }
2724 }
2725
2726 assert!(
2727 app.messages
2728 .iter()
2729 .any(|message| matches!(message, tui::ChatMessage::Error(_))),
2730 "expected an error event from the newly started run"
2731 );
2732 assert!(
2733 !app.is_processing,
2734 "processing should stop when the run emits a scoped error event"
2735 );
2736
2737 app.cancel_agent_task();
2738 }
2739
2740 #[test]
2741 fn test_shift_enter_inserts_newline_without_submitting() {
2742 let temp_dir = tempdir().unwrap();
2743 let settings = create_dummy_settings(temp_dir.path());
2744 let cwd = temp_dir.path();
2745 let (tx, _rx) = mpsc::unbounded_channel();
2746 let event_sender = TuiEventSender::new(tx);
2747 let mut app = ChatApp::new("Session".to_string(), cwd);
2748 app.set_input("hello".to_string());
2749
2750 handle_key_event(
2751 KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
2752 &mut app,
2753 &settings,
2754 cwd,
2755 &event_sender,
2756 || Ok((120, 40)),
2757 )
2758 .unwrap();
2759
2760 assert_eq!(app.input, "hello\n");
2761 assert!(app.messages.is_empty());
2762 assert!(!app.is_processing);
2763 }
2764
2765 #[test]
2766 fn test_shift_enter_press_followed_by_release_does_not_submit() {
2767 let temp_dir = tempdir().unwrap();
2768 let settings = create_dummy_settings(temp_dir.path());
2769 let cwd = temp_dir.path();
2770 let (tx, _rx) = mpsc::unbounded_channel();
2771 let event_sender = TuiEventSender::new(tx);
2772 let mut app = ChatApp::new("Session".to_string(), cwd);
2773 app.set_input("hello".to_string());
2774
2775 handle_key_event(
2776 KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::SHIFT, KeyEventKind::Press),
2777 &mut app,
2778 &settings,
2779 cwd,
2780 &event_sender,
2781 || Ok((120, 40)),
2782 )
2783 .unwrap();
2784
2785 handle_key_event(
2786 KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release),
2787 &mut app,
2788 &settings,
2789 cwd,
2790 &event_sender,
2791 || Ok((120, 40)),
2792 )
2793 .unwrap();
2794
2795 assert_eq!(app.input, "hello\n");
2796 assert!(app.messages.is_empty());
2797 assert!(!app.is_processing);
2798 }
2799
2800 #[test]
2801 fn test_ctrl_c_clears_non_empty_input() {
2802 let temp_dir = tempdir().unwrap();
2803 let settings = create_dummy_settings(temp_dir.path());
2804 let cwd = temp_dir.path();
2805 let (tx, _rx) = mpsc::unbounded_channel();
2806 let event_sender = TuiEventSender::new(tx);
2807 let mut app = ChatApp::new("Session".to_string(), cwd);
2808 app.set_input("hello".to_string());
2809
2810 handle_key_event(
2811 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
2812 &mut app,
2813 &settings,
2814 cwd,
2815 &event_sender,
2816 || Ok((120, 40)),
2817 )
2818 .unwrap();
2819
2820 assert!(app.input.is_empty());
2821 assert_eq!(app.cursor, 0);
2822 assert!(!app.should_quit);
2823 }
2824
2825 #[test]
2826 fn test_ctrl_c_quits_when_input_is_empty() {
2827 let temp_dir = tempdir().unwrap();
2828 let settings = create_dummy_settings(temp_dir.path());
2829 let cwd = temp_dir.path();
2830 let (tx, _rx) = mpsc::unbounded_channel();
2831 let event_sender = TuiEventSender::new(tx);
2832 let mut app = ChatApp::new("Session".to_string(), cwd);
2833
2834 handle_key_event(
2835 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
2836 &mut app,
2837 &settings,
2838 cwd,
2839 &event_sender,
2840 || Ok((120, 40)),
2841 )
2842 .unwrap();
2843
2844 assert!(app.should_quit);
2845 }
2846
2847 #[test]
2848 fn test_multiline_cursor_shortcuts_ctrl_and_vertical_arrows() {
2849 let temp_dir = tempdir().unwrap();
2850 let settings = create_dummy_settings(temp_dir.path());
2851 let cwd = temp_dir.path();
2852 let (tx, _rx) = mpsc::unbounded_channel();
2853 let event_sender = TuiEventSender::new(tx);
2854 let mut app = ChatApp::new("Session".to_string(), cwd);
2855 app.set_input("abc\ndefg\nxy".to_string());
2856
2857 handle_key_event(
2858 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
2859 &mut app,
2860 &settings,
2861 cwd,
2862 &event_sender,
2863 || Ok((120, 40)),
2864 )
2865 .unwrap();
2866 assert_eq!(app.cursor, 9);
2867
2868 handle_key_event(
2869 KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
2870 &mut app,
2871 &settings,
2872 cwd,
2873 &event_sender,
2874 || Ok((120, 40)),
2875 )
2876 .unwrap();
2877 assert_eq!(app.cursor, 4);
2878
2879 handle_key_event(
2880 KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
2881 &mut app,
2882 &settings,
2883 cwd,
2884 &event_sender,
2885 || Ok((120, 40)),
2886 )
2887 .unwrap();
2888 assert_eq!(app.cursor, 9);
2889
2890 handle_key_event(
2891 KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
2892 &mut app,
2893 &settings,
2894 cwd,
2895 &event_sender,
2896 || Ok((120, 40)),
2897 )
2898 .unwrap();
2899 assert_eq!(app.cursor, 11);
2900 }
2901
2902 #[test]
2903 fn test_ctrl_e_and_ctrl_a_can_cross_line_edges() {
2904 let temp_dir = tempdir().unwrap();
2905 let settings = create_dummy_settings(temp_dir.path());
2906 let cwd = temp_dir.path();
2907 let (tx, _rx) = mpsc::unbounded_channel();
2908 let event_sender = TuiEventSender::new(tx);
2909 let mut app = ChatApp::new("Session".to_string(), cwd);
2910 app.set_input("ab\ncd\nef".to_string());
2911
2912 app.cursor = 2;
2914
2915 handle_key_event(
2916 KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
2917 &mut app,
2918 &settings,
2919 cwd,
2920 &event_sender,
2921 || Ok((120, 40)),
2922 )
2923 .unwrap();
2924 assert_eq!(app.cursor, 5);
2925
2926 handle_key_event(
2928 KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
2929 &mut app,
2930 &settings,
2931 cwd,
2932 &event_sender,
2933 || Ok((120, 40)),
2934 )
2935 .unwrap();
2936 assert_eq!(app.cursor, 8);
2937
2938 handle_key_event(
2940 KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
2941 &mut app,
2942 &settings,
2943 cwd,
2944 &event_sender,
2945 || Ok((120, 40)),
2946 )
2947 .unwrap();
2948 assert_eq!(app.cursor, 8);
2949
2950 handle_key_event(
2952 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
2953 &mut app,
2954 &settings,
2955 cwd,
2956 &event_sender,
2957 || Ok((120, 40)),
2958 )
2959 .unwrap();
2960 assert_eq!(app.cursor, 6);
2961
2962 handle_key_event(
2964 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
2965 &mut app,
2966 &settings,
2967 cwd,
2968 &event_sender,
2969 || Ok((120, 40)),
2970 )
2971 .unwrap();
2972 assert_eq!(app.cursor, 3);
2973
2974 handle_key_event(
2975 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
2976 &mut app,
2977 &settings,
2978 cwd,
2979 &event_sender,
2980 || Ok((120, 40)),
2981 )
2982 .unwrap();
2983 assert_eq!(app.cursor, 0);
2984 }
2985
2986 #[test]
2987 fn test_left_and_right_move_cursor_across_newline() {
2988 let temp_dir = tempdir().unwrap();
2989 let settings = create_dummy_settings(temp_dir.path());
2990 let cwd = temp_dir.path();
2991 let (tx, _rx) = mpsc::unbounded_channel();
2992 let event_sender = TuiEventSender::new(tx);
2993 let mut app = ChatApp::new("Session".to_string(), cwd);
2994 app.set_input("ab\ncd".to_string());
2995 app.cursor = 2;
2996
2997 handle_key_event(
2998 KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
2999 &mut app,
3000 &settings,
3001 cwd,
3002 &event_sender,
3003 || Ok((120, 40)),
3004 )
3005 .unwrap();
3006 assert_eq!(app.cursor, 3);
3007
3008 handle_key_event(
3009 KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
3010 &mut app,
3011 &settings,
3012 cwd,
3013 &event_sender,
3014 || Ok((120, 40)),
3015 )
3016 .unwrap();
3017 assert_eq!(app.cursor, 2);
3018
3019 app.cursor = 0;
3020 handle_key_event(
3021 KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
3022 &mut app,
3023 &settings,
3024 cwd,
3025 &event_sender,
3026 || Ok((120, 40)),
3027 )
3028 .unwrap();
3029 assert_eq!(app.cursor, 0);
3030
3031 app.cursor = app.input.len();
3032 handle_key_event(
3033 KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
3034 &mut app,
3035 &settings,
3036 cwd,
3037 &event_sender,
3038 || Ok((120, 40)),
3039 )
3040 .unwrap();
3041 assert_eq!(app.cursor, app.input.len());
3042 }
3043
3044 #[test]
3045 fn test_paste_transforms_single_image_path_into_attachment() {
3046 let temp_dir = tempdir().unwrap();
3047 let image_path = temp_dir.path().join("example.png");
3048 std::fs::write(&image_path, [1u8, 2, 3, 4]).unwrap();
3049
3050 let prepared = prepare_paste(image_path.to_string_lossy().as_ref());
3051 assert_eq!(prepared.insert_text, "[pasted image: example.png]");
3052 assert_eq!(prepared.attachments.len(), 1);
3053 }
3054
3055 #[test]
3056 fn test_paste_transforms_shell_escaped_image_path_into_attachment() {
3057 let temp_dir = tempdir().unwrap();
3058 let image_path = temp_dir.path().join("my image.png");
3059 std::fs::write(&image_path, [1u8, 2, 3, 4]).unwrap();
3060 let escaped = image_path.to_string_lossy().replace(' ', "\\ ");
3061
3062 let prepared = prepare_paste(&escaped);
3063 assert_eq!(prepared.insert_text, "[pasted image: my image.png]");
3064 assert_eq!(prepared.attachments.len(), 1);
3065 }
3066
3067 #[test]
3068 fn test_paste_transforms_file_url_image_path_into_attachment() {
3069 let temp_dir = tempdir().unwrap();
3070 let image_path = temp_dir.path().join("my image.jpeg");
3071 std::fs::write(&image_path, [1u8, 2, 3, 4]).unwrap();
3072 let file_url = format!(
3073 "file://{}",
3074 image_path.to_string_lossy().replace(' ', "%20")
3075 );
3076
3077 let prepared = prepare_paste(&file_url);
3078 assert_eq!(prepared.insert_text, "[pasted image: my image.jpeg]");
3079 assert_eq!(prepared.attachments.len(), 1);
3080 }
3081
3082 #[test]
3083 fn test_paste_leaves_plain_text_unchanged() {
3084 let prepared = prepare_paste("hello\nworld");
3085 assert_eq!(prepared.insert_text, "hello\nworld");
3086 assert!(prepared.attachments.is_empty());
3087 }
3088
3089 #[test]
3090 fn test_apply_paste_inserts_content_at_cursor() {
3091 let temp_dir = tempdir().unwrap();
3092 let cwd = temp_dir.path();
3093 let mut app = ChatApp::new("Session".to_string(), cwd);
3094 app.set_input("abcXYZ".to_string());
3095 app.cursor = 3;
3096
3097 let image_path = temp_dir.path().join("shot.png");
3098 std::fs::write(&image_path, [1u8, 2, 3, 4]).unwrap();
3099
3100 apply_paste(&mut app, image_path.to_string_lossy().to_string());
3101
3102 assert_eq!(app.input, "abc[pasted image: shot.png]XYZ");
3103 assert_eq!(app.pending_attachments.len(), 1);
3104 }
3105
3106 #[test]
3107 fn test_cmd_v_does_not_insert_literal_v() {
3108 let temp_dir = tempdir().unwrap();
3109 let settings = create_dummy_settings(temp_dir.path());
3110 let cwd = temp_dir.path();
3111 let (tx, _rx) = mpsc::unbounded_channel();
3112 let event_sender = TuiEventSender::new(tx);
3113 let mut app = ChatApp::new("Session".to_string(), cwd);
3114 app.set_input("abc".to_string());
3115
3116 handle_key_event(
3117 KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER),
3118 &mut app,
3119 &settings,
3120 cwd,
3121 &event_sender,
3122 || Ok((120, 40)),
3123 )
3124 .unwrap();
3125
3126 assert_ne!(app.input, "abcv");
3127 }
3128
3129 #[test]
3130 fn test_mouse_wheel_event_keeps_cursor_coordinates() {
3131 let event = MouseEvent {
3132 kind: MouseEventKind::ScrollDown,
3133 column: 77,
3134 row: 14,
3135 modifiers: KeyModifiers::NONE,
3136 };
3137
3138 let translated = handle_mouse_event(event);
3139 assert!(matches!(
3140 translated,
3141 Some(InputEvent::ScrollDown { x: 77, y: 14 })
3142 ));
3143 }
3144
3145 #[test]
3146 fn test_sidebar_wheel_scroll_only_applies_inside_sidebar_column() {
3147 let temp_dir = tempdir().unwrap();
3148 let cwd = temp_dir.path();
3149 let mut app = ChatApp::new("Session".to_string(), cwd);
3150
3151 for idx in 0..120 {
3152 app.messages.push(tui::ChatMessage::ToolCall {
3153 name: "edit".to_string(),
3154 args: "{}".to_string(),
3155 output: Some(
3156 serde_json::json!({
3157 "path": format!("src/file-{idx}.rs"),
3158 "applied": true,
3159 "summary": {"added_lines": 1, "removed_lines": 0},
3160 "diff": ""
3161 })
3162 .to_string(),
3163 ),
3164 is_error: Some(false),
3165 });
3166 }
3167
3168 let terminal_rect = Rect {
3169 x: 0,
3170 y: 0,
3171 width: 120,
3172 height: 40,
3173 };
3174 let layout_rects = tui::compute_layout_rects(terminal_rect, &app);
3175 let sidebar_content = layout_rects
3176 .sidebar_content
3177 .expect("sidebar should be visible");
3178 let main_messages = layout_rects
3179 .main_messages
3180 .expect("main messages area should be visible");
3181
3182 let inside_scrolled = handle_area_scroll(
3184 &mut app,
3185 terminal_rect,
3186 sidebar_content.x,
3187 sidebar_content.y,
3188 0,
3189 3,
3190 );
3191 assert!(inside_scrolled);
3192 assert!(app.sidebar_scroll.offset > 0);
3193
3194 let previous_sidebar_offset = app.sidebar_scroll.offset;
3195 let previous_message_offset = app.message_scroll.offset;
3196
3197 let in_main_scrolled = handle_area_scroll(
3199 &mut app,
3200 terminal_rect,
3201 main_messages.x,
3202 main_messages.y,
3203 0,
3204 3,
3205 );
3206 assert!(in_main_scrolled);
3207 assert!(app.message_scroll.offset > previous_message_offset);
3208 assert_eq!(app.sidebar_scroll.offset, previous_sidebar_offset);
3209 }
3210
3211 #[test]
3212 fn test_scroll_up_from_auto_scroll_moves_immediately() {
3213 let temp_dir = tempdir().unwrap();
3214 let cwd = temp_dir.path();
3215 let mut app = ChatApp::new("Session".to_string(), cwd);
3216
3217 for i in 0..120 {
3218 app.messages
3219 .push(tui::ChatMessage::Assistant(format!("line {i}")));
3220 }
3221 app.mark_dirty();
3222 app.message_scroll.auto_follow = true;
3223 app.message_scroll.offset = 0;
3224
3225 scroll_up_steps(&mut app, 120, 30, 1);
3226
3227 assert!(!app.message_scroll.auto_follow);
3228 assert!(app.message_scroll.offset > 0);
3229 }
3230}