use std::io;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use tuika::prelude::*;
use tuika::probe::RectProbe;
use crate::app::{App, Popup};
pub const GUTTER: u16 = 1;
const MAX_COMPOSER_ROWS: u16 = 6;
const MAX_POPUP_ROWS: usize = 8;
const TRANSCRIPT_GAP: u16 = 1;
pub fn build(
app: &mut App,
area: Rect,
theme: &Theme,
sheet: &StyleSheet,
probe: &RectProbe,
) -> Element {
let width = area.width.saturating_sub(GUTTER * 2);
let popup_items = app.popup_items();
let popup_h = if popup_items.is_empty() {
0
} else {
popup_items.len().min(MAX_POPUP_ROWS) as u16 + 1
};
let working_h = if app.agent.is_running() { 2 } else { 0 };
let body_h = if app.approval.is_some() {
7
} else {
let rows = app
.composer
.visual_height(width.saturating_sub(4))
.clamp(1, MAX_COMPOSER_ROWS);
rows + 2
};
let bottom_h = working_h + popup_h + body_h + 1;
let transcript_h = area.height.saturating_sub(bottom_h).max(1);
let items = app.transcript(width, theme, sheet);
app.content_h = ItemScroll::measure_height(&items, width, TRANSCRIPT_GAP, true);
app.viewport_h = transcript_h as usize;
app.scroll.clamp(app.content_h, app.viewport_h);
let mut root = Flex::column()
.padding(Padding::symmetric(GUTTER, 0))
.background(Style::default().bg(theme.background))
.grow(
1,
element(ItemScroll::new(items, &app.scroll).gap(TRANSCRIPT_GAP)),
);
if working_h > 0 {
root = root.fixed(working_h, working(app, theme));
}
if popup_h > 0 {
root = root.fixed(popup_h, popup(app, &popup_items, theme));
}
root = root.fixed(
body_h,
match &app.approval {
Some(_) => approval(app, theme),
None => composer(app, theme, probe),
},
);
root = root.fixed(1, footer(app, theme));
element(root)
}
fn working(app: &App, theme: &Theme) -> Element {
let label = Line::from(vec![
Span::styled(
"Working",
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({}s • Esc to interrupt)", app.elapsed_secs()),
theme.muted_style(),
),
]);
let row = view! {
row(gap = 1) {
fixed(1) { node(Spinner::new(app.frame).color(theme.accent)) }
grow(1) { node(Text::new(vec![label])) }
}
};
element(Flex::column().fixed(1, element(Spacer)).fixed(1, row))
}
fn popup(app: &App, items: &[(String, String)], theme: &Theme) -> Element {
let pad = items
.iter()
.map(|(label, _)| label.chars().count())
.max()
.unwrap_or(0);
let rows: Vec<Line<'static>> = items
.iter()
.map(|(label, blurb)| {
Line::from(vec![
Span::styled(
format!(
"{label}{:width$} ",
"",
width = pad - label.chars().count()
),
Style::default().fg(theme.accent_alt),
),
Span::styled(blurb.clone(), theme.muted_style()),
])
})
.collect();
let state = match &app.popup {
Some(Popup::Token { state, .. } | Popup::Model(state) | Popup::Approvals(state)) => *state,
None => return element(Spacer),
};
let list = SelectList::new(rows, &state).viewport(MAX_POPUP_ROWS as u16);
element(
Flex::column()
.fixed(1, element(Spacer))
.grow(1, element(list)),
)
}
fn composer(app: &App, theme: &Theme, probe: &RectProbe) -> Element {
let input = element(
TextInput::new(&app.composer)
.style(Style::default().fg(theme.text))
.placeholder("Ask Codex to do something", Style::default().fg(theme.dim))
.highlights(app.composer_highlights(theme)),
);
let prompt = Text::new(vec![Line::from(Span::styled(
"› ",
Style::default().fg(theme.accent_alt),
))]);
view! {
boxed(border = BorderStyle::Rounded, border_color = theme.border,
padding = Padding::symmetric(1, 0)) {
row {
fixed(2) { node(prompt) }
grow(1) { node(probe.wrap(input)) }
}
}
}
}
fn approval(app: &App, theme: &Theme) -> Element {
let Some(pending) = &app.approval else {
return element(Spacer);
};
let program = pending
.command
.split_whitespace()
.next()
.unwrap_or(&pending.command)
.to_string();
let command = Text::new(vec![
Line::from(vec![
Span::styled("$ ", theme.muted_style()),
Span::styled(
pending.command.clone(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
]),
Line::default(),
]);
let options = vec![
Line::from("1. Yes, run it".to_string()),
Line::from(format!(
"2. Yes, and don't ask again for `{program}` this session"
)),
Line::from("3. No, and tell Codex what to do instead".to_string()),
];
let list = SelectList::new(options, &pending.state).scrollbar(false);
let title = Line::from(Span::styled(
" Codex wants to run a command ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
));
view! {
boxed(title = title, border = BorderStyle::Rounded, border_color = theme.accent,
padding = Padding::symmetric(1, 0)) {
col {
fixed(2) { node(command) }
grow(1) { node(list) }
}
}
}
}
fn footer(app: &App, theme: &Theme) -> Element {
let hints = if app.approval.is_some() {
" 1-3 choose ↑↓ move ⏎ confirm esc reject"
} else if app.popup.is_some() {
" ↑↓ move ⇥ complete ⏎ run esc dismiss"
} else {
" ⏎ send ⇧⏎ newline PgUp scroll ⌃C quit"
};
let bar = StatusBar::new()
.left(vec![Span::styled(hints, theme.muted_style())])
.right(vec![Span::styled(
format!("{}% context left ", app.session.context_left()),
Style::default().fg(theme.dim),
)]);
element(bar)
}
pub fn dump(scene: Option<&str>) -> io::Result<()> {
const SCENES: [&str; 6] = ["welcome", "turn", "approval", "slash", "mention", "status"];
let theme = crate::app::codex_theme();
let sheet = StyleSheet::from_theme(&theme);
let (w, h) = (96u16, 34u16);
for name in SCENES {
if scene.is_some_and(|s| s != name) {
continue;
}
let mut app = App::new();
match name {
"turn" => {
app.submit("why does the snapshot test fail?");
for _ in 0..160 {
app.tick();
}
}
"approval" => {
app.submit("clean up the build artifacts");
for _ in 0..120 {
app.tick();
}
}
"slash" => {
app.composer.set_text("/");
app.sync_popup();
}
"mention" => {
app.composer.set_text("explain @src/");
app.sync_popup();
}
"status" => {
app.submit("/status");
}
_ => {}
}
let probe = RectProbe::new();
let root = build(&mut app, Rect::new(0, 0, w, h), &theme, &sheet, &probe);
let buffer = tuika::testing::render(root.as_ref(), w, h, &theme);
println!(
"── {name} {}",
"─".repeat((w as usize).saturating_sub(4 + name.len()))
);
println!("{}", tuika::testing::grid(&buffer));
println!();
}
Ok(())
}