1use diffy::Line as DiffLine;
2use diffy::Patch;
3use ratatui::Frame;
4use ratatui::layout::Alignment;
5use ratatui::layout::Constraint;
6use ratatui::layout::Layout;
7use ratatui::layout::Rect;
8use ratatui::style::Modifier;
9use ratatui::style::Style;
10use ratatui::text::Line;
11use ratatui::text::Span;
12use ratatui::text::Text;
13use ratatui::widgets::Block;
14use ratatui::widgets::Paragraph;
15use ratatui::widgets::Wrap;
16
17use super::PreviewContent;
18use super::TranscriptEntry;
19use super::TranscriptTone;
20use super::TuiState;
21use super::attachment_label;
22use super::highlight;
23use super::markdown;
24use super::shimmer;
25use crate::frontend::catalog::MenuItem;
26use crate::frontend::catalog::UiCatalog;
27use crate::frontend::theme::Role;
28use crate::frontend::theme::current;
29use mobius::protocol::FrontendBlockFormat;
30use mobius::protocol::FrontendSlot;
31use mobius::protocol::FrontendTone;
32use mobius::protocol::FrontendWidget;
33
34const MAX_MENU_ROWS: usize = 6;
35const COMPOSER_PROMPT: &str = "» ";
36const AGENT_MARKER: &str = "◉ ";
37const WELCOME_EYE: [&str; 6] = [
38 " ⣠⡤⢶⣛⣯⣭⣭⣟⣳⠶⣤⣀ ",
39 "⣴⣾⡽⠞⠋⣽⠉ ⠈⢻⠉⠙⠷⣭⣳⠦",
40 "⠛⠙⠛⠓⠶⠾⠷⣤⣴⠿⠶⠚⠛⠉⠉⠛",
41 " ⢠⡶⢻⡟⢷⣄ ",
42 " ⢻⣼⡇ ⠹⣦⡀⣀⣀⡀",
43 " ⠈⣿⡇ ⠈⠻⣟⣀⡿",
44];
45
46pub(super) fn render(frame: &mut Frame<'_>, state: &mut TuiState, catalog: &UiCatalog) {
47 let theme = current();
48 frame.render_widget(
49 Block::default().style(theme.style(Role::Canvas)),
50 frame.area(),
51 );
52 let reference_suggestions = state
53 .picker
54 .is_none()
55 .then(|| {
56 state
57 .reference_suggestions(catalog)
58 .map(|(_, matches)| matches)
59 })
60 .flatten();
61 let slash_suggestions = (state.picker.is_none() && reference_suggestions.is_none())
62 .then(|| catalog.command_suggestions(&state.input, state.cursor))
63 .flatten();
64 let menu_height = if let Some(picker) = &state.picker {
65 u16::try_from(picker.options.len().clamp(1, MAX_MENU_ROWS) + 1).unwrap_or(0)
66 } else {
67 reference_suggestions
68 .as_ref()
69 .map(Vec::len)
70 .or_else(|| slash_suggestions.as_ref().map(Vec::len))
71 .map_or(0, |length| {
72 u16::try_from(length.clamp(1, MAX_MENU_ROWS)).unwrap_or(0)
73 })
74 };
75 let (input, cursor_end) = marked_input(state);
76 let inner_width = frame.area().width.saturating_sub(2).max(1);
77 let input_rows = Paragraph::new(input.as_str())
78 .wrap(Wrap { trim: false })
79 .line_count(inner_width);
80 let max_composer_height = frame
81 .area()
82 .height
83 .saturating_sub(menu_height.saturating_add(3))
84 .max(3);
85 let composer_height = u16::try_from(input_rows)
86 .unwrap_or(u16::MAX)
87 .saturating_add(2)
88 .clamp(3, max_composer_height);
89 let input_row = input_cursor_row(&input[..cursor_end], inner_width);
90 let areas = Layout::vertical([
91 Constraint::Min(1),
92 Constraint::Length(menu_height),
93 Constraint::Length(1),
94 Constraint::Length(composer_height),
95 Constraint::Length(1),
96 ])
97 .split(frame.area());
98
99 render_transcript(frame, state, areas[0]);
100 if let Some(picker) = state.picker.as_mut() {
101 picker.selected = picker.selected.min(picker.options.len().saturating_sub(1));
102 render_picker_menu(frame, areas[1], picker);
103 } else if let Some(suggestions) = reference_suggestions {
104 state.reference_selection = state
105 .reference_selection
106 .min(suggestions.len().saturating_sub(1));
107 render_menu(frame, areas[1], &suggestions, state.reference_selection);
108 } else if let Some(suggestions) = slash_suggestions {
109 state.slash_selection = state
110 .slash_selection
111 .min(suggestions.len().saturating_sub(1));
112 render_menu(frame, areas[1], &suggestions, state.slash_selection);
113 }
114 frame.render_widget(Paragraph::new(composer_header_line(state)), areas[2]);
115 frame.render_widget(
116 Paragraph::new(input)
117 .style(theme.style(Role::Text))
118 .block(
119 Block::bordered()
120 .border_style(theme.style(Role::Border))
121 .title(composer_title(state)),
122 )
123 .scroll((
124 input_row.saturating_sub(composer_height.saturating_sub(3)),
125 0,
126 ))
127 .wrap(Wrap { trim: false }),
128 areas[3],
129 );
130 render_footer(frame, state, areas[4]);
131}
132
133fn render_transcript(frame: &mut Frame<'_>, state: &mut TuiState, area: Rect) {
134 let lines = live_transcript_lines(state, 0, area.width);
135 let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
136 let rendered_lines = paragraph.line_count(area.width);
137 state
138 .transcript_viewport
139 .update(rendered_lines, usize::from(area.height));
140 let scroll = state
141 .transcript_viewport
142 .effective_scroll()
143 .min(usize::from(u16::MAX)) as u16;
144 frame.render_widget(paragraph.scroll((scroll, 0)), area);
145}
146
147pub(super) fn live_transcript_lines(
148 state: &mut TuiState,
149 start: usize,
150 width: u16,
151) -> Vec<Line<'static>> {
152 let previous_group = start
153 .checked_sub(1)
154 .and_then(|index| state.transcript.get(index))
155 .and_then(|entry| entry.group.clone());
156 let mut lines = transcript_lines(
157 state.transcript.iter_mut().skip(start),
158 width,
159 previous_group,
160 start > 0,
161 );
162 if !state.streaming.is_empty() {
163 push_lines(
164 &mut lines,
165 &state.streaming,
166 TranscriptTone::Assistant,
167 FrontendBlockFormat::PlainText,
168 width,
169 );
170 }
171 if !state.reasoning.is_empty() {
172 push_lines(
173 &mut lines,
174 &state.reasoning,
175 TranscriptTone::Reasoning,
176 FrontendBlockFormat::PlainText,
177 width,
178 );
179 }
180 for ((capability, _), item) in state
181 .widgets
182 .iter()
183 .filter(|(_, item)| item.slot == FrontendSlot::TranscriptTail)
184 {
185 if !lines.is_empty() {
186 lines.push(Line::default());
187 }
188 lines.push(Line::from(vec![
189 Span::styled("┊ ", current().style(Role::Muted)),
190 Span::styled(
191 format!("{} message", sentence_case(capability)),
192 current().style(Role::Muted).add_modifier(Modifier::ITALIC),
193 ),
194 ]));
195 let style = current().style(tone_role(item.tone));
196 lines.extend(item.text.split('\n').map(|line| {
197 Line::from(vec![
198 Span::styled("┊ ", current().style(Role::Muted)),
199 Span::styled(line.to_owned(), style),
200 ])
201 }));
202 }
203 if lines.is_empty() {
204 let card = responsive_welcome_card(state, width);
205 push_lines(
206 &mut lines,
207 &card,
208 TranscriptTone::Welcome,
209 FrontendBlockFormat::PlainText,
210 width,
211 );
212 }
213 lines
214}
215
216pub(super) fn render_preview(frame: &mut Frame<'_>, state: &mut TuiState) {
217 let theme = current();
218 let area = frame.area();
219 if area.width < 3 || area.height < 3 {
220 return;
221 }
222 let (title, live) = {
223 let Some(preview) = state.preview.as_ref() else {
224 return;
225 };
226 let has_older = matches!(&preview.content, PreviewContent::Snapshot(snapshot) if snapshot.next.is_some());
227 let older_hint = if has_older { " · O older" } else { "" };
228 let title = if preview.subtitle.is_empty() {
229 format!("{}{older_hint}", preview.title)
230 } else {
231 format!("{}{older_hint} · {}", preview.title, preview.subtitle)
232 };
233 (
234 title,
235 matches!(&preview.content, PreviewContent::LiveTranscript),
236 )
237 };
238 let block = Block::bordered()
239 .style(theme.style(Role::Canvas))
240 .border_style(theme.style(Role::Info))
241 .title(Line::styled(
242 format!(" {title} · ↑↓/PgUp/PgDn scroll · drag to copy · Esc/Ctrl+T close "),
243 theme.style(Role::Accent).add_modifier(Modifier::BOLD),
244 ));
245 let inner = block.inner(area);
246 let mut lines = if live {
247 live_transcript_lines(state, 0, inner.width)
248 } else if let Some(PreviewContent::Snapshot(snapshot)) =
249 state.preview.as_mut().map(|preview| &mut preview.content)
250 {
251 transcript_lines(snapshot.transcript.iter_mut(), inner.width, None, false)
252 } else {
253 Vec::new()
254 };
255 if lines.is_empty() {
256 lines.push(Line::styled(
257 "No transcript events.",
258 theme.style(Role::Muted).add_modifier(Modifier::ITALIC),
259 ));
260 }
261 let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
262 let rendered_lines = paragraph.line_count(inner.width);
263 let preview = state.preview.as_mut().expect("preview checked");
264 preview
265 .viewport
266 .update(rendered_lines, usize::from(inner.height));
267 let scroll = preview
268 .viewport
269 .effective_scroll()
270 .min(usize::from(u16::MAX)) as u16;
271
272 frame.render_widget(paragraph.block(block).scroll((scroll, 0)), area);
273}
274
275fn transcript_lines<'a>(
276 entries: impl Iterator<Item = &'a mut TranscriptEntry>,
277 width: u16,
278 mut previous_group: Option<super::BlockKey>,
279 mut has_previous: bool,
280) -> Vec<Line<'static>> {
281 let mut lines = Vec::new();
282 for entry in entries {
283 let grouped = entry.group.is_some() && entry.group == previous_group;
284 if has_previous && !grouped {
285 if matches!(entry.tone, TranscriptTone::User) {
286 lines.push(Line::styled(
287 "─".repeat(usize::from(width)),
288 current().style(Role::Border),
289 ));
290 } else {
291 lines.push(Line::default());
292 }
293 }
294 if entry
295 .rendered
296 .as_ref()
297 .is_none_or(|(cached_width, _)| *cached_width != width)
298 {
299 let text = if matches!(entry.tone, TranscriptTone::Welcome)
300 && entry
301 .text
302 .lines()
303 .any(|line| Line::from(line).width() > usize::from(width))
304 {
305 "◉ MÖBIUS · type / for commands"
306 } else {
307 &entry.text
308 };
309 let mut rendered = Vec::new();
310 push_lines(&mut rendered, text, entry.tone, entry.format, width);
311 entry.rendered = Some((width, rendered));
312 }
313 if let Some((_, rendered)) = &entry.rendered {
314 lines.extend(rendered.iter().cloned());
315 }
316 previous_group.clone_from(&entry.group);
317 has_previous = true;
318 }
319 lines
320}
321
322fn push_lines(
323 lines: &mut Vec<Line<'static>>,
324 text: &str,
325 tone: TranscriptTone,
326 format: FrontendBlockFormat,
327 width: u16,
328) {
329 if format == FrontendBlockFormat::UnifiedDiff
330 && push_unified_diff(lines, text, usize::from(width))
331 {
332 return;
333 }
334 let theme = current();
335 let mut style = theme.style(transcript_role(tone));
336 if matches!(tone, TranscriptTone::Reasoning) {
337 style = style.add_modifier(Modifier::ITALIC);
338 } else if matches!(tone, TranscriptTone::Welcome) {
339 style = style.add_modifier(Modifier::BOLD);
340 }
341 let first = lines.len();
342 if matches!(tone, TranscriptTone::Assistant | TranscriptTone::Reasoning) {
343 lines.extend(markdown::render(text, style));
344 } else {
345 lines.extend(text.split('\n').map(|line| {
346 line.strip_prefix(AGENT_MARKER).map_or_else(
347 || Line::styled(line.to_string(), style),
348 |content| {
349 Line::from(vec![
350 Span::styled(AGENT_MARKER, theme.style(Role::Accent)),
351 Span::styled(content.to_string(), style),
352 ])
353 },
354 )
355 }));
356 }
357 if matches!(tone, TranscriptTone::Assistant | TranscriptTone::Reasoning) {
358 for (index, line) in lines[first..].iter_mut().enumerate() {
359 line.spans.insert(
360 0,
361 Span::styled(
362 if index == 0 { AGENT_MARKER } else { " " },
363 theme.style(Role::Accent),
364 ),
365 );
366 }
367 }
368}
369
370fn push_unified_diff(lines: &mut Vec<Line<'static>>, text: &str, width: usize) -> bool {
371 let Ok(patch) = Patch::from_str(text) else {
372 return false;
373 };
374 let theme = current();
375 let raw_path = patch
376 .modified()
377 .or_else(|| patch.original())
378 .unwrap_or("file");
379 let path = terminal_text(raw_path);
380 let (added, removed, max_line_number) =
381 patch
382 .hunks()
383 .iter()
384 .fold((0, 0, 0), |(added, removed, max), hunk| {
385 let mut old_line = hunk.old_range().start();
386 let mut new_line = hunk.new_range().start();
387 let mut added = added;
388 let mut removed = removed;
389 let mut max = max;
390 for line in hunk.lines() {
391 match line {
392 DiffLine::Insert(_) => {
393 added += 1;
394 max = max.max(new_line);
395 new_line += 1;
396 }
397 DiffLine::Delete(_) => {
398 removed += 1;
399 max = max.max(old_line);
400 old_line += 1;
401 }
402 DiffLine::Context(_) => {
403 max = max.max(new_line);
404 old_line += 1;
405 new_line += 1;
406 }
407 }
408 }
409 (added, removed, max)
410 });
411 lines.push(Line::from(vec![
412 Span::styled(AGENT_MARKER, theme.style(Role::Accent)),
413 Span::styled(
414 "Edited ",
415 theme.style(Role::Text).add_modifier(Modifier::BOLD),
416 ),
417 Span::styled(path, theme.style(Role::Code)),
418 Span::raw(" ("),
419 Span::styled(format!("+{added}"), theme.style(Role::Success)),
420 Span::raw(" "),
421 Span::styled(format!("-{removed}"), theme.style(Role::Error)),
422 Span::raw(")"),
423 ]));
424
425 let number_width = max_line_number.max(1).to_string().len();
426 for (hunk_index, hunk) in patch.hunks().iter().enumerate() {
427 if hunk_index > 0 {
428 lines.push(Line::styled(
429 format!(" {:>number_width$} ⋮", ""),
430 theme.style(Role::Muted),
431 ));
432 }
433 let mut old_line = hunk.old_range().start();
434 let mut new_line = hunk.new_range().start();
435 let hunk_text = hunk
436 .lines()
437 .iter()
438 .map(|line| match line {
439 DiffLine::Insert(content)
440 | DiffLine::Delete(content)
441 | DiffLine::Context(content) => *content,
442 })
443 .collect::<String>();
444 let syntax = highlight::lines(&hunk_text, raw_path)
445 .filter(|syntax| syntax.len() == hunk.lines().len());
446 for (index, line) in hunk.lines().iter().enumerate() {
447 let (number, sign, sign_role, background, content) = match line {
448 DiffLine::Insert(content) => {
449 let number = new_line;
450 new_line += 1;
451 (
452 number,
453 "+",
454 Role::Success,
455 Some(theme.diff_add_background()),
456 content,
457 )
458 }
459 DiffLine::Delete(content) => {
460 let number = old_line;
461 old_line += 1;
462 (
463 number,
464 "-",
465 Role::Error,
466 Some(theme.diff_delete_background()),
467 content,
468 )
469 }
470 DiffLine::Context(content) => {
471 let number = new_line;
472 old_line += 1;
473 new_line += 1;
474 (number, " ", Role::Text, None, content)
475 }
476 };
477 let mut spans = vec![
478 Span::styled(
479 format!(" {number:>number_width$} "),
480 theme.style(Role::Muted),
481 ),
482 Span::styled(sign, theme.style(sign_role)),
483 ];
484 if let Some(syntax) = syntax
485 .as_ref()
486 .and_then(|syntax_lines| syntax_lines.get(index))
487 {
488 spans.extend(syntax.iter().cloned());
489 } else {
490 spans.push(Span::styled(
491 content.trim_end_matches(['\n', '\r']).to_string(),
492 theme.style(Role::Text),
493 ));
494 }
495 let mut line = Line::from(spans);
496 let padding = width.saturating_sub(line.width());
497 if padding > 0 {
498 line.push_span(Span::raw(" ".repeat(padding)));
499 }
500 if let Some(background) = background {
501 line = line.style(Style::default().bg(background));
502 }
503 lines.push(line);
504 }
505 }
506 true
507}
508
509fn transcript_role(tone: TranscriptTone) -> Role {
510 match tone {
511 TranscriptTone::Welcome => Role::Accent,
512 TranscriptTone::Assistant | TranscriptTone::User => Role::Text,
513 TranscriptTone::Reasoning => Role::Reasoning,
514 TranscriptTone::Neutral => Role::Neutral,
515 TranscriptTone::Success => Role::Success,
516 TranscriptTone::Warning => Role::Warning,
517 TranscriptTone::Error => Role::Error,
518 }
519}
520
521pub(super) fn welcome_card(state: &TuiState) -> String {
522 let details = state.agent_summary.lines().chain(std::iter::repeat(""));
523 let rows = WELCOME_EYE
524 .iter()
525 .zip(details)
526 .map(|(eye, detail)| {
527 if detail.is_empty() {
528 (*eye).to_owned()
529 } else {
530 format!("{eye} {detail}")
531 }
532 })
533 .collect::<Vec<_>>();
534 bordered_card(rows)
535}
536
537fn responsive_welcome_card(state: &TuiState, width: u16) -> String {
538 let welcome = welcome_card(state);
539 if card_fits(&welcome, width) {
540 return welcome;
541 }
542 let stacked = bordered_card(
543 WELCOME_EYE
544 .iter()
545 .map(|line| (*line).to_owned())
546 .chain(std::iter::once(String::new()))
547 .chain(state.agent_summary.lines().map(str::to_owned))
548 .collect(),
549 );
550 if card_fits(&stacked, width) {
551 return stacked;
552 }
553 let agent = bordered_card(state.agent_summary.lines().map(str::to_owned).collect());
554 if card_fits(&agent, width) {
555 agent
556 } else {
557 "◉ MÖBIUS AGENT · type / for commands".into()
558 }
559}
560
561fn card_fits(card: &str, width: u16) -> bool {
562 card.lines()
563 .all(|line| Line::from(line).width() <= usize::from(width))
564}
565
566fn bordered_card(rows: Vec<String>) -> String {
567 let width = rows
568 .iter()
569 .map(|row| Line::from(row.as_str()).width())
570 .max()
571 .unwrap_or_default();
572 let border = "─".repeat(width + 2);
573 let mut lines = vec![format!("╭{border}╮")];
574 lines.extend(rows.into_iter().map(|row| {
575 let padding = width.saturating_sub(Line::from(row.as_str()).width());
576 format!("│ {row}{} │", " ".repeat(padding))
577 }));
578 lines.push(format!("╰{border}╯"));
579 lines.join("\n")
580}
581
582fn tone_role(tone: FrontendTone) -> Role {
583 match tone {
584 FrontendTone::Neutral => Role::Neutral,
585 FrontendTone::Success => Role::Success,
586 FrontendTone::Warning => Role::Warning,
587 FrontendTone::Error => Role::Error,
588 }
589}
590
591fn marked_input(state: &TuiState) -> (String, usize) {
592 let (mut input, cursor) = state.visible_input();
593 input.insert(cursor, '█');
594 let mut marked = state
595 .attachments
596 .iter()
597 .map(attachment_label)
598 .collect::<Vec<_>>()
599 .join(" · ");
600 if !marked.is_empty() {
601 marked.push('\n');
602 }
603 marked.push_str(COMPOSER_PROMPT);
604 let cursor_end = marked.len() + cursor + '█'.len_utf8();
605 marked.push_str(&input);
606 (marked, cursor_end)
607}
608
609fn input_cursor_row(input_through_cursor: &str, width: u16) -> u16 {
610 let rows = Paragraph::new(input_through_cursor)
611 .wrap(Wrap { trim: false })
612 .line_count(width.max(1));
613 u16::try_from(rows.saturating_sub(1)).unwrap_or(u16::MAX)
614}
615
616fn render_footer(frame: &mut Frame<'_>, state: &TuiState, area: Rect) {
617 frame.render_widget(
618 Paragraph::new(footer_line(state, area.width)).alignment(Alignment::Right),
619 area,
620 );
621}
622
623fn footer_line(state: &TuiState, width: u16) -> Line<'static> {
624 let theme = current();
625 let reasoning = state.model.reasoning_effort.as_deref().unwrap_or("—");
626 let context = state
627 .usage
628 .context_fill
629 .map_or_else(|| "—".into(), |value| format!("{value:.1}%"));
630 let cache = state
631 .usage
632 .cache_hit
633 .map_or_else(|| "—".into(), |value| format!("{value:.1}%"));
634 let folder = state
635 .cwd
636 .rsplit(['/', '\\'])
637 .find(|part| !part.is_empty())
638 .unwrap_or(&state.cwd);
639 let values = [
640 (format!("cache {cache}"), Role::Neutral),
641 (format!("context {context}"), Role::Code),
642 (
643 format!(
644 "{} {}",
645 display_value(&state.model.model),
646 display_value(reasoning)
647 ),
648 Role::Reasoning,
649 ),
650 (display_value(folder), Role::Info),
651 ];
652 let mut widget_spans = widget_line(&state.widgets, FrontendSlot::Header).spans;
653 let footer_widgets = widget_line(&state.widgets, FrontendSlot::ComposerFooter);
654 if !footer_widgets.spans.is_empty() {
655 separator(&mut widget_spans);
656 widget_spans.extend(footer_widgets.spans);
657 }
658 let mut spans = widget_spans.clone();
659 for (value, role) in &values {
660 separator(&mut spans);
661 spans.push(Span::styled(value.clone(), theme.style(*role)));
662 }
663 let full = Line::from(spans);
664 if full.width() <= usize::from(width) {
665 return full;
666 }
667
668 let mut spans = widget_spans;
669 for (value, role) in [&values[2], &values[1], &values[3]] {
670 let mut candidate = spans.clone();
671 separator(&mut candidate);
672 candidate.push(Span::styled(value.clone(), theme.style(*role)));
673 if Line::from(candidate.clone()).width() <= usize::from(width) {
674 spans = candidate;
675 }
676 }
677 if spans.is_empty() {
678 Line::styled(values[2].0.clone(), theme.style(values[2].1))
679 } else {
680 Line::from(spans)
681 }
682}
683
684fn widget_line(
685 widgets: &[((String, String), FrontendWidget)],
686 slot: FrontendSlot,
687) -> Line<'static> {
688 let theme = current();
689 let mut spans = Vec::new();
690 for (_, item) in widgets.iter().filter(|(_, item)| item.slot == slot) {
691 separator(&mut spans);
692 let style = if slot == FrontendSlot::ComposerHeader && item.tone == FrontendTone::Neutral {
693 theme.style(Role::Muted).add_modifier(Modifier::ITALIC)
694 } else {
695 theme.style(tone_role(item.tone))
696 };
697 spans.push(Span::styled(item.text.clone(), style));
698 }
699 Line::from(spans)
700}
701
702fn composer_header_line(state: &TuiState) -> Line<'static> {
703 let mut line = status_line(state);
704 let widgets = widget_line(&state.widgets, FrontendSlot::ComposerHeader);
705 if line.width() > 0 && widgets.width() > 0 {
706 line.push_span(Span::styled(" · ", current().style(Role::Muted)));
707 }
708 for span in widgets.spans {
709 line.push_span(span);
710 }
711 line
712}
713
714fn separator(spans: &mut Vec<Span<'static>>) {
715 if !spans.is_empty() {
716 spans.push(Span::styled(" · ", current().style(Role::Muted)));
717 }
718}
719
720fn composer_title(state: &TuiState) -> Line<'static> {
721 let theme = current();
722 let (status, role) = if state.approval.is_some() {
723 ("approval".to_string(), Role::Warning)
724 } else if state.disconnected {
725 ("disconnected".to_string(), Role::Error)
726 } else if state.is_working() {
727 ("working".to_string(), Role::Accent)
728 } else {
729 ("ready".to_string(), Role::Accent)
730 };
731 let elapsed = state
732 .turn_started_at
733 .map(|started| format!(" · {}", elapsed_label(started.elapsed())))
734 .unwrap_or_default();
735 let title = format!(" mobius · {status}{elapsed} ");
736 if state.is_working() {
737 shimmer::line(
738 &title,
739 theme.color(Role::Accent),
740 theme.color(Role::AccentStrong),
741 )
742 } else {
743 Line::styled(title, theme.style(role))
744 }
745}
746
747fn status_line(state: &TuiState) -> Line<'static> {
748 let theme = current();
749 if state.approval.is_some() {
750 return Line::styled(
751 "approval · y once · a session · n deny · q abort",
752 theme.style(Role::Warning),
753 );
754 }
755 if state.input_limit_reached {
756 return Line::styled(
757 "input limit reached · maximum 1 MiB",
758 theme.style(Role::Warning),
759 );
760 }
761 Line::default()
762}
763
764fn elapsed_label(elapsed: std::time::Duration) -> String {
765 let seconds = elapsed.as_secs();
766 if seconds < 60 {
767 format!("{seconds}s")
768 } else if seconds < 3_600 {
769 format!("{}m {:02}s", seconds / 60, seconds % 60)
770 } else {
771 format!("{}h {:02}m", seconds / 3_600, seconds / 60 % 60)
772 }
773}
774
775pub(super) fn initial_widgets(catalog: &UiCatalog) -> Vec<((String, String), FrontendWidget)> {
776 catalog
777 .widgets()
778 .map(|(middleware, item)| {
779 let mut item = item.clone();
780 item.text = bounded_terminal_text(&item.text, 32 * 1024);
781 ((middleware.to_string(), item.id.clone()), item)
782 })
783 .collect()
784}
785
786pub(super) fn widget_status(widgets: &[((String, String), FrontendWidget)]) -> String {
787 widgets
788 .iter()
789 .map(|(_, item)| format!(" · {}", item.text))
790 .collect()
791}
792
793fn render_picker_menu(frame: &mut Frame<'_>, area: Rect, picker: &super::PickerState) {
794 let areas = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
795 frame.render_widget(
796 Paragraph::new(Line::styled(
797 format!(" {}", picker.title),
798 current().style(Role::Accent).add_modifier(Modifier::BOLD),
799 )),
800 areas[0],
801 );
802 let items = picker
803 .options
804 .iter()
805 .map(|option| {
806 let description = if !option.shows_detail || option.detail.is_empty() {
807 option.description.clone()
808 } else {
809 format!("{} · {}", option.description, option.detail)
810 };
811 MenuItem {
812 value: String::new(),
813 label: terminal_text(&option.label),
814 description: terminal_text(&description),
815 }
816 })
817 .collect::<Vec<_>>();
818 render_menu(frame, areas[1], &items, picker.selected);
819}
820
821fn render_menu(frame: &mut Frame<'_>, area: Rect, items: &[MenuItem], selected: usize) {
822 let theme = current();
823 if items.is_empty() {
824 frame.render_widget(
825 Paragraph::new(Line::styled(
826 " no matches",
827 theme.style(Role::Muted).add_modifier(Modifier::ITALIC),
828 )),
829 area,
830 );
831 return;
832 }
833 let name_width = items
834 .iter()
835 .map(|item| item.label.chars().count())
836 .max()
837 .unwrap_or_default();
838 let start = selected.saturating_add(1).saturating_sub(MAX_MENU_ROWS);
839 let lines = items
840 .iter()
841 .enumerate()
842 .skip(start)
843 .take(MAX_MENU_ROWS)
844 .map(|(index, item)| {
845 let is_selected = index == selected;
846 let style = if is_selected {
847 theme.style(Role::Selection)
848 } else {
849 theme.style(Role::Text)
850 };
851 let description_style = if is_selected {
852 style
853 } else {
854 theme.style(Role::Muted)
855 };
856 Line::from(vec![
857 Span::styled(if is_selected { "› " } else { " " }, style),
858 Span::styled(
859 format!(
860 "{:<width$}",
861 terminal_text(&item.label),
862 width = name_width + 2
863 ),
864 style,
865 ),
866 Span::styled(terminal_text(&item.description), description_style),
867 ])
868 })
869 .collect::<Vec<_>>();
870 frame.render_widget(Paragraph::new(lines), area);
871}
872
873pub fn terminal_text(value: &str) -> String {
874 value
875 .chars()
876 .filter(|character| matches!(character, '\n' | '\t') || !character.is_control())
877 .collect()
878}
879
880pub(super) fn bounded_terminal_text(value: &str, limit: usize) -> String {
881 let mut value = terminal_text(value);
882 if value.len() <= limit {
883 return value;
884 }
885 let mut end = limit;
886 while !value.is_char_boundary(end) {
887 end -= 1;
888 }
889 value.truncate(end);
890 value.push_str("\n[display truncated]");
891 value
892}
893
894fn display_value(value: &str) -> String {
895 terminal_text(value).chars().take(120).collect()
896}
897
898fn sentence_case(value: &str) -> String {
899 let mut value = terminal_text(value).replace(['_', '-'], " ");
900 if let Some(first) = value.get_mut(..1) {
901 first.make_ascii_uppercase();
902 }
903 value
904}