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