use super::{UsageScan, routed_suffix, scan_usage};
use crate::{
core::{
extensions::{
ExtensionMode, FooterCtx, FooterExtension, FooterLine, FooterSpan, is_nested_call_event,
},
provider::AgentMessage,
},
extensions::footer::{ACCENT, DIM, MUTED, SUCCESS, truncate_spans},
utils::display::display_width,
};
use serde_json::Value;
use std::{
path::Path,
sync::Mutex,
time::{Duration, Instant},
};
#[derive(Default)]
struct RichState {
counts: Vec<(String, usize)>,
llm_calls: usize,
task_start: Option<Instant>,
elapsed_ms: u64,
running: bool,
interactions: usize,
plan_mode: bool,
goal_active: bool,
tok_per_sec: f64,
rate_sample: Option<(Instant, f64)>,
seen_tok_out: u64,
seen_stream: f64,
out_drift: f64,
}
impl RichState {
fn reset(&mut self) {
*self = Self::default();
}
fn rebuild_from_history(&mut self, messages: &[AgentMessage]) {
self.counts.clear();
self.llm_calls = 0;
self.interactions = 0;
for msg in messages {
match msg.role.as_str() {
"user" => self.interactions += 1,
"assistant" => self.llm_calls += 1,
"toolResult" => {
let Some(name) = msg.tool_name.as_deref() else {
continue;
};
match self.counts.iter_mut().find(|(n, _)| n == name) {
Some((_, c)) => *c += 1,
None => self.counts.push((name.to_string(), 1)),
}
}
_ => {}
}
}
}
fn sample_token_rate(&mut self, tok_out: u64, stream_tokens: u64) {
let est = stream_tokens as f64;
if self.rate_sample.is_none() {
self.seen_tok_out = tok_out;
self.out_drift = 0.0;
} else if est < self.seen_stream {
let usage_delta = tok_out.saturating_sub(self.seen_tok_out) as f64;
self.out_drift += usage_delta - self.seen_stream;
}
self.seen_tok_out = tok_out;
self.seen_stream = est;
let progress = tok_out as f64 + est - self.out_drift;
match self.rate_sample {
None => self.rate_sample = Some((Instant::now(), progress)),
Some((at, prev)) => {
let dt = at.elapsed();
if dt >= Duration::from_secs(1) {
let delta = progress - prev;
self.tok_per_sec = if delta > 0.0 {
delta / dt.as_secs_f64()
} else {
0.0
};
self.rate_sample = Some((Instant::now(), progress));
}
}
}
}
}
pub struct RichFooter {
state: Mutex<RichState>,
}
impl RichFooter {
pub fn new() -> Self {
RichFooter {
state: Mutex::new(RichState::default()),
}
}
}
impl Default for RichFooter {
fn default() -> Self {
Self::new()
}
}
fn fmt(n: u64) -> String {
if n < 1000 {
n.to_string()
} else {
format!("{:.1}k", n as f64 / 1000.0)
}
}
fn basename(cwd: &str) -> String {
let trimmed = cwd.trim_end_matches('/');
Path::new(trimmed)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| cwd.to_string())
}
impl FooterExtension for RichFooter {
fn name(&self) -> &str {
"footer(rich)"
}
fn description(&self) -> &str {
"Rich footer: branch + model + context + cache hit, token rate + in/out + cost + plan + interactions + elapsed, cwd + tool counts + LLM calls."
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Minimal]
}
fn on_agent_event(&self, event: &Value) {
let ty = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
let mut st = self.state.lock().unwrap();
match ty {
"agent_start" => {
if !st.running {
st.task_start = Some(Instant::now());
st.rate_sample = None;
st.seen_tok_out = 0;
st.seen_stream = 0.0;
st.out_drift = 0.0;
st.tok_per_sec = 0.0;
}
st.running = true;
st.interactions += 1;
}
"agent_end" => {
if let Some(start) = st.task_start {
st.elapsed_ms = start.elapsed().as_millis() as u64;
}
st.running = false;
st.rate_sample = None;
st.tok_per_sec = 0.0;
}
"turn_start" => st.llm_calls += 1,
"tool_execution_end" => {
if is_nested_call_event(event) {
return;
}
if let Some(name) = event.get("toolName").and_then(|v| v.as_str()) {
match st.counts.iter_mut().find(|(n, _)| n == name) {
Some((_, c)) => *c += 1,
None => st.counts.push((name.to_string(), 1)),
}
}
}
"plan-mode:changed" => {
if let Some(enabled) = event.get("enabled").and_then(|v| v.as_bool()) {
st.plan_mode = enabled;
}
}
"goal:changed" => {
st.goal_active = event
.get("status")
.and_then(|v| v.as_str())
.is_some_and(|s| s == "active");
}
"session:new" => st.reset(),
_ => {}
}
}
fn on_session_switched(&self, _session_path: Option<&str>, messages: &[AgentMessage]) {
let mut st = self.state.lock().unwrap();
let (plan_mode, goal_active) = (st.plan_mode, st.goal_active);
*st = RichState::default();
st.plan_mode = plan_mode;
st.goal_active = goal_active;
st.rebuild_from_history(messages);
}
fn render(&self, ctx: &FooterCtx) -> Vec<FooterLine> {
let mut st = self.state.lock().unwrap();
let UsageScan {
tok_in,
tok_out,
cache_read,
cost,
..
} = scan_usage(ctx.messages);
let cache_hit_pct = if cache_read + tok_in > 0 {
cache_read as f64 / (cache_read + tok_in) as f64 * 100.0
} else {
0.0
};
let elapsed_ms = if st.running {
st.task_start
.map(|t| t.elapsed().as_millis() as u64)
.unwrap_or(0)
} else {
st.elapsed_ms
};
let mins = elapsed_ms / 60_000;
let secs = (elapsed_ms % 60_000) / 1000;
let elapsed_str = if mins > 0 {
format!("{}m {:02}s", mins, secs)
} else {
format!("{}s", secs)
};
if st.running {
st.sample_token_rate(tok_out, ctx.streaming_output_tokens);
}
let mut left: Vec<FooterSpan> = Vec::new();
if let Some(branch) = ctx.git_branch {
left.push(FooterSpan::new(DIM.0, DIM.1, " "));
left.push(FooterSpan::new(SUCCESS.0, SUCCESS.1, branch.to_string()));
left.push(FooterSpan::new(DIM.0, DIM.1, " "));
}
let model = match (ctx.model, ctx.model_reasoning) {
(Some(m), true) => format!(
"{}:{}{}",
m,
ctx.thinking_level.unwrap_or("off"),
routed_suffix(ctx)
),
(Some(m), false) => format!("{}{}", m, routed_suffix(ctx)),
(None, _) => String::new(),
};
if !model.is_empty() {
left.push(FooterSpan::new(DIM.0, DIM.1, format!("{} ", model)));
}
left.push(FooterSpan::new(
ACCENT.0,
ACCENT.1,
format!("{}%", ctx.context_percent.round()),
));
left.push(FooterSpan::new(MUTED.0, MUTED.1, " CH "));
left.push(FooterSpan::new(
ACCENT.0,
ACCENT.1,
format!("{}%", cache_hit_pct.round()),
));
let mut right: Vec<FooterSpan> = Vec::new();
right.push(FooterSpan::new(
MUTED.0,
MUTED.1,
format!("{}t/s ", fmt(st.tok_per_sec.round() as u64)),
));
right.push(FooterSpan::new(SUCCESS.0, SUCCESS.1, fmt(tok_in)));
right.push(FooterSpan::new(DIM.0, DIM.1, " in "));
right.push(FooterSpan::new(ACCENT.0, ACCENT.1, fmt(tok_out)));
right.push(FooterSpan::new(DIM.0, DIM.1, " out"));
right.push(FooterSpan::new(
SUCCESS.0,
SUCCESS.1,
format!(" ${:.4}", cost),
));
if st.goal_active {
right.push(FooterSpan::new(MUTED.0, MUTED.1, " 🎯 "));
right.push(FooterSpan::new(DIM.0, DIM.1, "goal"));
}
if st.plan_mode {
right.push(FooterSpan::new(MUTED.0, MUTED.1, " 📋 "));
right.push(FooterSpan::new(DIM.0, DIM.1, "plan"));
}
right.push(FooterSpan::new(
MUTED.0,
MUTED.1,
format!(" 💬 {}", st.interactions),
));
right.push(FooterSpan::new(
MUTED.0,
MUTED.1,
format!(" ⌛ {}", elapsed_str),
));
let lw: usize = left.iter().map(|s| display_width(&s.text)).sum();
let rw: usize = right.iter().map(|s| display_width(&s.text)).sum();
let pad_w = ctx.width.saturating_sub(lw + rw).max(1);
let mut line1 = left;
line1.push(FooterSpan::new(DIM.0, DIM.1, " ".repeat(pad_w)));
line1.extend(right);
let line1 = truncate_spans(line1, ctx.width);
let l2_left: Vec<FooterSpan> = vec![
FooterSpan::new(DIM.0, DIM.1, "📁 "),
FooterSpan::new(ACCENT.0, ACCENT.1, basename(ctx.cwd)),
];
let mut l2_right: Vec<FooterSpan> = Vec::new();
for (name, count) in st
.counts
.iter()
.chain(&[("turn".to_string(), st.llm_calls)])
{
l2_right.push(FooterSpan::new(SUCCESS.0, SUCCESS.1, format!(" {}", count)));
l2_right.push(FooterSpan::new(DIM.0, DIM.1, " "));
l2_right.push(FooterSpan::new(DIM.0, DIM.1, name.clone()));
}
let l2lw: usize = l2_left.iter().map(|s| display_width(&s.text)).sum();
let l2rw: usize = l2_right.iter().map(|s| display_width(&s.text)).sum();
let pad2_w = ctx.width.saturating_sub(l2lw + l2rw).max(1);
let mut line2 = l2_left;
line2.push(FooterSpan::new(DIM.0, DIM.1, " ".repeat(pad2_w)));
line2.extend(l2_right);
let line2 = truncate_spans(line2, ctx.width);
vec![line1, line2]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::provider::{AgentMessage, Cost, Usage};
use crate::utils::display::display_width;
use serde_json::json;
use std::time::Duration;
fn line_text(line: &FooterLine) -> String {
line.iter().map(|s| s.text.as_str()).collect()
}
fn ctx_with(width: usize, messages: Vec<AgentMessage>) -> FooterCtx<'static> {
FooterCtx {
width,
cwd: "/tmp/proj",
model: Some("gpt-test"),
model_reasoning: false,
thinking_level: None,
routed_model: None,
routed_thinking_level: None,
git_branch: Some("main"),
context_percent: 42.0,
context_window: 200_000,
busy: false,
streaming_output_tokens: 0,
messages: Box::leak(messages.into_boxed_slice()),
}
}
fn ev(ty: &str) -> Value {
json!({ "type": ty })
}
fn tool_result(name: &str) -> AgentMessage {
let mut m = AgentMessage::user_text("");
m.role = "toolResult".to_string();
m.tool_name = Some(name.to_string());
m
}
fn assistant() -> AgentMessage {
let mut m = AgentMessage::user_text("");
m.role = "assistant".to_string();
m
}
#[test]
fn line1_has_branch_model_context_and_cache_hit() {
let ext = RichFooter::new();
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.usage = Some(Usage {
input: 1000,
output: 500,
cache_read: 300,
cost: Cost {
total: 0.0123,
..Cost::default()
},
..Usage::default()
});
let lines = ext.render(&ctx_with(140, vec![msg]));
assert_eq!(lines.len(), 2);
let l1 = line_text(&lines[0]);
assert!(l1.contains("main"), "branch, got: {l1}");
assert!(l1.contains("gpt-test"), "model, got: {l1}");
assert!(l1.contains("42%"), "context pct(round), got: {l1}");
assert!(l1.contains("CH 23%"), "cache hit pct, got: {l1}");
let hit = lines[0]
.iter()
.find(|s| s.text == "23%")
.expect("cache hit span");
assert_eq!(hit.key, ACCENT.0, "CH 百分比应与上下文使用率同色");
let prefix = lines[0]
.iter()
.find(|s| s.text == " CH ")
.expect("cache hit label span");
assert_eq!(prefix.key, MUTED.0, "CH 前缀保持 muted");
assert!(l1.contains("0t/s"), "tok rate, got: {l1}");
assert!(l1.contains("1.0k in"), "tokIn fmt, got: {l1}");
assert!(l1.contains("500 out"), "tokOut fmt, got: {l1}");
assert!(l1.contains("$0.0123"), "cost 4位, got: {l1}");
}
#[test]
fn line2_has_cwd_basename_and_tool_counts_in_insertion_order() {
let ext = RichFooter::new();
ext.on_agent_event(&ev("tool_execution_end"));
ext.on_agent_event(&json!({ "type": "tool_execution_end" }));
ext.on_agent_event(&json!({ "type": "tool_execution_end", "toolName": "bash" }));
ext.on_agent_event(&json!({ "type": "tool_execution_end", "toolName": "read" }));
ext.on_agent_event(&json!({ "type": "tool_execution_end", "toolName": "bash" }));
let lines = ext.render(&ctx_with(140, Vec::new()));
let l2 = line_text(&lines[1]);
assert!(l2.contains("📁 proj"), "cwd basename, got: {l2}");
let p = l2.find("bash").expect("bash count shown");
let r = l2.find("read").expect("read count shown");
assert!(p < r, "插入序: bash 在 read 前, got: {l2}");
assert!(l2.contains(" 2 "), "bash count 2, got: {l2}");
}
#[test]
fn interactions_and_elapsed_from_agent_events() {
let ext = RichFooter::new();
ext.on_agent_event(&ev("agent_start"));
ext.on_agent_event(&ev("agent_start"));
std::thread::sleep(Duration::from_millis(20));
ext.on_agent_event(&ev("agent_end"));
let lines = ext.render(&ctx_with(140, Vec::new()));
let l1 = line_text(&lines[0]);
assert!(l1.contains("💬 2"), "interactions=2, got: {l1}");
assert!(l1.contains("⌛ 0s"), "elapsed <1s, got: {l1}");
}
#[test]
fn elapsed_pads_seconds_to_two_digits() {
let ext = RichFooter::new();
ext.state.lock().unwrap().elapsed_ms = 65_000;
let lines = ext.render(&ctx_with(140, Vec::new()));
let l1 = line_text(&lines[0]);
assert!(l1.contains("⌛ 1m 05s"), "秒数补零, got: {l1}");
}
#[test]
fn plan_mode_flag_shown_in_line1() {
let ext = RichFooter::new();
ext.on_agent_event(&json!({ "type": "plan-mode:changed", "enabled": true }));
let lines = ext.render(&ctx_with(140, Vec::new()));
let l1 = line_text(&lines[0]);
assert!(l1.contains("📋"), "plan marker, got: {l1}");
assert!(l1.contains("plan"), "plan label, got: {l1}");
}
#[test]
fn goal_flag_shown_in_line1() {
let ext = RichFooter::new();
ext.on_agent_event(&json!({ "type": "goal:changed", "status": "active" }));
let lines = ext.render(&ctx_with(140, Vec::new()));
let l1 = line_text(&lines[0]);
assert!(l1.contains("🎯"), "goal marker, got: {l1}");
assert!(l1.contains("goal"), "goal label, got: {l1}");
}
#[test]
fn token_rate_sampled_after_one_second() {
let ext = RichFooter::new();
{
let mut st = ext.state.lock().unwrap();
st.running = true;
st.task_start = Some(Instant::now());
st.rate_sample = Some((Instant::now() - Duration::from_secs(3), 0.0));
}
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.usage = Some(Usage {
input: 100,
output: 3000,
..Usage::default()
});
let lines = ext.render(&ctx_with(140, vec![msg]));
let l1 = line_text(&lines[0]);
assert!(l1.contains("1.0kt/s"), "tok rate 1.0k, got: {l1}");
let st = ext.state.lock().unwrap();
assert_eq!(st.seen_tok_out, 3000);
assert!(st.rate_sample.is_some());
}
#[test]
fn token_rate_covers_inflight_reasoning_output() {
let ext = RichFooter::new();
{
let mut st = ext.state.lock().unwrap();
st.running = true;
st.task_start = Some(Instant::now());
st.rate_sample = Some((Instant::now() - Duration::from_secs(2), 0.0));
}
let mut ctx = ctx_with(140, Vec::new());
ctx.streaming_output_tokens = 1000;
let l1 = line_text(&ext.render(&ctx)[0]);
assert!(l1.contains("500t/s"), "在途输出计入速率, got: {l1}");
}
#[test]
fn token_rate_smooth_across_message_end() {
let ext = RichFooter::new();
{
let mut st = ext.state.lock().unwrap();
st.running = true;
st.task_start = Some(Instant::now());
st.rate_sample = Some((Instant::now() - Duration::from_secs(2), 0.0));
}
let mut streaming = ctx_with(140, Vec::new());
streaming.streaming_output_tokens = 2000;
let l1 = line_text(&ext.render(&streaming)[0]);
assert!(l1.contains("1.0kt/s"), "流式期速率, got: {l1}");
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.usage = Some(Usage {
input: 0,
output: 1800,
..Usage::default()
});
let landed = ctx_with(140, vec![msg]);
let l1 = line_text(&ext.render(&landed)[0]);
assert!(l1.contains("1.0kt/s"), "消息边界不出现尖峰, got: {l1}");
let st = ext.state.lock().unwrap();
assert_eq!(st.out_drift, -200.0, "漂移修正 = 1800-2000");
}
#[test]
fn llm_calls_count_turn_starts_on_line2_right() {
let ext = RichFooter::new();
ext.on_agent_event(&json!({ "type": "turn_start", "turnIndex": 0 }));
ext.on_agent_event(&json!({ "type": "turn_start", "turnIndex": 1 }));
ext.on_agent_event(&json!({ "type": "turn_start", "turnIndex": 2 }));
ext.on_agent_event(&json!({ "type": "tool_execution_end", "toolName": "bash" }));
ext.on_agent_event(&json!({ "type": "message_start" }));
let lines = ext.render(&ctx_with(140, Vec::new()));
let l2 = line_text(&lines[1]);
assert!(l2.contains("3 turn"), "LLM 调用计数=3, got: {l2}");
let tools = l2.find("bash").expect("tool count shown");
let calls = l2.find("turn").expect("llm call count shown");
assert!(tools < calls, "LLM 调用计数应在最右, got: {l2}");
}
#[test]
fn session_new_resets_all_accumulated_state() {
let ext = RichFooter::new();
ext.on_agent_event(&json!({ "type": "tool_execution_end", "toolName": "bash" }));
ext.on_agent_event(&json!({ "type": "tool_execution_end", "toolName": "bash" }));
ext.on_agent_event(&json!({ "type": "tool_execution_end", "toolName": "read" }));
ext.on_agent_event(&ev("agent_start"));
ext.on_agent_event(&ev("agent_start"));
std::thread::sleep(Duration::from_millis(10));
ext.on_agent_event(&ev("agent_end"));
ext.on_agent_event(&json!({ "type": "turn_start", "turnIndex": 0 }));
ext.on_agent_event(&json!({ "type": "plan-mode:changed", "enabled": true }));
{
let mut st = ext.state.lock().unwrap();
st.tok_per_sec = 123.0;
}
let before = ext.render(&ctx_with(140, Vec::new()));
assert!(line_text(&before[0]).contains("💬 2"));
assert!(line_text(&before[1]).contains("bash"));
assert!(line_text(&before[1]).contains("1 turn"));
ext.on_agent_event(&json!({ "type": "session:new" }));
let after = ext.render(&ctx_with(140, Vec::new()));
let l1 = line_text(&after[0]);
assert!(l1.contains("💬 0"), "交互数重置, got: {l1}");
assert!(l1.contains("⌛ 0s"), "耗时重置, got: {l1}");
assert!(l1.contains("0t/s"), "token 速率重置, got: {l1}");
assert!(!l1.contains("📋"), "plan 模式重置, got: {l1}");
assert!(
line_text(&after[1]).contains("0 turn"),
"LLM 调用计数重置, got: {}",
line_text(&after[1])
);
}
#[test]
fn session_switch_rebuilds_counts_from_history() {
let ext = RichFooter::new();
ext.on_agent_event(&json!({ "type": "tool_execution_end", "toolName": "bash" }));
ext.on_agent_event(&ev("agent_start"));
ext.on_agent_event(&json!({ "type": "turn_start", "turnIndex": 0 }));
ext.state.lock().unwrap().elapsed_ms = 65_000;
let msgs = vec![
AgentMessage::user_text("hi"),
assistant(),
tool_result("read"),
assistant(),
tool_result("read"),
tool_result("bash"),
];
ext.on_session_switched(Some("/tmp/s.jsonl"), &msgs);
let lines = ext.render(&ctx_with(140, Vec::new()));
let l1 = line_text(&lines[0]);
let l2 = line_text(&lines[1]);
assert!(l1.contains("💬 1"), "交互数按历史重建, got: {l1}");
assert!(l1.contains("⌛ 0s"), "耗时回到本会话, got: {l1}");
assert!(l2.contains(" 2 read"), "read×2, got: {l2}");
assert!(l2.contains(" 1 bash"), "bash 只算历史里的 1 次, got: {l2}");
assert!(
l2.contains("2 turn"),
"assistant 条数 = LLM 调用数, got: {l2}"
);
}
#[test]
fn session_switch_keeps_plan_and_goal_markers() {
let ext = RichFooter::new();
ext.on_agent_event(&json!({ "type": "plan-mode:changed", "enabled": true }));
ext.on_agent_event(&json!({ "type": "goal:changed", "status": "active" }));
ext.on_session_switched(None, &[]);
let l1 = line_text(&ext.render(&ctx_with(140, Vec::new()))[0]);
assert!(l1.contains("📋 plan"), "plan 标记保留, got: {l1}");
assert!(l1.contains("🎯 goal"), "goal 标记保留, got: {l1}");
assert!(l1.contains("💬 0"), "历史为空则计数为 0, got: {l1}");
}
#[test]
fn rebuild_skips_tool_result_without_name() {
let ext = RichFooter::new();
let mut unnamed = AgentMessage::user_text("");
unnamed.role = "toolResult".to_string();
ext.on_session_switched(None, &[unnamed, tool_result("bash")]);
let l2 = line_text(&ext.render(&ctx_with(140, Vec::new()))[1]);
assert!(l2.contains(" 1 bash"), "无名工具结果跳过, got: {l2}");
assert!(!l2.contains(" 0 "), "不得插出 0 计数的空工具, got: {l2}");
}
#[test]
fn model_name_shows_thinking_level_when_reasoning() {
let ext = RichFooter::new();
let mut ctx = ctx_with(140, Vec::new());
ctx.model_reasoning = true;
ctx.thinking_level = Some("high");
let l1 = line_text(&ext.render(&ctx)[0]);
assert!(l1.contains("gpt-test:high"), "model:level, got: {l1}");
let mut ctx2 = ctx_with(140, Vec::new());
ctx2.model_reasoning = true;
ctx2.thinking_level = None;
let l1b = line_text(&ext.render(&ctx2)[0]);
assert!(l1b.contains("gpt-test:off"), "缺省 off, got: {l1b}");
let l1c = line_text(&ext.render(&ctx_with(140, Vec::new()))[0]);
assert!(l1c.contains("gpt-test "), "仅 id, got: {l1c}");
assert!(!l1c.contains("gpt-test:"), "不得带 level, got: {l1c}");
}
#[test]
fn no_branch_and_no_model_fallbacks() {
let ext = RichFooter::new();
let mut ctx = ctx_with(140, Vec::new());
ctx.git_branch = None;
ctx.model = None;
let lines = ext.render(&ctx);
let l1 = line_text(&lines[0]);
assert!(!l1.contains("main"), "无分支不显示, got: {l1}");
assert!(
!l1.contains("no-model"),
"无可用模型时不得显示占位模型名, got: {l1}"
);
}
#[test]
fn narrow_width_truncates_without_panic() {
let ext = RichFooter::new();
ext.on_agent_event(&json!({ "type": "tool_execution_end", "toolName": "bash" }));
ext.on_agent_event(&ev("agent_start"));
for w in [0usize, 1, 10, 40] {
let ctx = ctx_with(w, Vec::new());
let lines = ext.render(&ctx);
assert_eq!(lines.len(), 2);
for line in &lines {
let line_w: usize = line.iter().map(|sp| display_width(&sp.text)).sum();
assert!(
line_w <= w,
"width {} exceeds {}: {:?}",
line_w,
w,
line_text(line)
);
}
}
}
}