use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use unicode_width::UnicodeWidthStr;
use super::Model;
use super::theme::{Theme, blend};
fn cols(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GitStatus {
pub branch: String,
pub staged: usize,
pub unstaged: usize,
pub untracked: usize,
pub ahead: usize,
pub behind: usize,
}
impl GitStatus {
pub fn is_dirty(&self) -> bool {
self.staged + self.unstaged + self.untracked > 0
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TabMeta {
pub title: String,
pub model: String,
pub mode: String,
pub busy: bool,
pub unread: usize,
}
pub async fn poll_git(cwd: &std::path::Path) -> Option<GitStatus> {
let out = tokio::process::Command::new("git")
.args(["status", "--porcelain=v1", "-b", "--untracked-files=normal"])
.current_dir(cwd)
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
Some(parse_status(&String::from_utf8_lossy(&out.stdout)))
}
fn parse_status(text: &str) -> GitStatus {
let mut st = GitStatus::default();
for line in text.lines() {
if let Some(head) = line.strip_prefix("## ") {
let name = head.split([' ', '.']).next().unwrap_or("").to_string();
st.branch = if name.is_empty() { "HEAD".into() } else { name };
if let Some(bracket) = head.split_once('[').map(|(_, r)| r) {
for part in bracket.trim_end_matches(']').split(',') {
let part = part.trim();
if let Some(n) = part.strip_prefix("ahead ") {
st.ahead = n.trim().parse().unwrap_or(0);
} else if let Some(n) = part.strip_prefix("behind ") {
st.behind = n.trim().parse().unwrap_or(0);
}
}
}
continue;
}
let mut chars = line.chars();
let (Some(x), Some(y)) = (chars.next(), chars.next()) else {
continue;
};
if x == '?' {
st.untracked += 1;
continue;
}
if x != ' ' {
st.staged += 1;
}
if y != ' ' {
st.unstaged += 1;
}
}
st
}
pub fn meter(pct: u64, width: usize, fg: Color, track: Color) -> Vec<Span<'static>> {
const PARTIALS: [char; 8] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
if width == 0 {
return Vec::new();
}
let eighths = (pct.min(100) as usize * width * 8) / 100;
let full = eighths / 8;
let rem = eighths % 8;
let mut out = Vec::new();
if full > 0 {
out.push(Span::styled(
"█".repeat(full.min(width)),
Style::default().fg(fg),
));
}
let mut used = full.min(width);
if rem > 0 && used < width {
out.push(Span::styled(
PARTIALS[rem - 1].to_string(),
Style::default().fg(fg),
));
used += 1;
}
if used < width {
out.push(Span::styled(
"░".repeat(width - used),
Style::default().fg(track),
));
}
out
}
fn brand(theme: &Theme, bg: Color) -> Vec<Span<'static>> {
let word = "cordy";
let ramp = theme.ramp(word.chars().count());
let mut spans = vec![Span::styled(
" ◈ ",
Style::default()
.fg(theme.accent)
.bg(bg)
.add_modifier(Modifier::BOLD),
)];
for (c, col) in word.chars().zip(ramp) {
spans.push(Span::styled(
c.to_string(),
Style::default().fg(col).bg(bg).add_modifier(Modifier::BOLD),
));
}
spans
}
struct Seg {
spans: Vec<Span<'static>>,
hit: Option<super::Hit>,
}
impl Seg {
fn new(spans: Vec<Span<'static>>, hit: Option<super::Hit>) -> Self {
Seg { spans, hit }
}
fn width(&self) -> usize {
self.spans.iter().map(|s| cols(&s.content)).sum()
}
}
fn place(model: &mut Model, segs: Vec<Seg>, x: u16, y: u16, out: &mut Vec<Span<'static>>) -> u16 {
let mut cx = x;
for seg in segs {
let w = seg.width() as u16;
if let Some(hit) = seg.hit {
model.push_hit(cx, y, w, 1, hit);
}
cx += w;
out.extend(seg.spans);
}
cx
}
pub fn render_header(f: &mut Frame, area: Rect, model: &mut Model, theme: &Theme, tick: u64) {
let bg = theme.elevated;
f.render_widget(Block::default().style(Style::default().bg(bg)), area);
let width = area.width as usize;
let dim = Style::default().fg(theme.dim).bg(bg);
let sep = || {
Seg::new(
vec![Span::styled(
" · ",
Style::default().fg(theme.border).bg(bg),
)],
None,
)
};
let mut right: Vec<Seg> = vec![Seg::new(
vec![
Span::styled(format!("{} ", model.provider_kind), dim),
Span::styled("› ", Style::default().fg(theme.border).bg(bg)),
Span::styled(
short_model(&model.model_name),
Style::default()
.fg(theme.accent)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
],
Some(super::Hit::ModelChip),
)];
if let Some(git) = &model.git
&& !git.branch.is_empty()
{
let mut spans = vec![
Span::styled(
"⑂ ",
Style::default()
.fg(blend(theme.dim, theme.accent, 0.5))
.bg(bg),
),
Span::styled(git.branch.clone(), Style::default().fg(theme.user).bg(bg)),
];
for (n, glyph, color) in [
(git.staged, '●', theme.success),
(git.unstaged, '✎', theme.warning),
(git.untracked, '?', theme.dim),
(git.ahead, '↑', theme.info),
(git.behind, '↓', theme.danger),
] {
if n > 0 {
spans.push(Span::styled(
format!(" {glyph}{n}"),
Style::default().fg(color).bg(bg),
));
}
}
right.push(sep());
right.push(Seg::new(spans, Some(super::Hit::Git)));
}
let pct = model.ctx_pct();
if model.last_in > 0 {
let mut spans = meter(pct, 8, theme.gauge(pct), theme.border);
for sp in &mut spans {
sp.style = sp.style.bg(bg);
}
spans.push(Span::styled(format!(" {pct}%"), dim));
spans.push(Span::styled(
format!(" {}", super::fmt_tokens(model.last_in)),
Style::default().fg(theme.border).bg(bg),
));
right.push(sep());
right.push(Seg::new(spans, Some(super::Hit::Context)));
}
let cost = model.cost_str();
if !cost.is_empty() {
right.push(sep());
right.push(Seg::new(
vec![Span::styled(
cost,
Style::default().fg(theme.accent2).bg(bg),
)],
Some(super::Hit::Cost),
));
}
right.push(Seg::new(
vec![Span::styled(" ", Style::default().bg(bg))],
None,
));
let right_w: usize = right.iter().map(Seg::width).sum();
let mut left: Vec<Seg> = vec![Seg::new(brand(theme, bg), None)];
let budget = width.saturating_sub(right_w + 4);
let mut used: usize = left.iter().map(Seg::width).sum();
if model.tabs.len() > 1 {
left.push(Seg::new(
vec![Span::styled(" ", Style::default().bg(bg))],
None,
));
used += 2;
for (i, tab) in model.tabs.iter().enumerate() {
let active = i == model.tab;
let mark = if tab.busy {
super::spinner_frame(tick)
} else if active {
'▍'
} else if tab.unread > 0 {
'•'
} else {
' '
};
let label: String = tab.title.chars().take(14).collect();
let text = format!("{mark} {} {label} ", i + 1);
if used + cols(&text) > budget {
left.push(Seg::new(vec![Span::styled("…", dim)], None));
break;
}
used += cols(&text);
let style = if active {
Style::default()
.fg(theme.user)
.bg(theme.surface)
.add_modifier(Modifier::BOLD)
} else if tab.unread > 0 {
Style::default().fg(theme.accent2).bg(bg)
} else {
dim
};
left.push(Seg::new(
vec![Span::styled(text, style)],
Some(super::Hit::Tab(i)),
));
}
}
let left_w: usize = left.iter().map(Seg::width).sum();
let mut spans: Vec<Span> = Vec::new();
let cx = place(model, left, area.x, area.y, &mut spans);
let pad = width.saturating_sub(left_w + right_w);
spans.push(Span::styled(" ".repeat(pad), Style::default().bg(bg)));
place(model, right, cx + pad as u16, area.y, &mut spans);
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
const HINTS: [(&str, &str, &str, super::Hit); 8] = [
("^P", "⚡", "palette", super::Hit::Palette),
("^L", "◇", "model", super::Hit::ModelChip),
("^D", "⚙", "mode", super::Hit::Mode),
("^G", "⑂", "git", super::Hit::Git),
("^S", "★", "skills", super::Hit::Skills),
("^E", "✎", "editor", super::Hit::Editor),
("^T", "▣", "term", super::Hit::Term),
("^B", "⬚", "tabs", super::Hit::Tabs),
];
fn hint_seg(
key: &str,
glyph: &str,
label: &str,
hit: super::Hit,
theme: &Theme,
hovered: Option<super::Hit>,
) -> Seg {
let on = hovered == Some(hit);
Seg::new(
vec![
Span::styled(
key.to_string(),
Style::default()
.fg(if on { theme.accent2 } else { theme.accent })
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {glyph} "),
Style::default().fg(if on { theme.accent2 } else { theme.border }),
),
Span::styled(
format!("{label} "),
Style::default()
.fg(if on { theme.user } else { theme.dim })
.add_modifier(if on {
Modifier::UNDERLINED
} else {
Modifier::empty()
}),
),
],
Some(hit),
)
}
pub fn render_hints(f: &mut Frame, area: Rect, model: &mut Model, theme: &Theme, tick: u64) {
let hovered = model.hover;
let mut segs: Vec<Seg> = vec![Seg::new(
vec![
Span::styled(
format!(" {} ", model.mode()),
Style::default()
.fg(theme.on_accent)
.bg(if model.mode_flash > 0 {
theme.accent2
} else {
theme.accent
})
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
],
Some(super::Hit::Mode),
)];
if model.busy {
segs.push(hint_seg(
"^C",
"⏹",
"stop",
super::Hit::Stop,
theme,
hovered,
));
}
for (key, glyph, label, hit) in HINTS {
segs.push(hint_seg(key, glyph, label, hit, theme, hovered));
}
let mut right: Vec<Span> = Vec::new();
if model.busy {
right.push(Span::styled(
format!("{} {} ", super::spinner_frame(tick), model.status),
Style::default().fg(theme.accent),
));
} else if model.status.starts_with("error") {
right.push(Span::styled(
format!("{} ", model.status),
Style::default().fg(theme.danger),
));
}
for (n, glyph, color) in [
(model.bg_count, '⚙', theme.tool),
(model.subagent_count, '⚑', theme.accent2),
(model.term_count, '▸', theme.dim),
] {
if n > 0 {
right.push(Span::styled(
format!("{glyph}{n} "),
Style::default().fg(color),
));
}
}
if let Some(goal) = &model.goal_line {
right.push(Span::styled(
format!("{goal} "),
Style::default().fg(theme.warning),
));
}
right.push(Span::styled(
if model.mouse_on { "⠿ " } else { "⠄ " },
Style::default().fg(if model.mouse_on {
theme.accent
} else {
theme.border
}),
));
let rw: usize = right.iter().map(|s| cols(&s.content)).sum();
let width = area.width as usize;
while segs.len() > 1 && segs.iter().map(Seg::width).sum::<usize>() + rw > width {
segs.pop();
}
let mut spans: Vec<Span> = Vec::new();
place(model, segs, area.x, area.y, &mut spans);
f.render_widget(Paragraph::new(Line::from(spans)), area);
let rx = area.x + area.width.saturating_sub(rw as u16);
model.push_hit(
area.x + area.width.saturating_sub(2),
area.y,
2,
1,
super::Hit::Mouse,
);
f.render_widget(
Paragraph::new(Line::from(right)).alignment(Alignment::Right),
Rect {
x: rx,
width: rw as u16,
..area
},
);
}
pub fn short_model(name: &str) -> String {
let tail = name.rsplit('/').next().unwrap_or(name);
if tail.chars().count() > 26 {
format!(
"…{}",
tail.chars()
.skip(tail.chars().count() - 25)
.collect::<String>()
)
} else {
tail.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_branch_counts_and_divergence() {
let out = "## main...origin/main [ahead 2, behind 1]\nM src/a.rs\n M src/b.rs\nMM src/c.rs\n?? new.txt\n";
let st = parse_status(out);
assert_eq!(st.branch, "main");
assert_eq!(st.ahead, 2);
assert_eq!(st.behind, 1);
assert_eq!(st.staged, 2); assert_eq!(st.unstaged, 2); assert_eq!(st.untracked, 1);
assert!(st.is_dirty());
}
#[test]
fn parses_clean_repo_without_upstream() {
let st = parse_status("## feature/x\n");
assert_eq!(st.branch, "feature/x");
assert_eq!((st.ahead, st.behind), (0, 0));
assert!(!st.is_dirty());
}
#[test]
fn meter_fills_proportionally() {
let full = meter(100, 4, Color::Red, Color::Blue);
let text: String = full.iter().map(|s| s.content.to_string()).collect();
assert_eq!(text, "████");
let empty = meter(0, 4, Color::Red, Color::Blue);
let text: String = empty.iter().map(|s| s.content.to_string()).collect();
assert_eq!(text, "░░░░");
for pct in [1, 13, 37, 50, 66, 99] {
let w: usize = meter(pct, 10, Color::Red, Color::Blue)
.iter()
.map(|s| s.content.chars().count())
.sum();
assert_eq!(w, 10, "pct={pct}");
}
}
#[test]
fn model_chip_trims_provider_prefix() {
assert_eq!(short_model("anthropic/claude-opus-5"), "claude-opus-5");
assert_eq!(short_model("gpt-4o-mini"), "gpt-4o-mini");
assert!(short_model(&"x".repeat(80)).starts_with('…'));
assert_eq!(short_model(&"x".repeat(80)).chars().count(), 26);
}
}