Skip to main content

hh_cli/cli/tui/
app.rs

1use std::cell::RefCell;
2use std::path::Path;
3use std::process::Command;
4use std::time::Instant;
5
6use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7use ratatui::text::Line;
8use serde::Deserialize;
9use tokio::sync::oneshot;
10
11type QuestionResponder = std::sync::Arc<
12    std::sync::Mutex<Option<oneshot::Sender<anyhow::Result<crate::core::QuestionAnswers>>>>,
13>;
14
15use super::commands::{SlashCommand, get_default_commands};
16use super::event::{SubagentEventItem, TuiEvent};
17use super::tool_render::render_tool_result;
18use crate::core::MessageAttachment;
19
20const SIDEBAR_WIDTH: u16 = 38;
21const LEFT_COLUMN_RIGHT_MARGIN: u16 = 2;
22const DEFAULT_CONTEXT_LIMIT: usize = 128_000;
23
24#[derive(Debug, Clone, Copy)]
25pub struct ScrollState {
26    pub offset: usize,
27    pub auto_follow: bool,
28}
29
30impl ScrollState {
31    pub const fn new(auto_follow: bool) -> Self {
32        Self {
33            offset: 0,
34            auto_follow,
35        }
36    }
37
38    pub fn effective_offset(&self, total_lines: usize, visible_height: usize) -> usize {
39        let max_offset = total_lines.saturating_sub(visible_height);
40        if self.auto_follow {
41            max_offset
42        } else {
43            self.offset.min(max_offset)
44        }
45    }
46
47    pub fn scroll_up_steps(&mut self, total_lines: usize, visible_height: usize, steps: usize) {
48        if steps == 0 {
49            return;
50        }
51
52        if self.auto_follow {
53            self.offset = total_lines.saturating_sub(visible_height);
54            self.auto_follow = false;
55        }
56
57        self.offset = self.offset.saturating_sub(steps);
58        self.auto_follow = false;
59    }
60
61    pub fn scroll_down_steps(&mut self, total_lines: usize, visible_height: usize, steps: usize) {
62        if steps == 0 {
63            return;
64        }
65
66        let max_offset = total_lines.saturating_sub(visible_height);
67        self.offset = self.effective_offset(total_lines, visible_height);
68        self.offset = self.offset.saturating_add(steps).min(max_offset);
69        self.auto_follow = self.offset >= max_offset;
70    }
71
72    pub fn reset(&mut self, auto_follow: bool) {
73        self.offset = 0;
74        self.auto_follow = auto_follow;
75    }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct TodoItemView {
80    pub content: String,
81    pub status: TodoStatus,
82    pub priority: TodoPriority,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum TodoStatus {
87    Pending,
88    InProgress,
89    Completed,
90    Cancelled,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum TodoPriority {
95    High,
96    Medium,
97    Low,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum SubagentStatusView {
102    Pending,
103    Running,
104    Completed,
105    Failed,
106    Cancelled,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct SubagentItemView {
111    pub task_id: String,
112    pub name: String,
113    pub parent_task_id: Option<String>,
114    pub agent_name: String,
115    pub prompt: String,
116    pub summary: Option<String>,
117    pub depth: usize,
118    pub started_at: u64,
119    pub finished_at: Option<u64>,
120    pub status: SubagentStatusView,
121}
122
123#[derive(Debug, Clone)]
124pub enum ChatMessage {
125    User(String),
126    Assistant(String),
127    CompactionPending,
128    Compaction(String),
129    Thinking(String),
130    ToolCall {
131        name: String,
132        args: String,
133        output: Option<String>,
134        is_error: Option<bool>,
135    },
136    Error(String),
137    Footer {
138        agent_display_name: String,
139        provider_name: String,
140        model_name: String,
141        duration: String,
142        interrupted: bool,
143    },
144}
145
146#[derive(Debug, Clone)]
147pub struct PendingQuestionView {
148    pub header: String,
149    pub question: String,
150    pub options: Vec<QuestionOptionView>,
151    pub selected_index: usize,
152    pub custom_mode: bool,
153    pub custom_value: String,
154    pub question_index: usize,
155    pub total_questions: usize,
156    pub multiple: bool,
157}
158
159#[derive(Debug, Clone)]
160pub struct QuestionOptionView {
161    pub label: String,
162    pub description: String,
163    pub selected: bool,
164    pub active: bool,
165    pub custom: bool,
166    pub submit: bool,
167}
168
169#[derive(Debug)]
170struct PendingQuestionState {
171    questions: Vec<crate::core::QuestionPrompt>,
172    answers: crate::core::QuestionAnswers,
173    custom_values: Vec<String>,
174    question_index: usize,
175    selected_index: usize,
176    custom_mode: bool,
177    responder: Option<QuestionResponder>,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum QuestionKeyResult {
182    NotHandled,
183    Handled,
184    Submitted,
185    Dismissed,
186}
187
188use crate::session::SessionMetadata;
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub struct SelectionPosition {
192    pub line: usize,
193    pub column: usize,
194}
195
196#[derive(Debug, Clone, Copy)]
197pub struct ClipboardNotice {
198    pub x: u16,
199    pub y: u16,
200    pub expires_at: Instant,
201}
202
203impl SelectionPosition {
204    pub fn new(line: usize, column: usize) -> Self {
205        Self { line, column }
206    }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum TextSelection {
211    None,
212    InProgress {
213        start: SelectionPosition,
214    },
215    Active {
216        start: SelectionPosition,
217        end: SelectionPosition,
218    },
219}
220
221impl TextSelection {
222    pub fn is_none(&self) -> bool {
223        matches!(self, TextSelection::None)
224    }
225
226    pub fn is_active(&self) -> bool {
227        matches!(self, TextSelection::Active { .. })
228    }
229
230    pub fn get_range(&self) -> Option<(SelectionPosition, SelectionPosition)> {
231        match self {
232            TextSelection::Active { start, end } => {
233                let (start_pos, end_pos) = if start.line < end.line
234                    || (start.line == end.line && start.column <= end.column)
235                {
236                    (*start, *end)
237                } else {
238                    (*end, *start)
239                };
240                Some((start_pos, end_pos))
241            }
242            _ => None,
243        }
244    }
245
246    pub fn get_active_start(&self) -> Option<SelectionPosition> {
247        match self {
248            TextSelection::Active { start, .. } | TextSelection::InProgress { start } => {
249                Some(*start)
250            }
251            TextSelection::None => None,
252        }
253    }
254}
255
256pub struct ChatApp {
257    pub messages: Vec<ChatMessage>,
258    pub input: String,
259    pub cursor: usize,
260    pub message_scroll: ScrollState,
261    pub sidebar_scroll: ScrollState,
262    pub should_quit: bool,
263    pub is_processing: bool,
264    pub session_id: Option<String>,
265    pub session_name: String,
266    session_epoch: u64,
267    run_epoch: u64,
268    pub working_directory: String,
269    pub git_branch: Option<String>,
270    pub context_budget: usize,
271    processing_started_at: Option<Instant>,
272    pub todo_items: Vec<TodoItemView>,
273    pub subagent_items: Vec<SubagentItemView>,
274    // Cached rendered lines (rebuilt only when messages change)
275    pub cached_lines: RefCell<Vec<Line<'static>>>,
276    pub cached_width: RefCell<usize>,
277    pub needs_rebuild: RefCell<bool>,
278    pub available_sessions: Vec<SessionMetadata>,
279    pub is_picking_session: bool,
280    pub commands: Vec<SlashCommand>,
281    pub filtered_commands: Vec<SlashCommand>,
282    pub selected_command_index: usize,
283    pub pending_attachments: Vec<MessageAttachment>,
284    pub current_model_ref: String,
285    pub available_models: Vec<ModelOptionView>,
286    pub last_context_tokens: Option<usize>,
287    preferred_column: Option<usize>,
288    // Text selection state
289    pub text_selection: TextSelection,
290    pub clipboard_notice: Option<ClipboardNotice>,
291    pending_question: Option<PendingQuestionState>,
292    // Agent state
293    pub current_agent_name: Option<String>,
294    pub available_agents: Vec<AgentOptionView>,
295    // Running agent task handle (for cancellation)
296    agent_task: Option<tokio::task::JoinHandle<()>>,
297    esc_interrupt_pending: bool,
298    // Footer state for completed agent runs
299    last_run_duration: Option<String>,
300    last_run_interrupted: bool,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct AgentOptionView {
305    pub name: String,
306    pub display_name: String,
307    pub color: Option<String>,
308    pub mode: String,
309}
310
311pub struct SubmittedInput {
312    pub text: String,
313    pub attachments: Vec<MessageAttachment>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct ModelOptionView {
318    pub full_id: String,
319    pub provider_name: String,
320    pub model_name: String,
321    pub modality: String,
322    pub max_context_size: usize,
323}
324
325impl ChatApp {
326    pub fn new(session_name: String, cwd: &Path) -> Self {
327        let commands = get_default_commands();
328        Self {
329            messages: Vec::new(),
330            input: String::new(),
331            cursor: 0,
332            message_scroll: ScrollState::new(true),
333            sidebar_scroll: ScrollState::new(false),
334            should_quit: false,
335            is_processing: false,
336            session_id: None,
337            session_name,
338            session_epoch: 0,
339            run_epoch: 0,
340            working_directory: cwd.display().to_string(),
341            git_branch: detect_git_branch(cwd),
342            context_budget: DEFAULT_CONTEXT_LIMIT,
343            processing_started_at: None,
344            todo_items: Vec::new(),
345            subagent_items: Vec::new(),
346            cached_lines: RefCell::new(Vec::new()),
347            cached_width: RefCell::new(0),
348            needs_rebuild: RefCell::new(true),
349            available_sessions: Vec::new(),
350            is_picking_session: false,
351            commands,
352            filtered_commands: Vec::new(),
353            selected_command_index: 0,
354            pending_attachments: Vec::new(),
355            current_model_ref: String::new(),
356            available_models: Vec::new(),
357            last_context_tokens: None,
358            preferred_column: None,
359            text_selection: TextSelection::None,
360            clipboard_notice: None,
361            pending_question: None,
362            current_agent_name: None,
363            available_agents: Vec::new(),
364            agent_task: None,
365            esc_interrupt_pending: false,
366            last_run_duration: None,
367            last_run_interrupted: false,
368        }
369    }
370
371    pub fn set_agents(&mut self, agents: Vec<AgentOptionView>, selected: Option<String>) {
372        self.available_agents = agents;
373        self.current_agent_name = selected;
374    }
375
376    pub fn cycle_agent(&mut self) {
377        if self.available_agents.is_empty() {
378            return;
379        }
380
381        // Only cycle through primary agents
382        let primary_agents: Vec<_> = self
383            .available_agents
384            .iter()
385            .filter(|a| a.mode == "primary")
386            .collect();
387
388        if primary_agents.is_empty() {
389            return;
390        }
391
392        let current = self.current_agent_name.as_deref();
393
394        if let Some(current_name) = current {
395            // Find current agent index among primary agents
396            if let Some(pos) = primary_agents.iter().position(|a| a.name == current_name) {
397                // Move to next primary agent
398                let next_pos = (pos + 1) % primary_agents.len();
399                self.current_agent_name = Some(primary_agents[next_pos].name.clone());
400                return;
401            }
402        }
403
404        // If no current agent or not found, select first primary agent
405        self.current_agent_name = Some(primary_agents[0].name.clone());
406    }
407
408    pub fn selected_agent(&self) -> Option<&AgentOptionView> {
409        self.current_agent_name
410            .as_ref()
411            .and_then(|name| self.available_agents.iter().find(|a| a.name == *name))
412    }
413
414    pub fn handle_event(&mut self, event: &TuiEvent) {
415        match event {
416            TuiEvent::Thinking(text) => {
417                self.append_thinking_delta(text);
418                self.mark_dirty();
419            }
420            TuiEvent::ToolStart { name, args } => {
421                if !self.is_duplicate_pending_tool_call(name, args) {
422                    self.messages.push(ChatMessage::ToolCall {
423                        name: name.clone(),
424                        args: args.to_string(),
425                        output: None,
426                        is_error: None,
427                    });
428                }
429                self.mark_dirty();
430            }
431            TuiEvent::ToolEnd { name, result } => {
432                self.complete_tool_call(name, result);
433                if name == "question" {
434                    self.pending_question = None;
435                }
436                self.mark_dirty();
437            }
438            TuiEvent::TodoItemsChanged(items) => {
439                self.todo_items = items
440                    .iter()
441                    .map(|item| TodoItemView {
442                        content: item.content.clone(),
443                        status: TodoStatus::from_core(item.status.clone()),
444                        priority: TodoPriority::from_core(item.priority.clone()),
445                    })
446                    .collect();
447                self.mark_dirty();
448            }
449            TuiEvent::AssistantDelta(delta) => {
450                if let Some(ChatMessage::Assistant(existing)) = self.messages.last_mut() {
451                    existing.push_str(delta);
452                    self.mark_dirty();
453                    return;
454                }
455                self.messages.push(ChatMessage::Assistant(delta.clone()));
456                self.mark_dirty();
457            }
458            TuiEvent::ContextUsage(tokens) => {
459                self.last_context_tokens = Some(*tokens);
460            }
461            TuiEvent::AssistantDone => {
462                self.set_processing(false);
463
464                // Append footer if we have duration info
465                if let (Some(duration), Some(agent)) = (
466                    self.last_run_duration.take(),
467                    self.selected_agent().cloned(),
468                ) {
469                    // Get provider and model names
470                    let provider_name = self
471                        .available_models
472                        .iter()
473                        .find(|model| model.full_id == self.selected_model_ref())
474                        .map(|model| model.provider_name.clone())
475                        .unwrap_or_default();
476                    let model_name = self
477                        .available_models
478                        .iter()
479                        .find(|model| model.full_id == self.selected_model_ref())
480                        .map(|model| model.model_name.clone())
481                        .unwrap_or_default();
482
483                    self.messages.push(ChatMessage::Footer {
484                        agent_display_name: agent.display_name.clone(),
485                        provider_name,
486                        model_name,
487                        duration: duration.clone(),
488                        interrupted: self.last_run_interrupted,
489                    });
490                    self.mark_dirty();
491
492                    // Reset interrupted flag
493                    self.last_run_interrupted = false;
494                }
495            }
496            TuiEvent::SessionTitle(title) => {
497                self.session_name = title.clone();
498                self.mark_dirty();
499            }
500            TuiEvent::CompactionStart => {
501                self.messages.push(ChatMessage::CompactionPending);
502                self.mark_dirty();
503            }
504            TuiEvent::CompactionDone(summary) => {
505                let mut replaced_pending = false;
506                for message in self.messages.iter_mut().rev() {
507                    if matches!(message, ChatMessage::CompactionPending) {
508                        *message = ChatMessage::Compaction(summary.clone());
509                        replaced_pending = true;
510                        break;
511                    }
512                }
513                if !replaced_pending {
514                    self.messages.push(ChatMessage::Compaction(summary.clone()));
515                }
516                self.set_processing(false);
517                self.mark_dirty();
518            }
519            TuiEvent::QuestionPrompt {
520                questions,
521                responder,
522            } => {
523                self.pending_question = Some(PendingQuestionState {
524                    answers: vec![Vec::new(); questions.len()],
525                    custom_values: vec![String::new(); questions.len()],
526                    questions: questions.clone(),
527                    question_index: 0,
528                    selected_index: 0,
529                    custom_mode: false,
530                    responder: Some(responder.clone()),
531                });
532                self.mark_dirty();
533            }
534            TuiEvent::SubagentsChanged(items) => {
535                self.subagent_items = items.iter().filter_map(to_subagent_item_view).collect();
536                self.mark_dirty();
537            }
538            TuiEvent::Error(msg) => {
539                self.messages.push(ChatMessage::Error(msg.clone()));
540                self.set_processing(false);
541                self.mark_dirty();
542            }
543            TuiEvent::Tick => {}
544            TuiEvent::Key(_) => {}
545        }
546    }
547
548    pub fn has_pending_question(&self) -> bool {
549        self.pending_question.is_some()
550    }
551
552    pub fn pending_question_view(&self) -> Option<PendingQuestionView> {
553        let state = self.pending_question.as_ref()?;
554        let question = state.questions.get(state.question_index)?;
555        let mut options = Vec::new();
556        let selected = state.answers[state.question_index].clone();
557
558        for (idx, option) in question.options.iter().enumerate() {
559            options.push(QuestionOptionView {
560                label: option.label.clone(),
561                description: option.description.clone(),
562                selected: selected.contains(&option.label),
563                active: idx == state.selected_index,
564                custom: false,
565                submit: false,
566            });
567        }
568
569        if question.custom {
570            options.push(QuestionOptionView {
571                label: "Type your own answer".to_string(),
572                description: String::new(),
573                selected: !state.custom_values[state.question_index].trim().is_empty()
574                    && selected.contains(&state.custom_values[state.question_index]),
575                active: options.len() == state.selected_index,
576                custom: true,
577                submit: false,
578            });
579        }
580
581        if question.multiple {
582            options.push(QuestionOptionView {
583                label: "Submit answers".to_string(),
584                description: "Continue to the next question".to_string(),
585                selected: false,
586                active: options.len() == state.selected_index,
587                custom: false,
588                submit: true,
589            });
590        }
591
592        Some(PendingQuestionView {
593            header: question.header.clone(),
594            question: question.question.clone(),
595            options,
596            selected_index: state.selected_index,
597            custom_mode: state.custom_mode,
598            custom_value: state.custom_values[state.question_index].clone(),
599            question_index: state.question_index,
600            total_questions: state.questions.len(),
601            multiple: question.multiple,
602        })
603    }
604
605    pub fn handle_question_key(&mut self, key_event: KeyEvent) -> QuestionKeyResult {
606        let Some(state) = self.pending_question.as_mut() else {
607            return QuestionKeyResult::NotHandled;
608        };
609
610        let Some(question) = state.questions.get(state.question_index).cloned() else {
611            self.pending_question = None;
612            return QuestionKeyResult::Dismissed;
613        };
614
615        if state.custom_mode {
616            match key_event.code {
617                KeyCode::Char(c) if !key_event.modifiers.contains(KeyModifiers::CONTROL) => {
618                    state.custom_values[state.question_index].push(c);
619                    self.mark_dirty();
620                    return QuestionKeyResult::Handled;
621                }
622                KeyCode::Backspace => {
623                    state.custom_values[state.question_index].pop();
624                    self.mark_dirty();
625                    return QuestionKeyResult::Handled;
626                }
627                KeyCode::Esc => {
628                    let existing_custom = state.custom_values[state.question_index].clone();
629                    if !existing_custom.is_empty() {
630                        let normalized = normalize_custom_input(&existing_custom);
631                        state.answers[state.question_index].retain(|item| item != &normalized);
632                        state.custom_values[state.question_index].clear();
633                    }
634                    state.custom_mode = false;
635                    self.mark_dirty();
636                    return QuestionKeyResult::Handled;
637                }
638                KeyCode::Enter => {
639                    if key_event.modifiers.contains(KeyModifiers::SHIFT) {
640                        state.custom_values[state.question_index].push('\n');
641                        self.mark_dirty();
642                        return QuestionKeyResult::Handled;
643                    }
644
645                    let custom = normalize_custom_input(&state.custom_values[state.question_index]);
646                    state.custom_mode = false;
647                    if custom.trim().is_empty() {
648                        self.mark_dirty();
649                        return QuestionKeyResult::Handled;
650                    }
651                    if question.multiple {
652                        if !state.answers[state.question_index].contains(&custom) {
653                            state.answers[state.question_index].push(custom);
654                        }
655                        self.mark_dirty();
656                        return QuestionKeyResult::Handled;
657                    }
658
659                    state.answers[state.question_index] = vec![custom];
660                    return self.advance_or_submit_question();
661                }
662                _ => return QuestionKeyResult::Handled,
663            }
664        }
665
666        let option_count =
667            question.options.len() + usize::from(question.custom) + usize::from(question.multiple);
668
669        match key_event.code {
670            KeyCode::Char(_) if !key_event.modifiers.contains(KeyModifiers::CONTROL) => {
671                QuestionKeyResult::Handled
672            }
673            KeyCode::Up => {
674                state.selected_index = if state.selected_index == 0 {
675                    option_count.saturating_sub(1)
676                } else {
677                    state.selected_index.saturating_sub(1)
678                };
679                self.mark_dirty();
680                QuestionKeyResult::Handled
681            }
682            KeyCode::Down => {
683                state.selected_index = (state.selected_index + 1) % option_count.max(1);
684                self.mark_dirty();
685                QuestionKeyResult::Handled
686            }
687            KeyCode::Esc => {
688                let existing_custom = state.custom_values[state.question_index].clone();
689                if !existing_custom.is_empty() {
690                    let normalized = normalize_custom_input(&existing_custom);
691                    state.answers[state.question_index].retain(|item| item != &normalized);
692                    state.custom_values[state.question_index].clear();
693                    state.custom_mode = false;
694                    self.mark_dirty();
695                    return QuestionKeyResult::Handled;
696                }
697
698                self.finish_question_with_error(anyhow::anyhow!("question dismissed by user"));
699                QuestionKeyResult::Dismissed
700            }
701            KeyCode::Char(digit) if digit.is_ascii_digit() => {
702                let index = digit.to_digit(10).unwrap_or(0) as usize;
703                if index == 0 {
704                    return QuestionKeyResult::Handled;
705                }
706                let choice = index - 1;
707                if choice < option_count {
708                    state.selected_index = choice;
709                    return self.apply_question_selection(question);
710                }
711                QuestionKeyResult::Handled
712            }
713            KeyCode::Enter => self.apply_question_selection(question),
714            _ => QuestionKeyResult::Handled,
715        }
716    }
717
718    fn apply_question_selection(
719        &mut self,
720        question: crate::core::QuestionPrompt,
721    ) -> QuestionKeyResult {
722        let Some(state) = self.pending_question.as_mut() else {
723            return QuestionKeyResult::Dismissed;
724        };
725
726        let choice = state.selected_index;
727        let custom_index = if question.custom {
728            Some(question.options.len())
729        } else {
730            None
731        };
732        let submit_index = if question.multiple {
733            question.options.len() + usize::from(question.custom)
734        } else {
735            usize::MAX
736        };
737
738        if choice < question.options.len() {
739            let label = question.options[choice].label.clone();
740            if question.multiple {
741                if state.answers[state.question_index].contains(&label) {
742                    state.answers[state.question_index].retain(|item| item != &label);
743                } else {
744                    state.answers[state.question_index].push(label);
745                }
746                self.mark_dirty();
747                return QuestionKeyResult::Handled;
748            }
749
750            state.answers[state.question_index] = vec![label];
751            return self.advance_or_submit_question();
752        }
753
754        if custom_index.is_some() && custom_index == Some(choice) {
755            state.custom_mode = true;
756            self.mark_dirty();
757            return QuestionKeyResult::Handled;
758        }
759
760        if choice == submit_index {
761            return self.advance_or_submit_question();
762        }
763
764        QuestionKeyResult::Handled
765    }
766
767    fn advance_or_submit_question(&mut self) -> QuestionKeyResult {
768        let Some(state) = self.pending_question.as_mut() else {
769            return QuestionKeyResult::Dismissed;
770        };
771
772        if state.question_index + 1 < state.questions.len() {
773            state.question_index += 1;
774            state.selected_index = 0;
775            state.custom_mode = false;
776            self.mark_dirty();
777            return QuestionKeyResult::Handled;
778        }
779
780        let answers = state.answers.clone();
781        self.finish_question_with_answers(answers);
782        QuestionKeyResult::Submitted
783    }
784
785    fn finish_question_with_answers(&mut self, answers: crate::core::QuestionAnswers) {
786        if let Some(mut pending) = self.pending_question.take()
787            && let Some(guarded) = pending.responder.take()
788            && let Ok(mut lock) = guarded.lock()
789            && let Some(sender) = lock.take()
790        {
791            let _ = sender.send(Ok(answers));
792        }
793        self.mark_dirty();
794    }
795
796    fn finish_question_with_error(&mut self, error: anyhow::Error) {
797        if let Some(mut pending) = self.pending_question.take()
798            && let Some(guarded) = pending.responder.take()
799            && let Ok(mut lock) = guarded.lock()
800            && let Some(sender) = lock.take()
801        {
802            let _ = sender.send(Err(error));
803        }
804        self.mark_dirty();
805    }
806
807    pub fn submit_input(&mut self) -> SubmittedInput {
808        let input = std::mem::take(&mut self.input);
809        let attachments = std::mem::take(&mut self.pending_attachments);
810        self.cursor = 0;
811        self.preferred_column = None;
812        if !input.is_empty() || !attachments.is_empty() {
813            self.messages.push(ChatMessage::User(input.clone()));
814            self.set_processing(true);
815            self.message_scroll.auto_follow = true; // Follow the new response
816            self.mark_dirty();
817        }
818        SubmittedInput {
819            text: input,
820            attachments,
821        }
822    }
823
824    /// Get or rebuild cached lines for the given width (interior mutability)
825    pub fn get_lines(&self, width: usize) -> std::cell::Ref<'_, Vec<Line<'static>>> {
826        let needs_rebuild = *self.needs_rebuild.borrow();
827        let cached_width = *self.cached_width.borrow();
828
829        if needs_rebuild || cached_width != width {
830            let lines = super::ui::build_message_lines(self, width);
831            *self.cached_lines.borrow_mut() = lines;
832            *self.cached_width.borrow_mut() = width;
833            *self.needs_rebuild.borrow_mut() = false;
834        }
835        self.cached_lines.borrow()
836    }
837
838    pub fn progress_panel_height(&self) -> u16 {
839        0
840    }
841
842    pub fn message_viewport_height(&self, total_height: u16) -> usize {
843        total_height.saturating_sub(self.progress_panel_height() + 3 + 1 + 1 + 1 + 2) as usize
844    }
845
846    pub fn message_wrap_width(&self, total_width: u16) -> usize {
847        let main_width = if total_width > SIDEBAR_WIDTH {
848            total_width.saturating_sub(SIDEBAR_WIDTH + LEFT_COLUMN_RIGHT_MARGIN)
849        } else {
850            total_width
851        };
852        main_width.saturating_sub(2) as usize
853    }
854
855    pub fn context_usage(&self) -> (usize, usize) {
856        if let Some(tokens) = self.last_context_tokens {
857            return (tokens, self.context_budget);
858        }
859
860        let boundary = self
861            .messages
862            .iter()
863            .rposition(|message| matches!(message, ChatMessage::Compaction(_)))
864            .unwrap_or(0);
865        let mut chars = self.input.len();
866        for message in self.messages.iter().skip(boundary) {
867            chars += match message {
868                ChatMessage::User(text)
869                | ChatMessage::Assistant(text)
870                | ChatMessage::Compaction(text)
871                | ChatMessage::Thinking(text) => text.len(),
872                ChatMessage::CompactionPending => 0,
873                ChatMessage::ToolCall {
874                    name, args, output, ..
875                } => name.len() + args.len() + output.as_ref().map(|s| s.len()).unwrap_or(0),
876                ChatMessage::Error(text) => text.len(),
877                ChatMessage::Footer { .. } => 0,
878            };
879        }
880        let estimated_tokens = chars / 4;
881        (estimated_tokens, self.context_budget)
882    }
883
884    pub fn processing_step(&self, interval_ms: u128) -> usize {
885        if !self.is_processing {
886            return 0;
887        }
888
889        let elapsed_ms = self
890            .processing_started_at
891            .map(|started| started.elapsed().as_millis())
892            .unwrap_or_default();
893        let interval = interval_ms.max(1);
894        (elapsed_ms / interval) as usize
895    }
896
897    pub fn processing_duration(&self) -> String {
898        if !self.is_processing {
899            return String::new();
900        }
901
902        let elapsed_secs = self
903            .processing_started_at
904            .map(|started| started.elapsed().as_secs())
905            .unwrap_or_default();
906
907        let minutes = elapsed_secs / 60;
908        let seconds = elapsed_secs % 60;
909
910        if minutes == 0 {
911            format!("{}s", seconds)
912        } else {
913            format!("{}m {}s", minutes, seconds)
914        }
915    }
916
917    fn append_thinking_delta(&mut self, delta: &str) {
918        if delta.is_empty() {
919            return;
920        }
921
922        if let Some(ChatMessage::Thinking(existing)) = self.messages.last_mut() {
923            existing.push_str(delta);
924            return;
925        }
926
927        self.messages.push(ChatMessage::Thinking(delta.to_string()));
928    }
929
930    fn is_duplicate_pending_tool_call(&self, name: &str, args: &serde_json::Value) -> bool {
931        let Some(ChatMessage::ToolCall {
932            name: last_name,
933            args: last_args,
934            is_error,
935            ..
936        }) = self.messages.last()
937        else {
938            return false;
939        };
940
941        is_error.is_none() && last_name == name && last_args == &args.to_string()
942    }
943
944    fn complete_tool_call(&mut self, name: &str, result: &crate::tool::ToolResult) {
945        let rendered = render_tool_result(name, result);
946        if let Some(todos) = rendered.todos {
947            self.todo_items = todos;
948        }
949        if name == "task" && !result.is_error {
950            self.update_subagent_items_from_task_result(result);
951        }
952
953        // Find the matching ToolCall
954        for message in self.messages.iter_mut().rev() {
955            if let ChatMessage::ToolCall {
956                name: tool_name,
957                is_error: status,
958                output: out,
959                ..
960            } = message
961                && tool_name == name
962                && status.is_none()
963            {
964                *status = Some(result.is_error);
965                *out = Some(rendered.text);
966                return;
967            }
968        }
969    }
970
971    fn update_subagent_items_from_task_result(&mut self, result: &crate::tool::ToolResult) {
972        let parsed = parse_task_tool_output(&result.payload)
973            .or_else(|| serde_json::from_str::<TaskToolWireOutput>(&result.output).ok());
974        let Some(parsed) = parsed else {
975            return;
976        };
977
978        let Some(status) = SubagentStatusView::from_wire(&parsed.status) else {
979            return;
980        };
981
982        let item = SubagentItemView {
983            task_id: parsed.task_id,
984            name: parsed.name,
985            parent_task_id: parsed.parent_task_id,
986            agent_name: parsed.agent_name,
987            prompt: parsed.prompt,
988            summary: parsed.summary.or(parsed.error),
989            depth: parsed.depth,
990            started_at: parsed.started_at,
991            finished_at: parsed.finished_at,
992            status,
993        };
994
995        if let Some(existing) = self
996            .subagent_items
997            .iter_mut()
998            .find(|existing| existing.task_id == item.task_id)
999        {
1000            *existing = item;
1001        } else {
1002            self.subagent_items.push(item);
1003        }
1004    }
1005
1006    pub fn set_processing(&mut self, processing: bool) {
1007        // Capture final duration and interrupted status when ending processing
1008        if !processing && self.is_processing {
1009            if let Some(started) = self.processing_started_at {
1010                let elapsed_secs = started.elapsed().as_secs();
1011                let minutes = elapsed_secs / 60;
1012                let seconds = elapsed_secs % 60;
1013                self.last_run_duration = if minutes == 0 {
1014                    Some(format!("{}s", seconds))
1015                } else {
1016                    Some(format!("{}m {}s", minutes, seconds))
1017                };
1018            }
1019            self.last_run_interrupted = self.esc_interrupt_pending;
1020        }
1021
1022        self.is_processing = processing;
1023        if !processing {
1024            self.clear_pending_esc_interrupt();
1025        }
1026        self.processing_started_at = if processing {
1027            Some(Instant::now())
1028        } else {
1029            None
1030        };
1031    }
1032
1033    pub fn session_epoch(&self) -> u64 {
1034        self.session_epoch
1035    }
1036
1037    pub fn run_epoch(&self) -> u64 {
1038        self.run_epoch
1039    }
1040
1041    pub fn bump_session_epoch(&mut self) {
1042        self.session_epoch = self.session_epoch.wrapping_add(1);
1043    }
1044
1045    pub fn bump_run_epoch(&mut self) {
1046        self.run_epoch = self.run_epoch.wrapping_add(1);
1047    }
1048
1049    pub fn start_new_session(&mut self, session_name: String) {
1050        self.bump_session_epoch();
1051        self.messages.clear();
1052        self.todo_items.clear();
1053        self.subagent_items.clear();
1054        self.last_context_tokens = None;
1055        self.session_id = None;
1056        self.session_name = session_name;
1057        self.available_sessions.clear();
1058        self.is_picking_session = false;
1059        self.message_scroll.reset(true);
1060        self.sidebar_scroll.reset(false);
1061        self.set_processing(false);
1062        self.pending_question = None;
1063        self.cancel_agent_task();
1064        self.mark_dirty();
1065    }
1066
1067    /// Cancel any running agent task
1068    pub fn cancel_agent_task(&mut self) {
1069        if let Some(handle) = self.agent_task.take() {
1070            self.bump_run_epoch();
1071            handle.abort();
1072        }
1073        self.clear_pending_esc_interrupt();
1074    }
1075
1076    /// Set the agent task handle
1077    pub fn set_agent_task(&mut self, handle: tokio::task::JoinHandle<()>) {
1078        // Cancel any existing task first
1079        self.cancel_agent_task();
1080        self.agent_task = Some(handle);
1081    }
1082
1083    pub fn arm_esc_interrupt(&mut self) {
1084        self.esc_interrupt_pending = true;
1085    }
1086
1087    pub fn clear_pending_esc_interrupt(&mut self) {
1088        self.esc_interrupt_pending = false;
1089    }
1090
1091    pub fn should_interrupt_on_esc(&self) -> bool {
1092        self.esc_interrupt_pending
1093    }
1094
1095    pub fn processing_interrupt_hint(&self) -> &'static str {
1096        if self.esc_interrupt_pending {
1097            "esc again to interrupt"
1098        } else {
1099            "esc interrupt"
1100        }
1101    }
1102
1103    pub fn update_command_filtering(&mut self) {
1104        if self.input.starts_with('/') {
1105            let query = self.input.trim();
1106            self.filtered_commands = self
1107                .commands
1108                .iter()
1109                .filter(|cmd| cmd.name.starts_with(query))
1110                .cloned()
1111                .collect();
1112        } else {
1113            self.filtered_commands.clear();
1114        }
1115
1116        if self.selected_command_index >= self.filtered_commands.len() {
1117            self.selected_command_index = 0;
1118        }
1119    }
1120
1121    pub fn mark_dirty(&self) {
1122        *self.needs_rebuild.borrow_mut() = true;
1123    }
1124
1125    pub fn configure_models(
1126        &mut self,
1127        current_model_ref: String,
1128        available_models: Vec<ModelOptionView>,
1129    ) {
1130        self.current_model_ref = current_model_ref;
1131        self.available_models = available_models;
1132        self.context_budget = self
1133            .available_models
1134            .iter()
1135            .find(|model| model.full_id == self.current_model_ref)
1136            .map(|model| model.max_context_size)
1137            .unwrap_or(DEFAULT_CONTEXT_LIMIT);
1138        self.last_context_tokens = None;
1139    }
1140
1141    pub fn selected_model_ref(&self) -> &str {
1142        self.current_model_ref.as_str()
1143    }
1144
1145    pub fn set_selected_model(&mut self, model_ref: &str) {
1146        self.current_model_ref = model_ref.to_string();
1147        self.context_budget = self
1148            .available_models
1149            .iter()
1150            .find(|model| model.full_id == self.current_model_ref)
1151            .map(|model| model.max_context_size)
1152            .unwrap_or(DEFAULT_CONTEXT_LIMIT);
1153        self.last_context_tokens = None;
1154    }
1155
1156    pub fn insert_char(&mut self, ch: char) {
1157        self.input.insert(self.cursor, ch);
1158        self.cursor += ch.len_utf8();
1159        self.preferred_column = None;
1160    }
1161
1162    pub fn insert_str(&mut self, text: &str) {
1163        if text.is_empty() {
1164            return;
1165        }
1166        self.input.insert_str(self.cursor, text);
1167        self.cursor += text.len();
1168        self.preferred_column = None;
1169    }
1170
1171    pub fn backspace(&mut self) {
1172        if self.cursor == 0 {
1173            return;
1174        }
1175        if let Some((idx, _)) = self.input[..self.cursor].char_indices().next_back() {
1176            self.input.drain(idx..self.cursor);
1177            self.cursor = idx;
1178            self.preferred_column = None;
1179        }
1180    }
1181
1182    pub fn clear_input(&mut self) {
1183        self.input.clear();
1184        self.pending_attachments.clear();
1185        self.cursor = 0;
1186        self.preferred_column = None;
1187    }
1188
1189    pub fn set_input(&mut self, value: String) {
1190        self.input = value;
1191        self.pending_attachments.clear();
1192        self.cursor = self.input.len();
1193        self.preferred_column = None;
1194    }
1195
1196    pub fn add_pending_attachment(&mut self, attachment: MessageAttachment) {
1197        self.pending_attachments.push(attachment);
1198    }
1199
1200    pub fn move_to_line_start(&mut self) {
1201        let (start, _) = current_line_bounds(&self.input, self.cursor);
1202        if self.cursor == start {
1203            let (line_index, _) = cursor_line_col(&self.input, self.cursor);
1204            if line_index > 0
1205                && let Some((prev_start, _)) = line_bounds_by_index(&self.input, line_index - 1)
1206            {
1207                self.cursor = prev_start;
1208            }
1209        } else {
1210            self.cursor = start;
1211        }
1212        self.preferred_column = None;
1213    }
1214
1215    pub fn move_to_line_end(&mut self) {
1216        let (_, end) = current_line_bounds(&self.input, self.cursor);
1217        if self.cursor == end {
1218            let (line_index, _) = cursor_line_col(&self.input, self.cursor);
1219            if let Some((_, next_end)) = line_bounds_by_index(&self.input, line_index + 1) {
1220                self.cursor = next_end;
1221            }
1222        } else {
1223            self.cursor = end;
1224        }
1225        self.preferred_column = None;
1226    }
1227
1228    pub fn move_cursor_up(&mut self) {
1229        self.move_cursor_vertical(-1);
1230    }
1231
1232    pub fn move_cursor_down(&mut self) {
1233        self.move_cursor_vertical(1);
1234    }
1235
1236    pub fn move_cursor_left(&mut self) {
1237        if self.cursor == 0 {
1238            return;
1239        }
1240        if let Some((idx, _)) = self.input[..self.cursor].char_indices().next_back() {
1241            self.cursor = idx;
1242            self.preferred_column = None;
1243        }
1244    }
1245
1246    pub fn move_cursor_right(&mut self) {
1247        if self.cursor >= self.input.len() {
1248            return;
1249        }
1250        if let Some(ch) = self.input[self.cursor..].chars().next() {
1251            self.cursor += ch.len_utf8();
1252            self.preferred_column = None;
1253        }
1254    }
1255
1256    fn move_cursor_vertical(&mut self, direction: isize) {
1257        if self.input.is_empty() {
1258            return;
1259        }
1260
1261        let (line_index, column) = cursor_line_col(&self.input, self.cursor);
1262        let target_column = self.preferred_column.unwrap_or(column);
1263        let target_line = if direction < 0 {
1264            line_index.saturating_sub(1)
1265        } else {
1266            line_index + 1
1267        };
1268
1269        if direction < 0 && line_index == 0 {
1270            return;
1271        }
1272
1273        let total_lines = self.input.split('\n').count();
1274        if target_line >= total_lines {
1275            return;
1276        }
1277
1278        self.cursor = line_col_to_cursor(&self.input, target_line, target_column);
1279        self.preferred_column = Some(target_column);
1280    }
1281
1282    // Text selection methods
1283    pub fn start_selection(&mut self, line: usize, column: usize) {
1284        self.text_selection = TextSelection::InProgress {
1285            start: SelectionPosition::new(line, column),
1286        };
1287    }
1288
1289    pub fn update_selection(&mut self, line: usize, column: usize) {
1290        match &self.text_selection {
1291            TextSelection::InProgress { start } => {
1292                self.text_selection = TextSelection::Active {
1293                    start: *start,
1294                    end: SelectionPosition::new(line, column),
1295                };
1296            }
1297            TextSelection::Active { start, .. } => {
1298                self.text_selection = TextSelection::Active {
1299                    start: *start,
1300                    end: SelectionPosition::new(line, column),
1301                };
1302            }
1303            TextSelection::None => {
1304                self.start_selection(line, column);
1305            }
1306        }
1307    }
1308
1309    pub fn end_selection(&mut self) {
1310        if let TextSelection::InProgress { .. } = self.text_selection {
1311            self.text_selection = TextSelection::None;
1312        }
1313    }
1314
1315    pub fn clear_selection(&mut self) {
1316        self.text_selection = TextSelection::None;
1317    }
1318
1319    pub fn show_clipboard_notice(&mut self, x: u16, y: u16) {
1320        self.clipboard_notice = Some(ClipboardNotice {
1321            x,
1322            y,
1323            expires_at: Instant::now() + std::time::Duration::from_secs(1),
1324        });
1325    }
1326
1327    pub fn active_clipboard_notice(&self) -> Option<ClipboardNotice> {
1328        self.clipboard_notice
1329            .filter(|notice| Instant::now() <= notice.expires_at)
1330    }
1331
1332    /// Get selected text from the lines
1333    pub fn get_selected_text(&self, lines: &[Line<'static>]) -> String {
1334        if !self.text_selection.is_active() {
1335            return String::new();
1336        }
1337
1338        let (start, end) = match self.text_selection.get_range() {
1339            Some(range) => range,
1340            None => return String::new(),
1341        };
1342
1343        if start.line >= lines.len() || end.line >= lines.len() {
1344            return String::new();
1345        }
1346
1347        let mut selected_text = String::new();
1348        let start_idx = start.line;
1349        let end_idx = end.line;
1350
1351        for (offset, line) in lines[start_idx..=end_idx].iter().enumerate() {
1352            let line_idx = start_idx + offset;
1353            let line_text = line
1354                .spans
1355                .iter()
1356                .map(|s| s.content.as_ref())
1357                .collect::<String>();
1358
1359            let (start_col, end_col) = if line_idx == start_idx && line_idx == end_idx {
1360                (start.column, end.column)
1361            } else if line_idx == start_idx {
1362                (start.column, line_text.chars().count())
1363            } else if line_idx == end_idx {
1364                (0, end.column)
1365            } else {
1366                (0, line_text.chars().count())
1367            };
1368
1369            let chars: Vec<char> = line_text.chars().collect();
1370            let clamped_start = start_col.min(chars.len());
1371            let clamped_end = end_col.min(chars.len());
1372            if clamped_start >= clamped_end {
1373                continue;
1374            }
1375            let selected_line = chars[clamped_start..clamped_end].iter().collect::<String>();
1376
1377            selected_text.push_str(&selected_line);
1378            if line_idx < end_idx {
1379                selected_text.push('\n');
1380            }
1381        }
1382
1383        selected_text
1384    }
1385
1386    /// Check if a point (line, column) is within the selection
1387    pub fn is_point_selected(&self, line: usize, column: usize) -> bool {
1388        let (start, end) = match self.text_selection.get_range() {
1389            Some(range) => range,
1390            None => return false,
1391        };
1392
1393        if line > end.line || (line == end.line && column > end.column) {
1394            return false;
1395        }
1396
1397        if line < start.line || (line == start.line && column < start.column) {
1398            return false;
1399        }
1400
1401        true
1402    }
1403}
1404
1405#[derive(Debug, Deserialize)]
1406struct TaskToolWireOutput {
1407    task_id: String,
1408    status: String,
1409    name: String,
1410    agent_name: String,
1411    prompt: String,
1412    depth: usize,
1413    #[serde(default)]
1414    parent_task_id: Option<String>,
1415    started_at: u64,
1416    #[serde(default)]
1417    finished_at: Option<u64>,
1418    #[serde(default)]
1419    summary: Option<String>,
1420    #[serde(default)]
1421    error: Option<String>,
1422}
1423
1424fn parse_task_tool_output(value: &serde_json::Value) -> Option<TaskToolWireOutput> {
1425    serde_json::from_value(value.clone()).ok()
1426}
1427
1428fn normalize_custom_input(value: &str) -> String {
1429    value.trim_end_matches('\n').to_string()
1430}
1431impl TodoStatus {
1432    pub fn from_core(status: crate::core::TodoStatus) -> Self {
1433        match status {
1434            crate::core::TodoStatus::Pending => Self::Pending,
1435            crate::core::TodoStatus::InProgress => Self::InProgress,
1436            crate::core::TodoStatus::Completed => Self::Completed,
1437            crate::core::TodoStatus::Cancelled => Self::Cancelled,
1438        }
1439    }
1440
1441    pub fn from_wire(status: &str) -> Option<Self> {
1442        match status {
1443            "pending" => Some(Self::Pending),
1444            "in_progress" => Some(Self::InProgress),
1445            "completed" => Some(Self::Completed),
1446            "cancelled" => Some(Self::Cancelled),
1447            _ => None,
1448        }
1449    }
1450}
1451
1452impl TodoPriority {
1453    pub fn from_core(priority: crate::core::TodoPriority) -> Self {
1454        match priority {
1455            crate::core::TodoPriority::High => Self::High,
1456            crate::core::TodoPriority::Medium => Self::Medium,
1457            crate::core::TodoPriority::Low => Self::Low,
1458        }
1459    }
1460
1461    pub fn from_wire(priority: &str) -> Option<Self> {
1462        match priority {
1463            "high" => Some(Self::High),
1464            "medium" => Some(Self::Medium),
1465            "low" => Some(Self::Low),
1466            _ => None,
1467        }
1468    }
1469}
1470
1471impl SubagentStatusView {
1472    pub fn is_terminal(self) -> bool {
1473        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
1474    }
1475
1476    pub fn is_active(self) -> bool {
1477        matches!(self, Self::Pending | Self::Running)
1478    }
1479
1480    pub fn from_wire(status: &str) -> Option<Self> {
1481        match status {
1482            "pending" | "queued" => Some(Self::Pending),
1483            "running" => Some(Self::Running),
1484            "completed" | "done" => Some(Self::Completed),
1485            "failed" | "error" => Some(Self::Failed),
1486            "cancelled" => Some(Self::Cancelled),
1487            _ => None,
1488        }
1489    }
1490
1491    pub fn from_lifecycle(status: crate::session::types::SubAgentLifecycleStatus) -> Self {
1492        match status {
1493            crate::session::types::SubAgentLifecycleStatus::Pending => Self::Pending,
1494            crate::session::types::SubAgentLifecycleStatus::Running => Self::Running,
1495            crate::session::types::SubAgentLifecycleStatus::Completed => Self::Completed,
1496            crate::session::types::SubAgentLifecycleStatus::Failed => Self::Failed,
1497            crate::session::types::SubAgentLifecycleStatus::Cancelled => Self::Cancelled,
1498        }
1499    }
1500}
1501
1502fn to_subagent_item_view(item: &SubagentEventItem) -> Option<SubagentItemView> {
1503    let status = SubagentStatusView::from_wire(&item.status)?;
1504    Some(SubagentItemView {
1505        task_id: item.task_id.clone(),
1506        name: item.name.clone(),
1507        parent_task_id: item.parent_task_id.clone(),
1508        agent_name: item.agent_name.clone(),
1509        prompt: item.prompt.clone(),
1510        summary: item.summary.clone().or(item.error.clone()),
1511        depth: item.depth,
1512        started_at: item.started_at,
1513        finished_at: item.finished_at,
1514        status,
1515    })
1516}
1517
1518impl Default for ChatApp {
1519    fn default() -> Self {
1520        Self::new("Session".to_string(), Path::new("."))
1521    }
1522}
1523
1524fn detect_git_branch(cwd: &Path) -> Option<String> {
1525    let branch = run_git_command(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
1526    if branch == "HEAD" {
1527        return run_git_command(cwd, &["rev-parse", "--short", "HEAD"])
1528            .map(|hash| format!("detached@{hash}"));
1529    }
1530    Some(branch)
1531}
1532
1533fn run_git_command(cwd: &Path, args: &[&str]) -> Option<String> {
1534    let output = Command::new("git")
1535        .arg("-C")
1536        .arg(cwd)
1537        .args(args)
1538        .output()
1539        .ok()?;
1540
1541    if !output.status.success() {
1542        return None;
1543    }
1544
1545    let text = String::from_utf8(output.stdout).ok()?;
1546    let trimmed = text.trim();
1547    if trimmed.is_empty() {
1548        return None;
1549    }
1550
1551    Some(trimmed.to_string())
1552}
1553
1554fn current_line_bounds(input: &str, cursor: usize) -> (usize, usize) {
1555    let cursor = cursor.min(input.len());
1556    let start = input[..cursor].rfind('\n').map_or(0, |idx| idx + 1);
1557    let end = input[cursor..]
1558        .find('\n')
1559        .map_or(input.len(), |idx| cursor + idx);
1560    (start, end)
1561}
1562
1563fn cursor_line_col(input: &str, cursor: usize) -> (usize, usize) {
1564    let cursor = cursor.min(input.len());
1565    let mut line = 0usize;
1566    let mut line_start = 0usize;
1567
1568    for (idx, ch) in input.char_indices() {
1569        if idx >= cursor {
1570            break;
1571        }
1572        if ch == '\n' {
1573            line += 1;
1574            line_start = idx + 1;
1575        }
1576    }
1577
1578    let col = input[line_start..cursor].chars().count();
1579    (line, col)
1580}
1581
1582fn line_col_to_cursor(input: &str, target_line: usize, target_col: usize) -> usize {
1583    let mut line_start = 0usize;
1584
1585    for (line_idx, line) in input.split('\n').enumerate() {
1586        let line_end = line_start + line.len();
1587        if line_idx == target_line {
1588            let rel = line
1589                .char_indices()
1590                .nth(target_col)
1591                .map_or(line.len(), |(idx, _)| idx);
1592            return line_start + rel;
1593        }
1594        line_start = line_end + 1;
1595    }
1596
1597    input.len()
1598}
1599
1600fn line_bounds_by_index(input: &str, target_line: usize) -> Option<(usize, usize)> {
1601    let mut line_start = 0usize;
1602
1603    for (line_idx, line) in input.split('\n').enumerate() {
1604        let line_end = line_start + line.len();
1605        if line_idx == target_line {
1606            return Some((line_start, line_end));
1607        }
1608        line_start = line_end + 1;
1609    }
1610
1611    None
1612}