//! Full-screen rendering for the TUI. //! //! The layout is stacked top-to-bottom: //! //! ```text //! history view (fills remaining space) //! ──────────── separator ────────── //! status line (1 row) //! > input area (1 row in Phase 1) //! actionbar (1 row) //! ``` //! //! Every frame we walk the entire `App::blocks` vector, produce styled //! lines, and render the tail that fits the history area. No //! `insert_before` use — the terminal scrollback stays untouched. use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{ Block as UiBlock, BorderType, Borders, Clear, Padding, Paragraph, Widget, Wrap, }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use protocol::{AlertLevel, CompletionEntry, Greeting, PodEvent, Segment}; use crate::app::{App, CompletionState, alert_source_label, fmt_tokens}; use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState}; use crate::command::CommandCandidate; use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore}; /// Display density for the history view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { /// Every block fully expanded. Detail, /// Completed blocks compressed to roughly 5–6 lines; in-progress /// tool blocks stay in detail. Normal, /// Each block rendered as a single line. Overview, } impl Mode { pub fn cycle(self) -> Self { match self { Mode::Detail => Mode::Normal, Mode::Normal => Mode::Overview, Mode::Overview => Mode::Detail, } } pub fn label(self) -> &'static str { match self { Mode::Detail => "detail", Mode::Normal => "normal", Mode::Overview => "overview", } } } pub fn draw(frame: &mut Frame, app: &mut App) { let area = frame.area(); // Input content starts after the prompt (`> ` or `: `), so the width // available for wrapping is two columns narrower than the frame. let input_content_width = area.width.saturating_sub(2).max(1); let mut input_render = if app.is_command_mode() { app.command_input.render(input_content_width) } else { app.input.render(input_content_width) }; let input_height = input_area_height(&input_render, area.height); if app.is_command_mode() { app.command_input .apply_cursor_viewport(&mut input_render, input_height); } else { app.input .apply_cursor_viewport(&mut input_render, input_height); } let mini_view_h = task_mini_view_height(&app.task_store); // One blank row separates the history tail from the mini-view so // the latest message doesn't visually crash into the task summary. // Folds away with the mini-view when there are no tasks. let mini_view_gap = if mini_view_h > 0 { 1 } else { 0 }; let chunks = Layout::vertical([ Constraint::Min(0), // history view Constraint::Length(mini_view_gap), // gap above mini-view Constraint::Length(mini_view_h), // task mini-view (0 when empty) Constraint::Length(1), // separator Constraint::Length(1), // status Constraint::Length(input_height), // input area Constraint::Length(1), // actionbar ]) .split(area); draw_history(frame, app, chunks[0]); if mini_view_h > 0 { draw_task_mini_view(frame, &app.task_store, chunks[2]); } draw_separator(frame, chunks[3]); draw_status(frame, app, chunks[4]); draw_input(frame, app, &input_render, chunks[5]); draw_actionbar(frame, app, chunks[6]); if app.is_command_mode() { draw_command_popup(frame, app, chunks[5]); } else if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) { draw_completion_popup(frame, state, chunks[5]); } } /// Maximum number of active (pending / inprogress) tasks the mini-view /// shows above the summary line. Exceeding tasks are still counted in /// the summary. const MINI_VIEW_MAX_ACTIVE: usize = 3; /// Height the mini-view section occupies. Returns 0 when there are no /// tasks at all, so the section collapses cleanly into surrounding /// layout — there's no point reserving rows for an empty store. fn task_mini_view_height(store: &TaskStore) -> u16 { if store.is_empty() { return 0; } let active_shown = store.counts().active().min(MINI_VIEW_MAX_ACTIVE); // active rows + 1 summary line (active_shown as u16).saturating_add(1) } fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, area: Rect) { if area.height == 0 || area.width == 0 { return; } let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING)); let inner = outer_block.inner(area); if inner.width == 0 || inner.height == 0 { return; } let mut lines: Vec> = Vec::with_capacity(area.height as usize); let mut shown = 0usize; for entry in store.tasks() { if shown >= MINI_VIEW_MAX_ACTIVE { break; } if !matches!(entry.status, TaskStatus::Pending | TaskStatus::Inprogress) { continue; } lines.push(mini_view_active_line(entry, inner.width)); shown += 1; } lines.push(mini_view_summary_line(store.counts(), inner.width)); Paragraph::new(lines) .block(outer_block) .render(area, frame.buffer_mut()); } fn mini_view_active_line(entry: &TaskEntry, width: u16) -> Line<'static> { let mark = task_status_mark(entry.status); // Subject's first line only; embedded newlines would otherwise // wreck the one-row-per-task layout. let subject = entry.subject.lines().next().unwrap_or(""); let mark_width = UnicodeWidthStr::width(mark.0); // Reserve mark + space. let budget = (width as usize).saturating_sub(mark_width + 1); let shown = truncate_with_ellipsis(subject, budget); Line::from(vec![ Span::styled(mark.0.to_owned(), mark.1), Span::raw(" "), Span::raw(shown), ]) } fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> { let text = format!( "{} task(s) — pending: {}, inprogress: {}, completed: {}, deleted: {}", counts.total(), counts.pending, counts.inprogress, counts.completed, counts.deleted, ); let shown = truncate_with_ellipsis(&text, width as usize); Line::from(Span::styled(shown, Style::default().fg(Color::DarkGray))) } /// Two-character status marker + the style to render it with. Mirrors /// the four `TaskStatus` values; deleted ones never appear in the /// mini-view but are listed in the side pane. fn task_status_mark(status: TaskStatus) -> (&'static str, Style) { match status { TaskStatus::Pending => ("[ ]", Style::default().fg(Color::DarkGray)), TaskStatus::Inprogress => ( "[~]", Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ), TaskStatus::Completed => ("[x]", Style::default().fg(Color::Green)), TaskStatus::Deleted => ("[-]", Style::default().fg(Color::Red)), } } /// Render the candidate list directly above the input area. The popup /// overlays the status row (and history's bottom rows when it grows /// taller than that single row); `Clear` blanks the cells first so /// underlying text doesn't bleed through. The popup width matches the /// widest visible label, capped at the input-area width. fn draw_completion_popup(frame: &mut Frame, state: &CompletionState, input_area: Rect) { let entries = &state.entries; if entries.is_empty() || input_area.y == 0 { return; } let visible = entries.len().min(CompletionState::MAX_VISIBLE); // Scroll window keeps the selected item in view. let view_start = if state.selected + 1 <= visible { 0 } else { state.selected + 1 - visible }; let view_end = (view_start + visible).min(entries.len()); let label_for = |entry: &CompletionEntry| { let mut s = entry.value.clone(); if entry.is_dir { s.push('/'); } s }; let max_label = entries[view_start..view_end] .iter() .map(|e| label_for(e).chars().count() as u16) .max() .unwrap_or(0); let popup_w = max_label.saturating_add(2).min(input_area.width).max(1); let popup_h = (visible as u16).min(input_area.y); let popup_area = Rect::new( input_area.x, input_area.y.saturating_sub(popup_h), popup_w, popup_h, ); let highlight = Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD); let dir_style = Style::default().fg(Color::Cyan); let plain = Style::default(); let mut lines: Vec> = Vec::with_capacity(popup_h as usize); for (i, entry) in entries[view_start..view_end].iter().enumerate() { let abs = view_start + i; let text = label_for(entry); let base = if entry.is_dir { dir_style } else { plain }; let style = if abs == state.selected { highlight.patch(base) } else { base }; lines.push(Line::from(Span::styled(text, style))); } frame.render_widget(Clear, popup_area); frame.render_widget(Paragraph::new(lines), popup_area); } fn draw_command_popup(frame: &mut Frame, app: &App, input_area: Rect) { let suggestions = app.command_suggestions(); if suggestions.is_empty() || input_area.y == 0 { return; } let visible = suggestions.len().min(CompletionState::MAX_VISIBLE); let visible_suggestions = &suggestions[..visible]; let max_label = visible_suggestions .iter() .map(|candidate| command_suggestion_label(candidate).width() as u16) .max() .unwrap_or(0); let popup_w = max_label.saturating_add(2).min(input_area.width).max(1); let popup_h = (visible as u16).min(input_area.y); let popup_area = Rect::new( input_area.x, input_area.y.saturating_sub(popup_h), popup_w, popup_h, ); let command_style = Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD); let description_style = Style::default().fg(Color::DarkGray); let mut lines: Vec> = Vec::with_capacity(popup_h as usize); let selected = app.command_completion_selected(); for (idx, candidate) in visible_suggestions .iter() .take(popup_h as usize) .enumerate() { let selected_style = if Some(idx) == selected { Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD) } else { Style::default() }; lines.push(Line::from(vec![ Span::styled( candidate.name.to_owned(), command_style.patch(selected_style), ), Span::styled(" — ", description_style.patch(selected_style)), Span::styled( candidate.description.to_owned(), description_style.patch(selected_style), ), ])); } frame.render_widget(Clear, popup_area); frame.render_widget(Paragraph::new(lines), popup_area); } fn command_suggestion_label(candidate: &CommandCandidate) -> String { format!("{} — {}", candidate.name, candidate.description) } /// Cap the input area so it doesn't eat the history view: grows with the /// buffer but never past `min(10, terminal_height / 3)`. fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) -> u16 { let needed = render.lines.len().max(1) as u16; let cap = (terminal_height / 3).max(1).min(10); needed.clamp(1, cap) } /// Pre-rendered history lines plus the line indices at which each turn /// begins (used for Ctrl-[/] jumps). pub struct HistoryLayout { pub lines: Vec>, pub turn_starts: Vec, } pub fn compute_history(app: &App, width: u16) -> HistoryLayout { // Step 1: collect logical lines from each block (unwrapped). let mut logical: Vec> = Vec::new(); let mut logical_turn_starts: Vec = Vec::new(); let mut first = true; let mut i = 0; while i < app.blocks.len() { if !first { logical.push(Line::from("")); } first = false; let block = &app.blocks[i]; if matches!(block, Block::TurnHeader { .. }) { logical_turn_starts.push(logical.len()); } if matches!(block, Block::ToolCall(_)) { let out = crate::tool::render_tool(&app.cache, &app.blocks, i, width, app.mode); logical.extend(out.lines); i += out.consumed.max(1); continue; } render_block_into(&mut logical, block, width, app.mode); i += 1; } // Step 2: pre-wrap every logical line to char-based terminal rows so // scroll math is exact. Track the logical → wrapped mapping so // turn-start indices get translated into wrapped-row coordinates. let mut lines: Vec> = Vec::with_capacity(logical.len()); let mut logical_to_wrapped: Vec = Vec::with_capacity(logical.len() + 1); for line in logical { logical_to_wrapped.push(lines.len()); wrap_line_into(line, width, &mut lines); } logical_to_wrapped.push(lines.len()); let turn_starts = logical_turn_starts .into_iter() .map(|i| logical_to_wrapped.get(i).copied().unwrap_or(lines.len())) .collect(); HistoryLayout { lines, turn_starts } } /// Horizontal gutter around the log area. Applied via a /// [`Block`](ratatui::widgets::Block)'s padding so *every* row — including /// continuation rows from wrapping — sits inside the same margin, no /// leading-space hacks in the content itself. const HISTORY_PADDING: u16 = 1; fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { if area.height == 0 || area.width == 0 { app.scroll.area_height = area.height; app.scroll.total_lines = 0; app.scroll.tail_top_offset = 0; app.scroll.turn_starts.clear(); return; } // When the task pane is open and the area is wide enough, carve a // vertical strip on the right for it. Side pane lives inside the // history rect only — separator / status / input stay full width to // keep the input experience and completion popup geometry intact. let pane_w = task_side_pane_width(area.width, app.task_pane_open); let history_area = if pane_w > 0 { let split = Layout::horizontal([Constraint::Min(1), Constraint::Length(pane_w)]).split(area); draw_task_side_pane(frame, app, split[1]); split[0] } else { area }; let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING)); let inner = outer_block.inner(history_area); if inner.width == 0 || inner.height == 0 { return; } if let Some(picker) = app.rewind_picker.as_mut() { draw_rewind_picker(frame, history_area, inner, outer_block, picker); return; } let HistoryLayout { lines, turn_starts } = compute_history(app, inner.width); // `lines` is already pre-wrapped: 1 entry == 1 terminal row. Scroll // math degenerates to index arithmetic. let tail_top = lines.len().saturating_sub(inner.height as usize); app.scroll.area_height = inner.height; app.scroll.total_lines = lines.len(); app.scroll.tail_top_offset = tail_top; app.scroll.turn_starts = turn_starts; if app.scroll.follow_tail { app.scroll.top_offset = tail_top; } else { app.scroll.top_offset = app.scroll.top_offset.min(tail_top); } let end = (app.scroll.top_offset + inner.height as usize).min(lines.len()); let visible: Vec> = lines[app.scroll.top_offset..end].to_vec(); // Pre-wrapped input → render without ratatui's word-wrap (which // would otherwise re-wrap mid-row at word boundaries and desync the // height count). The outer Block handles left/right padding // uniformly for all rows. Paragraph::new(visible) .block(outer_block) .render(history_area, frame.buffer_mut()); } fn draw_rewind_picker( frame: &mut Frame, history_area: Rect, inner: Rect, outer_block: UiBlock<'_>, picker: &mut crate::app::RewindPickerState, ) { let mut logical: Vec> = Vec::new(); logical.push(Line::from(vec![ Span::styled( "Rewind targets", Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ), Span::raw(format!(" head={} ", picker.head_entries)), Span::styled("Enter", Style::default().fg(Color::Green)), Span::raw(" apply "), Span::styled("Esc", Style::default().fg(Color::Green)), Span::raw(" cancel"), ])); logical.push(Line::from(Span::styled( "Selecting a target discards the later history suffix; tool side effects are not undone.", Style::default().fg(Color::DarkGray), ))); logical.push(Line::from("")); if picker.targets.is_empty() { logical.push(Line::from(Span::styled( "No previous user messages are available to rewind.", Style::default().fg(Color::DarkGray), ))); } else { for (idx, target) in picker.targets.iter().enumerate() { let selected = idx == picker.selected; let marker = if selected { "▶" } else { " " }; let base_style = if selected { Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD) } else if target.eligible { Style::default() } else { Style::default().fg(Color::DarkGray) }; let ts = target .timestamp_ms .map(|ts| format!("{}", ts)) .unwrap_or_else(|| "-".into()); logical.push(Line::from(vec![ Span::styled(marker.to_owned(), base_style), Span::styled( format!( " turn {} idx {} ts {} ", target.turn_index, target.id.user_input_entry_index, ts ), base_style, ), Span::styled(target.preview.clone(), base_style), ])); if let Some(warning) = target.warning.as_ref() { logical.push(Line::from(Span::styled( format!(" warning: {warning}"), Style::default().fg(Color::Yellow), ))); } if let Some(reason) = target.disabled_reason.as_ref() { logical.push(Line::from(Span::styled( format!(" disabled: {reason}"), Style::default().fg(Color::Red), ))); } } } let mut lines = Vec::new(); for line in logical { wrap_line_into(line, inner.width, &mut lines); } let tail_top = lines.len().saturating_sub(inner.height as usize); picker.scroll.area_height = inner.height; picker.scroll.total_lines = lines.len(); picker.scroll.tail_top_offset = tail_top; picker.scroll.top_offset = picker.scroll.top_offset.min(tail_top); let end = (picker.scroll.top_offset + inner.height as usize).min(lines.len()); let visible = lines[picker.scroll.top_offset..end].to_vec(); Paragraph::new(visible) .block(outer_block) .render(history_area, frame.buffer_mut()); } /// Width to reserve for the task side pane within the history rect. /// Returns 0 when the pane is closed or the rect is too narrow to host /// it without crushing the history view. fn task_side_pane_width(area_width: u16, open: bool) -> u16 { if !open { return 0; } // Need a reasonable history column on the left, and enough room on // the right for taskid + status mark + a few words of subject. Skip // entirely on narrow terminals. if area_width < 60 { return 0; } (area_width / 3).clamp(28, 44) } fn draw_task_side_pane(frame: &mut Frame, app: &mut App, area: Rect) { if area.width < 4 || area.height < 1 { return; } let pane_block = UiBlock::default() .borders(Borders::LEFT) .border_style(Style::default().fg(Color::DarkGray)) .padding(Padding::horizontal(1)); let inner = pane_block.inner(area); if inner.width == 0 || inner.height == 0 { return; } let store = &app.task_store; let counts = store.counts(); let title_style = Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD); let body_style = Style::default().fg(Color::DarkGray); let muted_style = Style::default().fg(Color::DarkGray); let mut logical: Vec> = Vec::new(); logical.push(Line::from(Span::styled( format!("Tasks ({})", counts.total()), title_style, ))); logical.push(Line::from("")); if store.is_empty() { logical.push(Line::from(Span::styled("(no tasks)", muted_style))); } else { for entry in store.tasks() { let mark = task_status_mark(entry.status); let subject_first = entry.subject.lines().next().unwrap_or(""); logical.push(Line::from(vec![ Span::styled(format!("#{} ", entry.taskid), muted_style), Span::styled(mark.0.to_owned(), mark.1), Span::raw(" "), Span::raw(subject_first.to_owned()), ])); // Subject continuations (multiline subjects). for cont in entry.subject.lines().skip(1) { logical.push(Line::from(vec![ Span::raw(" "), Span::raw(cont.to_owned()), ])); } for raw in entry.description.lines() { logical.push(Line::from(vec![ Span::styled(" ", body_style), Span::styled(raw.to_owned(), body_style), ])); } logical.push(Line::from("")); } } // Pre-wrap to inner width so scroll math degenerates to row indices. let mut wrapped: Vec> = Vec::with_capacity(logical.len()); for line in logical { wrap_line_into(line, inner.width, &mut wrapped); } let max_scroll = wrapped.len().saturating_sub(inner.height as usize); if app.task_pane_scroll > max_scroll { app.task_pane_scroll = max_scroll; } let start = app.task_pane_scroll; let end = (start + inner.height as usize).min(wrapped.len()); let visible: Vec> = wrapped[start..end].to_vec(); Paragraph::new(visible) .block(pane_block) .render(area, frame.buffer_mut()); } /// Split one logical line into one-terminal-row `Line`s via char-aware /// wrapping. Preserves per-span styles, the source line's alignment, /// and the source line's own style. If the source line carries a /// background color, each emitted row is padded to `width` with spaces /// styled by that line style — so diff-style row highlights (red/green /// backgrounds) extend cleanly to the right edge of the terminal. fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec>) { if width == 0 { out.push(line); return; } let w = width as usize; let alignment = line.alignment; let line_style = line.style; let fill_to_width = line_style.bg.is_some(); let mut current: Vec> = Vec::new(); let mut row_width: usize = 0; let mut pending = String::new(); let mut pending_width: usize = 0; let mut pending_style = Style::default(); let commit_pending = |pending: &mut String, pending_width: &mut usize, pending_style: Style, current: &mut Vec>, row_width: &mut usize| { if pending.is_empty() { return; } current.push(Span::styled(std::mem::take(pending), pending_style)); *row_width += *pending_width; *pending_width = 0; }; let push_row = |current: &mut Vec>, row_width: &mut usize, out: &mut Vec>| { if fill_to_width && *row_width < w { let pad = w - *row_width; current.push(Span::styled(" ".repeat(pad), line_style)); *row_width = w; } let mut l = Line::from(std::mem::take(current)).style(line_style); if let Some(a) = alignment { l = l.alignment(a); } out.push(l); *row_width = 0; }; for span in line.spans { if !pending.is_empty() && span.style != pending_style { commit_pending( &mut pending, &mut pending_width, pending_style, &mut current, &mut row_width, ); } pending_style = span.style; for c in span.content.chars() { let cw = UnicodeWidthChar::width(c).unwrap_or(0); if row_width + pending_width + cw > w && (row_width + pending_width) > 0 { commit_pending( &mut pending, &mut pending_width, pending_style, &mut current, &mut row_width, ); push_row(&mut current, &mut row_width, out); } pending.push(c); pending_width += cw; } commit_pending( &mut pending, &mut pending_width, pending_style, &mut current, &mut row_width, ); } // Always emit the final row (empty line stays empty). push_row(&mut current, &mut row_width, out); } fn render_block_into(lines: &mut Vec>, block: &Block, width: u16, mode: Mode) { match block { Block::Greeting(g) => match mode { Mode::Overview => { let text = format!("{} {} ({})", g.pod_name, g.model, g.provider); lines.push(Line::from(Span::styled( text, Style::default().fg(Color::Cyan), ))); } _ => render_greeting(lines, g, width), }, Block::TurnHeader { turn } => { lines.push(Line::from(Span::styled( format!("#{turn}"), kind_style(MessageKind::TurnHeader), ))); } Block::UserMessage { segments } => render_user_message(lines, segments, width, mode), Block::SystemMessage { text } => render_system_message(lines, text, width, mode), Block::Notify { message } => { let text = format!("[notify] {message}"); match mode { Mode::Overview => push_overview_line(lines, &text, width, MessageKind::Notify, ""), _ => push_padded_lines(lines, &text, MessageKind::Notify), } } Block::PodEvent { event } => { let text = format_pod_event(event); match mode { Mode::Overview => push_overview_line(lines, &text, width, MessageKind::Notify, ""), _ => push_padded_lines(lines, &text, MessageKind::Notify), } } Block::AssistantText { text } => match mode { Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""), _ => lines.extend(crate::markdown::render( text, kind_style(MessageKind::Assistant), )), }, Block::Thinking(t) => render_thinking(lines, t, width, mode), // ToolCall is dispatched in `compute_history` via `tool::render_tool` // so it can consume multiple adjacent blocks (Read aggregation). Block::ToolCall(_) => unreachable!("ToolCall handled by compute_history"), Block::Alert { level, source, message, } => { let kind = match level { AlertLevel::Warn => MessageKind::NoticeWarn, AlertLevel::Error => MessageKind::NoticeError, }; let prefix = match level { AlertLevel::Warn => "[notice]", AlertLevel::Error => "[notice error]", }; let label = alert_source_label(*source); let text = format!("{prefix} {label}: {message}"); match mode { Mode::Overview => push_overview_line(lines, &text, width, kind, ""), _ => push_padded_lines(lines, &text, kind), } } Block::Compact(evt) => render_compact(lines, evt, width, mode), Block::TurnStats { requests, upload_tokens, output_tokens, } => { let text = format!( "{} reqs ↑{}/↓{}", requests, fmt_tokens(*upload_tokens), fmt_tokens(*output_tokens), ); lines.push( Line::from(Span::styled(text, kind_style(MessageKind::TurnStats))) .alignment(ratatui::layout::Alignment::Right), ); } } } fn push_padded_lines(lines: &mut Vec>, text: &str, kind: MessageKind) { let style = kind_style(kind); for raw in text.lines() { lines.push(Line::from(Span::styled(raw.to_owned(), style))); } if text.is_empty() { lines.push(Line::from("")); } } /// Render `Block::UserMessage` from typed segments. Each non-text /// segment renders as a one-piece chip whose colour matches the input /// area's chip presentation (paste = magenta, `@` file = cyan, /// `#` knowledge = green, `/` workflow = yellow), so the user /// recognises their own typed atoms in the scrollback. fn render_user_message( lines: &mut Vec>, segments: &[Segment], width: u16, mode: Mode, ) { if matches!(mode, Mode::Overview) { let text = segments .iter() .map(segment_display_text) .collect::>() .join(""); push_overview_line(lines, &text, width, MessageKind::User, "> "); return; } let user_style = kind_style(MessageKind::User); let mut current: Vec> = Vec::new(); for seg in segments { match seg { Segment::Text { content } => { let mut iter = content.split('\n').peekable(); while let Some(line) = iter.next() { if !line.is_empty() { current.push(Span::styled(line.to_owned(), user_style)); } if iter.peek().is_some() { lines.push(Line::from(std::mem::take(&mut current))); } } } other => { let (style, text) = chip_span_for(other, user_style); current.push(Span::styled(text, style)); } } } if !current.is_empty() { lines.push(Line::from(current)); } } fn render_system_message(lines: &mut Vec>, text: &str, width: u16, mode: Mode) { let header_style = kind_style(MessageKind::System); let body_style = Style::default().fg(Color::DarkGray); let (header, body) = split_system_message(text); let overview_text = if body.is_empty() { header.to_owned() } else { format!("{header} {body}") }; match mode { Mode::Overview => push_overview_line(lines, &overview_text, width, MessageKind::System, ""), Mode::Detail => { lines.push(Line::from(Span::styled(header.to_owned(), header_style))); for raw in body.lines() { lines.push(Line::from(vec![ Span::styled(" ", body_style), Span::styled(raw.to_owned(), body_style), ])); } if body.is_empty() && header.is_empty() { lines.push(Line::from("")); } } Mode::Normal => { lines.push(Line::from(Span::styled(header.to_owned(), header_style))); let preview = system_message_preview(body, 4); for line in preview.lines { lines.push(Line::from(vec![ Span::styled(" ", body_style), Span::styled(line, body_style), ])); } if preview.omitted_lines > 0 { lines.push(Line::from(vec![ Span::styled(" ", body_style), Span::styled( format!("… ({} more lines)", preview.omitted_lines), body_style.add_modifier(Modifier::ITALIC), ), ])); } } } } fn split_system_message(text: &str) -> (&str, &str) { match text.split_once('\n') { Some((header, body)) => (header, body.trim_start_matches('\n')), None => (text, ""), } } struct SystemMessagePreview { lines: Vec, omitted_lines: usize, } fn system_message_preview(body: &str, max_lines: usize) -> SystemMessagePreview { let all: Vec<&str> = body.lines().collect(); let lines = all .iter() .take(max_lines) .map(|line| (*line).to_owned()) .collect(); SystemMessagePreview { lines, omitted_lines: all.len().saturating_sub(max_lines), } } /// Style + display text for a single chip-style `Segment`. `fallback` /// is used for `Segment::Text` (which the caller handles inline) and /// for `Segment::Unknown` so future variants degrade gracefully. fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) { match seg { Segment::Text { content } => (fallback, content.clone()), Segment::Paste { id, chars, lines: line_count, .. } => ( Style::default().fg(Color::Magenta), format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"), ), Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")), Segment::KnowledgeRef { slug } => (Style::default().fg(Color::Green), format!("#{slug}")), Segment::WorkflowInvoke { slug } => { (Style::default().fg(Color::Yellow), format!("/{slug}")) } Segment::Unknown => (fallback, "[unknown segment]".to_owned()), } } /// One-line textual rendering of a segment, used by `Mode::Overview` /// (which collapses everything to a single string) and as the fallback /// inline rendering for non-paste, non-text segments. fn segment_display_text(seg: &Segment) -> String { match seg { Segment::Text { content } => content.replace('\n', " "), Segment::Paste { id, chars, lines, .. } => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"), Segment::FileRef { path } => format!("@{path}"), Segment::KnowledgeRef { slug } => format!("#{slug}"), Segment::WorkflowInvoke { slug } => format!("/{slug}"), Segment::Unknown => "[unknown segment]".to_owned(), } } /// Single-line summary for overview mode. The output is clipped to /// exactly one rendered terminal row at `width` columns — the first /// non-empty logical line is truncated (with `…`) to fit alongside an /// optional prefix and a `(+N lines)` tail that counts visual rows /// hidden by the fold (auto-wrap inclusive, so a single long paragraph /// that wraps to many rows correctly reports the hidden rows). fn push_overview_line( lines: &mut Vec>, text: &str, width: u16, kind: MessageKind, prefix: &str, ) { let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); let total_visual = count_visual_rows(text, width); let first_visual = count_visual_rows(first, width); // Budget for the first line's truncated content. Reserve columns // for the prefix up front and for the "(+N lines)" tail if we'll // emit one. `more` counts visual rows hidden by the fold; the one // row we're about to render is subtracted. let total_cols = width.max(1) as usize; let prefix_width = UnicodeWidthStr::width(prefix); let more = total_visual.saturating_sub(1); let tentative_tail = if more > 0 { format!(" (+{more} lines)") } else { String::new() }; let avail = total_cols.saturating_sub(prefix_width); // On very narrow widths where the tail alone would eat the row, // drop it — keeping at least some of the actual content visible // matters more than the hidden-rows label. let (tail, budget) = { let tw = UnicodeWidthStr::width(tentative_tail.as_str()); if !tentative_tail.is_empty() && tw >= avail { (String::new(), avail) } else { (tentative_tail, avail.saturating_sub(tw)) } }; let first_width = UnicodeWidthStr::width(first); let needs_truncation = first_width > budget || first_visual > 1; let shown = if needs_truncation { truncate_with_ellipsis(first, budget) } else { first.to_owned() }; let style = kind_style(kind); let mut spans: Vec> = Vec::new(); if !prefix.is_empty() { spans.push(Span::styled(prefix.to_owned(), style)); } spans.push(Span::styled(shown, style)); if !tail.is_empty() { spans.push(Span::styled(tail, Style::default().fg(Color::DarkGray))); } lines.push(Line::from(spans)); } /// Truncate `s` so its display width fits within `max_width`, appending /// `…` when a cut is actually applied. A budget of 0 yields an empty /// string; a budget of 1 drops the ellipsis and returns at most one /// column of content so we never blow past the cap. fn truncate_with_ellipsis(s: &str, max_width: usize) -> String { if max_width == 0 { return String::new(); } let s_width = UnicodeWidthStr::width(s); if s_width <= max_width { return s.to_owned(); } // Reserve 1 col for the ellipsis when we have room; on a 1-col // budget there's no room for both content and the marker, so just // clip to whatever single column fits. let (inner_budget, append_ellipsis) = if max_width >= 2 { (max_width - 1, true) } else { (max_width, false) }; let mut out = String::new(); let mut w = 0usize; for c in s.chars() { let cw = UnicodeWidthChar::width(c).unwrap_or(0); if w + cw > inner_budget { break; } out.push(c); w += cw; } if append_ellipsis { out.push('…'); } out } /// Visual row count of `text` when rendered at `width` columns with /// simple char-based wrap. Each empty logical line counts as 1 row. fn count_visual_rows(text: &str, width: u16) -> usize { if text.is_empty() { return 0; } let w = width.max(1) as usize; let mut total = 0usize; for line in text.lines() { let lw = UnicodeWidthStr::width(line); total += if lw == 0 { 1 } else { lw.div_ceil(w) }; } total.max(1) } fn render_thinking(lines: &mut Vec>, t: &ThinkingBlock, width: u16, mode: Mode) { let header_style = kind_style(MessageKind::Thinking); let body_style = Style::default().fg(Color::DarkGray); let header = match &t.state { ThinkingState::Streaming { started_at } => { let secs = started_at.elapsed().as_secs(); format!("Thinking... ({})", fmt_elapsed(secs)) } ThinkingState::Finished { elapsed_secs } => match elapsed_secs { Some(s) => format!("Thought for {}", fmt_elapsed(*s)), None => "Thought".to_owned(), }, ThinkingState::Incomplete { elapsed_secs } => match elapsed_secs { Some(s) => format!("Thinking interrupted ({})", fmt_elapsed(*s)), None => "Thinking interrupted".to_owned(), }, }; if matches!(mode, Mode::Overview) { push_overview_line(lines, &header, width, MessageKind::Thinking, ""); return; } lines.push(Line::from(Span::styled(header, header_style))); if t.text.is_empty() { return; } match mode { Mode::Detail => { for raw in t.text.lines() { lines.push(Line::from(vec![ Span::styled(" ", body_style), Span::styled(raw.to_owned(), body_style), ])); } } Mode::Normal => { // Streaming: show the *latest* tail to keep the cursor of // attention near where new tokens are appearing. Finished: // show the first line as a static preview — collapsing it // entirely would lose the only context most users want // ("what was it thinking about"). let preview = match &t.state { ThinkingState::Streaming { .. } => trailing_line_preview(&t.text), _ => first_line_preview(&t.text), }; if !preview.is_empty() { let budget = width.saturating_sub(2) as usize; let truncated = truncate_with_ellipsis(&preview, budget); lines.push(Line::from(vec![ Span::styled(" ", body_style), Span::styled(truncated, body_style), ])); } } Mode::Overview => unreachable!("handled above"), } } /// Last segment of `text` after the final newline (or the whole string /// if it has no newline). Used as the live "what is it thinking now" /// 1-liner. fn trailing_line_preview(text: &str) -> String { text.rsplit_once('\n') .map(|(_, tail)| tail) .unwrap_or(text) .trim_end() .to_owned() } /// First non-empty line of `text`. Used as the static preview after a /// thinking block finishes, mirroring the "first line + (+N lines)" /// idiom of the overview mode. fn first_line_preview(text: &str) -> String { text.lines() .find(|l| !l.trim().is_empty()) .unwrap_or("") .to_owned() } fn fmt_elapsed(secs: u64) -> String { if secs < 60 { format!("{secs}s") } else { format!("{}m{:02}s", secs / 60, secs % 60) } } fn render_compact(lines: &mut Vec>, evt: &CompactEvent, width: u16, mode: Mode) { let (text, kind) = match evt { CompactEvent::Streaming { started_at } => { let secs = started_at.elapsed().as_secs(); ( format!("Compacting... ({})", fmt_elapsed(secs)), MessageKind::NoticeWarn, ) } CompactEvent::Done { new_segment_id, elapsed_secs, } => { let short = new_segment_id .to_string() .chars() .take(8) .collect::(); let elapsed = elapsed_suffix(*elapsed_secs); ( format!("[compact] done (new session {short}){elapsed}"), MessageKind::NoticeWarn, ) } CompactEvent::Failed { error, elapsed_secs, } => { let elapsed = elapsed_suffix(*elapsed_secs); ( format!("[compact error] {error}{elapsed}"), MessageKind::NoticeError, ) } CompactEvent::Incomplete { elapsed_secs } => match elapsed_secs { Some(s) => ( format!("[compact] interrupted ({})", fmt_elapsed(*s)), MessageKind::NoticeError, ), None => ("[compact] interrupted".to_owned(), MessageKind::NoticeError), }, }; match mode { Mode::Overview => push_overview_line(lines, &text, width, kind, ""), _ => push_padded_lines(lines, &text, kind), } } fn elapsed_suffix(elapsed_secs: Option) -> String { elapsed_secs .map(|s| format!(" ({})", fmt_elapsed(s))) .unwrap_or_default() } fn draw_separator(frame: &mut Frame, area: Rect) { let line = "─".repeat(area.width as usize); frame.render_widget( Paragraph::new(Line::from(Span::styled( line, Style::default().fg(Color::DarkGray), ))), area, ); } fn context_usage_text(app: &App) -> String { let pct = if app.context_window == 0 { 0 } else { ((app.session_context_tokens as f64 / app.context_window as f64) * 100.0).round() as u64 }; format!( "{} / {} ({}%)", fmt_tokens(app.session_context_tokens), fmt_tokens(app.context_window), pct ) } fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let conn = if app.connected { Span::styled("●", Style::default().fg(Color::Green)) } else { Span::styled("○", Style::default().fg(Color::Red)) }; let mut spans = vec![ conn, Span::raw(" "), Span::styled( app.pod_name.clone(), Style::default().add_modifier(Modifier::BOLD), ), ]; if app.running { let status = if let Some(wait_event) = &app.latest_llm_wait_event { format!( "request: {} | ↑{}/↓{} | {wait_event}", app.run_requests, fmt_tokens(app.run_upload_tokens), fmt_tokens(app.run_output_tokens), ) } else if let Some(tool) = &app.current_tool { format!( "request: {} | ↑{}/↓{} | tool: {tool}", app.run_requests, fmt_tokens(app.run_upload_tokens), fmt_tokens(app.run_output_tokens), ) } else { format!( "request: {} | ↑{}/↓{}", app.run_requests, fmt_tokens(app.run_upload_tokens), fmt_tokens(app.run_output_tokens), ) }; spans.push(Span::raw(" | ")); spans.push(Span::styled(status, Style::default().fg(Color::Yellow))); } else if app.paused { spans.push(Span::raw(" | ")); spans.push(Span::styled( "paused", Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), )); spans.push(Span::styled( " — Enter to resume, type to start new turn", Style::default().fg(Color::DarkGray), )); } else { spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray))); } if let Some(queue) = queue_status_text(app) { spans.push(Span::raw(" | ")); spans.push(Span::styled(queue, Style::default().fg(Color::Magenta))); } let right_text = context_usage_text(app); let right_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray))) .alignment(ratatui::layout::Alignment::Right); frame.render_widget(Paragraph::new(Line::from(spans)), area); frame.render_widget(Paragraph::new(right_line), area); } fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) { let mut left: Vec> = Vec::new(); if app.is_command_mode() { left.push(Span::styled( "COMMAND", Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), )); } else if app.queued_input_count() > 0 { left.push(Span::styled( "Alt-q edit queued Alt-c clear queued", Style::default().fg(Color::DarkGray), )); } else if let Some(llm_event) = app.latest_llm_wait_event.as_deref() { left.push(Span::styled( truncate_with_ellipsis(llm_event, 96), Style::default().fg(Color::Yellow), )); } else if let Some(memory_event) = app.latest_memory_worker_event.as_deref() { left.push(Span::styled( truncate_with_ellipsis(memory_event, 72), Style::default().fg(Color::Blue), )); } let mut right: Vec> = Vec::new(); if !app.scroll.follow_tail { right.push(Span::styled( "↑ scrolled ", Style::default().fg(Color::Yellow), )); } right.push(Span::styled( format!("[{}]", app.mode.label()), Style::default().fg(Color::DarkGray), )); let left_line = Line::from(left); let right_line = Line::from(right).alignment(ratatui::layout::Alignment::Right); frame.render_widget(Paragraph::new(left_line), area); frame.render_widget(Paragraph::new(right_line), area); } fn queue_status_text(app: &App) -> Option { let count = app.queued_input_count(); if count == 0 { return None; } let mut text = format!("queued: {count}"); if let Some(preview) = app.next_queued_input_preview() { let preview = truncate_with_ellipsis(preview.trim(), 40); if !preview.is_empty() { text.push_str(" — "); text.push_str(&preview); } } Some(text) } fn draw_input(frame: &mut Frame, app: &App, render: &crate::input::InputRender, area: Rect) { // Prefix prompt on the first row, matching-width gutter for continuation // rows so multi-line input aligns visually. let prompt = if app.is_command_mode() { ": " } else { "> " }; let continuation = if app.is_command_mode() { ": " } else { " " }; let prompt_style = if app.is_command_mode() { Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; let mut lines: Vec> = Vec::with_capacity(render.lines.len()); for (i, src) in render.lines.iter().enumerate() { let absolute_row = render.viewport_start_row as usize + i; let prefix = if absolute_row == 0 { prompt } else { continuation }; let mut spans = vec![Span::styled(prefix.to_owned(), prompt_style)]; spans.extend(src.spans.iter().cloned()); lines.push(Line::from(spans)); } frame.render_widget(Paragraph::new(lines), area); let cursor_x = area.x + 2 + render.cursor_col; let cursor_y = area.y + render.cursor_row; if cursor_y < area.y + area.height { frame.set_cursor_position(Position::new(cursor_x, cursor_y)); } } fn render_greeting(lines: &mut Vec>, g: &Greeting, width: u16) { let inner = greeting_lines(g); let border_style = Style::default().fg(Color::DarkGray); // Render greeting into its own buffer so we can turn it into lines // for the outer history stream. Use a fixed width = area width. let box_width = width.min(80); let mut body_height: u16 = 0; let inner_width = box_width.saturating_sub(4); for l in &inner { let w = l.width() as u16; body_height += if inner_width == 0 || w == 0 { 1 } else { w.div_ceil(inner_width) }; } let total_height = body_height + 2; let area = Rect::new(0, 0, box_width, total_height); let mut buf = ratatui::buffer::Buffer::empty(area); Paragraph::new(inner) .block( UiBlock::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(border_style) .padding(Padding::horizontal(1)), ) .wrap(Wrap { trim: false }) .render(area, &mut buf); for y in 0..total_height { let mut spans: Vec> = Vec::new(); for x in 0..box_width { let cell = &buf[(x, y)]; spans.push(Span::styled(cell.symbol().to_string(), cell.style())); } lines.push(Line::from(spans)); } } fn greeting_lines(g: &Greeting) -> Vec> { let label = Style::default().fg(Color::DarkGray); let value = Style::default().fg(Color::White); let mut lines: Vec> = Vec::new(); lines.push(Line::from(Span::styled( g.pod_name.clone(), Style::default() .fg(Color::Green) .add_modifier(Modifier::BOLD), ))); lines.push(Line::from(Span::styled( format!("{} ({})", g.model, g.provider), Style::default().fg(Color::Cyan), ))); lines.push(Line::from("")); lines.push(Line::from(vec![ Span::styled("cwd: ", label), Span::styled(g.cwd.clone(), value), ])); lines.push(Line::from(vec![ Span::styled("tools: ", label), Span::styled(g.tools.join(", "), value), ])); if !g.scope_summary.is_empty() { lines.push(Line::from("")); for line in g.scope_summary.lines() { lines.push(Line::from(Span::styled(line.to_owned(), value))); } } lines } #[derive(Clone, Copy)] pub enum MessageKind { TurnHeader, User, /// External-input echoes (`Method::Notify` / `Method::PodEvent`). /// Visually distinct from User / Assistant / Notice so it's clear /// the line came from another Pod or operator, not the local user. Notify, /// Persisted role:system history item preview. System, Assistant, Thinking, TurnStats, NoticeWarn, NoticeError, } pub fn kind_style(kind: MessageKind) -> Style { match kind { MessageKind::TurnHeader => Style::default().fg(Color::DarkGray), MessageKind::User => Style::default().fg(Color::Green), MessageKind::Notify => Style::default().fg(Color::Yellow), MessageKind::System => Style::default().fg(Color::Cyan), MessageKind::Assistant => Style::default().fg(Color::White), MessageKind::Thinking => Style::default() .fg(Color::Magenta) .add_modifier(Modifier::ITALIC), MessageKind::TurnStats => Style::default().fg(Color::DarkGray), MessageKind::NoticeWarn => Style::default() .fg(Color::Black) .bg(Color::Yellow) .add_modifier(Modifier::BOLD), MessageKind::NoticeError => Style::default() .fg(Color::White) .bg(Color::Red) .add_modifier(Modifier::BOLD), } } /// One-line summary of a `PodEvent` for display in the activity log. /// Independent from the LLM-injection wrapper (`crate::ipc::event::render_event` /// in the pod crate) — that path applies prompt-pack wrapping, while /// this is the human-facing rendering of the raw structured event. fn format_pod_event(event: &PodEvent) -> String { match event { PodEvent::TurnEnded { pod_name } => { format!("[pod_event] {pod_name} → turn_ended") } PodEvent::Errored { pod_name, message } => { format!("[pod_event] {pod_name} → errored: {message}") } PodEvent::ShutDown { pod_name } => { format!("[pod_event] {pod_name} → shut_down") } PodEvent::ScopeSubDelegated { parent_pod, sub_pod, .. } => { format!("[pod_event] {parent_pod} → scope_sub_delegated: {sub_pod}") } } } #[cfg(test)] mod tests { use super::*; use crate::app::App; use protocol::PodStatus; #[test] fn queue_status_text_includes_count_and_preview() { let mut app = App::new("test".into()); app.set_pod_status(PodStatus::Running); for c in "queued preview".chars() { app.insert_char(c); } assert!(app.submit_input().is_none()); assert_eq!( queue_status_text(&app), Some("queued: 1 — queued preview".to_string()) ); } #[test] fn queue_status_text_is_absent_without_queue() { let app = App::new("test".into()); assert_eq!(queue_status_text(&app), None); } }