use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use tuika::prelude::*;
use crate::agent::{Agent, Decision, interrupted_notice};
use crate::history::{Cell, Tone};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Flow {
Continue,
Quit,
}
pub const COMMANDS: &[(&str, &str)] = &[
(
"/init",
"create an AGENTS.md file with instructions for Codex",
),
("/status", "show current session configuration"),
("/approvals", "choose what Codex can do without approval"),
("/model", "choose what model and reasoning effort to use"),
("/new", "start a new chat during a conversation"),
(
"/compact",
"summarize conversation to prevent hitting the context limit",
),
("/diff", "show git diff (including untracked files)"),
("/mention", "mention a file"),
("/quit", "exit Codex"),
];
const MODELS: &[(&str, &str)] = &[
("gpt-5-codex medium", "default — balances speed and depth"),
("gpt-5-codex high", "slower, more thorough reasoning"),
("gpt-5-codex low", "fastest, for small mechanical edits"),
("gpt-5 medium", "the general model, not the coding tune"),
];
const FILES: &[(&str, &str)] = &[
("@src/lib.rs", "crate root"),
("@src/markdown.rs", "streaming CommonMark renderer"),
("@src/components/scroll.rs", "line viewport"),
("@AGENTS.md", "repository guidance"),
("@README.md", "public entry point"),
];
const APPROVAL_MODES: &[(&str, &str)] = &[
("Read Only", "Codex can read files and answer questions"),
("Auto", "reads, edits, and runs commands in the workspace"),
(
"Full Access",
"network access and writes outside the workspace",
),
];
pub fn triggers() -> [Trigger; 2] {
[
Trigger::new('/').anchor(TriggerAnchor::BufferStart),
Trigger::new('@'),
]
}
pub enum Popup {
Token { trigger: char, state: SelectState },
Model(SelectState),
Approvals(SelectState),
}
pub struct Approval {
pub command: String,
pub state: SelectState,
}
pub struct Session {
pub version: String,
pub model: String,
pub cwd: String,
pub approval: String,
pub sandbox: String,
pub tokens: u32,
pub context_window: u32,
}
impl Session {
fn new() -> Self {
Self {
version: "0.45.0".into(),
model: "gpt-5-codex medium reasoning".into(),
cwd: "~/code/tuika".into(),
approval: "on-request".into(),
sandbox: "workspace-write".into(),
tokens: 0,
context_window: 272_000,
}
}
pub fn context_left(&self) -> u16 {
let used = (self.tokens as f32 / self.context_window as f32 * 100.0).round() as u16;
100u16.saturating_sub(used)
}
fn rows(&self) -> Vec<(String, String)> {
vec![
("model".into(), self.model.clone()),
("directory".into(), self.cwd.clone()),
("approval".into(), self.approval.clone()),
("sandbox".into(), self.sandbox.clone()),
]
}
}
pub struct App {
pub frame: u64,
pub cells: Vec<Cell>,
pub composer: TextInputState,
pub scroll: ScrollState,
pub agent: Agent,
pub popup: Option<Popup>,
pub approval: Option<Approval>,
pub session: Session,
pub turn_started: Option<u64>,
history: Vec<String>,
history_cursor: Option<usize>,
pub content_h: usize,
pub viewport_h: usize,
quit_requested: bool,
}
impl App {
pub fn new() -> Self {
let session = Session::new();
let banner = Cell::Banner {
version: session.version.clone(),
rows: session.rows(),
tips: COMMANDS
.iter()
.take(4)
.map(|(c, b)| ((*c).to_string(), (*b).to_string()))
.collect(),
};
Self {
frame: 0,
cells: vec![banner],
composer: TextInputState::new(),
scroll: ScrollState::new(),
agent: Agent::new(),
popup: None,
approval: None,
session,
turn_started: None,
history: Vec::new(),
history_cursor: None,
content_h: 0,
viewport_h: 0,
quit_requested: false,
}
}
pub fn tick(&mut self) {
self.frame = self.frame.wrapping_add(1);
self.agent.tick(&mut self.cells);
self.session.tokens = self.agent.tokens;
if !self.agent.is_running() {
self.turn_started = None;
}
if let Some(command) = self.agent.pending_approval()
&& self.approval.is_none()
{
self.approval = Some(Approval {
command: command.to_string(),
state: SelectState::new(),
});
}
}
pub fn elapsed_secs(&self) -> u64 {
let started = self.turn_started.unwrap_or(self.frame);
(self.frame.saturating_sub(started)) * crate::FRAME_MS / 1000
}
pub fn cursor(&self, composer_rect: Rect) -> Option<(u16, u16)> {
if self.approval.is_some() || composer_rect.width == 0 {
return None;
}
Some(self.composer.cursor_screen(composer_rect))
}
pub fn popup_items(&self) -> Vec<(String, String)> {
let (pairs, filter): (&[(&str, &str)], String) = match &self.popup {
Some(Popup::Token { trigger, .. }) => {
let token = self.composer.active_token(&triggers());
let filter = token.map(|t| t.text).unwrap_or_default();
match trigger {
'/' => (COMMANDS, filter),
_ => (FILES, filter),
}
}
Some(Popup::Model(_)) => (MODELS, String::new()),
Some(Popup::Approvals(_)) => (APPROVAL_MODES, String::new()),
None => return Vec::new(),
};
pairs
.iter()
.filter(|(label, _)| label.starts_with(filter.trim()))
.map(|(label, blurb)| ((*label).to_string(), (*blurb).to_string()))
.collect()
}
pub fn handle(&mut self, event: &Event) -> Flow {
let flow = self.route(event);
if self.quit_requested {
return Flow::Quit;
}
flow
}
fn route(&mut self, event: &Event) -> Flow {
if self.scrolling(event) {
let _ = self.scroll.handle(event, self.content_h, self.viewport_h);
return Flow::Continue;
}
let Event::Key(key) = event else {
return Flow::Continue;
};
if key.ctrl && key.code == KeyCode::Char('c') {
return self.interrupt_or_quit();
}
if key.ctrl && key.code == KeyCode::Char('d') && self.composer.is_empty() {
return Flow::Quit;
}
if self.approval.is_some() {
self.handle_approval(event, *key);
return Flow::Continue;
}
if self.popup.is_some() && self.handle_popup(event, *key) {
return Flow::Continue;
}
if key.plain() && key.code == KeyCode::Esc {
if self.agent.interrupt() {
self.cells.push(interrupted_notice());
self.follow();
}
return Flow::Continue;
}
if key.plain() && key.code == KeyCode::Up && self.composer.is_empty() {
self.recall();
return Flow::Continue;
}
match self.composer.handle(event) {
Some(TextInputEvent::Submit) => {
let text = self.composer.text().trim().to_string();
self.composer.clear();
self.popup = None;
self.history_cursor = None;
if !text.is_empty() {
self.submit(&text);
}
}
Some(TextInputEvent::Changed) | None => self.sync_popup(),
}
Flow::Continue
}
fn scrolling(&self, event: &Event) -> bool {
match event {
Event::Mouse(m) => matches!(m.kind, MouseKind::ScrollUp | MouseKind::ScrollDown),
Event::Key(k) => k.plain() && matches!(k.code, KeyCode::PageUp | KeyCode::PageDown),
_ => false,
}
}
fn interrupt_or_quit(&mut self) -> Flow {
if self.approval.take().is_some() {
self.agent.approve(Decision::Deny, &mut self.cells);
return Flow::Continue;
}
if self.agent.interrupt() {
self.cells.push(interrupted_notice());
self.follow();
return Flow::Continue;
}
Flow::Quit
}
fn handle_approval(&mut self, event: &Event, key: Key) {
let Some(approval) = &mut self.approval else {
return;
};
let picked = match key.code {
KeyCode::Char('1') if key.plain() => Some(0),
KeyCode::Char('2') if key.plain() => Some(1),
KeyCode::Char('3') if key.plain() => Some(2),
_ => match approval.state.handle(event, 3) {
SelectOutcome::Confirmed(i) => Some(i),
SelectOutcome::Cancelled => Some(2),
SelectOutcome::Moved(_) => None,
},
};
let Some(index) = picked else { return };
let decision = match index {
0 => Decision::Once,
1 => Decision::Session,
_ => Decision::Deny,
};
self.approval = None;
self.agent.approve(decision, &mut self.cells);
self.follow();
}
fn handle_popup(&mut self, event: &Event, key: Key) -> bool {
let items = self.popup_items();
let Some(popup) = self.popup.as_mut() else {
return false;
};
let is_token = matches!(popup, Popup::Token { .. });
let state = match popup {
Popup::Token { state, .. } | Popup::Model(state) | Popup::Approvals(state) => state,
};
if key.plain() && key.code == KeyCode::Tab {
let completion = items.get(state.selected()).map(|(label, _)| label.clone());
if is_token && let Some(label) = completion {
self.complete(&label);
}
return true;
}
match state.handle(event, items.len()) {
SelectOutcome::Confirmed(index) => {
let label = items.get(index).map(|(l, _)| l.clone());
if let (Some(label), Some(kind)) = (label, self.popup.take()) {
self.confirm_popup(kind, &label);
}
true
}
SelectOutcome::Cancelled => {
self.popup = None;
true
}
SelectOutcome::Moved(flow) => flow == EventFlow::Consumed,
}
}
fn confirm_popup(&mut self, popup: Popup, label: &str) {
match popup {
Popup::Token { trigger: '/', .. } => {
self.composer.clear();
self.submit(label);
}
Popup::Token { .. } => self.complete(&format!("{label} ")),
Popup::Model(_) => {
self.session.model = label.to_string();
self.cells.push(Cell::Notice {
tone: Tone::Info,
title: format!("Model set to {label}"),
body: Vec::new(),
});
self.follow();
}
Popup::Approvals(_) => {
self.session.approval = label.to_ascii_lowercase().replace(' ', "-");
self.cells.push(Cell::Notice {
tone: Tone::Info,
title: format!("Approval mode set to {label}"),
body: Vec::new(),
});
self.follow();
}
}
}
pub fn sync_popup(&mut self) {
let active = self.composer.active_token(&triggers());
match (&self.popup, active) {
(Some(Popup::Model(_)) | Some(Popup::Approvals(_)), _) => {}
(_, None) => {
if matches!(self.popup, Some(Popup::Token { .. })) {
self.popup = None;
}
}
(Some(Popup::Token { trigger, state }), Some(token)) if *trigger == token.trigger => {
let mut state = *state;
let len = self.popup_items().len();
state.clamp(len);
self.popup = Some(Popup::Token {
trigger: token.trigger,
state,
});
}
(_, Some(token)) => {
self.popup = Some(Popup::Token {
trigger: token.trigger,
state: SelectState::new(),
});
}
}
}
fn complete(&mut self, replacement: &str) {
let Some(token) = self.composer.active_token(&triggers()) else {
return;
};
self.composer.replace_token(&token, replacement);
self.sync_popup();
}
pub fn composer_highlights(&self, theme: &Theme) -> Vec<tuika::components::TextSpan> {
self.composer
.tokens(&triggers())
.iter()
.map(|token: &Token| {
let style = match token.trigger {
'/' => Style::default()
.fg(theme.accent_alt)
.add_modifier(Modifier::BOLD),
_ => Style::default().fg(theme.accent),
};
token.span(style)
})
.collect()
}
fn recall(&mut self) {
if self.history.is_empty() {
return;
}
let next = match self.history_cursor {
None => self.history.len() - 1,
Some(i) => i.saturating_sub(1),
};
self.history_cursor = Some(next);
let text = self.history[next].clone();
self.composer.set_text(&text);
}
pub fn submit(&mut self, text: &str) {
self.history.push(text.to_string());
if let Some(command) = text.strip_prefix('/') {
self.slash(command.split_whitespace().next().unwrap_or(""));
self.follow();
return;
}
self.cells.push(Cell::User(text.to_string()));
self.agent.start(text);
self.turn_started = Some(self.frame);
self.follow();
}
fn slash(&mut self, command: &str) {
match command {
"init" => {
self.cells.push(Cell::User("/init".into()));
self.agent.start("/init");
self.turn_started = Some(self.frame);
}
"status" => self.cells.push(Cell::Config {
title: "Session".into(),
rows: {
let mut rows = self.session.rows();
rows.push((
"tokens".into(),
format!(
"{} used · {}% context left",
self.session.tokens,
self.session.context_left()
),
));
rows
},
}),
"model" => self.popup = Some(Popup::Model(SelectState::new())),
"approvals" => self.popup = Some(Popup::Approvals(SelectState::new())),
"new" => {
let banner = Cell::Banner {
version: self.session.version.clone(),
rows: self.session.rows(),
tips: COMMANDS
.iter()
.take(4)
.map(|(c, b)| ((*c).to_string(), (*b).to_string()))
.collect(),
};
self.cells = vec![banner];
self.agent = Agent::new();
self.session.tokens = 0;
}
"compact" => self.cells.push(Cell::Notice {
tone: Tone::Info,
title: "Compacted context".into(),
body: vec![format!(
"summarized the conversation so far — {}% context left",
self.session.context_left()
)],
}),
"diff" => self.cells.push(Cell::Patch {
header: "Working tree diff (1 file, +2 -1)".into(),
hunk: vec![
"diff --git a/src/snapshots/composer.txt b/src/snapshots/composer.txt".into(),
"@@ -12,4 +12,5 @@".into(),
" ╰──────────────────────────────╯".into(),
"-".into(),
"+ ⏎ send ⇧⏎ newline ⌃C quit".into(),
"+".into(),
],
}),
"quit" | "exit" => self.quit_requested = true,
"mention" => {
self.composer.set_text("@");
self.sync_popup();
}
other => self.cells.push(Cell::Notice {
tone: Tone::Error,
title: format!("Unknown command: /{other}"),
body: vec!["press / to see the available commands".into()],
}),
}
}
fn follow(&mut self) {
self.scroll.jump_to_bottom(self.content_h, self.viewport_h);
}
pub fn transcript(&mut self, width: u16, theme: &Theme, sheet: &StyleSheet) -> Vec<Element> {
self.cells
.iter_mut()
.map(|cell| cell.view(width, theme, sheet))
.collect()
}
}
pub fn codex_theme() -> Theme {
Theme {
background: Color::Rgb(13, 14, 16),
surface: Color::Rgb(23, 25, 28),
text: Color::Rgb(223, 226, 230),
muted: Color::Rgb(142, 148, 158),
dim: Color::Rgb(88, 94, 104),
accent: Color::Rgb(94, 187, 209),
accent_alt: Color::Rgb(197, 154, 231),
border: Color::Rgb(60, 66, 74),
border_focused: Color::Rgb(94, 187, 209),
selection_bg: Color::Rgb(35, 44, 52),
selection_fg: Color::Rgb(235, 238, 242),
code: CodeTheme {
link: Color::Rgb(122, 172, 240),
string: Color::Rgb(140, 200, 140),
heading: Color::Rgb(223, 226, 230),
..CodeTheme::default()
},
}
}