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