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#[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; }
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; }
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 while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
149 pos -= 1;
150 }
151 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 while pos < len && !bytes[pos].is_ascii_whitespace() {
166 pos += 1;
167 }
168 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 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); 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
320pub 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 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 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 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 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
553fn 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; }
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); 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; 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}