Skip to main content

hh_cli/cli/
chat.rs

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    // Setup terminal
42    let terminal = tui::setup_terminal()?;
43    let mut tui_guard = tui::TuiGuard::new(terminal);
44
45    // Create app state and event channel
46    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    // Initialize agents
53    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
76/// Input event from terminal
77enum 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                // Clear input when not processing
201                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
588/// Copy selected text to clipboard
589fn 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
604/// Handle mouse click - start text selection
605fn 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
611/// Handle mouse drag - update text selection
612fn 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
618/// Handle mouse release - end text selection
619fn 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
634/// Convert screen coordinates to message line and column
635fn 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    // Simplified calculation - just check if it's roughly in the message area
647    // The message area is at the top, below it are processing indicator and input
648    let input_area_height = 6; // Approximate input area height
649    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    // Check if mouse is in sidebar
680    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        // Only scroll if sidebar has scrollable content
687        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        // Sidebar not scrollable, don't scroll anything
699        return true;
700    }
701
702    // Check if mouse is in main messages area
703    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    // Mouse not in a scrollable area
720    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                // For TUI mode, auto-approve tools (could prompt via TUI in future)
1117                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        // Ensure any run-epoch bump from replacing an existing task happens
1781        // before we scope events for the new run.
1782        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        // Create a dummy session
2040        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        // Setup ChatApp
2050        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        // Simulate typing "/resume"
2055        app.set_input("/resume".to_string());
2056        // verify submit_input sets processing to true
2057        let input = app.submit_input();
2058        assert!(app.is_processing);
2059
2060        handle_submitted_input(input, &mut app, &settings, cwd, &event_sender);
2061
2062        // processing should be false after listing sessions
2063        assert!(
2064            !app.is_processing,
2065            "Processing should be cleared after /resume lists sessions"
2066        );
2067        assert!(app.is_picking_session);
2068
2069        // Simulate picking session "1"
2070        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        // processing should be false after picking session
2077        assert!(
2078            !app.is_processing,
2079            "Processing should be cleared after picking session"
2080        );
2081        assert!(!app.is_picking_session);
2082        // The session ID might not match if listing logic uses UUIDs or if index logic is tricky.
2083        // But we provided title "Test Session", so it should be listed.
2084        // Let's verify session_id is SOME value, and name is correct.
2085        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        // End of first line.
2715        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        // End of second line should jump to end of third line.
2729        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        // On last line end, Ctrl+E stays there.
2741        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        // Ctrl+A at line end moves to that line's start.
2753        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        // Ctrl+A at line start jumps to previous line start.
2765        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        // Test: scrolling in sidebar area scrolls sidebar
2985        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        // Test: scrolling in main messages area scrolls messages, not sidebar
3000        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}