use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::shared::i18n::Locale;
use crate::shared::server::{ServerStatus, ServerStatuses};
use crate::shared::theme::Palette;
use crate::shared::ui;
use crate::shared::wrap;
const ESC_KEY: &str = "Esc";
const HELP_KEY: &str = "F1";
const HOTKEYS: [(&str, &str); 5] = [
("F1", "ui.status.hotkey.help"),
(ESC_KEY, "ui.status.hotkey.chats"),
("Ctrl+N", "ui.status.hotkey.new"),
("Ctrl+P", "ui.status.hotkey.settings"),
("Ctrl+Q", "ui.status.hotkey.quit"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EscTarget {
#[default]
ChatList,
SearchResults,
PreviousChat,
Tasks,
}
impl EscTarget {
fn hint_key(self) -> &'static str {
match self {
EscTarget::ChatList => "ui.status.hotkey.chats",
EscTarget::SearchResults => "ui.status.hotkey.results",
EscTarget::PreviousChat => "ui.status.hotkey.back_chat",
EscTarget::Tasks => "ui.status.hotkey.tasks",
}
}
}
fn esc_hint_key(model: &StatusModel) -> &'static str {
if model.generating && !model.background_run {
"ui.status.hotkey.cancel"
} else {
model.esc_target.hint_key()
}
}
pub struct StatusModel<'a> {
pub statuses: &'a ServerStatuses,
pub generating: bool,
pub tokens: u64,
pub context: Option<u64>,
pub context_exact: bool,
pub reasoning: u32,
pub mouse_scroll: bool,
pub background: Option<&'a str>,
pub speaking: bool,
pub attachments: Option<&'a str>,
pub staged_images: Option<&'a str>,
pub esc_target: EscTarget,
pub read_only: bool,
pub background_run: bool,
}
const SPEAKING_GLYPH: char = '♪';
const ATTACH_GLYPH: char = '§';
const IMAGE_GLYPH: char = '#';
const TRANSCRIPT_GLYPH: char = '≡';
pub fn render(
frame: &mut Frame,
area: Rect,
model: &StatusModel,
palette: &Palette,
loc: &'static Locale,
) {
let lines = lines(area.width as usize, model, palette, loc);
frame.render_widget(Paragraph::new(lines), area);
}
pub fn height(width: usize, model: &StatusModel, palette: &Palette, loc: &'static Locale) -> u16 {
lines(width, model, palette, loc)
.len()
.clamp(1, u16::MAX as usize) as u16
}
fn lines(
width: usize,
model: &StatusModel,
palette: &Palette,
loc: &'static Locale,
) -> Vec<Line<'static>> {
let state = state_spans(model, palette, loc);
let state_w = spans_width(&state);
let hotkeys = hotkey_list(model, loc);
let accent_idx = model.mouse_scroll.then_some(0usize);
let cell_w = ui::hint_cell_widths(&hotkeys);
let avail = width.saturating_sub(state_w + ui::HINT_GAP);
let kept = trim_to_fit(&cell_w, avail, model.mouse_scroll);
if !kept.is_empty() {
let sub: Vec<(&str, &str, bool)> = kept.iter().map(|&i| hotkeys[i]).collect();
let sub_w: Vec<usize> = kept.iter().map(|&i| cell_w[i]).collect();
let accent = accent_idx.and_then(|a| kept.iter().position(|&i| i == a));
let cols = corner_cols(&sub_w, avail).unwrap_or(1);
return ui::render_hint_grid(
&ui::HintGrid {
items: &sub,
cell_w: &sub_w,
cols,
width,
lead: Some(state),
accent,
},
palette,
);
}
debug_assert_eq!(hotkeys[HELP_IDX].0, HELP_KEY);
let mut out = vec![Line::from(state)];
if cell_w[HELP_IDX] <= width {
out.extend(ui::hotkey_grid(
palette,
&hotkeys[HELP_IDX..=HELP_IDX],
width,
));
}
out
}
const HINT_ROWS_MAX: usize = 2;
const HELP_IDX: usize = 1;
const STOP_KEY: &str = "F6";
const STOP_IDX: usize = 6;
fn keep_order(mouse_pinned: bool, with_stop: bool) -> Vec<usize> {
let mut order: Vec<usize> = if mouse_pinned {
vec![0, HELP_IDX, 2, 5, 4, 3]
} else {
vec![HELP_IDX, 2, 5, 0, 4, 3]
};
if with_stop {
let after_help = order.iter().position(|&i| i == HELP_IDX).unwrap_or(0) + 1;
order.insert(after_help, STOP_IDX);
}
order
}
fn trim_to_fit(cell_w: &[usize], avail: usize, mouse_pinned: bool) -> Vec<usize> {
let mut kept: Vec<usize> = Vec::new();
for idx in keep_order(mouse_pinned, cell_w.len() > STOP_IDX) {
let mut candidate = kept.clone();
candidate.push(idx);
candidate.sort_unstable();
let w: Vec<usize> = candidate.iter().map(|&i| cell_w[i]).collect();
if corner_cols(&w, avail).is_some() {
kept = candidate;
}
}
if !kept.contains(&HELP_IDX) {
return Vec::new();
}
kept
}
fn corner_cols(cell_w: &[usize], avail: usize) -> Option<usize> {
(cell_w.len().div_ceil(HINT_ROWS_MAX)..=cell_w.len())
.rev()
.find(|&c| ui::hint_grid_layout(cell_w, c).2 <= avail)
}
fn state_spans(model: &StatusModel, palette: &Palette, loc: &'static Locale) -> Vec<Span<'static>> {
let statuses = model.statuses;
let sep = || Span::styled(" │ ", Style::new().fg(palette.border));
let muted = palette.muted_style();
let mut state = chat_chip(&statuses.chat, palette, loc);
for chip in [
secondary_chip(loc.t("ui.status.chip.embed"), &statuses.embed, palette),
secondary_chip(
loc.t("ui.status.chip.imp"),
&statuses.impersonation,
palette,
),
]
.into_iter()
.flatten()
{
state.push(Span::raw(" ")); state.extend(chip);
}
if model.generating {
state.push(sep());
state.push(Span::styled(
format!(
"{} {}",
palette.glyphs().busy,
loc.t("ui.status.generating")
),
palette.accent_style(),
));
}
if let Some(counter) = token_counter_span(model, palette, loc) {
state.push(sep());
state.push(counter);
}
if let Some(hint) = model.background {
state.push(sep());
state.push(Span::styled(
format!("{} {hint}", palette.glyphs().background),
muted,
));
}
if model.speaking {
state.push(sep());
state.push(Span::styled(
format!("{SPEAKING_GLYPH} {}", loc.t("ui.status.speaking")),
muted,
));
}
if let Some(files) = model.attachments {
state.push(sep());
state.push(Span::styled(format!("{ATTACH_GLYPH} {files}"), muted));
}
if let Some(images) = model.staged_images {
state.push(sep());
state.push(Span::styled(format!("{IMAGE_GLYPH} {images}"), muted));
}
if model.read_only {
state.push(sep());
state.push(Span::styled(
format!("{TRANSCRIPT_GLYPH} {}", loc.t("ui.status.read_only")),
muted,
));
}
state
}
fn token_counter_span(
model: &StatusModel,
palette: &Palette,
loc: &'static Locale,
) -> Option<Span<'static>> {
if model.tokens == 0 && model.context.is_none() {
return None;
}
let total = model.context.unwrap_or(0) + model.tokens;
let approx = if model.context.is_some() && !model.context_exact {
"~"
} else {
""
};
let reason = if model.reasoning > 0 {
loc.tf(
"ui.status.reasoning",
&[("n", &model.reasoning.to_string())],
)
} else {
String::new()
};
let label = loc.tf(
"ui.status.tokens",
&[
("approx", approx),
("total", &total.to_string()),
("reason", &reason),
],
);
if model.generating {
Some(Span::styled(label, palette.accent_style()))
} else {
Some(Span::styled(label, palette.muted_style()))
}
}
fn hotkey_list(
model: &StatusModel,
loc: &'static Locale,
) -> Vec<(&'static str, &'static str, bool)> {
let mouse_desc = if model.mouse_scroll {
loc.t("ui.status.mouse.scroll")
} else {
loc.t("ui.status.mouse.select")
};
let mut hotkeys: Vec<(&'static str, &'static str, bool)> = vec![("Ctrl+W", mouse_desc, false)];
hotkeys.extend(HOTKEYS.iter().map(|(key, default)| {
let desc_key = if *key == ESC_KEY {
esc_hint_key(model)
} else {
*default
};
(*key, loc.t(desc_key), false)
}));
if model.background_run {
hotkeys.push((STOP_KEY, loc.t("ui.status.hotkey.stop_run"), false));
}
hotkeys
}
fn status_glyph(status: &ServerStatus, palette: &Palette) -> (&'static str, Color) {
let glyphs = palette.glyphs();
match status {
ServerStatus::Ready => ("●", palette.success),
ServerStatus::Connecting => (glyphs.status_connecting, palette.warning),
ServerStatus::NotConfigured => (glyphs.status_off, palette.warning),
ServerStatus::Disconnected(_) => (glyphs.status_off, palette.error),
}
}
fn chip(glyph: &'static str, label: String, color: Color) -> Vec<Span<'static>> {
vec![
Span::styled(glyph, Style::new().fg(color).add_modifier(Modifier::BOLD)),
Span::styled(format!(" {label}"), Style::new().fg(color)),
]
}
fn chat_chip(status: &ServerStatus, palette: &Palette, loc: &'static Locale) -> Vec<Span<'static>> {
let (glyph, color) = status_glyph(status, palette);
let label = match status {
ServerStatus::Ready | ServerStatus::Connecting => loc.t("ui.status.chip.chat").to_string(),
ServerStatus::NotConfigured => loc.t("ui.status.chip.chat_off").to_string(),
ServerStatus::Disconnected(why) => loc.tf("ui.status.chip.chat_down", &[("why", why)]),
};
chip(glyph, label, color)
}
fn secondary_chip(
label: &str,
status: &ServerStatus,
palette: &Palette,
) -> Option<Vec<Span<'static>>> {
if matches!(status, ServerStatus::NotConfigured) {
return None;
}
let (glyph, color) = status_glyph(status, palette);
Some(chip(glyph, label.to_string(), color))
}
fn str_w(s: &str) -> usize {
wrap::display_width(&s.chars().collect::<Vec<_>>())
}
fn spans_width(spans: &[Span<'_>]) -> usize {
spans.iter().map(|s| str_w(&s.content)).sum()
}
#[cfg(test)]
mod tests {
use super::*;
fn ru() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn only_chat(chat: ServerStatus) -> ServerStatuses {
ServerStatuses {
chat,
embed: ServerStatus::NotConfigured,
impersonation: ServerStatus::NotConfigured,
}
}
fn ready() -> ServerStatuses {
only_chat(ServerStatus::Ready)
}
fn model<'a>(
statuses: &'a ServerStatuses,
generating: bool,
tokens: u64,
context: Option<u64>,
context_exact: bool,
mouse_scroll: bool,
) -> StatusModel<'a> {
StatusModel {
statuses,
generating,
tokens,
context,
context_exact,
reasoning: 0,
mouse_scroll,
background: None,
speaking: false,
attachments: None,
staged_images: None,
esc_target: EscTarget::default(),
read_only: false,
background_run: false,
}
}
fn flat(
statuses: &ServerStatuses,
generating: bool,
tokens: u64,
context: Option<u64>,
context_exact: bool,
mouse_scroll: bool,
) -> String {
let m = model(
statuses,
generating,
tokens,
context,
context_exact,
mouse_scroll,
);
lines(200, &m, &Palette::default(), ru())
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect()
}
fn text(statuses: &ServerStatuses, generating: bool) -> String {
flat(statuses, generating, 0, None, false, false)
}
#[test]
fn shows_chat_state() {
assert!(text(&ready(), false).contains("чат"));
assert!(text(&only_chat(ServerStatus::NotConfigured), false).contains("не настроен"));
assert!(
text(&only_chat(ServerStatus::Disconnected("boom".into())), false).contains("boom")
);
}
#[test]
fn localized_for_all_langs() {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, false);
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
let flat: String = lines(200, &m, &Palette::default(), loc)
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(
flat.contains(loc.t("ui.status.chip.chat")),
"{lang:?}: {flat}"
);
assert!(
flat.contains(loc.t("ui.status.hotkey.quit")),
"{lang:?}: {flat}"
);
}
let en = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
assert_eq!(en.t("ui.status.chip.chat"), "chat");
}
#[test]
fn secondary_chips_shown_only_when_configured() {
let all = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::Connecting,
};
let t = text(&all, false);
assert!(
t.contains("чат") && t.contains("эмб") && t.contains("имп"),
"{t}"
);
let t = text(&ready(), false);
assert!(
t.contains("чат") && !t.contains("эмб") && !t.contains("имп"),
"{t}"
);
}
#[test]
fn compat_palette_swaps_chip_and_activity_glyphs() {
let compat = Palette::default().with_compat(true);
let flat_compat = |statuses: &ServerStatuses, generating: bool| -> String {
let m = StatusModel {
statuses,
generating,
tokens: 0,
context: None,
context_exact: false,
reasoning: 0,
mouse_scroll: false,
background: Some("рефлексия"),
speaking: false,
attachments: None,
staged_images: None,
esc_target: EscTarget::default(),
read_only: false,
background_run: false,
};
lines(200, &m, &compat, ru())
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect()
};
let t = flat_compat(&only_chat(ServerStatus::Connecting), true);
assert!(t.contains("○ чат"), "connecting compat glyph: {t}");
assert!(t.contains("» генерация"), "generation compat glyph: {t}");
assert!(
t.contains("* рефлексия"),
"background-task compat glyph: {t}"
);
for banned in ['◐', '⟳', '✻'] {
assert!(!t.contains(banned), "{banned} left behind: {t}");
}
let t = flat_compat(&only_chat(ServerStatus::Disconnected("boom".into())), false);
assert!(t.contains("× чат"), "disconnect compat glyph: {t}");
let t = flat_compat(&ready(), false);
assert!(t.contains("● чат"), "readiness stays ●: {t}");
}
#[test]
fn chip_color_reflects_status() {
let palette = Palette::default();
let glyph_fg = |status: ServerStatus| {
chat_chip(&status, &palette, ru())
.first()
.and_then(|s| s.style.fg)
.unwrap()
};
assert_eq!(glyph_fg(ServerStatus::Ready), palette.success);
assert_eq!(
glyph_fg(ServerStatus::Disconnected("x".into())),
palette.error
);
assert_eq!(glyph_fg(ServerStatus::Connecting), palette.warning);
}
#[test]
fn generating_indicator_toggles() {
assert!(text(&ready(), true).contains("генерация"));
assert!(!text(&ready(), false).contains("генерация"));
}
#[test]
fn shows_mouse_mode() {
let mode = |scroll| flat(&ready(), false, 0, None, false, scroll);
assert!(mode(true).contains("мышь: прокрутка"));
assert!(mode(false).contains("мышь: выделение"));
}
#[test]
fn scroll_mode_highlights_only_value() {
let palette = Palette::default();
let span_fg = |scroll, needle: &str| {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, scroll);
lines(200, &m, &palette, ru())
.iter()
.flat_map(|l| l.spans.clone())
.find(|s| s.content.contains(needle))
.and_then(|s| s.style.fg)
};
assert_eq!(span_fg(true, "прокрутка"), Some(palette.accent));
assert_eq!(span_fg(true, "мышь:"), Some(palette.muted));
assert_eq!(span_fg(false, "мышь: выделение"), Some(palette.muted));
}
#[test]
fn token_counter_shows_summed_total() {
let line = |tokens, context, exact| flat(&ready(), true, tokens, context, exact, false);
assert!(!line(0, None, false).contains("токены"));
assert!(line(42, None, false).contains("токены: 42"));
assert!(line(42, Some(1000), false).contains("токены: ~1042"));
assert!(line(42, Some(1000), true).contains("токены: 1042"));
assert!(line(0, Some(1000), false).contains("токены: ~1000"));
}
#[test]
fn token_counter_shows_reasoning_when_present() {
let statuses = ready();
let with_reason = |reasoning: u32| -> String {
let m = StatusModel {
statuses: &statuses,
generating: true,
tokens: 42,
context: Some(1000),
context_exact: true,
reasoning,
mouse_scroll: false,
background: None,
speaking: false,
attachments: None,
staged_images: None,
esc_target: EscTarget::default(),
read_only: false,
background_run: false,
};
lines(200, &m, &Palette::default(), ru())
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect()
};
assert!(with_reason(300).contains("токены: 1042 (рассужд. 300)"));
let none = with_reason(0);
assert!(none.contains("токены: 1042"));
assert!(!none.contains("рассужд"));
}
#[test]
fn hotkeys_fit_on_one_line_when_wide() {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, false);
let n = lines(200, &m, &Palette::default(), ru()).len();
assert_eq!(n, 1);
}
#[test]
fn a_narrow_bar_wraps_to_two_rows_and_sheds_the_rest() {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, false);
assert_eq!(height(40, &m, &Palette::default(), ru()), 2);
let text = rows_of(40, &m).join("\n");
for kept in ["F1", "справка", "чаты", "новый", "выход"] {
assert!(text.contains(kept), "{kept} kept: {text}");
}
for shed in ["мышь", "настройки"] {
assert!(!text.contains(shed), "{shed} shed: {text}");
}
let flat = flat(&ready(), false, 0, None, false, false);
assert!(flat.contains("Ctrl+W") && flat.contains("мышь: выделение"));
for (_, desc) in HOTKEYS {
assert!(flat.contains(ru().t(desc)));
}
}
fn busy(statuses: &ServerStatuses) -> StatusModel<'_> {
StatusModel {
generating: true,
tokens: 1218,
context: Some(36490),
context_exact: true,
reasoning: 1218,
attachments: Some("файлы: 2 (~532)"),
..model(statuses, false, 0, None, false, false)
}
}
fn rows(w: u16) -> Vec<String> {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, false);
rows_of(w, &m)
}
fn rows_of(w: u16, m: &StatusModel) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let p = Palette::default();
let h = height(w as usize, m, &p, ru());
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| render(f, f.area(), m, &p, ru())).unwrap();
let buf = term.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
#[test]
fn chips_left_hotkeys_right_on_one_line() {
let r = rows(160);
assert_eq!(r.len(), 1);
let line = &r[0];
assert!(
line.trim_start().starts_with("●"),
"chip on the left: {line:?}"
);
assert!(
line.trim_end().ends_with("выход"),
"the last hotkey is right-aligned: {line:?}"
);
assert!(
line.contains("чат "),
"there's a gap after the chip: {line:?}"
);
}
#[test]
fn wrapped_grid_is_right_aligned_with_pill_on_top_line() {
let r = rows(100);
assert_eq!(r.len(), 2, "expected a wrap to 2 lines: {r:?}");
assert!(
r[0].trim_start().starts_with("●"),
"chip on top, on the left: {:?}",
r[0]
);
assert!(
r[1].trim_start().starts_with("Ctrl+Q") && r[1].starts_with(" "),
"the wrap is right-aligned, the left part is empty: {:?}",
r[1]
);
let char_col = |line: &str, pat: &str| line.find(pat).map(|b| line[..b].chars().count());
assert_eq!(
char_col(&r[1], "Ctrl+Q"),
char_col(&r[0], "Ctrl+P"),
"the wrap lands exactly under the column above:\n{:?}\n{:?}",
r[0],
r[1]
);
}
#[test]
fn trim_keeps_the_highest_ranked_hints_that_fit() {
let cells = [24, 12, 10, 14, 18, 14];
assert_eq!(trim_to_fit(&cells, 107, false), vec![0, 1, 2, 3, 4, 5]);
assert_eq!(corner_cols(&cells, 107), Some(6));
assert_eq!(trim_to_fit(&cells, 71, false), vec![0, 1, 2, 3, 4, 5]);
assert_eq!(corner_cols(&cells, 71), Some(3));
assert_eq!(trim_to_fit(&cells, 54, false), vec![0, 1, 2, 5]);
assert_eq!(trim_to_fit(&cells, 32, false), vec![1, 2, 3, 5]);
assert_eq!(trim_to_fit(&cells, 12, false), vec![1, 2]);
assert!(trim_to_fit(&cells, 11, false).is_empty());
assert_eq!(trim_to_fit(&cells, 25, true), vec![0, 1]);
assert_eq!(trim_to_fit(&cells, 23, true), vec![1, 2]);
}
#[test]
fn keep_order_matches_the_hotkey_list() {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, false);
let keys: Vec<&str> = hotkey_list(&m, ru()).iter().map(|(k, _, _)| *k).collect();
assert_eq!(keys[HELP_IDX], HELP_KEY);
assert_eq!(
keys.len(),
STOP_IDX,
"the stop key is absent off a background run"
);
let named = |order: Vec<usize>| order.into_iter().map(|i| keys[i]).collect::<Vec<_>>();
assert_eq!(
named(keep_order(false, false)),
["F1", "Esc", "Ctrl+Q", "Ctrl+W", "Ctrl+P", "Ctrl+N"]
);
assert_eq!(
named(keep_order(true, false)),
["Ctrl+W", "F1", "Esc", "Ctrl+Q", "Ctrl+P", "Ctrl+N"]
);
let mut bg = model(&statuses, true, 0, None, false, false);
bg.background_run = true;
let keys: Vec<&str> = hotkey_list(&bg, ru()).iter().map(|(k, _, _)| *k).collect();
assert_eq!(keys[STOP_IDX], STOP_KEY);
let named = |order: Vec<usize>| order.into_iter().map(|i| keys[i]).collect::<Vec<_>>();
assert_eq!(
named(keep_order(false, true)),
["F1", "F6", "Esc", "Ctrl+Q", "Ctrl+W", "Ctrl+P", "Ctrl+N"]
);
assert_eq!(
named(keep_order(true, true)),
["Ctrl+W", "F1", "F6", "Esc", "Ctrl+Q", "Ctrl+P", "Ctrl+N"]
);
}
#[test]
fn a_background_transcript_advertises_the_stop_key_and_esc_goes_back() {
let statuses = ready();
let stop = ru().t("ui.status.hotkey.stop_run");
let cancel = ru().t("ui.status.hotkey.cancel");
let chats = ru().t("ui.status.hotkey.chats");
let mut m = model(&statuses, true, 0, None, false, false);
m.background_run = true;
m.read_only = true;
let text = rows_of(140, &m).join(" ");
assert!(text.contains("F6") && text.contains(stop), "{text}");
assert!(!text.contains(cancel), "{text}");
assert!(text.contains(chats), "{text}");
let turn = model(&statuses, true, 0, None, false, false);
let text = rows_of(140, &turn).join(" ");
assert!(!text.contains("F6"), "{text}");
assert!(text.contains(cancel), "{text}");
let mut ended = model(&statuses, false, 0, None, false, false);
ended.read_only = true;
let text = rows_of(140, &ended).join(" ");
assert!(!text.contains("F6"), "{text}");
}
#[test]
fn a_busy_pill_sheds_hints_but_keeps_the_corner_block() {
let statuses = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::NotConfigured,
};
let r = rows_of(120, &busy(&statuses));
assert_eq!(r.len(), 2, "pill plus a two-row corner block: {r:?}");
assert!(
r[0].trim_start().starts_with('●') && r[0].contains("токены"),
"the pill keeps the top row: {:?}",
r[0]
);
assert!(
r[0].contains("F1") && r[0].trim_end().ends_with(ru().t("ui.status.hotkey.cancel")),
"kept hints share the pill's row: {:?}",
r[0]
);
assert!(
r[1].starts_with(' ') && r[1].trim_end().ends_with("выход"),
"the wrapped row is right-aligned: {:?}",
r[1]
);
let char_col = |line: &str, pat: &str| line.find(pat).map(|b| line[..b].chars().count());
assert_eq!(
char_col(&r[1], "Ctrl+N"),
char_col(&r[0], "F1"),
"\n{:?}\n{:?}",
r[0],
r[1]
);
let text = r.join("\n");
assert!(
!text.contains("мышь") && !text.contains("настройки"),
"shed hints are hidden, not wrapped: {text}"
);
}
#[test]
fn the_generating_pill_keeps_the_hints_in_the_corner() {
let statuses = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::NotConfigured,
};
let m = StatusModel {
generating: true,
tokens: 4732,
context: Some(22597),
context_exact: true,
reasoning: 4732,
..model(&statuses, false, 0, None, false, false)
};
let r = rows_of(120, &m);
assert_eq!(r.len(), 2, "{r:?}");
assert!(r[0].contains("токены: 27329 (рассужд. 4732)"), "{:?}", r[0]);
assert!(
r[0].trim_end().ends_with("справка"),
"the mouse toggle and F1 share the pill's row: {:?}",
r[0]
);
let char_col = |line: &str, pat: &str| line.find(pat).map(|b| line[..b].chars().count());
assert_eq!(
char_col(&r[1], "Esc"),
char_col(&r[0], "Ctrl+W"),
"a right-aligned 2×2 block, nothing under the pill:\n{:?}\n{:?}",
r[0],
r[1]
);
let text = r.join("\n");
assert!(
!text.contains("новый") && !text.contains("настройки"),
"{text}"
);
}
#[test]
fn scroll_mode_pins_the_mouse_toggle_through_trimming() {
let statuses = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::NotConfigured,
};
let mut m = busy(&statuses);
m.mouse_scroll = true;
let p = Palette::default();
let ls = lines(120, &m, &p, ru());
assert_eq!(ls.len(), 2);
let accent_span = ls
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.contains("прокрутка"))
.expect("the pinned mouse toggle is shown");
assert_eq!(accent_span.style.fg, Some(p.accent));
let flat: String = ls
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(flat.contains("F1"), "F1 rides along: {flat}");
assert!(!flat.contains("настройки"), "sheds still happen: {flat}");
}
#[test]
fn a_wall_of_pill_leaves_f1_alone_below() {
let statuses = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::NotConfigured,
};
let r = rows_of(96, &busy(&statuses));
assert_eq!(r.len(), 2, "{r:?}");
assert!(r[0].contains("файлы"), "the pill keeps its row: {:?}", r[0]);
let below = r[1].trim();
assert!(
below.starts_with("F1") && below.ends_with("справка"),
"F1 alone: {:?}",
r[1]
);
assert!(r[1].starts_with(' '), "right-aligned: {:?}", r[1]);
}
#[test]
fn the_bar_never_exceeds_two_rows_and_keeps_f1() {
let p = Palette::default();
let quiet_statuses = ready();
let quiet = model(&quiet_statuses, false, 0, None, false, false);
let scroll = model(&quiet_statuses, false, 0, None, false, true);
let busy_statuses = ServerStatuses {
chat: ServerStatus::Disconnected("connection refused".into()),
embed: ServerStatus::Ready,
impersonation: ServerStatus::Connecting,
};
let busy = busy(&busy_statuses);
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
let f1_cell = 2 + 2 + 1 + str_w(loc.t("ui.status.hotkey.help"));
for w in 12..=240usize {
for m in [&quiet, &scroll, &busy] {
let ls = lines(w, m, &p, loc);
assert!(
ls.len() <= HINT_ROWS_MAX,
"{lang:?} w={w}: {} rows",
ls.len()
);
let flat: String = ls
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(
w < f1_cell || flat.contains("F1"),
"{lang:?} w={w}: F1 missing: {flat}"
);
}
}
}
}
#[test]
fn render_does_not_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut term = Terminal::new(TestBackend::new(60, 1)).unwrap();
let all = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::Connecting,
};
let m = StatusModel {
statuses: &all,
generating: true,
tokens: 123,
context: Some(456),
context_exact: true,
reasoning: 0,
mouse_scroll: true,
background: Some("рефлексия"),
speaking: true,
attachments: Some("файлы: 2 (~3.1k)"),
staged_images: Some("изображения: 1 (~1.2k)"),
esc_target: EscTarget::SearchResults,
read_only: false,
background_run: false,
};
term.draw(|f| render(f, f.area(), &m, &Palette::default(), ru()))
.unwrap();
}
#[test]
fn staged_images_chip_is_separate_and_one_column() {
let statuses = ready();
let text = |files, images| {
let mut m = model(&statuses, false, 0, None, false, false);
m.attachments = files;
m.staged_images = images;
lines(200, &m, &Palette::default(), ru())
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect::<String>()
};
let empty = text(None, None);
assert!(!empty.contains(IMAGE_GLYPH), "{empty}");
let both = text(Some("файлы: 2 (~3.1k)"), Some("изображения: 1 (~1.2k)"));
assert!(both.contains("§ файлы: 2"), "{both}");
assert!(both.contains("# изображения: 1"), "{both}");
let alone = text(None, Some("изображения: 1 (~1.2k)"));
assert!(alone.contains("# изображения: 1"), "{alone}");
assert!(!alone.contains(ATTACH_GLYPH), "{alone}");
assert_eq!(str_w(&IMAGE_GLYPH.to_string()), 1, "one column");
}
#[test]
fn esc_hint_follows_where_back_goes() {
let statuses = ready();
let chats = ru().t("ui.status.hotkey.chats");
let results = ru().t("ui.status.hotkey.results");
assert_ne!(chats, results, "the two labels must be distinguishable");
let text = |target| {
let mut m = model(&statuses, false, 0, None, false, false);
m.esc_target = target;
lines(200, &m, &Palette::default(), ru())
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect::<String>()
};
let ordinary = text(EscTarget::ChatList);
assert!(ordinary.contains(chats), "{ordinary}");
assert!(!ordinary.contains(results), "{ordinary}");
let from_results = text(EscTarget::SearchResults);
assert!(from_results.contains(results), "{from_results}");
assert!(!from_results.contains(chats), "{from_results}");
}
#[test]
fn esc_hint_says_cancel_while_a_turn_runs() {
let statuses = ready();
let cancel = ru().t("ui.status.hotkey.cancel");
let text = |generating, target| {
let mut m = model(&statuses, generating, 0, None, false, false);
m.esc_target = target;
rows_of(120, &m).join(" ")
};
for target in [
EscTarget::ChatList,
EscTarget::SearchResults,
EscTarget::PreviousChat,
] {
let goes_to = ru().t(target.hint_key());
let mid_turn = text(true, target);
assert!(mid_turn.contains(cancel), "{target:?}: {mid_turn}");
assert!(
!mid_turn.contains(goes_to),
"{target:?}: the navigation label outlived the turn: {mid_turn}"
);
let idle = text(false, target);
assert!(idle.contains(goes_to), "{target:?}: {idle}");
assert!(!idle.contains(cancel), "{target:?}: {idle}");
}
assert!(
ru().t("ui.chat.input.generating").contains(cancel),
"the bar and the input box must not use two words for one key"
);
}
}