use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::{
app::App,
layout::PaneLayout,
mode::Mode,
palette::{Action, BarMode, ItemKind},
pane::{PaneContent, PaneId},
};
pub const LINE_NUM_W: u16 = 6;
pub const POINTER_W: u16 = 2;
pub fn gutter_width(tuning: &crate::tuning::Tuning) -> u16 {
if tuning.line_numbers { LINE_NUM_W } else { POINTER_W }
}
fn rgb(c: [u8; 3]) -> Color {
Color::Rgb(c[0], c[1], c[2])
}
fn lighten(c: [u8; 3], amt: u8) -> Color {
Color::Rgb(c[0].saturating_add(amt), c[1].saturating_add(amt), c[2].saturating_add(amt))
}
pub fn render(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
let (tab_area, full_pane_area, status_area, bar_area) =
(chunks[0], chunks[1], chunks[2], chunks[3]);
let pane_area = if app.tree_open {
let tw = app.tuning.tree_width.min(full_pane_area.width.saturating_sub(20));
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(tw), Constraint::Min(1)])
.split(full_pane_area);
render_file_tree(frame, app, cols[0]);
cols[1]
} else {
full_pane_area
};
render_tab_bar(frame, app, tab_area);
render_panes(frame, app, pane_area);
if app.show_splash {
render_splash(frame, app, pane_area);
}
if app.shift_report.is_some() {
render_shift_report(frame, app, pane_area);
}
if !app.notices.is_empty() && !app.show_splash && app.shift_report.is_none() {
render_notice(frame, app, pane_area);
}
render_status(frame, app, status_area);
render_control_bar(frame, app, bar_area);
if app.palette.is_some() && matches!(app.mode, Mode::Bar) {
match app.palette.as_ref().map(|p| p.bar_mode.clone()) {
Some(BarMode::Ask) => render_ask_panel(frame, app, pane_area, bar_area),
Some(BarMode::Command) => {
let dropdown = render_bar_dropdown(frame, app, pane_area, bar_area);
if app.bar_return == Mode::Terminal {
render_shell_overlay(frame, app, pane_area, dropdown);
}
}
Some(BarMode::Shell) => render_shell_overlay(frame, app, pane_area, None),
None => {}
}
}
if !app.pending_prefix.is_empty()
&& app.frame_tick.saturating_sub(app.prefix_tick) >= app.tuning.which_key_delay_ticks()
{
render_which_key(frame, app, pane_area, status_area);
}
if matches!(app.mode, Mode::Tab) {
render_travel_panel(frame, app, pane_area, status_area);
}
}
fn render_travel_panel(frame: &mut Frame, app: &App, pane_area: Rect, status_area: Rect) {
let panel_width = app.tuning.travel_panel_width;
let rows: &[(&str, &str)] = &[
("← → ↑ ↓", "move focus · pane → tab at the edges"),
("1-9", "jump to tab"),
("z / Spc", "zoom (maximize) · toggle"),
("d / ⌫", "close focused · pane, or tab if last"),
("", ""),
("t / n", "new tab"),
("T", "new terminal tab"),
("| / -", "split right / below"),
("⇧ ← →", "reorder tab"),
("< >", "resize pane"),
("x", "swap pane"),
("r", "rename tab"),
("", ""),
("?", "why did this fail? (triage)"),
("w", "watch this pane (summarize when done)"),
("D", "detach session (keeps running)"),
("Esc ⏎", "done · creation exits, navigation stays"),
];
let mut lines: Vec<Line> = Vec::new();
for (keys, what) in rows {
if keys.is_empty() {
lines.push(Line::from(Span::raw("")));
continue;
}
lines.push(Line::from(vec![
Span::styled(
format!(" {:<9}", keys),
Style::default().fg(rgb(app.tuning.theme_accent_bright)).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {}", what), Style::default().fg(lighten(app.tuning.theme_terminal, 90))),
]));
}
let panel_h = (lines.len() as u16 + 2).min(pane_area.height.saturating_sub(1)); let width = panel_width.min(status_area.width);
let rect = Rect {
x: status_area.x + status_area.width.saturating_sub(width),
y: status_area.y.saturating_sub(panel_h),
width,
height: panel_h,
};
frame.render_widget(Clear, rect);
let teal = rgb(app.tuning.theme_terminal);
let block = Block::default()
.title(Span::styled(" WARP ", Style::default().fg(teal).add_modifier(Modifier::BOLD)))
.borders(Borders::ALL)
.border_style(Style::default().fg(teal));
let inner = block.inner(rect);
frame.render_widget(block, rect);
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
fn render_which_key(frame: &mut Frame, app: &App, pane_area: Rect, status_area: Rect) {
let conts = app.keys.continuations(&app.pending_prefix);
if conts.is_empty() {
return;
}
let prefix = crate::config::render_chords(&app.pending_prefix);
let mut lines: Vec<Line> = Vec::new();
for (tail, action) in &conts {
lines.push(Line::from(vec![
Span::styled(
format!(" {:<4}", tail),
Style::default()
.fg(rgb(app.tuning.theme_accent_bright))
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", action.label()),
Style::default().fg(Color::White),
),
]));
}
let panel_h = (lines.len() as u16 + 1).min(pane_area.height.saturating_sub(1)); let width = app.tuning.which_key_panel_width.min(status_area.width);
let rect = Rect {
x: status_area.x + status_area.width.saturating_sub(width),
y: status_area.y.saturating_sub(panel_h),
width,
height: panel_h,
};
frame.render_widget(Clear, rect);
let block = Block::default()
.title(Span::styled(
format!(" {} - ", prefix),
Style::default()
.fg(rgb(app.tuning.theme_accent_bright))
.add_modifier(Modifier::BOLD),
))
.borders(Borders::TOP | Borders::LEFT)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(rect);
frame.render_widget(block, rect);
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
fn verdict_color(app: &App, v: crate::briefing::Verdict) -> Color {
use crate::briefing::Verdict;
match v {
Verdict::Blocked => rgb(app.tuning.theme_status_blocked), Verdict::Failed => rgb(app.tuning.theme_status_failed), Verdict::Running => rgb(app.tuning.theme_healthy), Verdict::Done => rgb(app.tuning.theme_terminal), Verdict::Context => Color::Gray, }
}
pub(crate) fn pane_name(app: &App, pane_id: PaneId) -> String {
let Some(pane) = app.panes.get(&pane_id) else { return "—".to_string() };
match pane.content {
PaneContent::Editor(buf_id) => {
let b = app.buffers.get(&buf_id);
let name = b.map(|b| b.name.clone()).unwrap_or_else(|| "buffer".to_string());
if b.map(|b| b.modified).unwrap_or(false) { format!("{name} ●") } else { name }
}
PaneContent::Terminal(tid) => {
if let Some(t) = pane.title.as_deref() {
return t.to_string();
}
if let Some(cmd) = app.watches.get(&tid).and_then(|w| w.last_command.as_ref()) {
if let Some(w0) = cmd.split_whitespace().next() {
return w0.rsplit('/').next().unwrap_or(w0).to_string();
}
}
"shell".to_string()
}
}
}
pub(crate) fn workspace_name(app: &App, tab: &crate::tab::Tab, num: usize) -> String {
if !tab.name.is_empty() && tab.name.parse::<usize>().is_err() {
return tab.name.clone();
}
match app.panes.get(&tab.focused_pane).map(|p| &p.content) {
Some(PaneContent::Editor(buf_id)) => {
app.buffers.get(buf_id).map(|b| b.name.clone()).unwrap_or_else(|| "buffer".to_string())
}
_ => app
.panes
.get(&tab.focused_pane)
.and_then(|p| p.title.clone())
.unwrap_or_else(|| format!("terminal {num}")),
}
}
fn render_tab_bar(frame: &mut Frame, app: &App, area: Rect) {
let mut spans: Vec<Span> = Vec::new();
for (i, tab) in app.tabs.iter().enumerate() {
let bubble = verdict_color(app, app.tab_status(tab));
let name = workspace_name(app, tab, i + 1);
if i == app.active_tab {
let chip = rgb(app.tuning.theme_chip_fg);
let accent = rgb(app.tuning.theme_accent);
spans.push(Span::styled(format!(" ● {name} "),
Style::default().fg(chip).bg(accent).add_modifier(Modifier::BOLD)));
} else {
spans.push(Span::styled(" ● ", Style::default().fg(bubble)));
spans.push(Span::styled(format!("{name} "), Style::default().fg(Color::Gray)));
}
spans.push(Span::styled("│", Style::default().fg(Color::DarkGray)));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn compute_rects(layout: &PaneLayout, area: Rect) -> Vec<(PaneId, Rect)> {
match layout {
PaneLayout::Single(id) => vec![(*id, area)],
PaneLayout::HSplit { top, bottom, ratio } => {
let halves = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(*ratio), Constraint::Percentage(100 - *ratio)])
.split(area);
let mut v = compute_rects(top, halves[0]);
v.extend(compute_rects(bottom, halves[1]));
v
}
PaneLayout::VSplit { left, right, ratio } => {
let halves = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(*ratio), Constraint::Percentage(100 - *ratio)])
.split(area);
let mut v = compute_rects(left, halves[0]);
v.extend(compute_rects(right, halves[1]));
v
}
}
}
fn render_panes(frame: &mut Frame, app: &mut App, area: Rect) {
let focused_id = app.focused_pane_id();
{
let tab = &mut app.tabs[app.active_tab];
let stale = match tab.zoomed {
Some(z) => z != focused_id || !tab.layout.pane_ids().contains(&z),
None => false,
};
if stale {
tab.zoomed = None;
}
}
let rects: Vec<(PaneId, Rect)> = {
let tab = &app.tabs[app.active_tab];
match tab.zoomed {
Some(z) => vec![(z, area)],
None => compute_rects(&tab.layout, area),
}
};
app.pane_rects = rects.clone();
let margin = app.tuning.scroll_margin;
for (pane_id, rect) in &rects {
let inner_h = rect.height.saturating_sub(2) as usize;
if let Some(p) = app.panes.get_mut(pane_id) {
p.view_h = inner_h;
p.ensure_scroll(inner_h, margin);
}
}
for (pane_id, rect) in &rects {
let tid = match app.panes.get(pane_id).map(|p| p.content.clone()) {
Some(PaneContent::Terminal(id)) => Some(id),
_ => None,
};
if let Some(tid) = tid {
let iw = rect.width.saturating_sub(2);
let ih = rect.height.saturating_sub(2);
if let Some(t) = app.terms.get_mut(&tid) {
t.resize(ih, iw);
}
}
}
let bar_open = app.palette.is_some() && matches!(app.mode, Mode::Bar);
let mut cursor_screen: Option<(u16, u16)> = None;
for (pane_id, rect) in &rects {
let is_focused = *pane_id == focused_id;
if let Some(pos) = render_pane(frame, app, *pane_id, *rect, is_focused) {
cursor_screen = Some(pos);
}
}
app.cursor_screen = cursor_screen;
if !bar_open {
if let Some((cx, cy)) = cursor_screen {
if matches!(app.mode, Mode::Edit | Mode::Terminal) {
frame.set_cursor_position((cx, cy));
}
}
}
}
fn render_pane(
frame: &mut Frame,
app: &App,
pane_id: PaneId,
rect: Rect,
focused: bool,
) -> Option<(u16, u16)> {
let pane = app.panes.get(&pane_id)?;
match pane.content.clone() {
PaneContent::Editor(buf_id) => render_editor_pane(frame, app, pane_id, buf_id, rect, focused),
PaneContent::Terminal(_term_id) => render_terminal_pane(frame, app, pane_id, rect, focused),
}
}
fn render_editor_pane(
frame: &mut Frame,
app: &App,
pane_id: PaneId,
buf_id: usize,
rect: Rect,
focused: bool,
) -> Option<(u16, u16)> {
let pane = app.panes.get(&pane_id)?;
let buf = app.buffers.get(&buf_id)?;
if pane.md_view {
return render_markdown_pane(frame, app, pane, buf, rect, focused);
}
let border_style = if focused {
Style::default().fg(rgb(app.tuning.theme_accent))
} else {
Style::default().fg(Color::DarkGray)
};
let title_mod = if focused { Modifier::BOLD } else { Modifier::empty() };
let marker = if buf.modified { " ●" } else { "" };
let shown = pane.title.as_deref().unwrap_or(&buf.name);
let title = format!(" {}{} ", shown, marker);
let block = Block::default()
.title(Span::styled(title, Style::default().add_modifier(title_mod)))
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let vp_h = inner.height as usize;
let line_count = buf.line_count();
let mut lines: Vec<Line> = Vec::with_capacity(vp_h);
let sel: Option<((usize, usize), (usize, usize))> = pane.selection_anchor.map(|a| {
let c = (pane.cursor_row, pane.cursor_col);
if a <= c { (a, c) } else { (c, a) }
});
let [sr, sg, sb] = app.tuning.selection_bg;
let [hr, hg, hb] = app.tuning.search_match_bg;
let sel_style = Style::default().bg(Color::Rgb(sr, sg, sb));
let search_style = Style::default().bg(Color::Rgb(hr, hg, hb));
let label_style = Style::default()
.bg(rgb(app.tuning.theme_accent_bright))
.fg(rgb(app.tuning.theme_chip_fg))
.add_modifier(Modifier::BOLD);
let numbers = app.tuning.line_numbers;
for row_off in 0..vp_h {
let row = pane.scroll_row + row_off;
if row >= line_count {
let blank = " ".repeat(gutter_width(&app.tuning) as usize);
lines.push(Line::from(Span::styled(blank, Style::default().fg(Color::DarkGray))));
} else {
let content = buf.line_str(row);
let on_cursor = focused && row == pane.cursor_row;
let mut spans = Vec::new();
if numbers {
let num_style = if on_cursor {
Style::default().fg(rgb(app.tuning.theme_accent_bright)).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
spans.push(Span::styled(format!("{:>4}│ ", row + 1), num_style));
} else {
let (glyph, style) = if on_cursor {
("▸ ", Style::default().fg(rgb(app.tuning.theme_accent)).add_modifier(Modifier::BOLD))
} else {
(" ", Style::default())
};
spans.push(Span::styled(glyph, style));
}
let chars: Vec<char> = content.chars().collect();
let mut hl: Vec<u8> = vec![0; chars.len()];
if let Some(((sr, sc), (er, ec))) = sel {
if row >= sr && row <= er {
let start = (if row == sr { sc } else { 0 }).min(chars.len());
let end = (if row == er { ec } else { chars.len() }).min(chars.len());
for h in hl.iter_mut().take(end).skip(start) { *h = 1; }
}
}
let mut label_ch: Vec<Option<char>> = vec![None; chars.len()];
if focused {
for &(hr, hc, hlen) in &app.search_hl {
if hr == row {
let end = (hc + hlen).min(chars.len());
for h in hl.iter_mut().take(end).skip(hc.min(chars.len())) { *h = 2; }
}
}
if app.search_pick {
for &(lr, lc, ch) in &app.search_labels {
if lr == row && lc < chars.len() {
hl[lc] = 3;
label_ch[lc] = Some(ch);
}
}
}
}
if hl.iter().all(|&h| h == 0) {
spans.push(Span::raw(content));
} else {
let mut i = 0;
while i < chars.len() {
let kind = hl[i];
if kind == 3 {
let ch = label_ch[i].unwrap_or(chars[i]);
spans.push(Span::styled(ch.to_string(), label_style));
i += 1;
continue;
}
let mut j = i;
while j < chars.len() && hl[j] == kind && hl[j] != 3 { j += 1; }
let text: String = chars[i..j].iter().collect();
spans.push(match kind {
1 => Span::styled(text, sel_style),
2 => Span::styled(text, search_style),
_ => Span::raw(text),
});
i = j;
}
}
lines.push(Line::from(spans));
}
}
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
if focused {
let sy = inner.y + (pane.cursor_row.saturating_sub(pane.scroll_row)) as u16;
let sx = inner.x + gutter_width(&app.tuning) + pane.cursor_col as u16;
if sy < inner.y + inner.height && sx < inner.x + inner.width {
return Some((sx, sy));
}
}
None
}
fn mars_md_skin(app: &App) -> termimad::MadSkin {
use termimad::crossterm::style::Color as TColor;
let ct = |c: [u8; 3]| TColor::Rgb { r: c[0], g: c[1], b: c[2] };
let lite = |c: [u8; 3], a: u8| TColor::Rgb {
r: c[0].saturating_add(a), g: c[1].saturating_add(a), b: c[2].saturating_add(a),
};
let teal = ct(app.tuning.theme_terminal);
let accent = ct(app.tuning.theme_accent_bright);
let code = lite(app.tuning.theme_terminal, 110);
let mut s = termimad::MadSkin::default();
s.set_headers_fg(teal);
s.bold.set_fg(TColor::White);
s.italic.set_fg(accent);
s.inline_code.set_fg(code);
s.code_block.compound_style.set_fg(code);
s.bullet.set_fg(accent);
s.quote_mark.set_fg(accent);
s.table.set_fg(teal);
s
}
fn render_markdown_termimad(frame: &mut Frame, app: &App, buf: &crate::buffer::Buffer, pane: &crate::pane::Pane, inner: Rect) {
if inner.width == 0 || inner.height == 0 { return; }
let width = inner.width;
let md_scroll = pane.md_scroll;
let mut text = String::with_capacity(buf.line_count() * 40);
for i in 0..buf.line_count() {
if i > 0 { text.push('\n'); }
text.push_str(&buf.line_str(i));
}
let skin = mars_md_skin(app);
let fmt = skin.text(&text, Some(width as usize));
let total = fmt.lines.len().max(1);
pane.md_rendered_total.set(total);
let ansi = fmt.to_string().replace('\n', "\r\n");
let mut parser = vt100::Parser::new((total + 8) as u16, width, 0);
parser.process(ansi.as_bytes());
let screen = parser.screen();
let (rows, cols) = screen.size();
let vw = cols.min(width);
let start = md_scroll.min(total.saturating_sub(1));
let mut lines: Vec<Line> = Vec::with_capacity(inner.height as usize);
for r in 0..inner.height as usize {
let row = (start + r) as u16;
if row >= rows { lines.push(Line::from(Span::raw(""))); continue; }
let mut spans: Vec<Span> = Vec::new();
let mut run = String::new();
let mut run_style: Option<Style> = None;
for col in 0..vw {
let (ch, style) = match screen.cell(row, col) {
Some(cell) => {
let c = cell.contents();
let ch = if c.is_empty() { " ".to_string() } else { c };
let mut st = Style::default().fg(conv_color(cell.fgcolor())).bg(conv_color(cell.bgcolor()));
if cell.bold() { st = st.add_modifier(Modifier::BOLD); }
if cell.italic() { st = st.add_modifier(Modifier::ITALIC); }
if cell.underline() { st = st.add_modifier(Modifier::UNDERLINED); }
(ch, st)
}
None => (" ".to_string(), Style::default()),
};
if run_style == Some(style) {
run.push_str(&ch);
} else {
if let Some(s) = run_style { spans.push(Span::styled(std::mem::take(&mut run), s)); }
run = ch;
run_style = Some(style);
}
}
if let Some(s) = run_style { spans.push(Span::styled(run, s)); }
lines.push(Line::from(spans));
}
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
fn render_markdown_pane(
frame: &mut Frame,
app: &App,
pane: &crate::pane::Pane,
buf: &crate::buffer::Buffer,
rect: Rect,
focused: bool,
) -> Option<(u16, u16)> {
let border_style = if focused {
Style::default().fg(rgb(app.tuning.theme_accent))
} else {
Style::default().fg(Color::DarkGray)
};
let title_mod = if focused { Modifier::BOLD } else { Modifier::empty() };
let shown = pane.title.as_deref().unwrap_or(&buf.name);
let pos = {
let total = pane.md_rendered_total.get();
let vh = pane.view_h.max(1);
if total > vh {
let cap = total - vh;
let pct = (pane.md_scroll.min(cap) * 100) / cap.max(1);
format!(" · {pct}%")
} else { String::new() }
};
let title = format!(" {} — markdown{pos} ", shown);
let block = Block::default()
.title(Span::styled(title, Style::default().add_modifier(title_mod)))
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(rect);
frame.render_widget(block, rect);
render_markdown_termimad(frame, app, buf, pane, inner);
None
}
fn ansi_to_line(raw: &str) -> (Line<'static>, u16) {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut style = Style::default();
let mut buf = String::new();
let mut width: u16 = 0;
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if !buf.is_empty() {
spans.push(Span::styled(std::mem::take(&mut buf), style));
}
if chars.peek() == Some(&'[') {
chars.next();
}
let mut code = String::new();
for nc in chars.by_ref() {
if nc == 'm' {
break;
}
code.push(nc);
}
if code == "0" || code.is_empty() {
style = Style::default();
} else if let Some(rest) = code.strip_prefix("38;2;") {
let p: Vec<u8> = rest.split(';').filter_map(|x| x.parse().ok()).collect();
if p.len() == 3 {
style = Style::default().fg(Color::Rgb(p[0], p[1], p[2]));
}
}
} else {
buf.push(c);
width += 1;
}
}
if !buf.is_empty() {
spans.push(Span::styled(buf, style));
}
(Line::from(spans), width)
}
fn render_splash(frame: &mut Frame, app: &App, inner: Rect) {
let t = &app.tuning;
frame.render_widget(Clear, inner);
let parsed: Vec<(Line, u16)> = crate::banner::BANNER_LINES
.iter()
.map(|l| ansi_to_line(l))
.collect();
let banner_w = parsed.iter().map(|(_, w)| *w).max().unwrap_or(0);
let big = inner.width >= banner_w && inner.height >= (parsed.len() as u16 + 7);
let mut lines: Vec<Line> = Vec::new();
if big {
let pad = (inner.width.saturating_sub(banner_w) / 2) as usize;
for (line, _) in parsed {
let mut spans = vec![Span::raw(" ".repeat(pad))];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
lines.push(Line::raw(""));
} else {
lines.push(Line::from(Span::styled(
"M A R S",
Style::default().fg(rgb(t.theme_accent)).add_modifier(Modifier::BOLD),
)).centered());
lines.push(Line::from(Span::styled(
"mission control for your terminal",
Style::default().fg(rgb(t.theme_accent_bright)).add_modifier(Modifier::ITALIC),
)).centered());
lines.push(Line::raw(""));
}
let cmds: &[(&str, &str)] = &[
("C-Space", "mission control — search actions · ! shell · ? ask the agent"),
("C-x C-f", "navigator — browse & jump to any project file"),
("C-t", "space warp — tabs, panes, splits, open terminal"),
("C-u", "time-travel — scrub back through undo history"),
("C-x C-d", "detach — work keeps running while you're gone"),
("C-g", "cancel anything"),
];
let keyw = cmds.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0);
let block_w = cmds
.iter()
.map(|(_, d)| keyw + 3 + d.chars().count())
.max()
.unwrap_or(0) as u16;
let lpad = " ".repeat((inner.width.saturating_sub(block_w) / 2) as usize);
let key_style = Style::default().fg(rgb(t.theme_accent_bright)).add_modifier(Modifier::BOLD);
let desc_style = Style::default().fg(Color::Gray);
for (k, d) in cmds {
lines.push(Line::from(vec![
Span::raw(lpad.clone()),
Span::styled(format!("{k:>keyw$}"), key_style),
Span::raw(" "),
Span::styled(d.to_string(), desc_style),
]));
}
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
"or just start typing",
Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
)).centered());
let block_h = lines.len() as u16;
let top_pad = inner.height.saturating_sub(block_h) / 2;
let area = Rect {
x: inner.x,
y: inner.y + top_pad,
width: inner.width,
height: block_h.min(inner.height),
};
frame.render_widget(Paragraph::new(Text::from(lines)), area);
}
fn wrap(text: &str, width: usize) -> Vec<String> {
let width = width.max(8);
let mut out = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
if line.is_empty() {
line.push_str(word);
} else if line.chars().count() + 1 + word.chars().count() <= width {
line.push(' ');
line.push_str(word);
} else {
out.push(std::mem::take(&mut line));
line.push_str(word);
}
while line.chars().count() > width {
let head: String = line.chars().take(width).collect();
out.push(head);
line = line.chars().skip(width).collect();
}
}
if !line.is_empty() {
out.push(line);
}
out
}
fn render_shift_report(frame: &mut Frame, app: &App, inner: Rect) {
let Some(rep) = app.shift_report.as_ref() else { return };
frame.render_widget(Clear, inner);
let accent = rgb(app.tuning.theme_accent);
let bright = rgb(app.tuning.theme_accent_bright);
let teal = rgb(app.tuning.theme_terminal);
let green = rgb(app.tuning.theme_healthy);
let dim = Style::default().fg(Color::DarkGray);
let white = Style::default().fg(Color::White);
let cw = inner.width as usize;
let bw = (cw.saturating_sub(8)).clamp(24, 64);
let block_pad = " ".repeat(cw.saturating_sub(bw) / 2);
let centered = |spans: Vec<Span<'static>>, vis_len: usize| -> Line<'static> {
let pad = " ".repeat(cw.saturating_sub(vis_len) / 2);
let mut v = vec![Span::raw(pad)];
v.extend(spans);
Line::from(v)
};
let center1 = |s: String, style: Style| -> Line<'static> {
let len = s.chars().count();
centered(vec![Span::styled(s, style)], len)
};
let animate = app.tuning.mission_briefing_animate == 1;
let elapsed = if animate { rep.shown_at.elapsed().as_millis() } else { u128::MAX };
let rev = crate::briefing::reveal_at(elapsed, rep.rows.len());
let mut lines: Vec<Line> = Vec::new();
let logo_rows = &crate::banner::BANNER_LINES[1..=9];
if inner.height as usize > rep.rows.len() + logo_rows.len() + 14 {
let logo: Vec<(Line, u16)> = logo_rows.iter().map(|r| ansi_to_line(r)).collect();
let logo_w = logo.iter().map(|(_, wd)| *wd as usize).max().unwrap_or(0);
let logo_pad = " ".repeat(cw.saturating_sub(logo_w) / 2);
for (line, _) in logo {
let mut spans = vec![Span::raw(logo_pad.clone())];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
}
let (nf, nb, nd, nr) = (
rep.rows.iter().filter(|r| r.verdict == crate::briefing::Verdict::Failed).count(),
rep.rows.iter().filter(|r| r.verdict == crate::briefing::Verdict::Blocked).count(),
rep.rows.iter().filter(|r| r.verdict == crate::briefing::Verdict::Done).count(),
rep.rows.iter().filter(|r| r.verdict == crate::briefing::Verdict::Running).count(),
);
let ribbon = if rep.rows.is_empty() {
" · all quiet".to_string()
} else {
let mut parts = Vec::new();
if nf > 0 { parts.push(format!("✗{nf}")); }
if nb > 0 { parts.push(format!("⏸{nb}")); }
if nd > 0 { parts.push(format!("✓{nd}")); }
if nr > 0 { parts.push(format!("●{nr}")); }
format!(" · {}", parts.join(" "))
};
let title = "MISSION BRIEFING";
let caption = format!(" T+ {}{ribbon}", crate::briefing::fmt_clock(rep.away_secs));
let cap_len = title.chars().count() + caption.chars().count();
lines.push(centered(
vec![
Span::styled(title.to_string(), Style::default().fg(accent).add_modifier(Modifier::BOLD)),
Span::styled(caption, dim),
],
cap_len,
));
if let Some(m) = &rep.mission {
lines.push(center1(format!("mission: {m}"), dim));
}
lines.push(Line::from(""));
let type_ms = app.tuning.mission_briefing_type_ms.max(1) as u128;
let prose_rows = (app.tuning.mission_briefing_prose_rows as usize)
.min((inner.height as usize).saturating_sub(6))
.max(2);
let loading = animate && rep.narrative_streaming && !rep.narrative_from_model;
let target_len = rep.narrative.chars().count();
let show_n = if !animate || !rep.narrative_from_model {
target_len
} else {
rep.stream_started_at
.map(|s| (s.elapsed().as_millis() / type_ms) as usize)
.unwrap_or(0)
.min(target_len)
};
let typing = animate && rep.narrative_from_model && (show_n < target_len || rep.narrative_streaming);
let shown: String = rep.narrative.chars().take(show_n).collect();
let mut prose: Vec<Line> = Vec::new();
let mut signoff: Option<String> = None;
if loading {
let idx = (rep.shown_at.elapsed().as_millis() / crate::briefing::LOADING_FLASH_MS) as usize
% crate::briefing::BRIEF_LOADING.len();
prose.push(Line::from(Span::styled(
format!("{block_pad}{}…", crate::briefing::BRIEF_LOADING[idx]),
Style::default().fg(accent).add_modifier(Modifier::ITALIC),
)));
} else {
let cursor = if typing { "▏" } else { "" };
let full = format!("{shown}{cursor}");
let paras: Vec<&str> = full.split("\n\n").map(str::trim).filter(|p| !p.is_empty()).collect();
let npar = paras.len();
let has_signoff = npar >= 4;
let above = if has_signoff { ¶s[..npar - 1] } else { ¶s[..] };
signoff = has_signoff.then(|| paras[npar - 1].to_string());
let above_n = above.len();
for (pi, para) in above.iter().enumerate() {
let style = if pi == 0 {
Style::default().fg(accent).add_modifier(Modifier::BOLD) } else if above_n >= 3 && pi == above_n - 1 {
Style::default().fg(bright).add_modifier(Modifier::BOLD) } else {
white };
for l in wrap(para, bw) {
prose.push(Line::from(Span::styled(format!("{block_pad}{l}"), style)));
}
prose.push(Line::from(""));
}
if rep.rows.is_empty() {
prose.push(center1("· · ◜ · ·".to_string(), dim));
prose.push(Line::from(""));
}
}
if animate {
while prose.len() < prose_rows {
prose.push(Line::from(""));
}
}
lines.extend(prose);
let fixed_head = lines.len();
let manifest_full: usize = if rep.rows.is_empty() {
0
} else {
1 + rep
.rows
.iter()
.map(|r| {
let needsyou = matches!(
r.verdict,
crate::briefing::Verdict::Failed | crate::briefing::Verdict::Blocked
);
let has_detail =
needsyou && (r.cwd.is_some() || r.exit.is_some() || r.error_excerpt.is_some());
1 + usize::from(has_detail)
})
.sum::<usize>()
};
let signoff_full = if rep.rows.is_empty() { 2 } else { 6 };
if !rep.rows.is_empty() && rev.rows > 0 {
lines.push(Line::from(Span::styled(format!("{block_pad}{}", "─".repeat(bw)), dim)));
}
for r in rep.rows.iter().take(rev.rows) {
let needsyou = matches!(r.verdict, crate::briefing::Verdict::Failed | crate::briefing::Verdict::Blocked);
let goodnews = r.verdict == crate::briefing::Verdict::Done
&& r.dur_secs.map(|d| d > crate::briefing::GOODNEWS_SECS).unwrap_or(false);
let hue = match r.verdict {
crate::briefing::Verdict::Failed => bright,
crate::briefing::Verdict::Blocked => accent,
crate::briefing::Verdict::Running => green, crate::briefing::Verdict::Done => teal,
_ => Color::DarkGray,
};
let body_style = if needsyou || goodnews { white } else { dim };
let glyph = if goodnews { "★" } else { r.verdict.glyph() };
let tab = if r.tab.is_empty() { String::new() } else { format!("[{}] ", r.tab) };
let mut meta = Vec::new();
if let Some(d) = r.dur_secs.filter(|d| *d > 0) {
meta.push(format!("ran {}", crate::briefing::fmt_secs(d)));
}
if let Some(a) = r.ago_secs.filter(|a| *a > 0) {
meta.push(format!("{} ago", crate::briefing::fmt_secs(a)));
}
let meta = if meta.is_empty() { String::new() } else { format!(" ({})", meta.join(", ")) };
let body: String = format!("{tab}{}{meta}", r.text).chars().take(bw).collect();
lines.push(Line::from(vec![
Span::raw(block_pad.clone()),
Span::styled("▎ ".to_string(), Style::default().fg(hue)),
Span::styled(format!("{glyph} "), Style::default().fg(hue).add_modifier(Modifier::BOLD)),
Span::styled(body, body_style),
]));
if needsyou {
let mut detail = Vec::new();
if let Some(c) = &r.cwd { detail.push(c.clone()); }
if let Some(x) = r.exit { detail.push(format!("exit {x}")); }
if let Some(e) = &r.error_excerpt {
if let Some(first) = e.lines().find(|l| !l.trim().is_empty()) {
detail.push(format!("“{}”", first.trim()));
}
}
if !detail.is_empty() {
let d: String = format!("{block_pad} {}", detail.join(" · ")).chars().take(cw).collect();
lines.push(Line::from(Span::styled(d, dim)));
}
}
}
if rev.signoff {
if !rep.rows.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(format!("{block_pad}{}", "─".repeat(bw)), dim)));
}
if let Some(s) = &signoff {
let so = Style::default().fg(accent).add_modifier(Modifier::ITALIC);
for l in wrap(s, bw) {
lines.push(Line::from(Span::styled(format!("{block_pad}{l}"), so)));
}
lines.push(Line::from(""));
}
lines.push(center1("any key resumes exactly where you left off".to_string(), dim));
}
let total = if animate {
(fixed_head + manifest_full + signoff_full) as u16
} else {
lines.len() as u16
};
let top_pad = inner.height.saturating_sub(total) / 2;
let area = Rect {
x: inner.x,
y: inner.y + top_pad,
width: inner.width,
height: (lines.len() as u16).max(total).min(inner.height),
};
frame.render_widget(Paragraph::new(Text::from(lines)), area);
}
fn render_terminal_pane(
frame: &mut Frame,
app: &App,
pane_id: PaneId,
rect: Rect,
focused: bool,
) -> Option<(u16, u16)> {
let pane = app.panes.get(&pane_id)?;
let term_id = match pane.content {
PaneContent::Terminal(id) => id,
_ => return None,
};
let exited = app.terms.get(&term_id).map(|t| t.exited).unwrap_or(true);
let offset = app.terms.get(&term_id).map(|t| t.view_offset()).unwrap_or(0);
let border_style = if exited {
Style::default().fg(rgb(app.tuning.theme_accent_dark))
} else if focused {
Style::default().fg(rgb(app.tuning.theme_terminal))
} else {
Style::default().fg(Color::DarkGray)
};
let base = pane.title.as_deref().unwrap_or("terminal");
let title = if exited {
format!(" {base} · exited ")
} else if offset > 0 {
format!(" {base} ↑{offset} ")
} else {
format!(" {base} ")
};
let block = Block::default()
.title(Span::styled(
title,
Style::default().add_modifier(if focused { Modifier::BOLD } else { Modifier::empty() }),
))
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let term = match app.terms.get(&term_id) {
Some(t) => t,
None => {
frame.render_widget(
Paragraph::new(Span::styled(
"(terminal closed)",
Style::default().fg(Color::DarkGray),
)),
inner,
);
return None;
}
};
let screen = term.screen();
let (rows, cols) = screen.size();
let vh = inner.height.min(rows);
let vw = inner.width.min(cols);
let sel = app.term_sel.filter(|s| s.tid == term_id).map(|s| {
let (mut a, mut b) = (s.anchor, s.end);
if b < a { std::mem::swap(&mut a, &mut b); }
(a, b, s.vw.saturating_sub(1))
});
let [sr, sg, sb] = app.tuning.selection_bg;
let sel_bg = Color::Rgb(sr, sg, sb);
let mut lines: Vec<Line> = Vec::with_capacity(vh as usize);
for row in 0..vh {
let mut spans: Vec<Span> = Vec::with_capacity(vw as usize);
for col in 0..vw {
if let Some(cell) = screen.cell(row, col) {
let contents = cell.contents();
let ch = if contents.is_empty() { " ".to_string() } else { contents };
let mut style = Style::default()
.fg(conv_color(cell.fgcolor()))
.bg(conv_color(cell.bgcolor()));
if cell.bold() { style = style.add_modifier(Modifier::BOLD); }
if cell.italic() { style = style.add_modifier(Modifier::ITALIC); }
if cell.underline() { style = style.add_modifier(Modifier::UNDERLINED); }
if cell.inverse() { style = style.add_modifier(Modifier::REVERSED); }
if let Some((a, b, last)) = sel {
let c0 = if row == a.0 { a.1 } else { 0 };
let c1 = if row == b.0 { b.1 } else { last };
if row >= a.0 && row <= b.0 && col >= c0 && col <= c1 {
style = style.bg(sel_bg);
}
}
spans.push(Span::styled(ch, style));
} else {
spans.push(Span::raw(" "));
}
}
lines.push(Line::from(spans));
}
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
if exited && inner.height > 0 {
let notice = Rect { x: inner.x, y: inner.y + inner.height - 1, width: inner.width, height: 1 };
frame.render_widget(
Paragraph::new(Span::styled(
" process exited — Enter closes this pane ",
Style::default()
.fg(rgb(app.tuning.theme_chip_fg))
.bg(rgb(app.tuning.theme_accent_dark))
.add_modifier(Modifier::BOLD),
)),
notice,
);
return None;
}
if focused && !screen.hide_cursor() {
let (cr, cc) = screen.cursor_position();
let cx = inner.x + cc.min(vw.saturating_sub(1));
let cy = inner.y + cr.min(vh.saturating_sub(1));
return Some((cx, cy));
}
None
}
fn conv_color(c: vt100::Color) -> Color {
match c {
vt100::Color::Default => Color::Reset,
vt100::Color::Idx(i) => Color::Indexed(i),
vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
}
}
fn status_hints(app: &App) -> Vec<(String, String)> {
if matches!(app.mode, Mode::Edit) {
let mut v = vec![(bar_open_keys(app), "⌕ commands".to_string())];
for (action, label) in [
(Action::Save, "save"),
(Action::ToggleFileTree, "open"),
(Action::Search, "search"),
] {
if let Some(b) = app.keys.binding_for(&action) {
v.push((b, label.to_string()));
}
}
v.push(("C-g".to_string(), "cancel".to_string()));
v
} else if matches!(app.mode, Mode::Terminal) {
vec![
(bar_open_keys(app), "commands".to_string()),
("C-g".to_string(), "to editor".to_string()),
("type".to_string(), "to shell".to_string()),
]
} else {
app.mode
.hints()
.iter()
.map(|(k, a)| (k.to_string(), a.to_string()))
.collect()
}
}
fn bar_open_keys(app: &App) -> String {
app.keys
.bar_open
.iter()
.map(|c| crate::config::render_chords(std::slice::from_ref(c)))
.collect::<Vec<_>>()
.join(" / ")
}
fn render_status(frame: &mut Frame, app: &App, area: Rect) {
let accent = rgb(app.tuning.theme_accent);
let sand = rgb(app.tuning.theme_accent_bright);
let chipfg = rgb(app.tuning.theme_chip_fg);
let (mode_fg, mode_bg, key_bg, key_fg) = match &app.mode {
Mode::Edit => (chipfg, accent, accent, chipfg),
Mode::Prompt => (chipfg, sand, sand, chipfg),
Mode::Tab => (chipfg, accent, accent, chipfg),
Mode::Bar => (chipfg, accent, accent, chipfg),
Mode::Tree => (chipfg, accent, accent, chipfg),
Mode::Undo => (chipfg, sand, sand, chipfg),
Mode::Terminal => {
let teal = rgb(app.tuning.theme_terminal);
(Color::White, teal, teal, Color::White)
}
};
let mut spans: Vec<Span> = vec![
Span::styled(
format!(" {} ", app.mode.label()),
Style::default()
.fg(mode_fg)
.bg(mode_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
];
if crate::llm_log::enabled() {
let mem = crate::retrieval::MemoryMode::from_env();
let label = if matches!(mem, crate::retrieval::MemoryMode::None) {
" ● REC ".to_string()
} else {
format!(" ● REC mem:{} ", mem.as_str())
};
spans.push(Span::styled(
label,
Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(" ", Style::default()));
}
for (key, action) in status_hints(app) {
spans.push(Span::styled(
format!(" {} ", key),
Style::default()
.fg(key_fg)
.bg(key_bg)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!(":{} ", action),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(" ", Style::default()));
}
if !app.pending_prefix.is_empty() {
spans.push(Span::styled(
format!(" {}- ", crate::config::render_chords(&app.pending_prefix)),
Style::default().fg(rgb(app.tuning.theme_accent_bright)).add_modifier(Modifier::BOLD),
));
} else if let Some(msg) = &app.status_msg {
spans.push(Span::styled(
format!(" {msg} "),
Style::default().fg(rgb(app.tuning.theme_accent_bright)),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
let pane = app.focused_pane();
let session = app.session_name.as_ref().map(|s| format!(" ⚡{s}")).unwrap_or_default();
let readout = match pane.content {
PaneContent::Editor(buf_id) => {
let name = app
.buffers
.get(&buf_id)
.map(|b| format!("{}{}", b.name, if b.modified { " ●" } else { "" }))
.unwrap_or_default();
format!("{name} Ln {}, Col {}{session} ", pane.cursor_row + 1, pane.cursor_col + 1)
}
PaneContent::Terminal(_) => format!("terminal{session} "),
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
readout,
Style::default().fg(rgb(app.tuning.theme_accent_bright)).add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Right),
area,
);
}
fn render_control_bar(frame: &mut Frame, app: &App, area: Rect) {
match &app.mode {
Mode::Bar => {
let palette = match app.palette.as_ref() {
Some(p) => p,
None => return,
};
let mode_label = match palette.bar_mode {
BarMode::Command => "CMD",
BarMode::Ask => "ASK",
BarMode::Shell => "SH !",
};
let prompt = format!("[{}] › {}▎", mode_label, palette.query);
let style = Style::default()
.fg(rgb(app.tuning.theme_accent))
.add_modifier(Modifier::BOLD);
let legend = if palette.bar_mode == BarMode::Command && palette.query.is_empty() {
let keys: Vec<String> = crate::palette::bar_quick_legend()
.iter()
.map(|(k, what)| format!("{k} {what}"))
.collect();
format!(" {}", keys.join(" · "))
} else {
String::new()
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(prompt.clone(), style),
Span::styled(legend, Style::default().fg(Color::DarkGray)),
])),
area,
);
let cx = area.x + prompt.chars().count() as u16 - 1; if cx < area.x + area.width {
frame.set_cursor_position((cx, area.y));
}
}
Mode::Prompt => {
if let Some(p) = app.prompt.as_ref() {
let extra = if p.kind == crate::app::PromptKind::Search {
match app.isearch_status() {
Some((cur, total)) if total > 0 => {
let pick = if total >= 2 { " ⇥ jump" } else { "" };
format!(" {cur}/{total}{pick}")
}
_ if !p.input.is_empty() => " (no match)".to_string(),
_ => String::new(),
}
} else {
String::new()
};
let text = format!("{}{}{}", p.label, p.input, extra);
frame.render_widget(
Paragraph::new(Span::styled(
text.clone(),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
)),
area,
);
let cx = area.x + (p.label.chars().count() + p.input.chars().count()) as u16;
if cx < area.x + area.width {
frame.set_cursor_position((cx, area.y));
}
}
}
_ => {
let open = app.keys.binding_for(&Action::ToggleFileTree).unwrap_or_default();
let search = app.keys.binding_for(&Action::Search).unwrap_or_default();
let hint = format!(
" {} commands {} open {} search C-g cancel",
bar_open_keys(app), open, search
);
frame.render_widget(
Paragraph::new(Span::styled(hint, Style::default().fg(Color::DarkGray))),
area,
);
}
}
}
fn ellip(s: &str, w: usize) -> String {
if w == 0 { return String::new(); }
if s.chars().count() <= w { return s.to_string(); }
let t: String = s.chars().take(w.saturating_sub(1)).collect();
format!("{t}…")
}
fn command_lines(app: &App, rows: &[crate::palette::PaletteRow], sel: usize, navigated: bool, active: bool, max_rows: usize) -> Vec<Line<'static>> {
let mut out = Vec::new();
let body_max = max_rows.max(1);
let scroll = if sel >= body_max { sel + 1 - body_max } else { 0 };
for (idx, row) in rows.iter().enumerate().skip(scroll).take(body_max) {
let selected = active && navigated && idx == sel;
let bg = if selected { Color::DarkGray } else { Color::Reset };
let has_sub = matches!(row.kind, ItemKind::Submenu(_));
let quick = match &row.kind { ItemKind::Run(a) => crate::palette::bar_quick_key(a), _ => None };
let binding = match &row.kind { ItemKind::Run(a) => app.keys.binding_for(a).unwrap_or_default(), _ => String::new() };
let desc = if row.description.is_empty() { String::new() } else { format!(" — {}", row.description) };
let type_mark = if has_sub { " ▸" } else { "" };
let quick_span = match quick {
Some(q) => Span::styled(format!(" {q} "), Style::default().fg(rgb(app.tuning.theme_chip_fg)).bg(rgb(app.tuning.theme_accent)).add_modifier(Modifier::BOLD)),
None => Span::styled(" ", Style::default().bg(bg)),
};
out.push(Line::from(vec![
quick_span,
Span::styled(format!(" {:<w$}", binding, w = app.tuning.binding_badge_width),
Style::default().fg(rgb(app.tuning.theme_accent_bright)).bg(bg).add_modifier(Modifier::BOLD)),
Span::styled(" ", Style::default().bg(bg)),
Span::styled(format!("{}{}", row.label, type_mark),
if selected { Style::default().fg(rgb(app.tuning.theme_accent)).bg(bg).add_modifier(Modifier::BOLD) }
else { Style::default().fg(Color::White).bg(bg) }),
Span::styled(desc, Style::default().fg(Color::DarkGray).bg(bg)),
]));
}
out
}
fn workspace_lines(app: &App, rows: &[crate::palette::PaletteRow], sel: usize, active: bool, width: u16, max_rows: usize) -> Vec<Line<'static>> {
let mut out = Vec::new();
let body_max = max_rows.max(1);
let scroll = if sel >= body_max { sel + 1 - body_max } else { 0 };
for (idx, row) in rows.iter().enumerate().skip(scroll).take(body_max) {
let s = active && idx == sel;
let bg = if s { rgb(app.tuning.theme_terminal) } else { Color::Reset };
let sel_fg = rgb(app.tuning.theme_chip_fg);
let (glyph, vcolor, id, cur) = match &row.kind {
ItemKind::Surface(sr) => {
("●", verdict_color(app, sr.verdict), sr.tab_index + 1, sr.tab_index == app.active_tab)
}
_ => ("●", Color::Gray, 0, false),
};
let _ = id;
let marker = if cur { "‹" } else { " " };
let name_budget = (width as usize).saturating_sub(3 + 3);
let mut spans = vec![
Span::styled(marker.to_string(), Style::default().fg(if s { sel_fg } else { rgb(app.tuning.theme_accent_bright) }).bg(bg).add_modifier(Modifier::BOLD)),
Span::styled(format!("{glyph} "), Style::default().fg(if s { sel_fg } else { vcolor }).bg(bg).add_modifier(Modifier::BOLD)),
Span::styled(
ellip(&row.label, name_budget),
Style::default().fg(if s { sel_fg } else { Color::White }).bg(bg)
.add_modifier(if s { Modifier::BOLD } else { Modifier::empty() }),
),
];
let right = if s { "↵ " } else { " " };
let lw: usize = spans.iter().map(|x| x.content.chars().count()).sum();
let pad = (width as usize).saturating_sub(lw + right.chars().count());
spans.push(Span::styled(" ".repeat(pad), Style::default().bg(bg)));
spans.push(Span::styled(right.to_string(), Style::default().fg(if s { sel_fg } else { Color::DarkGray }).bg(bg).add_modifier(if s { Modifier::BOLD } else { Modifier::empty() })));
out.push(Line::from(spans));
}
out
}
fn wrap_summary(text: &str, width: usize, max_lines: usize) -> Vec<String> {
let width = width.max(1);
let mut lines: Vec<String> = Vec::new();
let mut cur = String::new();
for word in text.split_whitespace() {
let word: String = if word.chars().count() > width { word.chars().take(width).collect() } else { word.to_string() };
if cur.is_empty() {
cur = word;
} else if cur.chars().count() + 1 + word.chars().count() <= width {
cur.push(' ');
cur.push_str(&word);
} else {
lines.push(std::mem::take(&mut cur));
cur = word;
if lines.len() >= max_lines { break; }
}
}
if !cur.is_empty() && lines.len() < max_lines { lines.push(cur); }
if lines.len() > max_lines { lines.truncate(max_lines); }
if lines.len() == max_lines {
let more = text.split_whitespace().count()
> lines.iter().map(|l| l.split_whitespace().count()).sum::<usize>();
if more {
if let Some(last) = lines.last_mut() {
*last = ellip(&format!("{last} …"), width);
}
}
}
lines
}
fn detail_lines(app: &App, row: Option<&crate::palette::PaletteRow>, width: u16) -> Vec<Line<'static>> {
use crate::briefing::Verdict;
let w = width as usize;
let mut out = vec![Line::from(Span::styled(
format!(" {} ", "─".repeat(w.saturating_sub(2))),
Style::default().fg(Color::DarkGray),
))];
let Some(ItemKind::Surface(s)) = row.map(|r| &r.kind) else { return out };
let vcolor = verdict_color(app, s.verdict);
let vlabel = match s.verdict {
Verdict::Blocked => "blocked", Verdict::Failed => "failed",
Verdict::Running => "running", Verdict::Done => "done", Verdict::Context => "idle",
};
let teal = rgb(app.tuning.theme_terminal);
let rail = || Span::styled(" ▌ ", Style::default().fg(teal));
let content_w = w.saturating_sub(5); let age = if s.age_secs > 0 { format!(" · {}", crate::briefing::fmt_secs(s.age_secs)) } else { String::new() };
out.push(Line::from(vec![
rail(),
Span::styled("status: ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled(vlabel.to_string(), Style::default().fg(vcolor).add_modifier(Modifier::BOLD)),
Span::styled(age, Style::default().fg(Color::DarkGray)),
]));
let why = row.map(|r| r.description.clone()).unwrap_or_default();
for line in wrap_summary(&why, content_w, 4) {
out.push(Line::from(vec![
rail(),
Span::styled(line, Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC)),
]));
}
out.push(Line::from(Span::styled(" s summarize", Style::default().fg(Color::DarkGray))));
out
}
fn star_hash(x: usize, y: usize) -> u64 {
let mut h = (x as u64).wrapping_mul(73856093) ^ (y as u64).wrapping_mul(19349663);
h ^= h >> 13;
h = h.wrapping_mul(0x9E37_79B1);
h ^ (h >> 16)
}
fn starfield(app: &App, width: u16, height: u16) -> Vec<Line<'static>> {
let (w, h) = (width as usize, height as usize);
let teal = rgb(app.tuning.theme_terminal);
let mut lines = Vec::with_capacity(h);
for y in 0..h {
let mut spans: Vec<Span> = Vec::new();
let mut run = String::new();
for x in 0..w {
let seed = star_hash(x, y);
if seed % 31 == 0 {
if !run.is_empty() { spans.push(Span::raw(std::mem::take(&mut run))); }
let glyph = match seed % 13 { 0 => "✦", 1 => "⋆", _ => "·" };
let color = if seed % 101 == 0 { teal } else { Color::DarkGray }; spans.push(Span::styled(glyph.to_string(), Style::default().fg(color)));
} else {
run.push(' ');
}
}
if !run.is_empty() { spans.push(Span::raw(run)); }
lines.push(Line::from(spans));
}
lines
}
fn render_workspaces_panel(frame: &mut Frame, app: &App, rect: Rect) {
let active = app.palette.as_ref().map(|p| p.column == crate::palette::BarColumn::Workspaces).unwrap_or(false);
let sel = app.palette.as_ref().map(|p| p.sel_ws).unwrap_or(0);
let teal = rgb(app.tuning.theme_terminal);
let mut bstyle = Style::default().fg(teal);
if active { bstyle = bstyle.add_modifier(Modifier::BOLD); }
let block = Block::default()
.borders(Borders::ALL)
.border_style(bstyle)
.title(Span::styled(" SPACES ", Style::default().fg(teal).add_modifier(Modifier::BOLD)));
let inner = block.inner(rect);
frame.render_widget(block, rect);
let rows = app.bar_workspace_rows();
let ih = inner.height as usize;
let dlines = detail_lines(app, rows.get(sel), inner.width);
let summ_h = dlines.len().min(ih);
let list_h = rows.len().min(ih.saturating_sub(summ_h));
frame.render_widget(Paragraph::new(Text::from(workspace_lines(app, &rows, sel, active, inner.width, list_h))),
Rect { x: inner.x, y: inner.y, width: inner.width, height: list_h as u16 });
frame.render_widget(Paragraph::new(Text::from(dlines)),
Rect { x: inner.x, y: inner.y + list_h as u16, width: inner.width, height: summ_h as u16 });
let used = list_h + summ_h;
if ih > used {
frame.render_widget(
Paragraph::new(Text::from(starfield(app, inner.width, (ih - used) as u16))),
Rect { x: inner.x, y: inner.y + used as u16, width: inner.width, height: (ih - used) as u16 },
);
}
}
fn render_command_panel(frame: &mut Frame, app: &App, rect: Rect, left_border: bool, active: bool) {
let palette = match app.palette.as_ref() { Some(p) => p, None => return };
let borders = if left_border { Borders::TOP | Borders::LEFT | Borders::RIGHT } else { Borders::TOP | Borders::RIGHT };
let accent = rgb(app.tuning.theme_accent);
let mut bstyle = Style::default().fg(accent);
if active { bstyle = bstyle.add_modifier(Modifier::BOLD); }
let block = Block::default()
.borders(borders)
.border_style(bstyle)
.title(Span::styled(" COMMANDS ", Style::default().fg(accent).add_modifier(Modifier::BOLD)));
let inner = block.inner(rect);
frame.render_widget(block, rect);
let rows = app.bar_rows();
let lines = command_lines(app, &rows, palette.selected, palette.navigated, active, inner.height as usize);
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
fn render_bar_dropdown(
frame: &mut Frame,
app: &App,
pane_area: Rect,
bar_area: Rect,
) -> Option<Rect> {
let palette = app.palette.as_ref()?;
let show_ws = app.bar_show_workspaces();
let cmd_rows = app.bar_rows();
if cmd_rows.is_empty() && !show_ws {
return None;
}
let max_height = ((pane_area.height * app.tuning.panel_max_height_pct / 100) as usize)
.min(app.tuning.dropdown_max_rows as usize) as u16;
let cmd_h = (cmd_rows.len() as u16 + 1).min(max_height).max(2);
if !show_ws {
let full = Rect { x: bar_area.x, y: bar_area.y.saturating_sub(cmd_h), width: bar_area.width, height: cmd_h };
frame.render_widget(Clear, full);
render_command_panel(frame, app, full, true, true);
return Some(full);
}
let ws_h = max_height.max(4);
let ws_w = app.tuning.tree_width.clamp(20, bar_area.width.saturating_sub(30).max(20));
let ws_rect = Rect { x: bar_area.x, y: bar_area.y.saturating_sub(ws_h), width: ws_w, height: ws_h };
let cmd_rect = Rect {
x: bar_area.x + ws_w,
y: bar_area.y.saturating_sub(cmd_h),
width: bar_area.width.saturating_sub(ws_w),
height: cmd_h,
};
frame.render_widget(Clear, ws_rect);
frame.render_widget(Clear, cmd_rect);
render_workspaces_panel(frame, app, ws_rect);
render_command_panel(frame, app, cmd_rect, true, palette.column == crate::palette::BarColumn::Commands);
Some(Rect {
x: bar_area.x,
y: bar_area.y.saturating_sub(ws_h.max(cmd_h)),
width: bar_area.width,
height: ws_h.max(cmd_h),
})
}
fn render_notice(frame: &mut Frame, app: &App, pane_area: Rect) {
let Some(n) = app.notices.first() else { return };
if pane_area.height == 0 {
return;
}
let row = Rect { x: pane_area.x, y: pane_area.bottom() - 1, width: pane_area.width, height: 1 };
let (glyph, fg) = match n.kind {
crate::app::NoticeKind::Failure => ("✗", rgb(app.tuning.theme_accent_bright)),
crate::app::NoticeKind::Blocked => ("⏸", rgb(app.tuning.theme_accent)),
crate::app::NoticeKind::Info => ("✓", rgb(app.tuning.theme_terminal)),
};
let more = if app.notices.len() > 1 { format!(" (+{} more)", app.notices.len() - 1) } else { String::new() };
let text = format!(" {glyph} {}{more} Esc dismiss ", n.text);
frame.render_widget(Clear, row);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
text,
Style::default().fg(Color::Black).bg(fg).add_modifier(Modifier::BOLD),
))),
row,
);
}
fn render_file_tree(frame: &mut Frame, app: &App, area: Rect) {
let accent = rgb(app.tuning.theme_accent);
let focused = matches!(app.mode, Mode::Tree);
let border = if focused { rgb(app.tuning.theme_accent_bright) } else { Color::DarkGray };
frame.render_widget(Clear, area);
let (root_name, filter) = app
.file_tree
.as_ref()
.map(|t| {
let name = t
.root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| t.root.to_string_lossy().to_string());
(name, t.filter.clone())
})
.unwrap_or_default();
let title = if filter.is_empty() {
format!(" Navigator · {root_name}/ ")
} else {
format!(" Navigator · ⌕ {filter} ")
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border))
.title(Span::styled(
title,
Style::default().fg(accent).add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
frame.render_widget(block, area);
let selected = app.file_tree.as_ref().map(|t| t.selected).unwrap_or(0);
let max_show = inner.height as usize;
if max_show == 0 {
return;
}
let scroll = if selected >= max_show { selected + 1 - max_show } else { 0 };
let width = inner.width as usize;
let mut lines: Vec<Line> = Vec::new();
for (idx, row) in app.tree_rows.iter().enumerate().skip(scroll).take(max_show) {
let is_sel = idx == selected && focused;
let bg = if is_sel { accent } else { Color::Reset };
let indent = " ".repeat(row.depth);
let glyph = if row.updir {
"↑ "
} else if row.is_dir {
if row.expanded { "▾ " } else { "▸ " }
} else {
" "
};
let label = if row.is_dir && !row.updir {
format!("{}/", row.label)
} else {
row.label.clone()
};
let chip = rgb(app.tuning.theme_chip_fg);
let name_fg = if is_sel {
chip
} else if row.updir {
rgb(app.tuning.theme_accent_bright) } else if row.is_dir {
accent
} else {
Color::White
};
let glyph_fg = if is_sel { chip } else { accent };
let mut modifier = Modifier::empty();
if row.is_dir && !row.updir {
modifier |= Modifier::BOLD;
}
let used = indent.chars().count() + glyph.chars().count() + label.chars().count();
let pad = " ".repeat(width.saturating_sub(used));
lines.push(Line::from(vec![
Span::styled(format!("{indent}{glyph}"), Style::default().fg(glyph_fg).bg(bg)),
Span::styled(label, Style::default().fg(name_fg).bg(bg).add_modifier(modifier)),
Span::styled(pad, Style::default().bg(bg)),
]));
}
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
fn render_shell_overlay(frame: &mut Frame, app: &App, pane_area: Rect, avoid: Option<Rect>) {
let query = app.palette.as_ref().map(|p| p.query.as_str()).unwrap_or("");
let chipfg = rgb(app.tuning.theme_chip_fg);
let accent = rgb(app.tuning.theme_accent);
let shell_mode = app
.palette
.as_ref()
.map(|p| matches!(p.bar_mode, BarMode::Shell))
.unwrap_or(true);
let navigated = app.palette.as_ref().map(|p| p.navigated).unwrap_or(false);
let input = if query.is_empty() {
format!("{} run a command… ", if shell_mode { "!" } else { "›" })
} else {
format!("{} {query} ", if shell_mode { "!" } else { "›" })
};
let configured = crate::agent::AgentConfig::from_env().is_configured();
let err = app.agent_answer.as_deref().filter(|a| a.starts_with('⚠'));
let hint = if app.agent_pending {
let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let sp = frames[(app.frame_tick / app.tuning.spinner_speed_ticks % 10) as usize];
format!(" {sp} translating…")
} else if app.shell_ready {
" ✓ Enter runs · edit to change · Esc cancel".to_string()
} else if let Some(e) = err {
format!(" {e} · Enter runs literally · Esc")
} else if !configured {
" Enter runs (set GEMINI_API_KEY to type English) · Esc".to_string()
} else if shell_mode {
" type English, Enter translates → command · Esc".to_string()
} else if navigated {
" Enter runs the highlighted command · no match → shell · Esc".to_string()
} else {
" type a command (English ok) · ! pure shell · Esc".to_string()
};
let width = ((input.chars().count().max(hint.chars().count())) as u16)
.min(pane_area.width.max(4));
let (cx, cy) = app.cursor_screen.unwrap_or((pane_area.x, pane_area.y));
let max_x = pane_area.x + pane_area.width.saturating_sub(width);
let x = cx.min(max_x).max(pane_area.x);
let hint_below = cy + 1 < pane_area.y + pane_area.height;
let (input_y, hint_y) = if hint_below { (cy, cy + 1) } else { (cy, cy.saturating_sub(1)) };
let input_rect = Rect { x, y: input_y, width, height: 1 };
let hint_rect = Rect { x, y: hint_y, width, height: 1 };
if let Some(d) = avoid {
if input_rect.intersects(d) || hint_rect.intersects(d) {
return;
}
}
frame.render_widget(Clear, input_rect);
frame.render_widget(Clear, hint_rect);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
input,
Style::default().fg(chipfg).bg(accent).add_modifier(Modifier::BOLD),
))),
input_rect,
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
hint,
Style::default().fg(Color::DarkGray).bg(rgb(app.tuning.selection_bg)),
))),
hint_rect,
);
let curx = x + 2 + query.chars().count() as u16;
if curx < x + width {
frame.set_cursor_position((curx, input_y));
}
}
fn render_ask_panel(frame: &mut Frame, app: &App, pane_area: Rect, bar_area: Rect) {
let width = bar_area.width.saturating_sub(2).max(10) as usize;
let sand = rgb(app.tuning.theme_accent_bright);
let mut lines: Vec<Line> = Vec::new();
for (role, text) in &app.agent_history {
let (tag, tag_style) = if role == "user" {
("you › ", Style::default().fg(sand).add_modifier(Modifier::BOLD))
} else {
("mars › ", Style::default().fg(rgb(app.tuning.theme_accent)).add_modifier(Modifier::BOLD))
};
for (i, wrapped) in wrap_text(text, width.saturating_sub(7)).into_iter().enumerate() {
let prefix = if i == 0 { tag } else { " " };
lines.push(Line::from(vec![
Span::styled(prefix, tag_style),
Span::styled(wrapped, Style::default().fg(Color::White)),
]));
}
}
if let Some(partial) = app.agent_partial.as_ref().filter(|p| !p.is_empty()) {
let tag_style =
Style::default().fg(rgb(app.tuning.theme_accent)).add_modifier(Modifier::BOLD);
for (i, wrapped) in wrap_text(partial, width.saturating_sub(7)).into_iter().enumerate() {
let prefix = if i == 0 { "mars › " } else { " " };
lines.push(Line::from(vec![
Span::styled(prefix, tag_style),
Span::styled(wrapped, Style::default().fg(Color::White)),
]));
}
}
if app.agent_pending {
let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let speed = app.tuning.spinner_speed_ticks;
let sp = frames[(app.frame_tick / speed % frames.len() as u64) as usize];
lines.push(Line::from(Span::styled(
format!(" {} thinking…", sp),
Style::default().fg(sand).add_modifier(Modifier::BOLD),
)));
}
if let Some(notice) = &app.agent_answer {
for wrapped in wrap_text(notice, width) {
lines.push(Line::from(Span::styled(
wrapped,
Style::default().fg(rgb(app.tuning.theme_accent_dark)),
)));
}
}
if app.refactor_replacement.is_some() {
let n = app.refactor_replacement.as_deref().map(|c| c.lines().count()).unwrap_or(0);
let verb = match app.refactor_target {
Some((_, s, e)) if s == e => "insert at the cursor",
_ => "replace the selection",
};
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
format!(" ▶ Enter to {verb} ({n} lines) · C-l cancel "),
Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD),
)));
} else if let Some(d) = &app.agent_directive {
let label = match d {
crate::agent::AgentDirective::Run(name) => format!(" ▶ Enter to run: {name} "),
crate::agent::AgentDirective::Type(cmd) => {
format!(" ▶ Enter to type into terminal: {cmd} ")
}
crate::agent::AgentDirective::Open(loc) => format!(" ▶ Enter to open: {loc} "),
crate::agent::AgentDirective::Need(_) => String::new(), };
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
label,
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD),
)));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
" Ask about what's on your screen — Enter sends · C-l new chat",
Style::default().fg(Color::DarkGray),
)));
}
let max_h = ((pane_area.height as u32 * app.tuning.ask_panel_max_pct as u32 / 100)
as u16)
.max(3);
let content_h = lines.len() as u16 + 1; let panel_h = content_h.clamp(2, max_h);
let visible = panel_h.saturating_sub(1) as usize;
let total = lines.len();
if total > visible {
let max_scroll = total - visible;
let scroll = app.ask_scroll.min(max_scroll);
let start = max_scroll - scroll;
let mut view: Vec<Line> = lines.drain(start..start + visible).collect();
if scroll > 0 {
if let Some(last) = view.last_mut() {
*last = Line::from(Span::styled(
format!(" ↓ {} more (Down to scroll) ", scroll),
Style::default().fg(Color::DarkGray),
));
}
}
if start > 0 {
if let Some(first) = view.first_mut() {
*first = Line::from(Span::styled(
format!(" ↑ {} more (Up to scroll) ", start),
Style::default().fg(Color::DarkGray),
));
}
}
lines = view;
}
let panel_y = bar_area.y.saturating_sub(panel_h);
let rect = Rect {
x: bar_area.x,
y: panel_y,
width: bar_area.width,
height: panel_h,
};
frame.render_widget(Clear, rect);
let provider = crate::agent::AgentConfig::from_env().provider;
let title = if provider == "none" {
" ✦ ask ".to_string()
} else {
format!(" ✦ ask · {} ", provider)
};
let block = Block::default()
.title(Span::styled(
title,
Style::default()
.fg(rgb(app.tuning.theme_accent))
.add_modifier(Modifier::BOLD),
))
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(rgb(app.tuning.theme_accent)));
let inner = block.inner(rect);
frame.render_widget(block, rect);
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
let mut out = Vec::new();
for para in text.split('\n') {
if para.trim().is_empty() {
out.push(String::new());
continue;
}
let mut line = String::new();
for word in para.split_whitespace() {
if line.is_empty() {
line = word.to_string();
} else if line.chars().count() + 1 + word.chars().count() <= width {
line.push(' ');
line.push_str(word);
} else {
out.push(std::mem::take(&mut line));
line = word.to_string();
}
}
if !line.is_empty() {
out.push(line);
}
}
out
}