diff --git a/crates/tui/src/tui/streaming/line_buffer.rs b/crates/tui/src/tui/streaming/line_buffer.rs new file mode 100644 index 00000000..caea2848 --- /dev/null +++ b/crates/tui/src/tui/streaming/line_buffer.rs @@ -0,0 +1,223 @@ +//! Newline-boundary gate for streaming text. +//! +//! `LineBuffer` is an upstream-of-the-chunker safety layer that holds back any +//! text after the LAST `\n` until the next newline arrives. This prevents +//! partial multi-character markdown — most importantly partial code fences +//! (` ``` `) whose meaning flips depending on what follows on the same line — +//! from ever becoming visible state in the renderer. +//! +//! Mental model: +//! - `push(delta)` appends raw stream text to an internal pending buffer. +//! - `take_committable()` returns only the prefix up to and including the +//! LAST `\n` and clears that prefix. Whatever follows the last `\n` stays +//! in the buffer for the next push. +//! - `flush()` returns whatever is left, used at end-of-stream when the model +//! signals the turn is done. (The contract upstream of the chunker is that +//! only complete-line text is committed; `flush()` is the explicit escape +//! hatch when we know no more text will arrive.) +//! +//! See `cx5_chx5_newline_gate.md` in the task brief for full rationale. + +/// Holds streaming text until a newline boundary is reached. +/// +/// This is upstream of [`StreamChunker`](super::commit_tick::StreamChunker) +/// in the streaming pipeline: +/// +/// ```text +/// raw delta -> LineBuffer.push -> take_committable -> StreamChunker.push_delta -> commit tick +/// ``` +/// +/// The chunker also enforces a "drain-up-to-last-newline" rule on its pending +/// buffer, but `LineBuffer` exists as a *separate* layer so that: +/// 1. The contract is explicit and locally testable. +/// 2. Future downstream consumers (e.g. live preview that renders queued lines +/// optimistically) cannot accidentally see a partial fence. +/// 3. End-of-turn flush semantics are owned by the gate, not the policy. +#[derive(Debug, Default, Clone)] +pub struct LineBuffer { + /// Pending text not yet released because no terminating `\n` has been seen + /// since the last commit. + pending: String, +} + +impl LineBuffer { + /// Create an empty buffer. + pub fn new() -> Self { + Self::default() + } + + /// Append a raw delta. + pub fn push(&mut self, delta: &str) { + if delta.is_empty() { + return; + } + self.pending.push_str(delta); + } + + /// Return the prefix of the pending buffer up to and including the LAST + /// `\n`. Whatever follows that newline (if anything) stays buffered. + /// + /// Returns an empty string when the buffer is empty or contains no + /// newline yet — callers can treat the empty-string case as "nothing + /// committable on this push". + pub fn take_committable(&mut self) -> String { + let Some(last_nl) = self.pending.rfind('\n') else { + return String::new(); + }; + // Drain everything up to and including the last newline. The remaining + // tail (post-newline) stays in `pending` and is concatenated with the + // next `push` before the next commit decision is made. + self.pending.drain(..=last_nl).collect() + } + + /// Return whatever is left in the buffer, even if it is not newline + /// terminated. Used when the stream ends so we don't strand the final + /// partial line. + pub fn flush(&mut self) -> String { + std::mem::take(&mut self.pending) + } + + /// Whether the buffer holds any uncommitted text. + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } + + /// Length of the pending tail in bytes (testing/observability). + pub fn pending_len(&self) -> usize { + self.pending.len() + } + + /// Reset the buffer (e.g. on stream restart). + pub fn reset(&mut self) { + self.pending.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn push_without_newline_holds_everything() { + // Cornerstone invariant: nothing escapes the gate until a newline + // terminates the line. This is what protects partial code fences + // (e.g. ``` arriving in chunk N, language tag in chunk N+1). + let mut buf = LineBuffer::new(); + buf.push("hello"); + assert_eq!(buf.take_committable(), ""); + assert_eq!(buf.pending_len(), 5); + assert!(!buf.is_empty()); + } + + #[test] + fn push_with_trailing_partial_returns_only_prefix() { + let mut buf = LineBuffer::new(); + buf.push("hello\nwo"); + assert_eq!(buf.take_committable(), "hello\n"); + // Tail is held for next call. + assert_eq!(buf.pending_len(), 2); + assert!(!buf.is_empty()); + } + + #[test] + fn next_push_is_concatenated_with_held_tail() { + let mut buf = LineBuffer::new(); + buf.push("hello\nwo"); + assert_eq!(buf.take_committable(), "hello\n"); + // The held "wo" is concatenated with "rld\n", and the whole line + // becomes committable. + buf.push("rld\n"); + assert_eq!(buf.take_committable(), "world\n"); + assert!(buf.is_empty()); + } + + #[test] + fn flush_returns_unterminated_tail() { + let mut buf = LineBuffer::new(); + buf.push("trailing without newline"); + // No newline → nothing committable. + assert_eq!(buf.take_committable(), ""); + // End-of-stream flush returns it raw. + assert_eq!(buf.flush(), "trailing without newline"); + assert!(buf.is_empty()); + } + + #[test] + fn flush_is_empty_when_buffer_drained() { + let mut buf = LineBuffer::new(); + buf.push("a\n"); + assert_eq!(buf.take_committable(), "a\n"); + assert_eq!(buf.flush(), ""); + } + + #[test] + fn multi_line_burst_returns_prefix_through_last_newline() { + // Multiple newlines in one push: the entire prefix up through the + // last newline is committable in one go; only the unterminated tail + // is held. + let mut buf = LineBuffer::new(); + buf.push("a\nb\nc\nd"); + assert_eq!(buf.take_committable(), "a\nb\nc\n"); + assert_eq!(buf.pending_len(), 1); + // Finishing "d" with a newline releases it on the next take. + buf.push("\n"); + assert_eq!(buf.take_committable(), "d\n"); + } + + #[test] + fn partial_code_fence_never_escapes_the_gate() { + // Acceptance scenario from CX#5: a fenced code block whose opener + // arrives split across deltas must never expose "foo```rust" without + // a terminating newline. We assert that on every intermediate + // commit, the *committed* text either contains a newline or is empty + // — i.e. the pre-language partial fence never leaks. + let mut buf = LineBuffer::new(); + + // Chunk 1: a paragraph fragment ending with the fence opener. + buf.push("foo```"); + let c1 = buf.take_committable(); + assert!( + c1.is_empty() || c1.ends_with('\n'), + "partial fence leaked: {c1:?}" + ); + assert!( + !c1.contains("foo```"), + "fence opener escaped without newline: {c1:?}" + ); + + // Chunk 2: language tag + start of body. The fence line is now + // newline-terminated, so it can commit; the post-newline body is + // held. + buf.push("rust\nlet x"); + let c2 = buf.take_committable(); + assert!( + c2.ends_with('\n'), + "expected newline-terminated commit: {c2:?}" + ); + assert_eq!(c2, "foo```rust\n"); + + // Chunk 3: rest of body and the fence closer. + buf.push("= 1;\n```\n"); + let c3 = buf.take_committable(); + assert_eq!(c3, "let x= 1;\n```\n"); + assert!(buf.is_empty()); + } + + #[test] + fn empty_push_is_a_noop() { + let mut buf = LineBuffer::new(); + buf.push(""); + assert!(buf.is_empty()); + assert_eq!(buf.take_committable(), ""); + } + + #[test] + fn reset_clears_pending_tail() { + let mut buf = LineBuffer::new(); + buf.push("partial"); + assert_eq!(buf.pending_len(), 7); + buf.reset(); + assert!(buf.is_empty()); + assert_eq!(buf.flush(), ""); + } +} diff --git a/crates/tui/src/tui/streaming/mod.rs b/crates/tui/src/tui/streaming/mod.rs index cf3a5be3..7af06b23 100644 --- a/crates/tui/src/tui/streaming/mod.rs +++ b/crates/tui/src/tui/streaming/mod.rs @@ -18,9 +18,11 @@ use crate::palette; pub mod chunking; pub mod commit_tick; +pub mod line_buffer; pub use chunking::{AdaptiveChunkingPolicy, ChunkingMode}; pub use commit_tick::{StreamChunker, run_commit_tick}; +pub use line_buffer::LineBuffer; /// Collects streaming text and commits complete lines. #[derive(Debug, Clone)] pub struct MarkdownStreamCollector { @@ -220,10 +222,26 @@ fn wrap_line(line: &str, width: usize) -> Vec { } } -/// Per-block streaming substate: collector for newline-gating + chunker/policy -/// for two-gear pacing. +/// Per-block streaming substate: line-buffer (newline gate) feeding a +/// collector + chunker/policy for two-gear pacing. +/// +/// Pipeline: +/// ```text +/// raw delta -> LineBuffer.push -> take_committable -> collector + chunker -> commit tick +/// ``` +/// +/// The [`LineBuffer`] is upstream of the collector and chunker. It guarantees +/// that no partial multi-character markdown (e.g. an unfinished ``` fence) +/// reaches downstream consumers between deltas. Thinking blocks bypass the +/// gate because thinking is rendered live for responsiveness — its content +/// often arrives without newlines until a full paragraph is composed. #[derive(Debug, Default)] struct BlockState { + /// Newline gate: holds back trailing partial-line text between deltas. + /// Bypassed when `bypass_gate` is true (thinking blocks). + line_buffer: LineBuffer, + /// Whether to bypass the [`LineBuffer`] (thinking blocks stream live). + bypass_gate: bool, collector: MarkdownStreamCollector, chunker: StreamChunker, policy: AdaptiveChunkingPolicy, @@ -248,10 +266,14 @@ impl StreamingState { Self::default() } - /// Start a new text block + /// Start a new text block. Assistant text is subject to the newline gate + /// so partial code fences and other line-sensitive markdown can never + /// briefly appear between deltas. pub fn start_text(&mut self, index: usize, width: Option) { self.ensure_capacity(index); self.blocks[index] = Some(BlockState { + line_buffer: LineBuffer::new(), + bypass_gate: false, collector: MarkdownStreamCollector::new(width, false), chunker: StreamChunker::new(), policy: AdaptiveChunkingPolicy::new(), @@ -259,10 +281,15 @@ impl StreamingState { self.is_active = true; } - /// Start a new thinking block + /// Start a new thinking block. Thinking deltas bypass the newline gate so + /// they remain visually live — long reasoning often arrives as a single + /// paragraph without intermediate newlines, and gating it would create + /// long pauses where the user sees nothing. pub fn start_thinking(&mut self, index: usize, width: Option) { self.ensure_capacity(index); self.blocks[index] = Some(BlockState { + line_buffer: LineBuffer::new(), + bypass_gate: true, collector: MarkdownStreamCollector::new(width, true), chunker: StreamChunker::new(), policy: AdaptiveChunkingPolicy::new(), @@ -270,23 +297,48 @@ impl StreamingState { self.is_active = true; } - /// Push content to a block. The text is buffered in the collector and - /// any newline-complete portion is forwarded to the chunker, which decides - /// what (if anything) becomes visible on the next [`Self::commit_text`] tick. + /// Push content to a block. Routing depends on the block kind: + /// + /// - Assistant text blocks: incoming bytes go through [`LineBuffer`] + /// first, so only newline-terminated prefixes reach the collector and + /// chunker. This is what protects partial code fences and other + /// line-sensitive markdown from briefly appearing between deltas. + /// - Thinking blocks: bytes bypass the gate and go straight to the + /// collector/chunker so reasoning stays visually live (long thoughts + /// often have no intermediate newlines). + /// + /// `accumulated_text` / `accumulated_thinking` always track the full raw + /// stream so callers building API messages or doing retries see exactly + /// what the model emitted, regardless of UI gating. pub fn push_content(&mut self, index: usize, content: &str) { if let Some(Some(block)) = self.blocks.get_mut(index) { - block.collector.push(content); - // Update accumulated text + // Always update the raw accumulator first — UI gating must not + // affect what we send back to the model on retry/continuation. if block.collector.is_thinking { self.accumulated_thinking.push_str(content); } else { self.accumulated_text.push_str(content); } - // Forward newline-complete bytes to the chunker. Partial trailing - // content stays in the collector buffer (this is what protects - // partial code fences and other line-sensitive markdown from - // becoming briefly visible). + // Determine what bytes are safe to expose downstream on this push. + let downstream: String = if block.bypass_gate { + // Thinking: forward verbatim to collector + chunker. + content.to_string() + } else { + // Assistant text: gate at the last-newline boundary. + block.line_buffer.push(content); + block.line_buffer.take_committable() + }; + + if downstream.is_empty() { + return; + } + + block.collector.push(&downstream); + // The collector's own newline-gating is now redundant for gated + // blocks (LineBuffer already enforces it), but we keep the same + // call shape so the collector's bookkeeping (committed_line_count) + // stays consistent and the bypass path still benefits from it. let committed = block.collector.commit_complete_text(); if !committed.is_empty() { block.chunker.push_delta(&committed); @@ -384,12 +436,34 @@ impl StreamingState { lines } - /// Finalize a block and get remaining raw text. Drains any chunker - /// backlog plus any unterminated partial line in the collector. + /// Finalize a block and get remaining raw text. Drains the full pipeline + /// in upstream-to-downstream order: + /// + /// 1. [`LineBuffer::flush`] returns any post-newline tail held by the gate. + /// For gated blocks this is critical — without it, a final partial + /// line (e.g. text the model emitted without a trailing newline before + /// the turn ended) would otherwise be stranded in the gate. + /// 2. The collector's `finalize_text` releases any partial line it still + /// holds (relevant for the bypass path where the collector receives + /// raw deltas directly). + /// 3. The chunker's `drain_remaining` releases queued whole-line text + /// that the policy hadn't yet committed. pub fn finalize_block_text(&mut self, index: usize) -> String { if let Some(Some(block)) = self.blocks.get_mut(index) { - // First, push any tail buffered in the collector through (it may - // not be newline-terminated; finalize_text returns it raw). + // Flush the gate first so any held tail rejoins the stream + // before the collector/chunker drain. For thinking blocks the + // gate is unused, so this is a no-op. + let gate_tail = block.line_buffer.flush(); + if !gate_tail.is_empty() { + block.collector.push(&gate_tail); + } + // Any newly committable text after the gate flush feeds the + // chunker so drain order remains "queued-lines, then partial-tail". + let post_flush = block.collector.commit_complete_text(); + if !post_flush.is_empty() { + block.chunker.push_delta(&post_flush); + } + // Any unterminated tail still in the collector is returned raw. let tail = block.collector.finalize_text(); // Any whole-line text held by the chunker is safe to emit now. let mut out = block.chunker.drain_remaining(); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 20f5ae6e..ff73c1c5 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -82,7 +82,8 @@ use super::history::{ }; use super::views::{ConfigView, HelpView, ModalKind, ViewEvent}; use super::widgets::{ - ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable, slash_completion_hints, + ChatWidget, ComposerWidget, FooterProps, FooterToast, FooterWidget, HeaderData, HeaderWidget, + Renderable, slash_completion_hints, }; // === Constants === @@ -3445,37 +3446,51 @@ fn status_color(level: StatusToastLevel) -> ratatui::style::Color { } fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { - let available_width = area.width as usize; - if available_width == 0 { + if area.width == 0 || area.height == 0 { return; } - let right_spans = footer_auxiliary_spans(app, available_width); - let right_width = spans_width(&right_spans); - let active_status = app.active_status_toast(); - let min_gap = if right_width > 0 { 2 } else { 0 }; - let max_left_width = available_width - .saturating_sub(right_width) - .saturating_sub(min_gap) - .max(1); + // Pull in the toast first so we don't re-borrow `app` mutably mid-build, + // then build the FooterProps once. The widget itself is a pure render — + // it owns no `App` knowledge; all width-aware layout lives in the widget. + let toast = app.active_status_toast().map(|toast| FooterToast { + text: toast.text, + color: status_color(toast.level), + }); - let left_spans = if let Some(toast) = active_status.as_ref() { - footer_toast_spans(toast, max_left_width) + let (state_label, state_color) = footer_state_label(app); + let coherence = footer_coherence_spans(app); + let reasoning_replay = footer_reasoning_replay_spans(app); + let cache = footer_cache_spans(app); + let cost = if app.session_cost > 0.001 { + vec![Span::styled( + format!("${:.2}", app.session_cost), + Style::default().fg(palette::TEXT_MUTED), + )] } else { - footer_status_line_spans(app, max_left_width) + Vec::new() }; - let left_width = spans_width(&left_spans); - let spacer_width = available_width.saturating_sub(left_width + right_width); - - let mut all_spans = left_spans; - all_spans.push(Span::raw(" ".repeat(spacer_width))); - all_spans.extend(right_spans); - - let footer = Paragraph::new(Line::from(all_spans)); - f.render_widget(footer, area); + let props = FooterProps::from_app( + app, + toast, + state_label, + state_color, + coherence, + reasoning_replay, + cache, + cost, + ); + let widget = FooterWidget::new(props); + let buf = f.buffer_mut(); + widget.render(area, buf); } +/// Test-only helper retained as a parity reference for `FooterWidget`'s +/// auxiliary-span composition. Production rendering is performed by the +/// widget itself; the existing footer parity tests still exercise this +/// function directly to guard against drift. +#[allow(dead_code)] fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec> { // Context % is already shown in the header signal bar — don't // duplicate it in the footer. The footer carries unique info only: @@ -3572,6 +3587,7 @@ fn footer_reasoning_replay_spans(app: &App) -> Vec> { vec![Span::styled(label, Style::default().fg(color))] } +#[allow(dead_code)] fn footer_toast_spans( toast: &crate::tui::app::StatusToast, max_width: usize, @@ -3583,6 +3599,7 @@ fn footer_toast_spans( )] } +#[allow(dead_code)] fn footer_status_line_spans(app: &App, max_width: usize) -> Vec> { if max_width == 0 { return Vec::new(); @@ -3656,6 +3673,7 @@ fn footer_state_label(app: &App) -> (&'static str, ratatui::style::Color) { ("ready", palette::TEXT_MUTED) } +#[allow(dead_code)] fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Color) { let label = app.mode.as_setting(); let color = match app.mode { @@ -3697,6 +3715,7 @@ fn format_context_budget(used: i64, max: u32) -> String { ) } +#[allow(dead_code)] fn spans_width(spans: &[Span<'_>]) -> usize { spans.iter().map(|span| span.content.width()).sum() } diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs new file mode 100644 index 00000000..d649168d --- /dev/null +++ b/crates/tui/src/tui/widgets/footer.rs @@ -0,0 +1,424 @@ +//! Footer bar widget displaying mode, status, model, and auxiliary chips. +//! +//! `FooterWidget` is a pure render of a [`FooterProps`] struct: all content +//! (labels, colors, span clusters) is computed once per redraw at a higher +//! level, then `FooterWidget::new(props).render(area, buf)` paints the +//! result. The widget owns no `App` knowledge; this mirrors the layout used +//! by `HeaderWidget` (and Codex's `bottom_pane::footer::Footer`). + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Paragraph, Widget}, +}; +use unicode_width::UnicodeWidthStr; + +use crate::palette; +use crate::tui::app::{App, AppMode}; + +use super::Renderable; + +/// Pre-computed data the footer needs to render. +/// +/// All fields are owned `String` / `Vec>` values so the props +/// can be built once per redraw and then handed to a borrow-free widget. +#[derive(Debug, Clone)] +pub struct FooterProps { + /// The current model identifier shown after the mode chip. + pub model: String, + /// `"agent"` / `"yolo"` / `"plan"` — the canonical setting label. + pub mode_label: &'static str, + /// Color used for the mode chip. + pub mode_color: Color, + /// Status label like `"ready"`, `"thinking ⌫"`, `"working"`. When the + /// label equals `"ready"` the footer hides the status segment entirely. + pub state_label: String, + /// Color used for the status label. + pub state_color: Color, + /// Coherence chip spans (empty when no active intervention). + pub coherence: Vec>, + /// Reasoning-replay chip spans (empty when zero / not applicable). + pub reasoning_replay: Vec>, + /// Cache-hit-rate chip spans (empty when no usage reported). + pub cache: Vec>, + /// Session-cost chip spans (empty when below the display threshold). + pub cost: Vec>, + /// Optional toast that, when present, replaces the left status line. + pub toast: Option, +} + +/// A status toast routed to the footer's left segment for a short time. +#[derive(Debug, Clone)] +pub struct FooterToast { + pub text: String, + pub color: Color, +} + +impl FooterProps { + /// Build footer props from common app state. Helpers in `tui/ui.rs` + /// (e.g. `footer_state_label`, `footer_coherence_spans`) supply the + /// pre-styled spans and labels — this constructor just bundles them. + /// + /// Argument fan-out is intentional: each input maps 1:1 to a piece of + /// pre-computed footer content the caller resolved from `App`. Forcing + /// these into a builder would obscure the call site without making the + /// data flow any clearer. + #[must_use] + #[allow(clippy::too_many_arguments)] + pub fn from_app( + app: &App, + toast: Option, + state_label: &'static str, + state_color: Color, + coherence: Vec>, + reasoning_replay: Vec>, + cache: Vec>, + cost: Vec>, + ) -> Self { + let (mode_label, mode_color) = mode_style(app.mode); + Self { + model: app.model.clone(), + mode_label, + mode_color, + state_label: state_label.to_string(), + state_color, + coherence, + reasoning_replay, + cache, + cost, + toast, + } + } +} + +fn mode_style(mode: AppMode) -> (&'static str, Color) { + let label = match mode { + AppMode::Agent => "agent", + AppMode::Yolo => "yolo", + AppMode::Plan => "plan", + }; + let color = match mode { + AppMode::Agent => palette::MODE_AGENT, + AppMode::Yolo => palette::MODE_YOLO, + AppMode::Plan => palette::MODE_PLAN, + }; + (label, color) +} + +/// Pure-render footer. Build once per frame, then `render(area, buf)`. +pub struct FooterWidget { + props: FooterProps, +} + +impl FooterWidget { + #[must_use] + pub fn new(props: FooterProps) -> Self { + Self { props } + } + + fn auxiliary_spans(&self, max_width: usize) -> Vec> { + let parts: Vec<&Vec>> = [ + &self.props.coherence, + &self.props.reasoning_replay, + &self.props.cache, + &self.props.cost, + ] + .into_iter() + .filter(|spans| !spans.is_empty()) + .collect(); + + // Try to fit as many parts as possible, dropping from the end. + for end in (0..=parts.len()).rev() { + let mut combined: Vec> = Vec::new(); + for (i, part) in parts[..end].iter().enumerate() { + if i > 0 { + combined.push(Span::raw(" ")); + } + combined.extend(part.iter().cloned()); + } + if span_width(&combined) <= max_width { + return combined; + } + } + Vec::new() + } + + fn toast_spans(toast: &FooterToast, max_width: usize) -> Vec> { + let truncated = truncate_to_width(&toast.text, max_width.max(1)); + vec![Span::styled(truncated, Style::default().fg(toast.color))] + } + + fn status_line_spans(&self, max_width: usize) -> Vec> { + if max_width == 0 { + return Vec::new(); + } + + let mode_label = self.props.mode_label; + let sep = " \u{00B7} "; + let show_status = self.props.state_label != "ready"; + let status_label = self.props.state_label.as_str(); + + let fixed_width = mode_label.width() + + sep.width() + + if show_status { + sep.width() + status_label.width() + } else { + 0 + }; + + if max_width <= mode_label.width() { + return vec![Span::styled( + truncate_to_width(mode_label, max_width), + Style::default().fg(self.props.mode_color), + )]; + } + + let model_budget = max_width.saturating_sub(fixed_width).max(1); + let model_label = truncate_to_width(&self.props.model, model_budget); + + let mut spans = vec![ + Span::styled( + mode_label.to_string(), + Style::default().fg(self.props.mode_color), + ), + Span::styled(sep.to_string(), Style::default().fg(palette::TEXT_DIM)), + Span::styled(model_label, Style::default().fg(palette::TEXT_HINT)), + ]; + + if show_status { + spans.push(Span::styled( + sep.to_string(), + Style::default().fg(palette::TEXT_DIM), + )); + spans.push(Span::styled( + status_label.to_string(), + Style::default().fg(self.props.state_color), + )); + } + + spans + } +} + +impl Renderable for FooterWidget { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + let available_width = area.width as usize; + if available_width == 0 { + return; + } + + let right_spans = self.auxiliary_spans(available_width); + let right_width = span_width(&right_spans); + let min_gap = if right_width > 0 { 2 } else { 0 }; + let max_left_width = available_width + .saturating_sub(right_width) + .saturating_sub(min_gap) + .max(1); + + let left_spans = if let Some(toast) = self.props.toast.as_ref() { + Self::toast_spans(toast, max_left_width) + } else { + self.status_line_spans(max_left_width) + }; + + let left_width = span_width(&left_spans); + let spacer_width = available_width.saturating_sub(left_width + right_width); + + let mut all_spans = left_spans; + all_spans.push(Span::raw(" ".repeat(spacer_width))); + all_spans.extend(right_spans); + + let paragraph = Paragraph::new(Line::from(all_spans)); + paragraph.render(area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + 1 + } +} + +fn span_width(spans: &[Span<'_>]) -> usize { + spans.iter().map(|span| span.content.width()).sum() +} + +fn truncate_to_width(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(text) <= max_width { + return text.to_string(); + } + if max_width <= 3 { + return text.chars().take(max_width).collect(); + } + + let mut out = String::new(); + let mut width = 0usize; + let limit = max_width.saturating_sub(3); + for ch in text.chars() { + let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > limit { + break; + } + out.push(ch); + width += ch_width; + } + out.push_str("..."); + out +} + +#[cfg(test)] +mod tests { + use super::{FooterProps, FooterWidget, Renderable}; + use crate::config::Config; + use crate::palette; + use crate::tui::app::{App, AppMode, TuiOptions}; + use ratatui::{style::Color, text::Span}; + use std::path::PathBuf; + + fn make_app() -> App { + let options = TuiOptions { + model: "deepseek-v4-flash".to_string(), + workspace: PathBuf::from("."), + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + max_subagents: 1, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: true, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }; + let mut app = App::new(options, &Config::default()); + // App::new may pick up `default_model` from a local user Settings + // file, which overrides the option above. Pin the model explicitly + // so these tests are independent of any host-side configuration. + app.model = "deepseek-v4-flash".to_string(); + app + } + + fn idle_props_for(app: &App) -> FooterProps { + FooterProps::from_app( + app, + None, + "ready", + palette::TEXT_MUTED, + Vec::>::new(), + Vec::>::new(), + Vec::>::new(), + Vec::>::new(), + ) + } + + #[test] + fn from_app_idle_state_carries_ready_label_and_no_chips() { + let app = make_app(); + let props = idle_props_for(&app); + + assert_eq!(props.state_label, "ready"); + assert_eq!(props.state_color, palette::TEXT_MUTED); + assert_eq!(props.mode_label, "agent"); + assert_eq!(props.mode_color, palette::MODE_AGENT); + assert_eq!(props.model, "deepseek-v4-flash"); + assert!(props.coherence.is_empty()); + assert!(props.cache.is_empty()); + assert!(props.cost.is_empty()); + assert!(props.reasoning_replay.is_empty()); + assert!(props.toast.is_none()); + } + + #[test] + fn from_app_loading_state_uses_thinking_label_and_warning_color() { + let app = make_app(); + let props = FooterProps::from_app( + &app, + None, + "thinking \u{238B}", + palette::STATUS_WARNING, + Vec::>::new(), + Vec::>::new(), + Vec::>::new(), + Vec::>::new(), + ); + + assert!(props.state_label.starts_with("thinking")); + assert_eq!(props.state_color, palette::STATUS_WARNING); + } + + #[test] + fn from_app_mode_color_matches_mode_for_each_variant() { + let mut app = make_app(); + let cases = [ + (AppMode::Agent, "agent", palette::MODE_AGENT), + (AppMode::Yolo, "yolo", palette::MODE_YOLO), + (AppMode::Plan, "plan", palette::MODE_PLAN), + ]; + for (mode, expected_label, expected_color) in cases { + app.mode = mode; + let props = idle_props_for(&app); + assert_eq!( + props.mode_label, expected_label, + "label mismatch for {mode:?}", + ); + assert_eq!( + props.mode_color, expected_color, + "color mismatch for {mode:?}", + ); + } + } + + #[test] + fn render_emits_mode_and_model_when_idle() { + let app = make_app(); + let props = idle_props_for(&app); + let widget = FooterWidget::new(props); + + let area = ratatui::layout::Rect::new(0, 0, 60, 1); + let mut buf = ratatui::buffer::Buffer::empty(area); + widget.render(area, &mut buf); + + let rendered: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect(); + assert!(rendered.contains("agent")); + assert!(rendered.contains("deepseek-v4-flash")); + assert!(!rendered.contains("ready")); + } + + #[test] + fn render_swaps_toast_for_status_line() { + let app = make_app(); + let toast = super::FooterToast { + text: "session saved".to_string(), + color: Color::Green, + }; + let props = FooterProps::from_app( + &app, + Some(toast), + "ready", + palette::TEXT_MUTED, + Vec::>::new(), + Vec::>::new(), + Vec::>::new(), + Vec::>::new(), + ); + let widget = FooterWidget::new(props); + + let area = ratatui::layout::Rect::new(0, 0, 60, 1); + let mut buf = ratatui::buffer::Buffer::empty(area); + widget.render(area, &mut buf); + + let rendered: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect(); + assert!(rendered.contains("session saved")); + assert!(!rendered.contains("agent")); + assert!(!rendered.contains("deepseek-v4-flash")); + } +} diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 9eeb9a7f..b736c1e4 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1,6 +1,8 @@ +mod footer; mod header; mod renderable; +pub use footer::{FooterProps, FooterToast, FooterWidget}; pub use header::{HeaderData, HeaderWidget}; pub use renderable::Renderable;