#![allow(
clippy::field_reassign_with_default,
clippy::let_and_return,
clippy::borrow_interior_mutable_const,
clippy::derivable_impls
)]
use std::io::{self, Stdout, Write};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Result;
use crossterm::{
cursor::{Hide, Show},
event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEventKind,
KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use oxi_agent::AgentEvent;
use oxi_vtui::theme::{ThemeStyles, active_styles};
use oxi_vtui::tui::core::{
InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext, InlineHeaderStatusBadge,
InlineHeaderStatusTone, InlineMessageKind, InlineSegment, InlineTextStyle,
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
use crate::App;
use crate::app::agent_session::SessionEvent;
use crate::tui_vt::slash::registry::{SlashCtx, SlashOutcome, SlashRegistry};
pub struct Tui {
terminal: Terminal<CrosstermBackend<Stdout>>,
tty_ok: bool,
}
impl Tui {
pub fn enter() -> Result<Self> {
Self::set_panic_hook();
let tty_ok = enable_raw_mode().is_ok();
let mut stdout = io::stdout();
if tty_ok {
let flags = if std::env::var("OXI_KITTY_KEYBOARD").as_deref() == Ok("1") {
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
} else {
KeyboardEnhancementFlags::REPORT_EVENT_TYPES
};
let _ = execute!(
stdout,
EnterAlternateScreen,
Hide,
EnableBracketedPaste,
PushKeyboardEnhancementFlags(flags)
);
let _ = stdout.flush();
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
if tty_ok {
let _ = terminal.clear();
}
Ok(Self { terminal, tty_ok })
}
pub fn exit(&mut self) -> Result<()> {
if self.tty_ok {
let _ = execute!(
self.terminal.backend_mut(),
PopKeyboardEnhancementFlags,
DisableBracketedPaste
);
let _ = self.terminal.show_cursor();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
disable_raw_mode()?;
self.tty_ok = false;
}
Ok(())
}
fn set_panic_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = execute!(io::stdout(), LeaveAlternateScreen, Show);
let _ = disable_raw_mode();
original_hook(panic_info);
}));
}
}
impl Drop for Tui {
fn drop(&mut self) {
let _ = self.exit();
}
}
#[derive(Default)]
pub struct RenderState {
pub input_buffer: String,
pub input_cursor: usize,
pub transcript: Vec<TranscriptLine>,
pub scroll_offset: usize,
pub header_context: InlineHeaderContext,
pub input_enabled: bool,
pub footer_left: Option<String>,
pub footer_right: Option<String>,
pub prompt_prefix: String,
pub placeholder: Option<String>,
pub shutdown_requested: bool,
pub message_buffer: String,
}
#[derive(Debug, Clone)]
pub struct TranscriptLine {
pub kind: InlineMessageKind,
pub segments: Vec<InlineSegment>,
}
impl RenderState {
fn new_with_header(header: InlineHeaderContext) -> Self {
let mut s = Self::default();
s.header_context = header;
s.prompt_prefix = "> ".to_string();
s.input_enabled = true;
s
}
fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
self.transcript.push(TranscriptLine { kind, segments });
}
fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
if let Some(last) = self.transcript.last_mut()
&& last.kind == kind
{
last.segments.push(segment);
return;
}
self.transcript.push(TranscriptLine {
kind,
segments: vec![segment],
});
}
}
pub async fn run_tui(app: App) -> Result<()> {
let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
let git_branch = crate::util::git_utils::get_current_branch(&cwd);
super::host::activate_theme(app.settings());
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
let handle = InlineHandle::new_for_tests(cmd_tx);
let session = build_agent_session(&app).await?;
session.install_runtime_hooks();
let session_handle = session.clone_handle();
let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
let _sub_guard = session.subscribe(Box::new(move |event| {
let _ = session_tx.send(event.clone());
}));
let header = build_header_context(&app, &cwd, git_branch.as_deref());
handle.set_header_context(header.clone());
let mut tui = Tui::enter()?;
handle.set_prompt("> ".to_string(), InlineTextStyle::default());
handle.set_placeholder(Some("Describe what you want to build\u{2026}".to_string()));
let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
header,
)));
spawn_input_thread(state.clone(), evt_tx.clone());
let prompt_tx = spawn_agent_worker(session_handle.clone());
let result = run_event_loop(
&mut tui.terminal,
&mut cmd_rx,
&mut evt_rx,
&mut session_rx,
&handle,
&state,
&session_handle,
prompt_tx.clone(),
)
.await;
drop(prompt_tx);
handle.shutdown();
drop(tui);
result
}
#[allow(clippy::too_many_arguments)]
async fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
handle: &InlineHandle,
state: &Arc<parking_lot::Mutex<RenderState>>,
session: &crate::app::agent_session::AgentSessionHandle,
prompt_tx: tokio::sync::mpsc::UnboundedSender<String>,
) -> Result<()> {
while let Ok(cmd) = cmd_rx.try_recv() {
apply_command(&mut state.lock(), cmd);
}
{
let snapshot = state.lock();
if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
tracing::warn!(?err, "initial tui draw failed");
}
}
let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
biased;
Some(cmd) = cmd_rx.recv() => {
let shutdown = {
let mut s = state.lock();
apply_command(&mut s, cmd)
};
if shutdown {
break;
}
}
Some(event) = session_rx.recv() => {
handle_session_event(&mut state.lock(), handle, &event);
}
Some(evt) = evt_rx.recv() => {
let outcome = handle_inline_event(
&mut state.lock(),
handle,
session,
&prompt_tx,
evt,
);
if outcome == LoopOutcome::Exit {
break;
}
}
_ = tokio::signal::ctrl_c() => {
let outcome = {
let mut s = state.lock();
handle_interrupt(&mut s, session, handle)
};
if outcome == LoopOutcome::Exit {
break;
}
}
_ = render_tick.tick() => {}
}
let snapshot = state.lock();
let draw_result = terminal.draw(|frame| render_frame(frame, &snapshot, handle));
if let Err(err) = draw_result {
tracing::warn!(?err, "tui draw failed");
break;
}
}
Ok(())
}
#[derive(PartialEq, Eq)]
enum LoopOutcome {
Continue,
Exit,
}
fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
match cmd {
InlineCommand::AppendLine { kind, segments } => {
state.append_line(kind, segments);
}
InlineCommand::Inline { kind, segment } => {
state.inline_segment(kind, segment);
}
InlineCommand::ReplaceLast {
count, kind, lines, ..
} => {
let drop = count.min(state.transcript.len());
for _ in 0..drop {
state.transcript.pop();
}
for line in lines {
state.append_line(kind, line);
}
}
InlineCommand::AppendPastedMessage { kind, text, .. } => {
state.append_line(kind, vec![plain_segment(text)]);
}
InlineCommand::SetPrompt { prefix, .. } => {
state.prompt_prefix = prefix;
}
InlineCommand::SetPlaceholder { hint, .. } => {
state.placeholder = hint;
}
InlineCommand::SetHeaderContext { context } => {
state.header_context = *context;
}
InlineCommand::SetInputStatus { left, right } => {
state.footer_left = left;
state.footer_right = right;
}
InlineCommand::SetInputEnabled(enabled) => {
state.input_enabled = enabled;
}
InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {
}
InlineCommand::Shutdown => {
state.shutdown_requested = true;
return true;
}
_ => {
tracing::trace!("unhandled InlineCommand (not rendered)");
}
}
false
}
fn handle_session_event(state: &mut RenderState, handle: &InlineHandle, event: &SessionEvent) {
match event {
SessionEvent::Agent(boxed) => {
map_agent_event(handle, *boxed.clone(), state);
}
SessionEvent::CompactionStart { .. } => {
handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
}
SessionEvent::CompactionEnd { error_message, .. } => {
handle.set_reasoning_stage(None);
if let Some(msg) = error_message {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("Compaction failed: {msg}"))],
);
}
}
SessionEvent::ThinkingLevelChanged { .. } => {
}
SessionEvent::QueueUpdate { .. } => {
let pending = state.transcript.len();
handle.set_input_status(
None,
Some(if pending == 0 {
"ready".to_string()
} else {
"queued".to_string()
}),
);
}
SessionEvent::Advisor { body, .. } => {
handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
}
SessionEvent::SessionInfoChanged => {
}
}
}
fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
match event {
AgentEvent::TextChunk { text } => {
state.message_buffer.push_str(&text);
handle.inline(InlineMessageKind::Agent, plain_segment(text));
}
AgentEvent::MessageStart { .. } => {
state.message_buffer.clear();
}
AgentEvent::MessageUpdate {
delta: Some(delta), ..
} => {
state.message_buffer.push_str(&delta);
handle.inline(InlineMessageKind::Agent, plain_segment(delta));
}
AgentEvent::MessageUpdate { delta: None, .. } => {
if !state.message_buffer.is_empty() {
let lines = oxi_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
let count = lines.len();
if count > 0 {
handle.replace_last(count, InlineMessageKind::Agent, lines);
}
state.message_buffer.clear();
}
}
AgentEvent::MessageEnd { .. } => {
if !state.message_buffer.is_empty() {
let lines = oxi_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
let count = lines.len();
if count > 0 {
handle.replace_last(count, InlineMessageKind::Agent, lines);
}
state.message_buffer.clear();
}
}
AgentEvent::ToolStart { tool_name, .. } => {
handle.append_line(
InlineMessageKind::Tool,
vec![plain_segment(format!("\u{2192} {tool_name}"))],
);
handle.set_reasoning_stage(Some(format!("tool: {tool_name}")));
}
AgentEvent::ToolComplete { result } => {
let preview = preview_tool_result(&result.content);
handle.append_line(InlineMessageKind::Tool, vec![plain_segment(preview)]);
handle.set_reasoning_stage(None);
handle.set_input_enabled(true);
}
AgentEvent::ToolError { error, .. } => {
handle.append_line(
InlineMessageKind::Error,
vec![plain_segment(format!("tool error: {error}"))],
);
handle.set_reasoning_stage(None);
handle.set_input_enabled(true);
}
AgentEvent::Error { message, .. } => {
handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
handle.set_input_enabled(true);
handle.set_input_status(None, None);
}
AgentEvent::Compaction { .. } => {
}
AgentEvent::Cancelled => {
handle.set_input_enabled(true);
handle.set_input_status(None, Some("cancelled".to_string()));
}
AgentEvent::AutoRetryStart {
attempt,
max_attempts,
..
} => {
handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
}
_ => {
tracing::debug!(?event, "ignored AgentEvent variant");
}
}
}
fn handle_inline_event(
state: &mut RenderState,
handle: &InlineHandle,
session: &crate::app::agent_session::AgentSessionHandle,
prompt_tx: &tokio::sync::mpsc::UnboundedSender<String>,
evt: InlineEvent,
) -> LoopOutcome {
match evt {
InlineEvent::Submit(text) => {
let prompt = text.to_string();
state.input_buffer.clear();
state.input_cursor = 0;
if prompt.is_empty() {
return LoopOutcome::Continue;
}
if prompt.trim_start().starts_with('/') {
state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
let mut ctx = SlashCtx {
session,
handle,
state,
};
return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
SlashOutcome::Quit => LoopOutcome::Exit,
SlashOutcome::Handled => LoopOutcome::Continue,
SlashOutcome::NotHandled => {
ctx.reply(
InlineMessageKind::Error,
format!("Unknown command: {}", prompt.trim()),
);
LoopOutcome::Continue
}
};
}
state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
let _ = prompt_tx.send(prompt);
}
InlineEvent::Cancel | InlineEvent::Exit => {
return LoopOutcome::Exit;
}
InlineEvent::Interrupt => {
return handle_interrupt(state, session, handle);
}
InlineEvent::ScrollLineUp => {
state.scroll_offset = state.scroll_offset.saturating_add(1);
}
InlineEvent::ScrollLineDown => {
state.scroll_offset = state.scroll_offset.saturating_sub(1);
}
InlineEvent::ScrollPageUp => {
state.scroll_offset = state.scroll_offset.saturating_add(10);
}
InlineEvent::ScrollPageDown => {
state.scroll_offset = state.scroll_offset.saturating_sub(10);
}
InlineEvent::CyclePrimaryAgent => {
let _ = session.cycle_model();
}
InlineEvent::CyclePrimaryAgentPrevious => {
let _ = session.cycle_model();
}
_ => {
}
}
LoopOutcome::Continue
}
struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
impl Drop for StreamingGuard<'_> {
fn drop(&mut self) {
use std::sync::atomic::Ordering;
self.0.store(false, Ordering::SeqCst);
}
}
fn handle_interrupt(
state: &mut RenderState,
session: &crate::app::agent_session::AgentSessionHandle,
_handle: &InlineHandle,
) -> LoopOutcome {
if session.is_streaming() {
let s = session.clone();
tokio::spawn(async move {
s.abort().await;
});
state.footer_left = Some("Stopping\u{2026} press Ctrl+C again to quit".to_string());
LoopOutcome::Continue
} else {
LoopOutcome::Exit
}
}
fn spawn_input_thread(
state: Arc<parking_lot::Mutex<RenderState>>,
evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
loop {
match event::poll(std::time::Duration::from_millis(50)) {
Ok(true) => {}
Ok(false) => continue,
Err(_) => break,
}
let event = match event::read() {
Ok(ev) => ev,
Err(_) => continue,
};
let mut pasted = String::new();
let mut key_event = None;
match event {
Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
Event::Paste(p) => pasted = p,
_ => {}
}
if !pasted.is_empty() {
let mut s = state.lock();
let cursor = s.input_cursor;
s.input_buffer.insert_str(cursor, &pasted);
s.input_cursor = cursor + pasted.len();
continue;
}
let Some(key) = key_event else { continue };
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
let _ = evt_tx.send(InlineEvent::Interrupt);
continue;
}
match key.code {
KeyCode::Enter => {
let submitted = {
let mut s = state.lock();
let buf = std::mem::take(&mut s.input_buffer);
s.input_cursor = 0;
buf
};
let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
}
KeyCode::Esc => {
let _ = evt_tx.send(InlineEvent::Cancel);
}
KeyCode::Backspace => {
let mut s = state.lock();
if s.input_cursor > 0 {
let cursor = s.input_cursor;
let prev = s
.input_buffer
.char_indices()
.take_while(|(i, _)| *i < cursor)
.last()
.map(|(i, _)| i)
.unwrap_or(0);
s.input_buffer.replace_range(prev..cursor, "");
s.input_cursor = prev;
}
}
KeyCode::Delete => {
let mut s = state.lock();
if s.input_cursor < s.input_buffer.len() {
let cursor = s.input_cursor;
let next = s.input_buffer[cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| cursor + i)
.unwrap_or(s.input_buffer.len());
s.input_buffer.replace_range(cursor..next, "");
}
}
KeyCode::Left => {
let mut s = state.lock();
s.input_cursor = s.input_cursor.saturating_sub(1);
}
KeyCode::Right => {
let mut s = state.lock();
let len = s.input_buffer.len();
s.input_cursor = (s.input_cursor + 1).min(len);
}
KeyCode::Up => {
let _ = evt_tx.send(InlineEvent::ScrollLineUp);
}
KeyCode::Down => {
let _ = evt_tx.send(InlineEvent::ScrollLineDown);
}
KeyCode::PageUp => {
let _ = evt_tx.send(InlineEvent::ScrollPageUp);
}
KeyCode::PageDown => {
let _ = evt_tx.send(InlineEvent::ScrollPageDown);
}
KeyCode::Char(ch) => {
let mut s = state.lock();
let cursor = s.input_cursor;
s.input_buffer.insert(cursor, ch);
s.input_cursor = cursor + ch.len_utf8();
}
_ => {}
}
}
})
}
fn spawn_agent_worker(
session: crate::app::agent_session::AgentSessionHandle,
) -> tokio::sync::mpsc::UnboundedSender<String> {
let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
std::thread::spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(err) => {
tracing::error!(?err, "failed to build agent worker runtime");
return;
}
};
runtime.block_on(async move {
let local = tokio::task::LocalSet::new();
local
.run_until(async move {
while let Some(prompt) = prompt_rx.recv().await {
run_one_prompt(&session, prompt).await;
}
})
.await;
});
});
prompt_tx
}
async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
let session_for_forward = session.clone();
let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
let forwarder = std::thread::spawn(move || {
while let Ok(event) = event_rx.recv() {
session_for_forward.forward_event_to_extensions(&event);
}
});
use std::sync::atomic::Ordering;
session.reset_should_stop();
let streaming = session.streaming_flag();
streaming.store(true, Ordering::SeqCst);
let _stream_guard = StreamingGuard(&streaming);
let agent = session.agent_ref();
let local = tokio::task::LocalSet::new();
let result = local
.run_until(agent.run_with_channel(prompt, event_tx))
.await;
let _ = forwarder.join();
if let Err(err) = result {
tracing::warn!(?err, "agent run failed");
}
}
fn build_header_context(
app: &App,
cwd: &std::path::Path,
git_branch: Option<&str>,
) -> InlineHeaderContext {
let workspace_name = cwd
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "oxi".to_string());
let model_id = app.model_id();
let provider = model_id
.split_once('/')
.map(|(p, _)| p.to_string())
.unwrap_or_else(|| "Provider".to_string());
let branch = git_branch.unwrap_or("\u{2014}").to_string();
let mut ctx = InlineHeaderContext::default();
ctx.app_name = "oxi".to_string();
ctx.provider = provider;
ctx.model = model_id.clone();
ctx.git = format!("git: {workspace_name}@{branch}");
ctx.tools = "Tools: ready".to_string();
ctx.search_tools = Some(InlineHeaderStatusBadge {
text: workspace_name,
tone: InlineHeaderStatusTone::Ready,
});
ctx.persistent_memory = Some(InlineHeaderStatusBadge {
text: branch,
tone: InlineHeaderStatusTone::Ready,
});
ctx.editor_context = Some(model_id);
ctx
}
async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
use crate::app::agent_session_runtime::{
CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
create_agent_session_from_services, create_agent_session_services,
};
use crate::store::session::SessionManager;
let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
let services =
create_agent_session_services(CreateAgentSessionServicesOptions::new(cwd.clone()))?;
let services = Arc::new(services);
let model_id = app.model_id();
let tools = app.agent_tools();
let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
services,
session_manager,
model_id: if model_id.is_empty() {
None
} else {
Some(model_id)
},
thinking_level: None,
scoped_models: Vec::new(),
tool_registry: Some(tools),
})
.await?;
if let Some(msg) = result.model_fallback_message {
tracing::warn!(message = %msg, "agent session model fallback");
}
Ok(result.session)
}
fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
let area = frame.area();
let bg = active_styles().background;
frame
.buffer_mut()
.set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
let layout = super::frame_layout::render_chrome(frame, area, state);
render_transcript(frame, layout.scrollback, state);
render_composer(frame, layout.prompt, state);
}
fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
let styles = active_styles();
let items: Vec<ListItem<'_>> = state
.transcript
.iter()
.map(|line| transcript_item(line, &styles))
.collect();
let total = items.len();
let viewport = area.height as usize;
let start = effective_scroll_offset(state.scroll_offset, total, viewport);
let visible = if start >= total {
Vec::new()
} else {
items
.into_iter()
.skip(start)
.take(viewport.max(1))
.collect()
};
let list = List::new(visible).block(Block::default());
frame.render_widget(list, area);
}
fn transcript_item<'a>(line: &'a TranscriptLine, styles: &'a ThemeStyles) -> ListItem<'a> {
let (kind_style, kind_label) = match line.kind {
InlineMessageKind::Agent => (
Style::default().fg(color_from_anstyle(styles.response.get_fg_color())),
"assistant",
),
InlineMessageKind::User => (
Style::default().fg(color_from_anstyle(styles.user.get_fg_color())),
"you",
),
InlineMessageKind::Tool => (
Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
"tool",
),
InlineMessageKind::Error => (
Style::default().fg(color_from_anstyle(styles.error.get_fg_color())),
"error",
),
InlineMessageKind::Warning => (
Style::default().fg(color_from_anstyle(styles.status.get_fg_color())),
"warn",
),
InlineMessageKind::Info => (
Style::default().fg(color_from_anstyle(styles.info.get_fg_color())),
"info",
),
InlineMessageKind::Policy => (
Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color())),
"policy",
),
InlineMessageKind::Pty => (
Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color())),
"pty",
),
};
let mut spans = Vec::with_capacity(line.segments.len() + 1);
spans.push(Span::styled(format!("{kind_label} \u{2502} "), kind_style));
for segment in &line.segments {
let style = segment_style(segment, kind_style, styles);
spans.push(Span::styled(segment.text.clone(), style));
}
ListItem::new(Line::from(spans))
}
fn segment_style(segment: &InlineSegment, fallback: Style, styles: &ThemeStyles) -> Style {
let mut style = fallback;
let inline = segment.style.as_ref();
if let Some(color) = inline.color {
style = style.fg(color_from_anstyle(Some(color)));
} else {
style = style.fg(color_from_anstyle(styles.response.get_fg_color()));
}
if inline.effects.contains(anstyle::Effects::BOLD) {
style = style.add_modifier(Modifier::BOLD);
}
if inline.effects.contains(anstyle::Effects::ITALIC) {
style = style.add_modifier(Modifier::ITALIC);
}
if inline.effects.contains(anstyle::Effects::UNDERLINE) {
style = style.add_modifier(Modifier::UNDERLINED);
}
if inline.effects.contains(anstyle::Effects::DIMMED) {
style = style.add_modifier(Modifier::DIM);
}
style
}
fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
let styles = active_styles();
let prefix_style = Style::default()
.fg(color_from_anstyle(styles.primary.get_fg_color()))
.bold();
let text_style = Style::default().fg(color_from_anstyle(Some(styles.foreground)));
let prefix = state.prompt_prefix.clone();
let body = state.input_buffer.clone();
let placeholder = state.placeholder.clone();
let mut line_spans = vec![Span::styled(prefix, prefix_style)];
if body.is_empty()
&& let Some(ph) = placeholder
{
line_spans.push(Span::styled(
ph,
Style::default()
.fg(color_from_anstyle(styles.secondary.get_fg_color()))
.dim(),
));
} else {
line_spans.push(Span::styled(body, text_style));
}
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())));
let paragraph = Paragraph::new(Line::from(line_spans))
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
if state.input_enabled {
let cursor_x =
area.left() + state.prompt_prefix.chars().count() as u16 + state.input_cursor as u16;
let cursor_y = area.top() + 1; frame.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
}
}
pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
InlineSegment {
text: text.into(),
style: Arc::new(InlineTextStyle::default()),
}
}
pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
if offset == usize::MAX {
return total.saturating_sub(viewport);
}
let max_start = total.saturating_sub(viewport);
offset.min(max_start)
}
fn preview_tool_result(content: &str) -> String {
const MAX: usize = 200;
if content.chars().count() <= MAX {
return content.to_string();
}
let truncated: String = content.chars().take(MAX).collect();
format!("{truncated}\u{2026}")
}
fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
match color {
Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
None => Color::Reset,
}
}
fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
use anstyle::AnsiColor as A;
match color {
A::Black => Color::Black,
A::Red => Color::Red,
A::Green => Color::Green,
A::Yellow => Color::Yellow,
A::Blue => Color::Blue,
A::Magenta => Color::Magenta,
A::Cyan => Color::Cyan,
A::White => Color::Gray,
A::BrightBlack => Color::DarkGray,
A::BrightRed => Color::LightRed,
A::BrightGreen => Color::LightGreen,
A::BrightYellow => Color::LightYellow,
A::BrightBlue => Color::LightBlue,
A::BrightMagenta => Color::LightMagenta,
A::BrightCyan => Color::LightCyan,
A::BrightWhite => Color::White,
}
}
#[allow(dead_code, clippy::declare_interior_mutable_const)]
const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);