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 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 pub text_selection: TextSelection,
290 pub clipboard_notice: Option<ClipboardNotice>,
291 pending_question: Option<PendingQuestionState>,
292 pub current_agent_name: Option<String>,
294 pub available_agents: Vec<AgentOptionView>,
295 agent_task: Option<tokio::task::JoinHandle<()>>,
297 esc_interrupt_pending: bool,
298 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 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 if let Some(pos) = primary_agents.iter().position(|a| a.name == current_name) {
397 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 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 if let (Some(duration), Some(agent)) = (
466 self.last_run_duration.take(),
467 self.selected_agent().cloned(),
468 ) {
469 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 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; self.mark_dirty();
821 }
822 SubmittedInput {
823 text: input,
824 attachments,
825 }
826 }
827
828 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 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 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 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 pub fn set_agent_task(&mut self, handle: tokio::task::JoinHandle<()>) {
1082 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 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 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 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}