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