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            if let Some(guarded) = pending.responder.take() {
788                if let Ok(mut lock) = guarded.lock() {
789                    if let Some(sender) = lock.take() {
790                        let _ = sender.send(Ok(answers));
791                    }
792                }
793            }
794        }
795        self.mark_dirty();
796    }
797
798    fn finish_question_with_error(&mut self, error: anyhow::Error) {
799        if let Some(mut pending) = self.pending_question.take() {
800            if let Some(guarded) = pending.responder.take() {
801                if let Ok(mut lock) = guarded.lock() {
802                    if let Some(sender) = lock.take() {
803                        let _ = sender.send(Err(error));
804                    }
805                }
806            }
807        }
808        self.mark_dirty();
809    }
810
811    pub fn submit_input(&mut self) -> SubmittedInput {
812        let input = std::mem::take(&mut self.input);
813        let attachments = std::mem::take(&mut self.pending_attachments);
814        self.cursor = 0;
815        self.preferred_column = None;
816        if !input.is_empty() || !attachments.is_empty() {
817            self.messages.push(ChatMessage::User(input.clone()));
818            self.set_processing(true);
819            self.message_scroll.auto_follow = true; // Follow the new response
820            self.mark_dirty();
821        }
822        SubmittedInput {
823            text: input,
824            attachments,
825        }
826    }
827
828    /// Get or rebuild cached lines for the given width (interior mutability)
829    pub fn get_lines(&self, width: usize) -> std::cell::Ref<'_, Vec<Line<'static>>> {
830        let needs_rebuild = *self.needs_rebuild.borrow();
831        let cached_width = *self.cached_width.borrow();
832
833        if needs_rebuild || cached_width != width {
834            let lines = super::ui::build_message_lines(self, width);
835            *self.cached_lines.borrow_mut() = lines;
836            *self.cached_width.borrow_mut() = width;
837            *self.needs_rebuild.borrow_mut() = false;
838        }
839        self.cached_lines.borrow()
840    }
841
842    pub fn progress_panel_height(&self) -> u16 {
843        0
844    }
845
846    pub fn message_viewport_height(&self, total_height: u16) -> usize {
847        total_height.saturating_sub(self.progress_panel_height() + 3 + 1 + 1 + 1 + 2) as usize
848    }
849
850    pub fn message_wrap_width(&self, total_width: u16) -> usize {
851        let main_width = if total_width > SIDEBAR_WIDTH {
852            total_width.saturating_sub(SIDEBAR_WIDTH + LEFT_COLUMN_RIGHT_MARGIN)
853        } else {
854            total_width
855        };
856        main_width.saturating_sub(2) as usize
857    }
858
859    pub fn context_usage(&self) -> (usize, usize) {
860        if let Some(tokens) = self.last_context_tokens {
861            return (tokens, self.context_budget);
862        }
863
864        let boundary = self
865            .messages
866            .iter()
867            .rposition(|message| matches!(message, ChatMessage::Compaction(_)))
868            .unwrap_or(0);
869        let mut chars = self.input.len();
870        for message in self.messages.iter().skip(boundary) {
871            chars += match message {
872                ChatMessage::User(text)
873                | ChatMessage::Assistant(text)
874                | ChatMessage::Compaction(text)
875                | ChatMessage::Thinking(text) => text.len(),
876                ChatMessage::CompactionPending => 0,
877                ChatMessage::ToolCall {
878                    name, args, output, ..
879                } => name.len() + args.len() + output.as_ref().map(|s| s.len()).unwrap_or(0),
880                ChatMessage::Error(text) => text.len(),
881                ChatMessage::Footer { .. } => 0,
882            };
883        }
884        let estimated_tokens = chars / 4;
885        (estimated_tokens, self.context_budget)
886    }
887
888    pub fn processing_step(&self, interval_ms: u128) -> usize {
889        if !self.is_processing {
890            return 0;
891        }
892
893        let elapsed_ms = self
894            .processing_started_at
895            .map(|started| started.elapsed().as_millis())
896            .unwrap_or_default();
897        let interval = interval_ms.max(1);
898        (elapsed_ms / interval) as usize
899    }
900
901    pub fn processing_duration(&self) -> String {
902        if !self.is_processing {
903            return String::new();
904        }
905
906        let elapsed_secs = self
907            .processing_started_at
908            .map(|started| started.elapsed().as_secs())
909            .unwrap_or_default();
910
911        let minutes = elapsed_secs / 60;
912        let seconds = elapsed_secs % 60;
913
914        if minutes == 0 {
915            format!("{}s", seconds)
916        } else {
917            format!("{}m {}s", minutes, seconds)
918        }
919    }
920
921    fn append_thinking_delta(&mut self, delta: &str) {
922        if delta.is_empty() {
923            return;
924        }
925
926        if let Some(ChatMessage::Thinking(existing)) = self.messages.last_mut() {
927            existing.push_str(delta);
928            return;
929        }
930
931        self.messages.push(ChatMessage::Thinking(delta.to_string()));
932    }
933
934    fn is_duplicate_pending_tool_call(&self, name: &str, args: &serde_json::Value) -> bool {
935        let Some(ChatMessage::ToolCall {
936            name: last_name,
937            args: last_args,
938            is_error,
939            ..
940        }) = self.messages.last()
941        else {
942            return false;
943        };
944
945        is_error.is_none() && last_name == name && last_args == &args.to_string()
946    }
947
948    fn complete_tool_call(&mut self, name: &str, result: &crate::tool::ToolResult) {
949        let rendered = render_tool_result(name, result);
950        if let Some(todos) = rendered.todos {
951            self.todo_items = todos;
952        }
953        if name == "task" && !result.is_error {
954            self.update_subagent_items_from_task_result(result);
955        }
956
957        // Find the matching ToolCall
958        for message in self.messages.iter_mut().rev() {
959            if let ChatMessage::ToolCall {
960                name: tool_name,
961                is_error: status,
962                output: out,
963                ..
964            } = message
965                && tool_name == name
966                && status.is_none()
967            {
968                *status = Some(result.is_error);
969                *out = Some(rendered.text);
970                return;
971            }
972        }
973    }
974
975    fn update_subagent_items_from_task_result(&mut self, result: &crate::tool::ToolResult) {
976        let parsed = parse_task_tool_output(&result.payload)
977            .or_else(|| serde_json::from_str::<TaskToolWireOutput>(&result.output).ok());
978        let Some(parsed) = parsed else {
979            return;
980        };
981
982        let Some(status) = SubagentStatusView::from_wire(&parsed.status) else {
983            return;
984        };
985
986        let item = SubagentItemView {
987            task_id: parsed.task_id,
988            name: parsed.name,
989            parent_task_id: parsed.parent_task_id,
990            agent_name: parsed.agent_name,
991            prompt: parsed.prompt,
992            summary: parsed.summary.or(parsed.error),
993            depth: parsed.depth,
994            started_at: parsed.started_at,
995            finished_at: parsed.finished_at,
996            status,
997        };
998
999        if let Some(existing) = self
1000            .subagent_items
1001            .iter_mut()
1002            .find(|existing| existing.task_id == item.task_id)
1003        {
1004            *existing = item;
1005        } else {
1006            self.subagent_items.push(item);
1007        }
1008    }
1009
1010    pub fn set_processing(&mut self, processing: bool) {
1011        // Capture final duration and interrupted status when ending processing
1012        if !processing && self.is_processing {
1013            if let Some(started) = self.processing_started_at {
1014                let elapsed_secs = started.elapsed().as_secs();
1015                let minutes = elapsed_secs / 60;
1016                let seconds = elapsed_secs % 60;
1017                self.last_run_duration = if minutes == 0 {
1018                    Some(format!("{}s", seconds))
1019                } else {
1020                    Some(format!("{}m {}s", minutes, seconds))
1021                };
1022            }
1023            self.last_run_interrupted = self.esc_interrupt_pending;
1024        }
1025
1026        self.is_processing = processing;
1027        if !processing {
1028            self.clear_pending_esc_interrupt();
1029        }
1030        self.processing_started_at = if processing {
1031            Some(Instant::now())
1032        } else {
1033            None
1034        };
1035    }
1036
1037    pub fn session_epoch(&self) -> u64 {
1038        self.session_epoch
1039    }
1040
1041    pub fn run_epoch(&self) -> u64 {
1042        self.run_epoch
1043    }
1044
1045    pub fn bump_session_epoch(&mut self) {
1046        self.session_epoch = self.session_epoch.wrapping_add(1);
1047    }
1048
1049    pub fn bump_run_epoch(&mut self) {
1050        self.run_epoch = self.run_epoch.wrapping_add(1);
1051    }
1052
1053    pub fn start_new_session(&mut self, session_name: String) {
1054        self.bump_session_epoch();
1055        self.messages.clear();
1056        self.todo_items.clear();
1057        self.subagent_items.clear();
1058        self.last_context_tokens = None;
1059        self.session_id = None;
1060        self.session_name = session_name;
1061        self.available_sessions.clear();
1062        self.is_picking_session = false;
1063        self.message_scroll.reset(true);
1064        self.sidebar_scroll.reset(false);
1065        self.set_processing(false);
1066        self.pending_question = None;
1067        self.cancel_agent_task();
1068        self.mark_dirty();
1069    }
1070
1071    /// Cancel any running agent task
1072    pub fn cancel_agent_task(&mut self) {
1073        if let Some(handle) = self.agent_task.take() {
1074            self.bump_run_epoch();
1075            handle.abort();
1076        }
1077        self.clear_pending_esc_interrupt();
1078    }
1079
1080    /// Set the agent task handle
1081    pub fn set_agent_task(&mut self, handle: tokio::task::JoinHandle<()>) {
1082        // Cancel any existing task first
1083        self.cancel_agent_task();
1084        self.agent_task = Some(handle);
1085    }
1086
1087    pub fn arm_esc_interrupt(&mut self) {
1088        self.esc_interrupt_pending = true;
1089    }
1090
1091    pub fn clear_pending_esc_interrupt(&mut self) {
1092        self.esc_interrupt_pending = false;
1093    }
1094
1095    pub fn should_interrupt_on_esc(&self) -> bool {
1096        self.esc_interrupt_pending
1097    }
1098
1099    pub fn processing_interrupt_hint(&self) -> &'static str {
1100        if self.esc_interrupt_pending {
1101            "esc again to interrupt"
1102        } else {
1103            "esc interrupt"
1104        }
1105    }
1106
1107    pub fn update_command_filtering(&mut self) {
1108        if self.input.starts_with('/') {
1109            let query = self.input.trim();
1110            self.filtered_commands = self
1111                .commands
1112                .iter()
1113                .filter(|cmd| cmd.name.starts_with(query))
1114                .cloned()
1115                .collect();
1116        } else {
1117            self.filtered_commands.clear();
1118        }
1119
1120        if self.selected_command_index >= self.filtered_commands.len() {
1121            self.selected_command_index = 0;
1122        }
1123    }
1124
1125    pub fn mark_dirty(&self) {
1126        *self.needs_rebuild.borrow_mut() = true;
1127    }
1128
1129    pub fn configure_models(
1130        &mut self,
1131        current_model_ref: String,
1132        available_models: Vec<ModelOptionView>,
1133    ) {
1134        self.current_model_ref = current_model_ref;
1135        self.available_models = available_models;
1136        self.context_budget = self
1137            .available_models
1138            .iter()
1139            .find(|model| model.full_id == self.current_model_ref)
1140            .map(|model| model.max_context_size)
1141            .unwrap_or(DEFAULT_CONTEXT_LIMIT);
1142        self.last_context_tokens = None;
1143    }
1144
1145    pub fn selected_model_ref(&self) -> &str {
1146        self.current_model_ref.as_str()
1147    }
1148
1149    pub fn set_selected_model(&mut self, model_ref: &str) {
1150        self.current_model_ref = model_ref.to_string();
1151        self.context_budget = self
1152            .available_models
1153            .iter()
1154            .find(|model| model.full_id == self.current_model_ref)
1155            .map(|model| model.max_context_size)
1156            .unwrap_or(DEFAULT_CONTEXT_LIMIT);
1157        self.last_context_tokens = None;
1158    }
1159
1160    pub fn insert_char(&mut self, ch: char) {
1161        self.input.insert(self.cursor, ch);
1162        self.cursor += ch.len_utf8();
1163        self.preferred_column = None;
1164    }
1165
1166    pub fn insert_str(&mut self, text: &str) {
1167        if text.is_empty() {
1168            return;
1169        }
1170        self.input.insert_str(self.cursor, text);
1171        self.cursor += text.len();
1172        self.preferred_column = None;
1173    }
1174
1175    pub fn backspace(&mut self) {
1176        if self.cursor == 0 {
1177            return;
1178        }
1179        if let Some((idx, _)) = self.input[..self.cursor].char_indices().next_back() {
1180            self.input.drain(idx..self.cursor);
1181            self.cursor = idx;
1182            self.preferred_column = None;
1183        }
1184    }
1185
1186    pub fn clear_input(&mut self) {
1187        self.input.clear();
1188        self.pending_attachments.clear();
1189        self.cursor = 0;
1190        self.preferred_column = None;
1191    }
1192
1193    pub fn set_input(&mut self, value: String) {
1194        self.input = value;
1195        self.pending_attachments.clear();
1196        self.cursor = self.input.len();
1197        self.preferred_column = None;
1198    }
1199
1200    pub fn add_pending_attachment(&mut self, attachment: MessageAttachment) {
1201        self.pending_attachments.push(attachment);
1202    }
1203
1204    pub fn move_to_line_start(&mut self) {
1205        let (start, _) = current_line_bounds(&self.input, self.cursor);
1206        if self.cursor == start {
1207            let (line_index, _) = cursor_line_col(&self.input, self.cursor);
1208            if line_index > 0
1209                && let Some((prev_start, _)) = line_bounds_by_index(&self.input, line_index - 1)
1210            {
1211                self.cursor = prev_start;
1212            }
1213        } else {
1214            self.cursor = start;
1215        }
1216        self.preferred_column = None;
1217    }
1218
1219    pub fn move_to_line_end(&mut self) {
1220        let (_, end) = current_line_bounds(&self.input, self.cursor);
1221        if self.cursor == end {
1222            let (line_index, _) = cursor_line_col(&self.input, self.cursor);
1223            if let Some((_, next_end)) = line_bounds_by_index(&self.input, line_index + 1) {
1224                self.cursor = next_end;
1225            }
1226        } else {
1227            self.cursor = end;
1228        }
1229        self.preferred_column = None;
1230    }
1231
1232    pub fn move_cursor_up(&mut self) {
1233        self.move_cursor_vertical(-1);
1234    }
1235
1236    pub fn move_cursor_down(&mut self) {
1237        self.move_cursor_vertical(1);
1238    }
1239
1240    pub fn move_cursor_left(&mut self) {
1241        if self.cursor == 0 {
1242            return;
1243        }
1244        if let Some((idx, _)) = self.input[..self.cursor].char_indices().next_back() {
1245            self.cursor = idx;
1246            self.preferred_column = None;
1247        }
1248    }
1249
1250    pub fn move_cursor_right(&mut self) {
1251        if self.cursor >= self.input.len() {
1252            return;
1253        }
1254        if let Some(ch) = self.input[self.cursor..].chars().next() {
1255            self.cursor += ch.len_utf8();
1256            self.preferred_column = None;
1257        }
1258    }
1259
1260    fn move_cursor_vertical(&mut self, direction: isize) {
1261        if self.input.is_empty() {
1262            return;
1263        }
1264
1265        let (line_index, column) = cursor_line_col(&self.input, self.cursor);
1266        let target_column = self.preferred_column.unwrap_or(column);
1267        let target_line = if direction < 0 {
1268            line_index.saturating_sub(1)
1269        } else {
1270            line_index + 1
1271        };
1272
1273        if direction < 0 && line_index == 0 {
1274            return;
1275        }
1276
1277        let total_lines = self.input.split('\n').count();
1278        if target_line >= total_lines {
1279            return;
1280        }
1281
1282        self.cursor = line_col_to_cursor(&self.input, target_line, target_column);
1283        self.preferred_column = Some(target_column);
1284    }
1285
1286    // Text selection methods
1287    pub fn start_selection(&mut self, line: usize, column: usize) {
1288        self.text_selection = TextSelection::InProgress {
1289            start: SelectionPosition::new(line, column),
1290        };
1291    }
1292
1293    pub fn update_selection(&mut self, line: usize, column: usize) {
1294        match &self.text_selection {
1295            TextSelection::InProgress { start } => {
1296                self.text_selection = TextSelection::Active {
1297                    start: *start,
1298                    end: SelectionPosition::new(line, column),
1299                };
1300            }
1301            TextSelection::Active { start, .. } => {
1302                self.text_selection = TextSelection::Active {
1303                    start: *start,
1304                    end: SelectionPosition::new(line, column),
1305                };
1306            }
1307            TextSelection::None => {
1308                self.start_selection(line, column);
1309            }
1310        }
1311    }
1312
1313    pub fn end_selection(&mut self) {
1314        if let TextSelection::InProgress { .. } = self.text_selection {
1315            self.text_selection = TextSelection::None;
1316        }
1317    }
1318
1319    pub fn clear_selection(&mut self) {
1320        self.text_selection = TextSelection::None;
1321    }
1322
1323    pub fn show_clipboard_notice(&mut self, x: u16, y: u16) {
1324        self.clipboard_notice = Some(ClipboardNotice {
1325            x,
1326            y,
1327            expires_at: Instant::now() + std::time::Duration::from_secs(1),
1328        });
1329    }
1330
1331    pub fn active_clipboard_notice(&self) -> Option<ClipboardNotice> {
1332        self.clipboard_notice
1333            .filter(|notice| Instant::now() <= notice.expires_at)
1334    }
1335
1336    /// Get selected text from the lines
1337    pub fn get_selected_text(&self, lines: &[Line<'static>]) -> String {
1338        if !self.text_selection.is_active() {
1339            return String::new();
1340        }
1341
1342        let (start, end) = match self.text_selection.get_range() {
1343            Some(range) => range,
1344            None => return String::new(),
1345        };
1346
1347        if start.line >= lines.len() || end.line >= lines.len() {
1348            return String::new();
1349        }
1350
1351        let mut selected_text = String::new();
1352        let start_idx = start.line;
1353        let end_idx = end.line;
1354
1355        for (offset, line) in lines[start_idx..=end_idx].iter().enumerate() {
1356            let line_idx = start_idx + offset;
1357            let line_text = line
1358                .spans
1359                .iter()
1360                .map(|s| s.content.as_ref())
1361                .collect::<String>();
1362
1363            let (start_col, end_col) = if line_idx == start_idx && line_idx == end_idx {
1364                (start.column, end.column)
1365            } else if line_idx == start_idx {
1366                (start.column, line_text.chars().count())
1367            } else if line_idx == end_idx {
1368                (0, end.column)
1369            } else {
1370                (0, line_text.chars().count())
1371            };
1372
1373            let chars: Vec<char> = line_text.chars().collect();
1374            let clamped_start = start_col.min(chars.len());
1375            let clamped_end = end_col.min(chars.len());
1376            if clamped_start >= clamped_end {
1377                continue;
1378            }
1379            let selected_line = chars[clamped_start..clamped_end].iter().collect::<String>();
1380
1381            selected_text.push_str(&selected_line);
1382            if line_idx < end_idx {
1383                selected_text.push('\n');
1384            }
1385        }
1386
1387        selected_text
1388    }
1389
1390    /// Check if a point (line, column) is within the selection
1391    pub fn is_point_selected(&self, line: usize, column: usize) -> bool {
1392        let (start, end) = match self.text_selection.get_range() {
1393            Some(range) => range,
1394            None => return false,
1395        };
1396
1397        if line > end.line || (line == end.line && column > end.column) {
1398            return false;
1399        }
1400
1401        if line < start.line || (line == start.line && column < start.column) {
1402            return false;
1403        }
1404
1405        true
1406    }
1407}
1408
1409#[derive(Debug, Deserialize)]
1410struct TaskToolWireOutput {
1411    task_id: String,
1412    status: String,
1413    name: String,
1414    agent_name: String,
1415    prompt: String,
1416    depth: usize,
1417    #[serde(default)]
1418    parent_task_id: Option<String>,
1419    started_at: u64,
1420    #[serde(default)]
1421    finished_at: Option<u64>,
1422    #[serde(default)]
1423    summary: Option<String>,
1424    #[serde(default)]
1425    error: Option<String>,
1426}
1427
1428fn parse_task_tool_output(value: &serde_json::Value) -> Option<TaskToolWireOutput> {
1429    serde_json::from_value(value.clone()).ok()
1430}
1431
1432fn normalize_custom_input(value: &str) -> String {
1433    value.trim_end_matches('\n').to_string()
1434}
1435impl TodoStatus {
1436    pub fn from_core(status: crate::core::TodoStatus) -> Self {
1437        match status {
1438            crate::core::TodoStatus::Pending => Self::Pending,
1439            crate::core::TodoStatus::InProgress => Self::InProgress,
1440            crate::core::TodoStatus::Completed => Self::Completed,
1441            crate::core::TodoStatus::Cancelled => Self::Cancelled,
1442        }
1443    }
1444
1445    pub fn from_wire(status: &str) -> Option<Self> {
1446        match status {
1447            "pending" => Some(Self::Pending),
1448            "in_progress" => Some(Self::InProgress),
1449            "completed" => Some(Self::Completed),
1450            "cancelled" => Some(Self::Cancelled),
1451            _ => None,
1452        }
1453    }
1454}
1455
1456impl TodoPriority {
1457    pub fn from_core(priority: crate::core::TodoPriority) -> Self {
1458        match priority {
1459            crate::core::TodoPriority::High => Self::High,
1460            crate::core::TodoPriority::Medium => Self::Medium,
1461            crate::core::TodoPriority::Low => Self::Low,
1462        }
1463    }
1464
1465    pub fn from_wire(priority: &str) -> Option<Self> {
1466        match priority {
1467            "high" => Some(Self::High),
1468            "medium" => Some(Self::Medium),
1469            "low" => Some(Self::Low),
1470            _ => None,
1471        }
1472    }
1473}
1474
1475impl SubagentStatusView {
1476    pub fn is_terminal(self) -> bool {
1477        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
1478    }
1479
1480    pub fn is_active(self) -> bool {
1481        matches!(self, Self::Pending | Self::Running)
1482    }
1483
1484    pub fn from_wire(status: &str) -> Option<Self> {
1485        match status {
1486            "pending" | "queued" => Some(Self::Pending),
1487            "running" => Some(Self::Running),
1488            "completed" | "done" => Some(Self::Completed),
1489            "failed" | "error" => Some(Self::Failed),
1490            "cancelled" => Some(Self::Cancelled),
1491            _ => None,
1492        }
1493    }
1494
1495    pub fn from_lifecycle(status: crate::session::types::SubAgentLifecycleStatus) -> Self {
1496        match status {
1497            crate::session::types::SubAgentLifecycleStatus::Pending => Self::Pending,
1498            crate::session::types::SubAgentLifecycleStatus::Running => Self::Running,
1499            crate::session::types::SubAgentLifecycleStatus::Completed => Self::Completed,
1500            crate::session::types::SubAgentLifecycleStatus::Failed => Self::Failed,
1501            crate::session::types::SubAgentLifecycleStatus::Cancelled => Self::Cancelled,
1502        }
1503    }
1504}
1505
1506fn to_subagent_item_view(item: &SubagentEventItem) -> Option<SubagentItemView> {
1507    let status = SubagentStatusView::from_wire(&item.status)?;
1508    Some(SubagentItemView {
1509        task_id: item.task_id.clone(),
1510        name: item.name.clone(),
1511        parent_task_id: item.parent_task_id.clone(),
1512        agent_name: item.agent_name.clone(),
1513        prompt: item.prompt.clone(),
1514        summary: item.summary.clone().or(item.error.clone()),
1515        depth: item.depth,
1516        started_at: item.started_at,
1517        finished_at: item.finished_at,
1518        status,
1519    })
1520}
1521
1522impl Default for ChatApp {
1523    fn default() -> Self {
1524        Self::new("Session".to_string(), Path::new("."))
1525    }
1526}
1527
1528fn detect_git_branch(cwd: &Path) -> Option<String> {
1529    let branch = run_git_command(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
1530    if branch == "HEAD" {
1531        return run_git_command(cwd, &["rev-parse", "--short", "HEAD"])
1532            .map(|hash| format!("detached@{hash}"));
1533    }
1534    Some(branch)
1535}
1536
1537fn run_git_command(cwd: &Path, args: &[&str]) -> Option<String> {
1538    let output = Command::new("git")
1539        .arg("-C")
1540        .arg(cwd)
1541        .args(args)
1542        .output()
1543        .ok()?;
1544
1545    if !output.status.success() {
1546        return None;
1547    }
1548
1549    let text = String::from_utf8(output.stdout).ok()?;
1550    let trimmed = text.trim();
1551    if trimmed.is_empty() {
1552        return None;
1553    }
1554
1555    Some(trimmed.to_string())
1556}
1557
1558fn current_line_bounds(input: &str, cursor: usize) -> (usize, usize) {
1559    let cursor = cursor.min(input.len());
1560    let start = input[..cursor].rfind('\n').map_or(0, |idx| idx + 1);
1561    let end = input[cursor..]
1562        .find('\n')
1563        .map_or(input.len(), |idx| cursor + idx);
1564    (start, end)
1565}
1566
1567fn cursor_line_col(input: &str, cursor: usize) -> (usize, usize) {
1568    let cursor = cursor.min(input.len());
1569    let mut line = 0usize;
1570    let mut line_start = 0usize;
1571
1572    for (idx, ch) in input.char_indices() {
1573        if idx >= cursor {
1574            break;
1575        }
1576        if ch == '\n' {
1577            line += 1;
1578            line_start = idx + 1;
1579        }
1580    }
1581
1582    let col = input[line_start..cursor].chars().count();
1583    (line, col)
1584}
1585
1586fn line_col_to_cursor(input: &str, target_line: usize, target_col: usize) -> usize {
1587    let mut line_start = 0usize;
1588
1589    for (line_idx, line) in input.split('\n').enumerate() {
1590        let line_end = line_start + line.len();
1591        if line_idx == target_line {
1592            let rel = line
1593                .char_indices()
1594                .nth(target_col)
1595                .map_or(line.len(), |(idx, _)| idx);
1596            return line_start + rel;
1597        }
1598        line_start = line_end + 1;
1599    }
1600
1601    input.len()
1602}
1603
1604fn line_bounds_by_index(input: &str, target_line: usize) -> Option<(usize, usize)> {
1605    let mut line_start = 0usize;
1606
1607    for (line_idx, line) in input.split('\n').enumerate() {
1608        let line_end = line_start + line.len();
1609        if line_idx == target_line {
1610            return Some((line_start, line_end));
1611        }
1612        line_start = line_end + 1;
1613    }
1614
1615    None
1616}