use std::ops::Range;
use alacritty_terminal::term::cell::Flags;
use crate::frame::{Frame, RenderCell};
pub struct TabLabel {
pub title: String,
pub active: bool,
pub bell: bool,
pub zoomed: bool,
pub broadcast: bool,
pub danger: bool,
pub agent: Option<(&'static str, bool)>,
}
pub struct Colors {
pub fg: [u8; 3],
pub bg: [u8; 3],
pub dim: [u8; 3],
pub accent: [u8; 3],
pub alert: [u8; 3],
}
pub struct TabBar {
pub frame: Frame,
pub hits: Vec<Range<usize>>,
}
pub fn build(labels: &[TabLabel], cols: usize, status: Option<&str>, colors: &Colors) -> TabBar {
let mut cells = vec![RenderCell::blank(colors.fg, colors.bg); cols];
let mut hits = Vec::new();
let status: Vec<char> = status
.map(|s| format!(" {s} ").chars().collect())
.unwrap_or_default();
let room = cols.saturating_sub(status.len());
let count = labels.len().max(1);
let fixed = 6; let max_title = (room / count).saturating_sub(fixed).clamp(3, 32);
let mut col = 0;
for (index, label) in labels.iter().enumerate() {
let mut title: String = label.title.chars().take(max_title).collect();
if label.title.chars().count() > max_title {
title.pop();
title.push('…');
}
let mut text = if label.danger {
format!(" ⚠ {} {title}", index + 1)
} else {
format!(" {} {title}", index + 1)
};
if label.zoomed {
text.push_str(" (z)");
}
if label.broadcast {
text.push_str(" (b)");
}
if let Some((mark, _)) = label.agent {
text.push(' ');
text.push_str(mark);
}
if label.bell && !label.active {
text.push_str(" •");
}
text.push(' ');
let (fg, bg) = if label.active && label.danger {
(colors.bg, colors.alert)
} else if label.active {
(colors.bg, colors.accent)
} else if label.danger || label.bell || label.agent.is_some_and(|a| a.1) {
(colors.alert, colors.bg)
} else {
(colors.dim, colors.bg)
};
let start = col;
for ch in text.chars() {
if col >= room {
break;
}
cells[col] = RenderCell {
ch,
fg,
bg,
flags: if label.active {
Flags::BOLD
} else {
Flags::empty()
},
..RenderCell::blank(fg, bg)
};
col += 1;
}
hits.push(start..col);
col += 1;
if col >= room {
break;
}
}
for (i, ch) in status.iter().enumerate() {
let c = cols - status.len() + i;
cells[c] = RenderCell {
ch: *ch,
flags: Flags::BOLD,
..RenderCell::blank(colors.bg, colors.alert)
};
}
TabBar {
frame: Frame {
cols,
rows: 1,
cells,
cursor: None,
display_offset: 0,
history: 0,
bg: colors.bg,
},
hits,
}
}
pub fn tab_at(bar: &TabBar, col: usize) -> Option<usize> {
bar.hits.iter().position(|r| r.contains(&col))
}
#[cfg(test)]
mod tests {
use super::*;
fn colors() -> Colors {
Colors {
fg: [200; 3],
bg: [0; 3],
dim: [100; 3],
accent: [0, 255, 0],
alert: [255, 0, 0],
}
}
fn label(title: &str, active: bool) -> TabLabel {
TabLabel {
title: title.into(),
active,
bell: false,
zoomed: false,
broadcast: false,
danger: false,
agent: None,
}
}
#[test]
fn agent_tabs_carry_their_state() {
let mut waiting = label("claude", false);
waiting.agent = Some(("◆!", true));
let mut done = label("codex", false);
done.agent = Some(("✓", false));
let bar = build(&[label("api", true), waiting, done], 60, None, &colors());
let t = text(&bar);
assert!(t.contains(" 2 claude ◆! "), "{t}");
assert!(t.contains(" 3 codex ✓ "), "{t}");
let col = t.find("claude").unwrap();
assert_eq!(bar.frame.cells[col].fg, [255, 0, 0]);
}
fn text(bar: &TabBar) -> String {
bar.frame.cells.iter().map(|c| c.ch).collect()
}
#[test]
fn numbers_tabs_and_highlights_the_active_one() {
let bar = build(
&[label("zsh", true), label("logs", false)],
40,
None,
&colors(),
);
assert!(text(&bar).starts_with(" 1 zsh 2 logs "));
assert_eq!(bar.frame.cells[1].bg, [0, 255, 0]);
assert_eq!(bar.frame.cells[8].fg, [100; 3]);
assert_eq!(tab_at(&bar, 2), Some(0));
assert_eq!(tab_at(&bar, 9), Some(1));
assert_eq!(tab_at(&bar, 30), None);
}
#[test]
fn markers_and_status() {
let mut l = label("build", false);
l.bell = true;
let mut z = label("vim", true);
z.zoomed = true;
z.broadcast = true;
let bar = build(&[z, l], 50, Some("LEADER"), &colors());
let t = text(&bar);
assert!(t.contains("vim (z) (b)"));
assert!(t.contains("build •"));
assert!(t.trim_end().ends_with("LEADER"));
}
#[test]
fn long_titles_are_truncated_to_fit() {
let labels: Vec<_> = (0..4).map(|i| label(&"x".repeat(100), i == 0)).collect();
let bar = build(&labels, 60, None, &colors());
assert_eq!(bar.hits.len(), 4);
assert!(text(&bar).contains('…'));
}
}