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#[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; }
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; }
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 while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
193 pos -= 1;
194 }
195 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 while pos < len && !bytes[pos].is_ascii_whitespace() {
210 pos += 1;
211 }
212 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 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); 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
400pub 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 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 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 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 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
720fn 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; }
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(¤t);
1000 last_whitespace_byte = last_whitespace_byte_in(¤t);
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(¤t);
1032 last_whitespace_byte = last_whitespace_byte_in(¤t);
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); 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; 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}