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:
@@ -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(), "");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user