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