use ratatui::Frame;
use ratatui::style::{Color as RColor, Style};
use super::bottom::{AvatarSpec, BottomBody, BottomStrip};
use super::chat::{ChatPane, crossterm_to_ratatui};
use super::frame::{ChatBotFrame, TopFrame};
use super::layout::Layout;
use super::panels::{LeftPanel, RightPanel};
use crate::ui::renderer::{
LeftPanelInfo, LineEntry, PanelData, PanelMode, SelectionRange, SubagentStatusRow,
};
#[allow(unused_imports)] use ratatui::style::Color as _RColor;
pub struct Scene<'a> {
pub chat_buffer: &'a [LineEntry],
pub scroll_offset: usize,
pub input_rows: u16,
pub chat_selection: Option<SelectionRange>,
pub panel_data: &'a PanelData,
#[cfg(feature = "dap")]
pub debug_panel_data: Option<&'a crate::dap::types::DebugPanelData>,
pub right_panel_mode: PanelMode,
pub modified_offset: usize,
pub left_info: &'a LeftPanelInfo,
pub subagents: &'a [SubagentStatusRow],
pub avatar: Option<AvatarSpec<'a>>,
pub body: BottomBody<'a>,
pub status: &'a str,
pub show_left_panel: bool,
pub show_right_panel: bool,
pub frame_color: crossterm::style::Color,
pub background: crossterm::style::Color,
pub picker: Option<&'a crate::ui::picker::PickerOverlay>,
}
pub fn render_frame(scene: &Scene, f: &mut Frame<'_>) {
let area = f.area();
let layout = Layout::with_panels(
area.width,
area.height,
scene.input_rows,
scene.show_left_panel,
scene.show_right_panel,
);
let frame_style = Style::default().fg(crossterm_to_ratatui(scene.frame_color));
f.render_widget(TopFrame::new(&layout).style(frame_style), area);
if scene.show_left_panel && layout.left_panel.width >= 12 {
f.render_widget(
LeftPanel::new(scene.left_info, scene.subagents).border_style(frame_style),
layout.left_panel,
);
}
let mut chat =
ChatPane::new(&layout, scene.chat_buffer, scene.scroll_offset).border_style(frame_style);
if let Some(sel) = scene.chat_selection {
chat = chat.selection(sel);
}
f.render_widget(chat, area);
if scene.show_right_panel && layout.right_panel.width >= 16 {
#[allow(unused_variables)]
let is_debug = scene.right_panel_mode == PanelMode::Debug;
#[cfg(feature = "dap")]
if is_debug {
if let Some(dbg_data) = scene.debug_panel_data {
use super::panels::debug::DebugRightPanel;
f.render_widget(DebugRightPanel::new(dbg_data), layout.right_panel);
}
}
#[cfg(feature = "dap")]
if !is_debug || scene.debug_panel_data.is_none() {
f.render_widget(
RightPanel::new(scene.panel_data).modified_offset(scene.modified_offset),
layout.right_panel,
);
}
#[cfg(not(feature = "dap"))]
f.render_widget(
RightPanel::new(scene.panel_data)
.border_style(frame_style)
.modified_offset(scene.modified_offset),
layout.right_panel,
);
}
f.render_widget(ChatBotFrame::new(&layout).style(frame_style), area);
let mut strip = BottomStrip::new(&layout)
.status(scene.status)
.border_style(frame_style)
.body(scene.body);
if let Some(avatar) = &scene.avatar {
strip = strip.avatar(AvatarSpec {
face: avatar.face,
color: avatar.color,
});
}
f.render_widget(strip, area);
if let Some(picker) = scene.picker {
paint_picker_overlay(f, &layout, picker);
}
if scene.background != crossterm::style::Color::Reset {
f.buffer_mut().set_style(
area,
Style::default().bg(crossterm_to_ratatui(scene.background)),
);
}
if let BottomBody::Editor {
cursor_row,
cursor_col,
is_running,
..
} = scene.body
{
let prompt_w = super::bottom::input_prompt_width(is_running);
let cursor_x = layout
.input_box
.x
.saturating_add(1) .saturating_add(prompt_w)
.saturating_add(cursor_col);
let cursor_y = layout
.input_box
.y
.saturating_add(1)
.saturating_add(cursor_row);
let cursor_x_max = layout
.input_box
.x
.saturating_add(layout.input_box.width)
.saturating_sub(2);
let cursor_y_max = layout
.input_box
.y
.saturating_add(layout.input_box.height)
.saturating_sub(2);
f.set_cursor_position((cursor_x.min(cursor_x_max), cursor_y.min(cursor_y_max)));
}
}
fn paint_picker_overlay(
f: &mut Frame<'_>,
layout: &Layout,
picker: &crate::ui::picker::PickerOverlay,
) {
let chat = layout.chat;
if chat.width == 0 || chat.height == 0 {
return;
}
let accent = Style::default().fg(crossterm_to_ratatui(crate::ui::theme::accent()));
let dim = Style::default().fg(crossterm_to_ratatui(crate::ui::theme::dim()));
let width = chat.width as usize;
let mut lines: Vec<(String, Style)> = Vec::new();
if picker.rows.is_empty() {
let hint = picker.empty_hint.as_deref().unwrap_or("");
if !hint.is_empty() {
lines.push((hint.to_string(), dim));
}
} else {
let title_rows = usize::from(picker.title.is_some());
let max_list = (chat.height as usize).min(10).saturating_sub(title_rows);
if max_list > 0 {
let n = max_list.min(picker.rows.len());
let start = picker
.selected
.saturating_sub(n / 2)
.min(picker.rows.len().saturating_sub(n));
for i in start..(start + n) {
let marker = if i == picker.selected { "▸ " } else { " " };
let style = if i == picker.selected { accent } else { dim };
lines.push((format!("{marker}{}", picker.rows[i]), style));
}
}
if let Some(title) = &picker.title {
lines.push((format!(" {title}"), accent));
}
}
if lines.is_empty() {
return;
}
let block_h = (lines.len() as u16).min(chat.height);
let buf = f.buffer_mut();
for (offset, (text, style)) in lines.iter().take(block_h as usize).enumerate() {
let y = chat.bottom().saturating_sub(1 + offset as u16);
let mut padded: String = text.chars().take(width).collect();
let cells = padded.chars().count();
if cells < width {
padded.push_str(&" ".repeat(width - cells));
}
buf.set_stringn(chat.x, y, &padded, width, *style);
}
}
#[allow(dead_code)]
pub const EMPTY_ROWS: &[String] = &[];
#[allow(dead_code)]
pub fn empty_scene<'a>(
chat_buffer: &'a [LineEntry],
panel_data: &'a PanelData,
left_info: &'a LeftPanelInfo,
subagents: &'a [SubagentStatusRow],
status: &'a str,
) -> Scene<'a> {
Scene {
chat_buffer,
scroll_offset: 0,
input_rows: 1,
chat_selection: None,
panel_data,
#[cfg(feature = "dap")]
debug_panel_data: None,
right_panel_mode: PanelMode::Auto,
modified_offset: 0,
left_info,
subagents,
avatar: None,
body: BottomBody::Editor {
rows: EMPTY_ROWS,
cursor_row: 0,
cursor_col: 0,
is_running: false,
completion_preview: "",
ghost: "",
},
status,
show_left_panel: true,
show_right_panel: true,
frame_color: crossterm::style::Color::Green,
background: crossterm::style::Color::Reset,
picker: None,
}
}
const _: RColor = RColor::Green;
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
#[test]
fn renders_empty_scene_with_frames_and_borders() {
let buf: Vec<LineEntry> = Vec::new();
let pd = PanelData::default();
let info = LeftPanelInfo::default();
let subs: Vec<SubagentStatusRow> = Vec::new();
let scene = empty_scene(&buf, &pd, &info, &subs, "ready");
let mut backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend.clone()).unwrap();
terminal.draw(|f| render_frame(&scene, f)).unwrap();
backend = terminal.backend().clone();
let row0: String = (0..160)
.map(|x| backend.buffer().cell((x, 0)).unwrap().symbol().to_string())
.collect();
assert!(row0.contains("[AGENT STATUS]"));
assert!(row0.contains("[AGENT LOG STREAM]"));
assert!(row0.contains("[SYSTEM]"));
let layout = Layout::new(160, 30, 1);
assert_eq!(
backend
.buffer()
.cell((layout.chat_v_left_col, 1))
.unwrap()
.symbol(),
"│"
);
assert_eq!(
backend
.buffer()
.cell((layout.chat_v_right_col, 1))
.unwrap()
.symbol(),
"│"
);
let status_row: String = (0..160)
.map(|x| {
backend
.buffer()
.cell((x, layout.status.y))
.unwrap()
.symbol()
.to_string()
})
.collect();
assert!(status_row.starts_with("ready"));
}
#[test]
fn theme_background_fills_cells_only_when_set() {
let buf: Vec<LineEntry> = Vec::new();
let pd = PanelData::default();
let info = LeftPanelInfo::default();
let subs: Vec<SubagentStatusRow> = Vec::new();
let mut scene = empty_scene(&buf, &pd, &info, &subs, "ready");
let bg = crossterm::style::Color::Rgb {
r: 0x22,
g: 0x22,
b: 0x22,
};
scene.background = bg;
let mut t = Terminal::new(TestBackend::new(160, 30)).unwrap();
t.draw(|f| render_frame(&scene, f)).unwrap();
assert_eq!(
t.backend().buffer().cell((5, 5)).unwrap().bg,
crossterm_to_ratatui(bg),
"configured background must fill cells",
);
scene.background = crossterm::style::Color::Reset;
let mut t2 = Terminal::new(TestBackend::new(160, 30)).unwrap();
t2.draw(|f| render_frame(&scene, f)).unwrap();
assert_eq!(
t2.backend().buffer().cell((5, 5)).unwrap().bg,
RColor::Reset,
"Reset background must NOT fill — terminal default shows through",
);
}
#[test]
fn overlay_replaces_input_editor() {
use crossterm::style::Color as CC;
let buf: Vec<LineEntry> = Vec::new();
let pd = PanelData::default();
let info = LeftPanelInfo::default();
let subs: Vec<SubagentStatusRow> = Vec::new();
let overlay_lines: Vec<(String, CC)> = vec![
("⚠ PERMISSION REQUIRED".into(), CC::Yellow),
("tool: read_file".into(), CC::Yellow),
];
let scene = Scene {
chat_buffer: &buf,
scroll_offset: 0,
input_rows: 4,
chat_selection: None,
panel_data: &pd,
#[cfg(feature = "dap")]
debug_panel_data: None,
right_panel_mode: PanelMode::Auto,
modified_offset: 0,
left_info: &info,
subagents: &subs,
avatar: None,
body: BottomBody::Overlay {
title: "[ALERT]",
lines: &overlay_lines,
},
status: "permission required",
show_left_panel: true,
show_right_panel: true,
frame_color: crossterm::style::Color::Green,
background: crossterm::style::Color::Reset,
picker: None,
};
let mut backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend.clone()).unwrap();
terminal.draw(|f| render_frame(&scene, f)).unwrap();
backend = terminal.backend().clone();
let layout = Layout::new(160, 30, 4);
let top_y = layout.input_box.y;
let top_row: String = (layout.input_box.x..layout.input_box.x + layout.input_box.width)
.map(|x| {
backend
.buffer()
.cell((x, top_y))
.unwrap()
.symbol()
.to_string()
})
.collect();
assert!(top_row.contains("[ALERT]"), "got top {:?}", top_row);
let body_row: String = (layout.input_box.x..layout.input_box.x + layout.input_box.width)
.map(|x| {
backend
.buffer()
.cell((x, top_y + 1))
.unwrap()
.symbol()
.to_string()
})
.collect();
assert!(
body_row.contains("PERMISSION REQUIRED"),
"got body {:?}",
body_row
);
}
fn dump(backend: &TestBackend, w: u16, h: u16) -> String {
let mut s = String::new();
for y in 0..h {
for x in 0..w {
s.push_str(backend.buffer().cell((x, y)).unwrap().symbol());
}
s.push('\n');
}
s
}
#[test]
fn picker_overlay_paints_candidate_list() {
let buf: Vec<LineEntry> = Vec::new();
let pd = PanelData::default();
let info = LeftPanelInfo::default();
let subs: Vec<SubagentStatusRow> = Vec::new();
let overlay = crate::ui::picker::PickerOverlay {
title: None,
rows: vec![
"src/main.rs".into(),
"src/lib.rs".into(),
"Cargo.toml".into(),
],
selected: 1,
empty_hint: Some("no matches".into()),
};
let mut scene = empty_scene(&buf, &pd, &info, &subs, "ready");
scene.picker = Some(&overlay);
let mut terminal = Terminal::new(TestBackend::new(120, 30)).unwrap();
terminal.draw(|f| render_frame(&scene, f)).unwrap();
let dumped = dump(terminal.backend(), 120, 30);
assert!(dumped.contains("src/main.rs"), "non-selected row missing");
assert!(dumped.contains("Cargo.toml"), "non-selected row missing");
assert!(
dumped.contains("▸ src/lib.rs"),
"selected row + marker missing"
);
}
#[test]
fn picker_overlay_empty_shows_hint() {
let buf: Vec<LineEntry> = Vec::new();
let pd = PanelData::default();
let info = LeftPanelInfo::default();
let subs: Vec<SubagentStatusRow> = Vec::new();
let overlay = crate::ui::picker::PickerOverlay {
title: None,
rows: vec![],
selected: 0,
empty_hint: Some("no matches".into()),
};
let mut scene = empty_scene(&buf, &pd, &info, &subs, "ready");
scene.picker = Some(&overlay);
let mut terminal = Terminal::new(TestBackend::new(120, 30)).unwrap();
terminal.draw(|f| render_frame(&scene, f)).unwrap();
assert!(dump(terminal.backend(), 120, 30).contains("no matches"));
}
#[test]
fn narrow_terminal_skips_side_panels() {
let buf: Vec<LineEntry> = Vec::new();
let pd = PanelData::default();
let info = LeftPanelInfo::default();
let subs: Vec<SubagentStatusRow> = Vec::new();
let mut scene = empty_scene(&buf, &pd, &info, &subs, "narrow");
scene.show_left_panel = true;
scene.show_right_panel = true;
let mut backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend.clone()).unwrap();
terminal.draw(|f| render_frame(&scene, f)).unwrap();
backend = terminal.backend().clone();
let layout = Layout::new(60, 20, 1);
assert_eq!(layout.left_panel.width, 0);
assert_eq!(layout.right_panel.width, 0);
let mut found_dirge = false;
for y in 0..20 {
let r: String = (0..60)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect();
if r.contains("D I R G E") {
found_dirge = true;
break;
}
}
assert!(
!found_dirge,
"DIRGE banner should not appear on narrow term"
);
}
#[test]
fn left_and_right_panels_toggle_independently() {
fn region_has_content(backend: &TestBackend, r: ratatui::layout::Rect) -> bool {
for y in r.y..r.y.saturating_add(r.height) {
for x in r.x..r.x.saturating_add(r.width) {
if let Some(cell) = backend.buffer().cell((x, y))
&& cell.symbol().trim() != ""
{
return true;
}
}
}
false
}
let buf: Vec<LineEntry> = Vec::new();
let pd = PanelData::default();
let info = LeftPanelInfo::default();
let subs: Vec<SubagentStatusRow> = Vec::new();
let both = Layout::new(160, 30, 1);
assert!(both.left_panel.width >= 12 && both.right_panel.width >= 16);
let render = |show_left: bool, show_right: bool| {
let mut scene = empty_scene(&buf, &pd, &info, &subs, "ready");
scene.show_left_panel = show_left;
scene.show_right_panel = show_right;
let backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(&scene, f)).unwrap();
terminal.backend().clone()
};
let left_only = Layout::with_panels(160, 30, 1, true, false);
assert_eq!(left_only.right_panel.width, 0);
assert_eq!(
left_only.chat.width,
both.chat.width + both.right_panel.width
);
let b = render(true, false);
assert!(
region_has_content(&b, left_only.left_panel),
"left should draw"
);
let right_only = Layout::with_panels(160, 30, 1, false, true);
assert_eq!(right_only.left_panel.width, 0);
assert_eq!(
right_only.chat.width,
both.chat.width + both.left_panel.width
);
let b = render(false, true);
assert!(
region_has_content(&b, right_only.right_panel),
"right should draw"
);
}
#[test]
fn editor_text_updates_between_draws() {
let buf: Vec<LineEntry> = Vec::new();
let pd = PanelData::default();
let info = LeftPanelInfo::default();
let subs: Vec<SubagentStatusRow> = Vec::new();
let mut backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend.clone()).unwrap();
let s1 = Scene {
chat_buffer: &buf,
scroll_offset: 0,
input_rows: 1,
chat_selection: None,
panel_data: &pd,
#[cfg(feature = "dap")]
debug_panel_data: None,
right_panel_mode: PanelMode::Auto,
modified_offset: 0,
left_info: &info,
subagents: &subs,
avatar: None,
body: BottomBody::Editor {
rows: EMPTY_ROWS,
cursor_row: 0,
cursor_col: 0,
is_running: false,
completion_preview: "",
ghost: "",
},
status: "",
show_left_panel: true,
show_right_panel: true,
frame_color: crossterm::style::Color::Green,
background: crossterm::style::Color::Reset,
picker: None,
};
terminal.draw(|f| render_frame(&s1, f)).unwrap();
let hello_rows: Vec<String> = vec!["hello".to_string()];
let s2 = Scene {
chat_buffer: &buf,
scroll_offset: 0,
input_rows: 1,
chat_selection: None,
panel_data: &pd,
#[cfg(feature = "dap")]
debug_panel_data: None,
right_panel_mode: PanelMode::Auto,
modified_offset: 0,
left_info: &info,
subagents: &subs,
avatar: None,
body: BottomBody::Editor {
rows: &hello_rows,
cursor_row: 0,
cursor_col: 5,
is_running: false,
completion_preview: "",
ghost: "",
},
status: "",
show_left_panel: true,
show_right_panel: true,
frame_color: crossterm::style::Color::Green,
background: crossterm::style::Color::Reset,
picker: None,
};
terminal.draw(|f| render_frame(&s2, f)).unwrap();
backend = terminal.backend().clone();
let layout = Layout::new(160, 30, 1);
let inner_y = layout.input_box.y + 1;
let row: String = (layout.input_box.x..layout.input_box.x + layout.input_box.width)
.map(|x| {
backend
.buffer()
.cell((x, inner_y))
.unwrap()
.symbol()
.to_string()
})
.collect();
assert!(
row.contains("hello"),
"input row should contain typed text; got {row:?}"
);
}
#[test]
fn chat_buffer_paints_into_chat_region() {
let buf: Vec<LineEntry> = vec![
LineEntry {
text: "first line".into(),
color: crossterm::style::Color::Green,
},
LineEntry {
text: "second line".into(),
color: crossterm::style::Color::Cyan,
},
];
let pd = PanelData::default();
let info = LeftPanelInfo::default();
let subs: Vec<SubagentStatusRow> = Vec::new();
let scene = empty_scene(&buf, &pd, &info, &subs, "");
let mut backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend.clone()).unwrap();
terminal.draw(|f| render_frame(&scene, f)).unwrap();
backend = terminal.backend().clone();
let layout = Layout::new(160, 30, 1);
let read = |y: u16| -> String {
(layout.chat.x..layout.chat.x + layout.chat.width)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect()
};
assert!(read(layout.chat.y).starts_with("first line"));
assert!(read(layout.chat.y + 1).starts_with("second line"));
}
}