1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
//! Footer component for TUI.
//!
//! Based on TypeScript implementation:
//! packages/coding-agent/src/modes/interactive/components/footer.ts
//!
//! The TS footer is a two-line status bar: a dim `pwd (branch)` line then a
//! stats line with the model right-aligned. Our Rust host carries less session
//! state (no pwd/token/cost aggregation here), so this is a compact single-row
//! version with `[model]`, status, and dimmed keybinding hints. The editor's own
//! bottom edge provides separation, so the footer adds no duplicate rule. The
//! model is right-aligned to mirror pi's layout; an optional thinking-level suffix (pi shows
//! `model • thinking off`) follows the model when set.
use std::any::Any;
use std::sync::Mutex;
use super::component::Component;
use crate::theme::theme;
use crate::utils::{truncate_to_width, visible_width};
/// Footer component that displays status information.
///
/// Mirrors TypeScript FooterComponent class (simplified version).
pub struct FooterComponent {
/// Current status text
status: Mutex<String>,
/// Model name
model: Mutex<String>,
/// Keybinding hints
hints: Mutex<String>,
/// Optional thinking-level suffix shown after the model (pi parity:
/// `model • thinking off` / `model • medium`). `None` → not shown.
thinking_level: Mutex<Option<String>>,
}
impl FooterComponent {
/// Create a new footer component.
pub fn new() -> Self {
Self {
status: Mutex::new(String::new()),
model: Mutex::new("claude-sonnet-5".to_string()),
// Compact default so the right-aligned hints actually fit
// alongside the model on an 80-col terminal (the render path
// drops hints when `left + right + 2 > width`; the old 50-char
// default was silently dropped on every common width).
hints: Mutex::new("Enter send · Ctrl+C abort · /help".to_string()),
thinking_level: Mutex::new(None),
}
}
/// Set the status text.
pub fn set_status(&self, status: &str) {
if let Ok(mut s) = self.status.lock() {
*s = status.to_string();
}
}
/// Set the model name.
pub fn set_model(&self, model: &str) {
if let Ok(mut m) = self.model.lock() {
*m = model.to_string();
}
}
/// Set the keybinding hints.
pub fn set_hints(&self, hints: &str) {
if let Ok(mut h) = self.hints.lock() {
*h = hints.to_string();
}
}
/// Set the thinking-level suffix shown after the model name (pi parity).
/// Pass `None` (or [`Self::clear_thinking_level`]) to hide it.
pub fn set_thinking_level(&self, level: Option<&str>) {
if let Ok(mut t) = self.thinking_level.lock() {
*t = level.map(|s| s.to_string());
}
}
/// Clear the thinking-level suffix.
pub fn clear_thinking_level(&self) {
if let Ok(mut t) = self.thinking_level.lock() {
*t = None;
}
}
/// Get the status text.
pub fn get_status(&self) -> String {
self.status.lock().unwrap().clone()
}
/// Get the model name.
pub fn get_model(&self) -> String {
self.model.lock().unwrap().clone()
}
}
impl Default for FooterComponent {
fn default() -> Self {
Self::new()
}
}
impl Component for FooterComponent {
fn render(&self, width: usize) -> Vec<String> {
let colors = theme().colors;
let status = self.status.lock().unwrap();
let model = self.model.lock().unwrap();
let hints = self.hints.lock().unwrap();
let thinking_level = self.thinking_level.lock().unwrap();
// Status row. The editor already has a bottom edge, so the footer does
// not draw another horizontal separator below it.
// (dim model name inside) + a status word colored by run state.
// Right side: the keybinding hints, dimmed. Right-aligned to mirror
// pi's model-on-the-right layout.
let model_label = if !model.is_empty() {
// pi renders the model dim; the brackets are the accent cue.
let inner = if let Some(ref lvl) = *thinking_level {
format!("{} • {}", model, lvl)
} else {
model.clone()
};
format!(
"{}{} {}",
colors.accent.fg("["),
colors.dim.fg(&inner),
colors.accent.fg("]")
)
} else {
String::new()
};
// Status word colored by content: working/aborting → accent, error →
// error, idle/blank → muted. Anything else stays as-is.
let status_word = if status.is_empty() {
String::new()
} else {
let lower = status.to_ascii_lowercase();
let color = if lower.contains("abort") {
colors.error
} else if lower.contains("work") || lower.contains("running") {
colors.accent
} else {
colors.muted
};
format!(" {}", color.fg(&status))
};
let left = format!("{}{}", model_label, status_word);
let right = colors.dim.fg(&hints);
let left_w = visible_width(&left);
let right_w = visible_width(&right);
let row = if left_w + right_w + 2 <= width {
// Both fit — pad to right-align the hints.
let pad = " ".repeat(width.saturating_sub(left_w + right_w));
format!("{}{}{}", left, pad, right)
} else {
// Too tight — drop the right-aligned hints, show left side,
// truncate if needed.
let combined = format!("{}", left);
if visible_width(&combined) > width {
truncate_to_width(&combined, width, "…")
} else {
combined
}
};
vec![row]
}
fn invalidate(&self) {
// Footer has no cached state
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ansi::strip_ansi;
#[test]
fn test_footer_basic() {
let footer = FooterComponent::new();
let lines = footer.render(80);
assert_eq!(lines.len(), 1);
assert!(strip_ansi(&lines[0]).contains("Ctrl+C"));
}
#[test]
fn test_footer_with_status() {
let footer = FooterComponent::new();
footer.set_status("Working…");
footer.set_model("claude-sonnet-5");
let lines = footer.render(80);
let row = strip_ansi(&lines[0]);
assert!(row.contains("Working…"));
assert!(row.contains("claude-sonnet-5"));
}
#[test]
fn test_footer_truncate() {
let footer = FooterComponent::new();
footer.set_status("This is a very long status message that should be truncated");
footer.set_model("claude-opus-4");
footer.set_hints("Ctrl+C: Exit | Shift+Enter: Send | Ctrl+L: Clear | More hints here");
let lines = footer.render(40);
assert!(
visible_width(&lines[0]) <= 40,
"row too wide: {}",
visible_width(&lines[0])
);
}
#[test]
fn test_footer_thinking_level_suffix() {
let footer = FooterComponent::new();
footer.set_model("test-model");
footer.set_thinking_level(Some("medium"));
let row = strip_ansi(&footer.render(80)[0]);
assert!(row.contains("test-model • medium"), "suffix missing: {row}");
}
}