Skip to main content

imp_tui/views/
editor.rs

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