CX#5 + CX#11: line-buffer newline gate + pure-render footer

CX#5 (newline-boundary streaming gate):
- New crates/tui/src/tui/streaming/line_buffer.rs — LineBuffer holds
  text after the last \n until the next \n arrives, so partial code
  fences never become visible state.
- Wired into BlockState in streaming/mod.rs. Assistant text gates;
  thinking deltas bypass (reasoning stays live).
- 9 unit tests including the partial-fence regression case.

CX#11 (pure-render footer):
- New crates/tui/src/tui/widgets/footer.rs — FooterProps / FooterToast
  / FooterWidget. Pure render of pre-computed props.
- ui.rs::render_footer rewritten to build props once and delegate to
  FooterWidget. Visual output identical; existing 10 footer tests
  pass unchanged. 5 new from_app tests for the props builder.

908/908 tests pass; cargo clippy --workspace -D warnings clean;
cargo fmt clean.
This commit is contained in:
Hunter Bown
2026-04-25 22:39:55 -05:00
parent 1ad0c886b8
commit 8f05f272d3
5 changed files with 782 additions and 40 deletions
+223
View File
@@ -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(), "");
}
}
+91 -17
View File
@@ -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<String> {
}
}
/// 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<usize>) {
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<usize>) {
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();
+42 -23
View File
@@ -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<Span<'static>> {
// 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<Span<'static>> {
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<Span<'static>> {
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()
}
+424
View File
@@ -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<Span<'static>>` 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<Span<'static>>,
/// Reasoning-replay chip spans (empty when zero / not applicable).
pub reasoning_replay: Vec<Span<'static>>,
/// Cache-hit-rate chip spans (empty when no usage reported).
pub cache: Vec<Span<'static>>,
/// Session-cost chip spans (empty when below the display threshold).
pub cost: Vec<Span<'static>>,
/// Optional toast that, when present, replaces the left status line.
pub toast: Option<FooterToast>,
}
/// 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<FooterToast>,
state_label: &'static str,
state_color: Color,
coherence: Vec<Span<'static>>,
reasoning_replay: Vec<Span<'static>>,
cache: Vec<Span<'static>>,
cost: Vec<Span<'static>>,
) -> 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<Span<'static>> {
let parts: Vec<&Vec<Span<'static>>> = [
&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<Span<'static>> = 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<Span<'static>> {
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<Span<'static>> {
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::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::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::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::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::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::new(),
Vec::<Span<'static>>::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"));
}
}
+2
View File
@@ -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;