Skip to main content

imp_tui/views/
editor.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use crate::animation::{activity_label, format_elapsed, ActivitySurface, AnimationState};
5use imp_core::config::AnimationLevel;
6use imp_llm::ThinkingLevel;
7use ratatui::buffer::Buffer;
8use ratatui::layout::{Alignment, Rect};
9use ratatui::style::{Color, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Borders, Widget};
12use unicode_width::UnicodeWidthChar;
13
14use crate::theme::Theme;
15
16/// Multi-line editor state with cursor management.
17#[derive(Debug, Clone)]
18pub struct EditorState {
19    pub content: String,
20    pub cursor: usize,
21    pub cursor_line: usize,
22    pub cursor_col: usize,
23    pub history: Vec<String>,
24    pub history_idx: Option<usize>,
25    pub scroll_offset: usize,
26}
27
28impl EditorState {
29    fn normalized_cursor(&self) -> usize {
30        clamp_cursor_to_boundary(&self.content, self.cursor)
31    }
32
33    fn normalize_cursor(&mut self) {
34        self.cursor = self.normalized_cursor();
35    }
36
37    pub fn new() -> Self {
38        Self {
39            content: String::new(),
40            cursor: 0,
41            cursor_line: 0,
42            cursor_col: 0,
43            history: Vec::new(),
44            history_idx: None,
45            scroll_offset: 0,
46        }
47    }
48
49    pub fn insert_char(&mut self, c: char) {
50        self.normalize_cursor();
51        self.content.insert(self.cursor, c);
52        self.cursor += c.len_utf8();
53        self.update_position();
54    }
55
56    pub fn insert_newline(&mut self) {
57        self.normalize_cursor();
58        self.content.insert(self.cursor, '\n');
59        self.cursor += 1;
60        self.update_position();
61    }
62
63    pub fn delete_back(&mut self) {
64        self.normalize_cursor();
65        if self.cursor > 0 {
66            let prev = prev_char_boundary(&self.content, self.cursor);
67            self.content.drain(prev..self.cursor);
68            self.cursor = prev;
69            self.update_position();
70        }
71    }
72
73    pub fn delete_forward(&mut self) {
74        self.normalize_cursor();
75        if self.cursor < self.content.len() {
76            let next = next_char_boundary(&self.content, self.cursor);
77            self.content.drain(self.cursor..next);
78            self.update_position();
79        }
80    }
81
82    pub fn move_left(&mut self) {
83        self.normalize_cursor();
84        if self.cursor > 0 {
85            self.cursor = prev_char_boundary(&self.content, self.cursor);
86            self.update_position();
87        }
88    }
89
90    pub fn move_right(&mut self) {
91        self.normalize_cursor();
92        if self.cursor < self.content.len() {
93            self.cursor = next_char_boundary(&self.content, self.cursor);
94            self.update_position();
95        }
96    }
97
98    pub fn move_up(&mut self) -> bool {
99        self.normalize_cursor();
100        self.update_position();
101        if self.cursor_line == 0 {
102            return false; // signal: at top, caller may use for history
103        }
104        let lines: Vec<&str> = self.content.split('\n').collect();
105        let target_line = self.cursor_line - 1;
106        let target_col = self.cursor_col.min(lines[target_line].len());
107        self.cursor = line_col_to_byte(&lines, target_line, target_col);
108        self.update_position();
109        true
110    }
111
112    pub fn move_down(&mut self) -> bool {
113        self.normalize_cursor();
114        self.update_position();
115        let lines: Vec<&str> = self.content.split('\n').collect();
116        if self.cursor_line >= lines.len() - 1 {
117            return false; // signal: at bottom, caller may use for history
118        }
119        let target_line = self.cursor_line + 1;
120        let target_col = self.cursor_col.min(lines[target_line].len());
121        self.cursor = line_col_to_byte(&lines, target_line, target_col);
122        self.update_position();
123        true
124    }
125
126    pub fn move_home(&mut self) {
127        self.normalize_cursor();
128        let before = &self.content[..self.cursor];
129        self.cursor = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
130        self.update_position();
131    }
132
133    pub fn move_end(&mut self) {
134        self.normalize_cursor();
135        let after = &self.content[self.cursor..];
136        self.cursor += after.find('\n').unwrap_or(after.len());
137        self.update_position();
138    }
139
140    pub fn move_word_left(&mut self) {
141        self.normalize_cursor();
142        if self.cursor == 0 {
143            return;
144        }
145        let bytes = self.content.as_bytes();
146        let mut pos = self.cursor;
147        // Skip whitespace
148        while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
149            pos -= 1;
150        }
151        // Skip word chars
152        while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
153            pos -= 1;
154        }
155        self.cursor = pos;
156        self.update_position();
157    }
158
159    pub fn move_word_right(&mut self) {
160        self.normalize_cursor();
161        let bytes = self.content.as_bytes();
162        let len = bytes.len();
163        let mut pos = self.cursor;
164        // Skip current word
165        while pos < len && !bytes[pos].is_ascii_whitespace() {
166            pos += 1;
167        }
168        // Skip whitespace
169        while pos < len && bytes[pos].is_ascii_whitespace() {
170            pos += 1;
171        }
172        self.cursor = pos;
173        self.update_position();
174    }
175
176    pub fn delete_word_back(&mut self) {
177        self.normalize_cursor();
178        if self.cursor == 0 {
179            return;
180        }
181        let start = self.cursor;
182        self.move_word_left();
183        self.content.drain(self.cursor..start);
184        self.update_position();
185    }
186
187    pub fn delete_to_start(&mut self) {
188        self.normalize_cursor();
189        let line_start = {
190            let before = &self.content[..self.cursor];
191            before.rfind('\n').map(|p| p + 1).unwrap_or(0)
192        };
193        self.content.drain(line_start..self.cursor);
194        self.cursor = line_start;
195        self.update_position();
196    }
197
198    pub fn delete_to_end(&mut self) {
199        self.normalize_cursor();
200        let line_end = {
201            let after = &self.content[self.cursor..];
202            self.cursor + after.find('\n').unwrap_or(after.len())
203        };
204        self.content.drain(self.cursor..line_end);
205        self.update_position();
206    }
207
208    pub fn clear(&mut self) {
209        self.content.clear();
210        self.cursor = 0;
211        self.update_position();
212    }
213
214    pub fn set_content(&mut self, text: &str) {
215        self.content = text.to_string();
216        self.cursor = self.content.len();
217        self.update_position();
218    }
219
220    pub fn content(&self) -> &str {
221        &self.content
222    }
223
224    pub fn is_empty(&self) -> bool {
225        self.content.trim().is_empty()
226    }
227
228    pub fn line_count(&self) -> usize {
229        self.content.split('\n').count().max(1)
230    }
231
232    pub fn visual_line_count_with_summary(&self, inner_width: u16, summarize_paste: bool) -> usize {
233        editor_display_lines(&self.content, inner_width, summarize_paste)
234            .len()
235            .max(1)
236    }
237
238    pub fn visual_line_count(&self, inner_width: u16) -> usize {
239        self.visual_line_count_with_summary(inner_width, false)
240    }
241
242    pub fn push_history(&mut self) {
243        if !self.content.trim().is_empty() {
244            self.history.push(self.content.clone());
245        }
246        self.history_idx = None;
247    }
248
249    pub fn history_prev(&mut self) {
250        if self.history.is_empty() {
251            return;
252        }
253        let idx = match self.history_idx {
254            Some(i) if i > 0 => i - 1,
255            Some(_) => return,
256            None => {
257                if !self.content.is_empty() {
258                    self.history.push(self.content.clone());
259                }
260                self.history.len() - 1
261            }
262        };
263        self.history_idx = Some(idx);
264        self.content = self.history[idx].clone();
265        self.cursor = self.content.len();
266        self.update_position();
267    }
268
269    pub fn history_next(&mut self) {
270        if let Some(i) = self.history_idx {
271            if i + 1 < self.history.len() {
272                self.history_idx = Some(i + 1);
273                self.content = self.history[i + 1].clone();
274            } else {
275                self.history_idx = None;
276                self.content.clear();
277            }
278            self.cursor = self.content.len();
279            self.update_position();
280        }
281    }
282
283    /// Calculate cursor position relative to a render area, accounting for soft wraps.
284    pub fn cursor_screen_position(&self, area: Rect) -> (u16, u16) {
285        if area.width == 0 || area.height == 0 {
286            return (area.x, area.y);
287        }
288
289        let inner_x = area.x.saturating_add(1); // account for border
290        let inner_y = area.y.saturating_add(1);
291        let inner_width = area.width.saturating_sub(2).max(1);
292        let cursor = self.normalized_cursor();
293        let (visual_line, visual_col) =
294            cursor_visual_position_for_text(&self.content, cursor, inner_width);
295        let x = inner_x.saturating_add(visual_col as u16);
296        let y =
297            inner_y.saturating_add((visual_line as u16).saturating_sub(self.scroll_offset as u16));
298        let max_x = area.x.saturating_add(area.width.saturating_sub(2));
299        let max_y = area.y.saturating_add(area.height.saturating_sub(2));
300        (x.min(max_x), y.min(max_y))
301    }
302
303    fn update_position(&mut self) {
304        self.normalize_cursor();
305        let before = &self.content[..self.cursor];
306        self.cursor_line = before.matches('\n').count();
307        self.cursor_col = before
308            .rfind('\n')
309            .map(|p| self.cursor - p - 1)
310            .unwrap_or(self.cursor);
311    }
312}
313
314impl Default for EditorState {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320/// The editor widget renders the input area with border and cursor.
321pub struct EditorView<'a> {
322    state: &'a EditorState,
323    theme: &'a Theme,
324    thinking_level: ThinkingLevel,
325    summarize_paste: bool,
326    model_name: &'a str,
327    cwd: &'a str,
328    session_name: &'a str,
329    is_streaming: bool,
330    has_queued: bool,
331    current_context_tokens: u32,
332    context_window: u32,
333    show_context_usage: bool,
334    turn_elapsed: Option<Duration>,
335    extension_items: Option<&'a HashMap<String, String>>,
336    peek: bool,
337    tick: u64,
338    animation_level: AnimationLevel,
339    activity_state: AnimationState,
340}
341
342impl<'a> EditorView<'a> {
343    pub fn new(state: &'a EditorState, theme: &'a Theme, thinking_level: ThinkingLevel) -> Self {
344        Self {
345            state,
346            theme,
347            thinking_level,
348            summarize_paste: false,
349            model_name: "",
350            cwd: "",
351            session_name: "",
352            is_streaming: false,
353            has_queued: false,
354            current_context_tokens: 0,
355            context_window: 0,
356            show_context_usage: true,
357            turn_elapsed: None,
358            extension_items: None,
359            peek: false,
360            tick: 0,
361            animation_level: AnimationLevel::Minimal,
362            activity_state: AnimationState::Idle,
363        }
364    }
365
366    pub fn summarize_paste(mut self, summarize: bool) -> Self {
367        self.summarize_paste = summarize;
368        self
369    }
370
371    /// Set the model name shown in the editor border.
372    pub fn model(mut self, name: &'a str) -> Self {
373        self.model_name = name;
374        self
375    }
376
377    pub fn identity(mut self, cwd: &'a str, session_name: &'a str) -> Self {
378        self.cwd = cwd;
379        self.session_name = session_name;
380        self
381    }
382
383    pub fn turn_elapsed(mut self, elapsed: Option<Duration>) -> Self {
384        self.turn_elapsed = elapsed;
385        self
386    }
387
388    pub fn extension_items(mut self, items: &'a HashMap<String, String>, peek: bool) -> Self {
389        self.extension_items = Some(items);
390        self.peek = peek;
391        self
392    }
393
394    pub fn streaming(mut self, streaming: bool) -> Self {
395        self.is_streaming = streaming;
396        self
397    }
398
399    pub fn queued(mut self, has_queued: bool) -> Self {
400        self.has_queued = has_queued;
401        self
402    }
403
404    pub fn context_usage(mut self, current_tokens: u32, context_window: u32, show: bool) -> Self {
405        self.current_context_tokens = current_tokens;
406        self.context_window = context_window;
407        self.show_context_usage = show;
408        self
409    }
410
411    pub fn tick(mut self, tick: u64) -> Self {
412        self.tick = tick;
413        self
414    }
415
416    pub fn animation_level(mut self, level: AnimationLevel) -> Self {
417        self.animation_level = level;
418        self
419    }
420
421    pub fn activity_state(mut self, state: AnimationState) -> Self {
422        self.activity_state = state;
423        self
424    }
425}
426
427impl Widget for EditorView<'_> {
428    fn render(self, area: Rect, buf: &mut Buffer) {
429        if area.height == 0 || area.width < 4 {
430            return;
431        }
432
433        let prompt_activity_state = if self.has_queued {
434            AnimationState::Queued
435        } else {
436            self.activity_state
437        };
438
439        let border_style = superbar_border_style(
440            self.theme,
441            self.thinking_level,
442            prompt_activity_state,
443            self.tick,
444            self.animation_level,
445        );
446
447        let top_left = build_identity_label(self.cwd, self.session_name, area.width);
448        let top_right = build_top_right_label(self.turn_elapsed, self.theme);
449        let bottom_left =
450            build_bottom_left_label(prompt_activity_state, self.tick, self.animation_level);
451        let activity =
452            editor_activity_label(prompt_activity_state, self.tick, self.animation_level);
453
454        // Build bottom-right metadata cluster
455        let thinking_label = match self.thinking_level {
456            ThinkingLevel::Off => "",
457            ThinkingLevel::Minimal => "min",
458            ThinkingLevel::Low => "low",
459            ThinkingLevel::Medium => "med",
460            ThinkingLevel::High => "high",
461            ThinkingLevel::XHigh => "xhigh",
462        };
463        let model_label = if self.model_name.is_empty() {
464            None
465        } else {
466            Some(self.model_name.to_string())
467        };
468        let queue_label = None;
469        let context_ratio = if self.context_window > 0 {
470            self.current_context_tokens as f64 / self.context_window as f64
471        } else {
472            0.0
473        };
474        let context_style = if context_ratio >= 0.75 {
475            self.theme.error_style()
476        } else if context_ratio >= 0.50 {
477            self.theme.warning_style()
478        } else {
479            self.theme.muted_style()
480        };
481        let mut bottom_spans = Vec::new();
482        let mut push_part = |text: String, style: Style| {
483            if !bottom_spans.is_empty() {
484                bottom_spans.push(Span::styled(" · ".to_string(), self.theme.muted_style()));
485            }
486            bottom_spans.push(Span::styled(text, style));
487        };
488        if let Some(model) = model_label {
489            push_part(model, self.theme.accent_style());
490        }
491        if !thinking_label.is_empty() {
492            push_part(
493                thinking_label.to_string(),
494                Style::default().fg(self.theme.thinking_border_color(self.thinking_level)),
495            );
496        }
497        if self.show_context_usage && self.context_window > 0 {
498            push_part(
499                format_context_usage(self.current_context_tokens, self.context_window),
500                context_style,
501            );
502        }
503        if let Some(queue) = queue_label {
504            push_part(queue, self.theme.warning_style());
505        }
506        if !activity.is_empty() {
507            push_part(activity, self.theme.muted_style());
508        }
509
510        let block = Block::default()
511            .title(Line::from(top_left))
512            .title(Line::from(top_right).alignment(Alignment::Right))
513            .title_bottom(Line::from(bottom_left))
514            .title_bottom(Line::from(bottom_spans).alignment(Alignment::Right))
515            .borders(Borders::ALL)
516            .border_style(border_style);
517
518        let inner = block.inner(area);
519        block.render(area, buf);
520
521        // Render editor content using wrapped visual lines so auto-grow and cursor math stay aligned.
522        let lines = editor_display_lines(&self.state.content, inner.width, self.summarize_paste)
523            .into_iter()
524            .skip(self.state.scroll_offset)
525            .take(inner.height as usize)
526            .collect::<Vec<_>>();
527
528        for (idx, line) in lines.iter().enumerate() {
529            if idx >= inner.height as usize {
530                break;
531            }
532            buf.set_line(
533                inner.x,
534                inner.y + idx as u16,
535                &Line::raw(line.clone()),
536                inner.width,
537            );
538        }
539
540        // Placeholder text when empty and not streaming
541        if self.state.content.is_empty() && !self.is_streaming {
542            let placeholder = "Ask anything… ⇧↵ newline  @file attach context  / palette  : shell";
543            buf.set_string(
544                inner.x,
545                inner.y,
546                placeholder,
547                Style::default().fg(Color::DarkGray),
548            );
549        }
550    }
551}
552
553// --- Helpers ---
554
555fn editor_display_lines(text: &str, inner_width: u16, summarize_paste: bool) -> Vec<String> {
556    if summarize_paste {
557        if let Some(summary) = crate::views::chat::pasted_block_summary(text) {
558            return wrapped_lines_for_width(&summary, inner_width);
559        }
560    }
561
562    wrapped_lines_for_width(text, inner_width)
563}
564
565fn build_identity_label(cwd: &str, session_name: &str, area_width: u16) -> Vec<Span<'static>> {
566    let max_path = (area_width as usize / 3).clamp(12, 36);
567    let cwd = abbreviate_home(cwd);
568    let cwd = shorten_path(&cwd, max_path);
569    let session_name = session_name.trim();
570
571    let mut spans = vec![Span::raw(cwd)];
572    if !session_name.is_empty() {
573        spans.push(Span::raw(" · "));
574        spans.push(Span::raw(session_name.to_string()));
575    }
576    spans
577}
578
579fn build_top_right_label(turn_elapsed: Option<Duration>, theme: &Theme) -> Vec<Span<'static>> {
580    turn_elapsed
581        .map(|elapsed| vec![Span::styled(format_elapsed(elapsed), theme.muted_style())])
582        .unwrap_or_default()
583}
584
585fn build_bottom_left_label(
586    activity_state: AnimationState,
587    tick: u64,
588    animation_level: AnimationLevel,
589) -> Vec<Span<'static>> {
590    let label = editor_activity_label(activity_state, tick, animation_level);
591    if label.is_empty() {
592        Vec::new()
593    } else {
594        vec![Span::raw(label)]
595    }
596}
597
598fn editor_activity_label(
599    activity_state: AnimationState,
600    tick: u64,
601    animation_level: AnimationLevel,
602) -> String {
603    match activity_state {
604        AnimationState::Thinking | AnimationState::WaitingForResponse => String::new(),
605        _ => activity_label(
606            activity_state,
607            tick,
608            animation_level,
609            ActivitySurface::Editor,
610        ),
611    }
612}
613
614fn superbar_border_style(
615    theme: &Theme,
616    thinking_level: ThinkingLevel,
617    activity_state: AnimationState,
618    tick: u64,
619    animation_level: AnimationLevel,
620) -> Style {
621    let color = superbar_border_color(theme, thinking_level, activity_state, tick, animation_level);
622    let mut style = Style::default().fg(color);
623    if superbar_border_is_animated(activity_state, animation_level) {
624        style = style.add_modifier(ratatui::style::Modifier::BOLD);
625    }
626    style
627}
628
629fn superbar_border_is_animated(
630    activity_state: AnimationState,
631    animation_level: AnimationLevel,
632) -> bool {
633    if animation_level == AnimationLevel::None {
634        return false;
635    }
636
637    !matches!(
638        activity_state,
639        AnimationState::Idle | AnimationState::Thinking | AnimationState::WaitingForResponse
640    )
641}
642
643fn superbar_border_color(
644    theme: &Theme,
645    thinking_level: ThinkingLevel,
646    activity_state: AnimationState,
647    tick: u64,
648    animation_level: AnimationLevel,
649) -> Color {
650    let base = theme.thinking_border_color(thinking_level);
651    if !superbar_border_is_animated(activity_state, animation_level) {
652        return base;
653    }
654
655    let target = match activity_state {
656        AnimationState::Idle => base,
657        AnimationState::WaitingForResponse => theme.muted,
658        AnimationState::Thinking => theme.accent,
659        AnimationState::ExecutingTools { .. } => theme.warning,
660        AnimationState::Streaming => theme.success,
661        AnimationState::Queued => theme.warning,
662    };
663
664    let pulse = match animation_level {
665        AnimationLevel::None => 0.0,
666        AnimationLevel::Minimal => {
667            const PULSE: [f32; 4] = [0.10, 0.22, 0.34, 0.22];
668            PULSE[(tick / 4) as usize % PULSE.len()]
669        }
670        AnimationLevel::Spinner => {
671            const PULSE: [f32; 6] = [0.16, 0.32, 0.50, 0.68, 0.50, 0.32];
672            PULSE[(tick / 2) as usize % PULSE.len()]
673        }
674    };
675
676    mix_color(base, target, pulse)
677}
678
679fn mix_color(base: Color, target: Color, amount: f32) -> Color {
680    let amount = amount.clamp(0.0, 1.0);
681    match (base, target) {
682        (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => Color::Rgb(
683            mix_channel(br, tr, amount),
684            mix_channel(bg, tg, amount),
685            mix_channel(bb, tb, amount),
686        ),
687        _ if amount >= 0.5 => target,
688        _ => base,
689    }
690}
691
692fn mix_channel(base: u8, target: u8, amount: f32) -> u8 {
693    (base as f32 + (target as f32 - base as f32) * amount).round() as u8
694}
695
696fn abbreviate_home(path: &str) -> String {
697    if path == "/Users/asher" {
698        "~".to_string()
699    } else if let Some(rest) = path.strip_prefix("/Users/asher/") {
700        format!("~/{rest}")
701    } else {
702        path.to_string()
703    }
704}
705
706fn shorten_path(path: &str, max_len: usize) -> String {
707    if path.len() <= max_len {
708        return path.to_string();
709    }
710
711    if let Some(rest) = path.strip_prefix("~/") {
712        let shortened = shorten_path(&format!("home/{rest}"), max_len.saturating_sub(1));
713        return shortened.replacen("home/", "~/", 1);
714    }
715
716    let parts: Vec<&str> = path.split('/').collect();
717    let mut result = String::new();
718    for part in parts.iter().rev() {
719        let candidate = if result.is_empty() {
720            part.to_string()
721        } else {
722            format!("{part}/{result}")
723        };
724        if candidate.len() > max_len {
725            break;
726        }
727        result = candidate;
728    }
729
730    if result.len() < path.len() {
731        format!("…/{result}")
732    } else {
733        result
734    }
735}
736
737fn format_context_usage(current_tokens: u32, context_window: u32) -> String {
738    if context_window == 0 {
739        return format_compact_tokens(current_tokens);
740    }
741    let percent = ((current_tokens as f64 / context_window as f64) * 100.0).round();
742    format!("{percent:.0}%/{}", format_compact_tokens(context_window))
743}
744
745fn format_compact_tokens(tokens: u32) -> String {
746    if tokens >= 1_000_000 {
747        format!("{:.1}M", tokens as f64 / 1_000_000.0)
748    } else if tokens >= 1_000 {
749        let value = tokens as f64 / 1_000.0;
750        if value >= 100.0 {
751            format!("{:.0}k", value)
752        } else if value >= 10.0 {
753            format!("{:.1}k", value)
754        } else {
755            format!("{:.2}k", value)
756        }
757    } else {
758        tokens.to_string()
759    }
760}
761
762fn prev_char_boundary(s: &str, pos: usize) -> usize {
763    let mut p = pos;
764    while p > 0 {
765        p -= 1;
766        if s.is_char_boundary(p) {
767            return p;
768        }
769    }
770    0
771}
772
773fn next_char_boundary(s: &str, pos: usize) -> usize {
774    let mut p = pos.min(s.len());
775    while p < s.len() {
776        p += 1;
777        if s.is_char_boundary(p) {
778            return p;
779        }
780    }
781    s.len()
782}
783
784pub fn clamp_cursor_to_boundary(text: &str, cursor: usize) -> usize {
785    let mut clamped = cursor.min(text.len());
786    while clamped > 0 && !text.is_char_boundary(clamped) {
787        clamped -= 1;
788    }
789    clamped
790}
791
792fn line_col_to_byte(lines: &[&str], line: usize, col: usize) -> usize {
793    let mut byte = 0;
794    for (i, l) in lines.iter().enumerate() {
795        if i == line {
796            return byte + col.min(l.len());
797        }
798        byte += l.len() + 1; // +1 for \n
799    }
800    byte
801}
802
803pub fn wrapped_lines_for_width(text: &str, inner_width: u16) -> Vec<String> {
804    let width = inner_width.max(1) as usize;
805    let mut out = Vec::new();
806
807    for logical in text.split('\n') {
808        if logical.is_empty() {
809            out.push(String::new());
810            continue;
811        }
812
813        let mut current = String::new();
814        let mut current_width = 0usize;
815
816        for ch in logical.chars() {
817            let ch_width = char_display_width(ch);
818
819            if !current.is_empty() && current_width + ch_width > width {
820                out.push(current);
821                current = String::new();
822                current_width = 0;
823            }
824
825            if current.is_empty() && ch_width > width {
826                out.push(ch.to_string());
827                continue;
828            }
829
830            current.push(ch);
831            current_width += ch_width;
832
833            if current_width == width {
834                out.push(current);
835                current = String::new();
836                current_width = 0;
837            }
838        }
839
840        if !current.is_empty() {
841            out.push(current);
842        }
843    }
844
845    if out.is_empty() {
846        out.push(String::new());
847    }
848
849    out
850}
851
852pub fn cursor_visual_position_for_text(
853    text: &str,
854    cursor: usize,
855    inner_width: u16,
856) -> (usize, usize) {
857    let width = inner_width.max(1) as usize;
858    let mut row = 0usize;
859    let mut col = 0usize;
860    let mut byte = 0usize;
861
862    for ch in text.chars() {
863        if byte >= cursor {
864            break;
865        }
866
867        if ch == '\n' {
868            row += 1;
869            col = 0;
870            byte += ch.len_utf8();
871            continue;
872        }
873
874        let ch_width = char_display_width(ch);
875
876        if col > 0 && col + ch_width > width {
877            row += 1;
878            col = 0;
879        }
880
881        if col == 0 && ch_width > width {
882            row += 1;
883            col = 0;
884            byte += ch.len_utf8();
885            continue;
886        }
887
888        col += ch_width;
889        byte += ch.len_utf8();
890
891        if col == width {
892            row += 1;
893            col = 0;
894        }
895    }
896
897    (row, col)
898}
899
900fn char_display_width(ch: char) -> usize {
901    match ch {
902        '\t' => 4,
903        _ => ch.width().unwrap_or(1).max(1),
904    }
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910    use ratatui::layout::Rect;
911
912    #[test]
913    fn format_context_usage_prefers_percent_over_current_tokens() {
914        assert_eq!(format_context_usage(82_400, 1_000_000), "8%/1.0M");
915        assert_eq!(format_context_usage(500_000, 1_000_000), "50%/1.0M");
916    }
917
918    #[test]
919    fn format_compact_tokens_handles_millions() {
920        assert_eq!(format_compact_tokens(1_000_000), "1.0M");
921        assert_eq!(format_compact_tokens(1_250_000), "1.2M");
922    }
923
924    #[test]
925    fn format_compact_tokens_handles_thousands() {
926        assert_eq!(format_compact_tokens(9_500), "9.50k");
927        assert_eq!(format_compact_tokens(12_300), "12.3k");
928        assert_eq!(format_compact_tokens(234_000), "234k");
929    }
930
931    #[test]
932    fn visual_line_count_includes_soft_wraps() {
933        let mut editor = EditorState::new();
934        editor.set_content("abcdefghij");
935
936        assert_eq!(editor.visual_line_count(4), 3);
937    }
938
939    #[test]
940    fn visual_line_count_with_paste_summary_uses_summary_height() {
941        let mut editor = EditorState::new();
942        editor.set_content(
943            &(1..=25)
944                .map(|i| format!("fn example_{i}() {{}}"))
945                .collect::<Vec<_>>()
946                .join("\n"),
947        );
948
949        assert_eq!(editor.visual_line_count_with_summary(80, true), 1);
950    }
951
952    #[test]
953    fn cursor_screen_position_tracks_soft_wraps() {
954        let mut editor = EditorState::new();
955        editor.set_content("abcdefghij");
956
957        let area = Rect::new(0, 0, 6, 5); // inner width = 4
958        let (x, y) = editor.cursor_screen_position(area);
959
960        assert_eq!((x, y), (3, 3));
961    }
962
963    #[test]
964    fn editor_operations_clamp_cursor_past_end() {
965        let mut editor = EditorState::new();
966        editor.set_content("abc");
967        editor.cursor = 99;
968
969        editor.delete_back();
970
971        assert_eq!(editor.content(), "ab");
972        assert_eq!(editor.cursor, 2);
973    }
974
975    #[test]
976    fn editor_operations_clamp_invalid_utf8_boundary() {
977        let mut editor = EditorState::new();
978        editor.set_content("éx");
979        editor.cursor = 1; // inside 'é'
980
981        editor.insert_char('!');
982
983        assert_eq!(editor.content(), "!éx");
984        assert!(editor.content().is_char_boundary(editor.cursor));
985    }
986
987    #[test]
988    fn cursor_screen_position_handles_tiny_area_without_underflow() {
989        let mut editor = EditorState::new();
990        editor.set_content("abc");
991        editor.cursor = usize::MAX;
992
993        let (x, y) = editor.cursor_screen_position(Rect::new(5, 7, 0, 0));
994        assert_eq!((x, y), (5, 7));
995
996        let (x, y) = editor.cursor_screen_position(Rect::new(5, 7, 1, 1));
997        assert_eq!((x, y), (5, 7));
998    }
999
1000    #[test]
1001    fn abbreviate_home_prefers_tilde() {
1002        assert_eq!(abbreviate_home("/Users/asher/tower/imp"), "~/tower/imp");
1003        assert_eq!(abbreviate_home("/tmp/project"), "/tmp/project");
1004    }
1005
1006    #[test]
1007    fn identity_label_prefers_tilde_path() {
1008        let rendered = build_identity_label("/Users/asher/tower/imp", "chat", 80);
1009        let text: String = rendered
1010            .into_iter()
1011            .map(|span| span.content.into_owned())
1012            .collect();
1013        assert!(text.contains("~/tower/imp"));
1014        assert!(text.contains("chat"));
1015    }
1016
1017    #[test]
1018    fn bottom_left_label_uses_live_run_state() {
1019        let rendered = build_bottom_left_label(
1020            AnimationState::ExecutingTools { active_tools: 2 },
1021            0,
1022            AnimationLevel::Minimal,
1023        );
1024        let text: String = rendered
1025            .into_iter()
1026            .map(|span| span.content.into_owned())
1027            .collect();
1028        assert!(text.contains("working"));
1029    }
1030
1031    #[test]
1032    fn top_right_label_renders_elapsed() {
1033        let theme = Theme::default();
1034        let rendered = build_top_right_label(Some(Duration::from_secs(75)), &theme);
1035        let text: String = rendered
1036            .into_iter()
1037            .map(|span| span.content.into_owned())
1038            .collect();
1039        assert!(text.contains("1m15s"));
1040    }
1041
1042    #[test]
1043    fn bottom_left_label_hides_thinking_state() {
1044        let rendered =
1045            build_bottom_left_label(AnimationState::Thinking, 0, AnimationLevel::Minimal);
1046        assert!(rendered.is_empty());
1047    }
1048
1049    #[test]
1050    fn superbar_border_color_stays_base_when_idle() {
1051        let theme = Theme::default();
1052        let color = superbar_border_color(
1053            &theme,
1054            ThinkingLevel::Medium,
1055            AnimationState::Idle,
1056            0,
1057            AnimationLevel::Spinner,
1058        );
1059        assert_eq!(color, theme.thinking_border_color(ThinkingLevel::Medium));
1060    }
1061
1062    #[test]
1063    fn superbar_border_color_stays_base_when_thinking() {
1064        let theme = Theme::default();
1065        let color = superbar_border_color(
1066            &theme,
1067            ThinkingLevel::Medium,
1068            AnimationState::Thinking,
1069            6,
1070            AnimationLevel::Spinner,
1071        );
1072        assert_eq!(color, theme.thinking_border_color(ThinkingLevel::Medium));
1073    }
1074
1075    #[test]
1076    fn superbar_border_color_pulses_when_active() {
1077        let theme = Theme::default();
1078        let early = superbar_border_color(
1079            &theme,
1080            ThinkingLevel::Medium,
1081            AnimationState::ExecutingTools { active_tools: 1 },
1082            0,
1083            AnimationLevel::Spinner,
1084        );
1085        let peak = superbar_border_color(
1086            &theme,
1087            ThinkingLevel::Medium,
1088            AnimationState::ExecutingTools { active_tools: 1 },
1089            6,
1090            AnimationLevel::Spinner,
1091        );
1092        assert_ne!(early, peak);
1093    }
1094}