consciousness/poc-agent/src/tui.rs
Kent Overstreet f2c2c02a22 tui: fix cursor position with proper word-wrap simulation
The previous approach scanned ratatui's rendered buffer to find the
cursor position, but couldn't distinguish padding spaces from text
spaces, causing incorrect cursor placement on wrapped lines.

Replace with a word_wrap_breaks() function that computes soft line
break positions by simulating ratatui's Wrap { trim: false } algorithm
(break at word boundaries, fall back to character wrap for long words).
cursor_visual_pos() then maps a character index to (col, row) using
those break positions.

Also fixes the input area height calculation to use word-wrap semantics
instead of character-wrap, matching the actual Paragraph rendering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:09:55 -04:00

1199 lines
44 KiB
Rust

// tui.rs — Terminal UI with split panes
//
// Four-pane layout:
// Left top: Autonomous output (DMN annotations + model prose)
// Left bottom: Conversation (user input + model responses)
// Right: Tool activity (tool calls with full results)
// Bottom: Status bar (DMN state, turns, tokens, model)
//
// Uses ratatui + crossterm. The App struct holds all TUI state and
// handles rendering. Input is processed from crossterm key events.
use unicode_width::UnicodeWidthChar;
use crossterm::{
event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use std::io;
use crate::ui_channel::{ContextInfo, SharedContextState, StatusInfo, UiMessage};
/// Strip ANSI escape sequences (color codes, cursor movement, etc.)
/// from text so tool output renders cleanly in the TUI.
fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
// CSI sequence: ESC [ ... final_byte
if chars.peek() == Some(&'[') {
chars.next(); // consume '['
// Consume parameter bytes (0x30-0x3F), intermediate (0x20-0x2F),
// then one final byte (0x40-0x7E)
while let Some(&c) = chars.peek() {
if c.is_ascii() && (0x20..=0x3F).contains(&(c as u8)) {
chars.next();
} else {
break;
}
}
// Final byte
if let Some(&c) = chars.peek() {
if c.is_ascii() && (0x40..=0x7E).contains(&(c as u8)) {
chars.next();
}
}
}
// Other escape sequences (ESC + single char)
else if let Some(&c) = chars.peek() {
if c.is_ascii() && (0x40..=0x5F).contains(&(c as u8)) {
chars.next();
}
}
} else {
out.push(ch);
}
}
out
}
/// Check if a Unicode character is zero-width (invisible but takes space
/// in the character count, causing rendering artifacts like `[]`).
fn is_zero_width(ch: char) -> bool {
matches!(ch,
'\u{200B}'..='\u{200F}' | // zero-width space, joiners, directional marks
'\u{2028}'..='\u{202F}' | // line/paragraph separators, embedding
'\u{2060}'..='\u{2069}' | // word joiner, invisible operators
'\u{FEFF}' // byte order mark
)
}
/// Which pane receives scroll keys.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActivePane {
Autonomous,
Conversation,
Tools,
}
/// Maximum lines kept per pane. Older lines are evicted to prevent
/// unbounded memory growth during long sessions.
const MAX_PANE_LINES: usize = 10_000;
/// A scrollable text pane with auto-scroll behavior.
///
/// Scroll offset is in visual (wrapped) lines so that auto-scroll
/// correctly tracks the bottom even when long lines wrap.
struct PaneState {
lines: Vec<Line<'static>>,
/// Current line being built (no trailing newline yet) — plain mode only.
current_line: String,
/// Color applied to streaming text (set before append_text) — plain mode only.
current_color: Color,
/// Raw markdown text of the current streaming response.
md_buffer: String,
/// Whether this pane parses streaming text as markdown.
use_markdown: bool,
/// Scroll offset in visual (wrapped) lines from the top.
scroll: u16,
/// Whether the user has scrolled away from the bottom.
pinned: bool,
/// Last known inner dimensions (set during draw). Used by
/// scroll_down to compute max scroll without hardcoding.
last_height: u16,
last_width: u16,
}
impl PaneState {
fn new(use_markdown: bool) -> Self {
Self {
lines: Vec::new(),
current_line: String::new(),
current_color: Color::Reset,
md_buffer: String::new(),
use_markdown,
scroll: 0,
pinned: false,
last_height: 20,
last_width: 80,
}
}
/// Evict old lines if we're over the cap.
fn evict(&mut self) {
if self.lines.len() > MAX_PANE_LINES {
let excess = self.lines.len() - MAX_PANE_LINES;
self.lines.drain(..excess);
// Approximate: reduce scroll by the wrapped height of evicted lines.
// Not perfectly accurate but prevents scroll from jumping wildly.
self.scroll = self.scroll.saturating_sub(excess as u16);
}
}
/// Append text, splitting on newlines. Strips ANSI escapes.
/// In markdown mode, raw text accumulates in md_buffer for
/// live parsing during render. In plain mode, character-by-character
/// processing builds lines with current_color.
fn append_text(&mut self, text: &str) {
let clean = strip_ansi(text);
if self.use_markdown {
self.md_buffer.push_str(&clean);
} else {
for ch in clean.chars() {
if ch == '\n' {
let line = std::mem::take(&mut self.current_line);
self.lines.push(Line::styled(line, Style::default().fg(self.current_color)));
} else if ch == '\t' {
self.current_line.push_str(" ");
} else if ch.is_control() || is_zero_width(ch) {
// Skip control chars and zero-width Unicode
} else {
self.current_line.push(ch);
}
}
}
self.evict();
}
/// Finalize any pending content (markdown buffer or current line).
fn flush_pending(&mut self) {
if self.use_markdown && !self.md_buffer.is_empty() {
self.lines.extend(parse_markdown(&self.md_buffer));
self.md_buffer.clear();
}
if !self.current_line.is_empty() {
let line = std::mem::take(&mut self.current_line);
self.lines.push(Line::styled(line, Style::default().fg(self.current_color)));
}
}
/// Push a complete line with a color. Flushes any pending
/// markdown or plain-text content first.
fn push_line(&mut self, line: String, color: Color) {
self.flush_pending();
self.lines.push(Line::styled(strip_ansi(&line), Style::default().fg(color)));
self.evict();
}
/// Total visual (wrapped) lines given a pane width.
fn wrapped_line_count(&self, width: u16) -> u16 {
let w = width as usize;
let mut count: usize = 0;
for line in &self.lines {
count += wrapped_height_line(line, w);
}
if self.use_markdown && !self.md_buffer.is_empty() {
for line in parse_markdown(&self.md_buffer) {
count += wrapped_height_line(&line, w);
}
} else if !self.current_line.is_empty() {
count += wrapped_height(&self.current_line, w);
}
count.min(u16::MAX as usize) as u16
}
/// Auto-scroll to bottom unless user has pinned. Uses visual
/// (wrapped) line count so long lines don't cause clipping.
fn auto_scroll(&mut self, height: u16, width: u16) {
self.last_height = height;
self.last_width = width;
if !self.pinned {
let total = self.wrapped_line_count(width);
self.scroll = total.saturating_sub(height);
}
}
/// Scroll up by n visual lines, pinning if we move away from bottom.
fn scroll_up(&mut self, n: u16) {
self.scroll = self.scroll.saturating_sub(n);
self.pinned = true;
}
/// Scroll down by n visual lines, un-pinning if we reach bottom.
fn scroll_down(&mut self, n: u16) {
let total = self.wrapped_line_count(self.last_width);
let max = total.saturating_sub(self.last_height);
self.scroll = (self.scroll + n).min(max);
if self.scroll >= max {
self.pinned = false;
}
}
/// Get all lines as ratatui Lines. Includes finalized lines plus
/// any pending content (live-parsed markdown or in-progress plain line).
/// Scrolling is handled by Paragraph::scroll().
fn all_lines(&self) -> Vec<Line<'static>> {
let mut result: Vec<Line<'static>> = self.lines.clone();
if self.use_markdown && !self.md_buffer.is_empty() {
result.extend(parse_markdown(&self.md_buffer));
} else if !self.current_line.is_empty() {
result.push(Line::styled(
self.current_line.clone(),
Style::default().fg(self.current_color),
));
}
result
}
}
/// How many visual lines a string occupies at a given width.
fn wrapped_height(line: &str, width: usize) -> usize {
if width == 0 || line.is_empty() {
return 1;
}
// Use unicode display width to match ratatui's Wrap behavior
let w = ratatui::text::Line::raw(line).width();
((w + width - 1) / width).max(1)
}
/// How many visual lines a ratatui Line occupies at a given width.
fn wrapped_height_line(line: &Line<'_>, width: usize) -> usize {
if width == 0 {
return 1;
}
let w = line.width();
if w == 0 {
return 1;
}
((w + width - 1) / width).max(1)
}
/// Compute soft line break positions for word-wrapped text.
/// Returns the character index where each soft line starts.
/// Matches ratatui Wrap { trim: false } — breaks at word boundaries.
fn word_wrap_breaks(text: &str, width: usize) -> Vec<usize> {
let mut breaks = vec![0usize];
if width == 0 {
return breaks;
}
let chars: Vec<char> = text.chars().collect();
let mut col = 0usize;
let mut last_space: Option<usize> = None;
for (i, &ch) in chars.iter().enumerate() {
if ch == '\n' {
breaks.push(i + 1);
col = 0;
last_space = None;
continue;
}
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if col + cw > width && col > 0 {
if let Some(sp) = last_space {
breaks.push(sp);
col = 0;
last_space = None;
for j in sp..i {
col += UnicodeWidthChar::width(chars[j]).unwrap_or(0);
if chars[j] == ' ' {
last_space = Some(j + 1);
}
}
} else {
breaks.push(i);
col = 0;
}
}
if ch == ' ' {
last_space = Some(i + 1);
}
col += cw;
}
breaks
}
/// Compute visual (col, row) for a character position in word-wrapped text.
fn cursor_visual_pos(text: &str, char_pos: usize, width: u16) -> (u16, u16) {
let breaks = word_wrap_breaks(text, width as usize);
let chars: Vec<char> = text.chars().collect();
for r in 0..breaks.len() {
let start = breaks[r];
let end = breaks.get(r + 1).copied().unwrap_or(chars.len());
if char_pos < end || r == breaks.len() - 1 {
let mut col = 0u16;
for j in start..char_pos.min(end) {
if chars[j] != '\n' {
col += UnicodeWidthChar::width(chars[j]).unwrap_or(0) as u16;
}
}
return (col, r as u16);
}
}
(0, 0)
}
/// Parse markdown text into owned ratatui Lines.
fn parse_markdown(md: &str) -> Vec<Line<'static>> {
tui_markdown::from_str(md)
.lines
.into_iter()
.map(|line| {
let spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|span| Span::styled(span.content.into_owned(), span.style))
.collect();
let mut result = Line::from(spans).style(line.style);
result.alignment = line.alignment;
result
})
.collect()
}
/// A tool call currently in flight — shown above the status bar.
struct ActiveTool {
id: String,
name: String,
detail: String,
started: std::time::Instant,
}
/// Main TUI application state.
pub struct App {
autonomous: PaneState,
conversation: PaneState,
tools: PaneState,
status: StatusInfo,
/// Live activity indicator ("thinking...", "calling: bash", etc).
activity: String,
/// When the current turn started (for elapsed timer).
turn_started: Option<std::time::Instant>,
/// Number of running child processes (updated by main loop).
pub running_processes: u32,
/// Current reasoning effort level (for status display).
pub reasoning_effort: String,
active_tools: Vec<ActiveTool>,
active_pane: ActivePane,
/// User input buffer.
pub input: String,
/// Cursor position within input.
pub cursor: usize,
/// Input history for up/down navigation.
input_history: Vec<String>,
history_index: Option<usize>,
/// Whether to quit.
pub should_quit: bool,
/// Submitted input lines waiting to be consumed.
pub submitted: Vec<String>,
/// Pending hotkey actions for the main loop to process.
pub hotkey_actions: Vec<HotkeyAction>,
/// Pane areas from last draw (for mouse click → pane selection).
pane_areas: [Rect; 3], // [autonomous, conversation, tools]
/// Debug screen visible (Ctrl+D toggle).
debug_visible: bool,
/// Debug screen scroll offset.
debug_scroll: u16,
/// Index of selected context section in debug view (for expand/collapse).
debug_selected: Option<usize>,
/// Which context section indices are expanded.
debug_expanded: std::collections::HashSet<usize>,
/// Context loading info for the debug screen.
context_info: Option<ContextInfo>,
/// Live context state — shared with agent, read directly for debug screen.
shared_context: SharedContextState,
}
/// Actions triggered by hotkeys, consumed by the main loop.
#[derive(Debug)]
pub enum HotkeyAction {
/// Ctrl+R: cycle reasoning effort
CycleReasoning,
/// Ctrl+K: show/kill running processes
KillProcess,
/// Escape: interrupt current turn (kill processes, clear queue)
Interrupt,
/// Ctrl+P: cycle DMN autonomy (foraging → resting → paused → foraging)
CycleAutonomy,
}
impl App {
pub fn new(model: String, shared_context: SharedContextState) -> Self {
Self {
autonomous: PaneState::new(true), // markdown
conversation: PaneState::new(true), // markdown
tools: PaneState::new(false), // plain text
status: StatusInfo {
dmn_state: "resting".into(),
dmn_turns: 0,
dmn_max_turns: 20,
prompt_tokens: 0,
completion_tokens: 0,
model,
turn_tools: 0,
context_budget: String::new(),
},
activity: String::new(),
turn_started: None,
running_processes: 0,
reasoning_effort: "none".to_string(),
active_tools: Vec::new(),
active_pane: ActivePane::Conversation,
input: String::new(),
cursor: 0,
input_history: Vec::new(),
history_index: None,
should_quit: false,
submitted: Vec::new(),
hotkey_actions: Vec::new(),
pane_areas: [Rect::default(); 3],
debug_visible: false,
debug_scroll: 0,
debug_selected: None,
debug_expanded: std::collections::HashSet::new(),
context_info: None,
shared_context,
}
}
/// Process a UiMessage, routing content to the appropriate pane.
pub fn handle_ui_message(&mut self, msg: UiMessage) {
use crate::ui_channel::StreamTarget;
match msg {
UiMessage::TextDelta(text, target) => match target {
StreamTarget::Conversation => {
self.conversation.current_color = Color::Reset;
self.conversation.append_text(&text);
}
StreamTarget::Autonomous => {
self.autonomous.current_color = Color::Reset;
self.autonomous.append_text(&text);
}
},
UiMessage::UserInput(text) => {
self.conversation.push_line(format!("you> {}", text), Color::Green);
// Mark turn start
self.turn_started = Some(std::time::Instant::now());
self.status.turn_tools = 0;
}
UiMessage::ToolCall { name, args_summary } => {
self.status.turn_tools += 1;
let line = if args_summary.is_empty() {
format!("[{}]", name)
} else {
format!("[{}] {}", name, args_summary)
};
self.tools.push_line(line, Color::Yellow);
}
UiMessage::ToolResult { name: _, result } => {
// Indent result lines and add to tools pane
for line in result.lines() {
self.tools.push_line(format!(" {}", line), Color::DarkGray);
}
self.tools.push_line(String::new(), Color::Reset); // blank separator
}
UiMessage::DmnAnnotation(text) => {
self.autonomous.push_line(text, Color::Yellow);
// DMN turn start
self.turn_started = Some(std::time::Instant::now());
self.status.turn_tools = 0;
}
UiMessage::StatusUpdate(info) => {
// Merge: non-empty/non-zero fields overwrite.
// DMN state always comes as a group from the main loop.
if !info.dmn_state.is_empty() {
self.status.dmn_state = info.dmn_state;
self.status.dmn_turns = info.dmn_turns;
self.status.dmn_max_turns = info.dmn_max_turns;
}
// Token counts come from the agent after API calls.
if info.prompt_tokens > 0 {
self.status.prompt_tokens = info.prompt_tokens;
}
if !info.model.is_empty() {
self.status.model = info.model;
}
if !info.context_budget.is_empty() {
self.status.context_budget = info.context_budget;
}
}
UiMessage::Activity(text) => {
self.activity = text;
}
UiMessage::Reasoning(text) => {
self.autonomous.current_color = Color::DarkGray;
self.autonomous.append_text(&text);
}
UiMessage::ToolStarted { id, name, detail } => {
self.active_tools.push(ActiveTool {
id,
name,
detail,
started: std::time::Instant::now(),
});
}
UiMessage::ToolFinished { id } => {
self.active_tools.retain(|t| t.id != id);
}
UiMessage::Debug(text) => {
self.tools.push_line(format!("[debug] {}", text), Color::DarkGray);
}
UiMessage::Info(text) => {
self.conversation.push_line(text, Color::Cyan);
}
UiMessage::ContextInfoUpdate(info) => {
self.context_info = Some(info);
}
}
}
/// Handle a crossterm key event.
pub fn handle_key(&mut self, key: KeyEvent) {
// Ctrl+C always quits
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') => {
self.should_quit = true;
return;
}
KeyCode::Char('r') => {
self.hotkey_actions.push(HotkeyAction::CycleReasoning);
return;
}
KeyCode::Char('k') => {
self.hotkey_actions.push(HotkeyAction::KillProcess);
return;
}
KeyCode::Char('d') => {
self.debug_visible = !self.debug_visible;
self.debug_scroll = 0;
return;
}
KeyCode::Char('p') => {
self.hotkey_actions.push(HotkeyAction::CycleAutonomy);
return;
}
_ => {}
}
}
// Debug screen captures scroll keys and Esc
if self.debug_visible {
match key.code {
KeyCode::Esc => {
self.debug_visible = false;
return;
}
KeyCode::Up => {
let cs = self.read_context_state();
let n = self.debug_item_count(&cs);
if n > 0 {
self.debug_selected = Some(match self.debug_selected {
None => n - 1,
Some(0) => 0,
Some(i) => i - 1,
});
}
return;
}
KeyCode::Down => {
let cs = self.read_context_state();
let n = self.debug_item_count(&cs);
if n > 0 {
self.debug_selected = Some(match self.debug_selected {
None => 0,
Some(i) if i >= n - 1 => n - 1,
Some(i) => i + 1,
});
}
return;
}
KeyCode::PageUp => { self.debug_scroll = self.debug_scroll.saturating_sub(10); return; }
KeyCode::PageDown => { self.debug_scroll += 10; return; }
KeyCode::Right | KeyCode::Enter => {
// Expand selected section
if let Some(idx) = self.debug_selected {
self.debug_expanded.insert(idx);
}
return;
}
KeyCode::Left => {
// Collapse selected section
if let Some(idx) = self.debug_selected {
self.debug_expanded.remove(&idx);
}
return;
}
_ => {}
}
}
match key.code {
KeyCode::Esc => {
self.hotkey_actions.push(HotkeyAction::Interrupt);
return;
}
KeyCode::Enter => {
if key.modifiers.contains(KeyModifiers::ALT)
|| key.modifiers.contains(KeyModifiers::SHIFT)
{
self.input.insert(self.cursor, '\n');
self.cursor += 1;
} else if !self.input.is_empty() {
let line = self.input.clone();
if self.input_history.last().map_or(true, |h| h != &line) {
self.input_history.push(line.clone());
}
self.history_index = None;
self.submitted.push(line);
self.input.clear();
self.cursor = 0;
}
}
KeyCode::Backspace => {
if self.cursor > 0 {
self.cursor -= 1;
self.input.remove(self.cursor);
}
}
KeyCode::Delete => {
if self.cursor < self.input.len() {
self.input.remove(self.cursor);
}
}
KeyCode::Left => {
if self.cursor > 0 {
self.cursor -= 1;
}
}
KeyCode::Right => {
if self.cursor < self.input.len() {
self.cursor += 1;
}
}
KeyCode::Home => {
self.cursor = 0;
}
KeyCode::End => {
self.cursor = self.input.len();
}
KeyCode::Up => {
// If Ctrl is held, scroll the active pane
if key.modifiers.contains(KeyModifiers::CONTROL) {
self.scroll_active_up(3);
} else {
// History navigation
if !self.input_history.is_empty() {
let idx = match self.history_index {
None => self.input_history.len() - 1,
Some(i) => i.saturating_sub(1),
};
self.history_index = Some(idx);
self.input = self.input_history[idx].clone();
self.cursor = self.input.len();
}
}
}
KeyCode::Down => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
self.scroll_active_down(3);
} else {
// History navigation
if let Some(idx) = self.history_index {
if idx + 1 < self.input_history.len() {
self.history_index = Some(idx + 1);
self.input = self.input_history[idx + 1].clone();
self.cursor = self.input.len();
} else {
self.history_index = None;
self.input.clear();
self.cursor = 0;
}
}
}
}
KeyCode::PageUp => {
self.scroll_active_up(10);
}
KeyCode::PageDown => {
self.scroll_active_down(10);
}
KeyCode::Tab => {
// Cycle active pane
self.active_pane = match self.active_pane {
ActivePane::Autonomous => ActivePane::Tools,
ActivePane::Tools => ActivePane::Conversation,
ActivePane::Conversation => ActivePane::Autonomous,
};
}
KeyCode::Char(c) => {
self.input.insert(self.cursor, c);
self.cursor += 1;
}
_ => {}
}
}
fn scroll_active_up(&mut self, n: u16) {
match self.active_pane {
ActivePane::Autonomous => self.autonomous.scroll_up(n),
ActivePane::Conversation => self.conversation.scroll_up(n),
ActivePane::Tools => self.tools.scroll_up(n),
}
}
fn scroll_active_down(&mut self, n: u16) {
match self.active_pane {
ActivePane::Autonomous => self.autonomous.scroll_down(n),
ActivePane::Conversation => self.conversation.scroll_down(n),
ActivePane::Tools => self.tools.scroll_down(n),
}
}
/// Handle mouse events: scroll wheel and click-to-select-pane.
pub fn handle_mouse(&mut self, mouse: MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_active_up(3),
MouseEventKind::ScrollDown => self.scroll_active_down(3),
MouseEventKind::Down(MouseButton::Left) => {
let (x, y) = (mouse.column, mouse.row);
for (i, area) in self.pane_areas.iter().enumerate() {
if x >= area.x && x < area.x + area.width
&& y >= area.y && y < area.y + area.height
{
self.active_pane = match i {
0 => ActivePane::Autonomous,
1 => ActivePane::Conversation,
_ => ActivePane::Tools,
};
break;
}
}
}
_ => {}
}
}
/// Draw the full TUI layout.
pub fn draw(&mut self, frame: &mut Frame) {
let size = frame.area();
if self.debug_visible {
self.draw_debug(frame, size);
return;
}
// Main layout: content area + active tools overlay + status bar
let tool_lines = self.active_tools.len() as u16;
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), // content area
Constraint::Length(tool_lines), // active tools (0 when empty)
Constraint::Length(1), // status bar
])
.split(size);
let content_area = main_chunks[0];
let tools_overlay_area = main_chunks[1];
let status_area = main_chunks[2];
// Content: left column (55%) + right column (45%)
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(55),
Constraint::Percentage(45),
])
.split(content_area);
let left_col = columns[0];
let right_col = columns[1];
// Left column: autonomous (35%) + conversation (65%)
let left_panes = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(35),
Constraint::Percentage(65),
])
.split(left_col);
let auto_area = left_panes[0];
let conv_area = left_panes[1];
// Store pane areas for mouse click detection
self.pane_areas = [auto_area, conv_area, right_col];
// Draw autonomous pane
let auto_active = self.active_pane == ActivePane::Autonomous;
draw_pane(frame, auto_area, "autonomous", &mut self.autonomous, auto_active);
// Draw tools pane
let tools_active = self.active_pane == ActivePane::Tools;
draw_pane(frame, right_col, "tools", &mut self.tools, tools_active);
// Draw conversation pane (with input line)
let conv_active = self.active_pane == ActivePane::Conversation;
// Calculate input height using word wrap (matches ratatui Wrap behavior)
let prompt = "you> ";
let full_input = format!("{}{}", prompt, &self.input);
let input_width = conv_area.width as usize;
let input_height = word_wrap_breaks(&full_input, input_width).len()
.max(1)
.min(5) as u16;
// Split conversation area: text + input lines
let conv_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), // conversation text
Constraint::Length(input_height), // input area
])
.split(conv_area);
let text_area = conv_chunks[0];
let input_area = conv_chunks[1];
draw_pane(frame, text_area, "conversation", &mut self.conversation, conv_active);
// Input lines — split on newlines, style the prompt on the first line
let input_lines: Vec<Line> = full_input
.split('\n')
.enumerate()
.map(|(i, part)| {
if i == 0 && part.len() >= prompt.len() {
Line::from(vec![
Span::styled(
prompt,
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
),
Span::raw(part[prompt.len()..].to_string()),
])
} else {
Line::raw(part.to_string())
}
})
.collect();
let input_para = Paragraph::new(input_lines).wrap(Wrap { trim: false });
frame.render_widget(input_para, input_area);
// Cursor position: simulate word wrap to find visual (col, row)
let cursor_char_pos = prompt.chars().count()
+ self.input[..self.cursor].chars().count();
let (cx, cy) = cursor_visual_pos(&full_input, cursor_char_pos, input_area.width);
let cursor_x = cx + input_area.x;
let cursor_y = cy + input_area.y;
if cursor_y < input_area.y + input_area.height {
frame.set_cursor_position((cursor_x, cursor_y));
}
// Draw active tools overlay
if !self.active_tools.is_empty() {
let tool_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::DIM);
let tool_text: Vec<Line> = self.active_tools.iter().map(|t| {
let elapsed = t.started.elapsed().as_secs();
let line = if t.detail.is_empty() {
format!(" [{}] ({}s)", t.name, elapsed)
} else {
format!(" [{}] {} ({}s)", t.name, t.detail, elapsed)
};
Line::styled(line, tool_style)
}).collect();
let tool_para = Paragraph::new(tool_text);
frame.render_widget(tool_para, tools_overlay_area);
}
// Draw status bar with live activity indicator
let elapsed = self.turn_started.map(|t| t.elapsed());
let timer = match elapsed {
Some(d) if !self.activity.is_empty() => format!(" {:.0}s", d.as_secs_f64()),
_ => String::new(),
};
let tools_info = if self.status.turn_tools > 0 {
format!(" ({}t)", self.status.turn_tools)
} else {
String::new()
};
let activity_part = if self.activity.is_empty() {
String::new()
} else {
format!(" | {}{}{}", self.activity, tools_info, timer)
};
let budget_part = if self.status.context_budget.is_empty() {
String::new()
} else {
format!(" [{}]", self.status.context_budget)
};
let left_status = format!(
" {} | {}/{} dmn | {}K tok in{}{}",
self.status.dmn_state,
self.status.dmn_turns,
self.status.dmn_max_turns,
self.status.prompt_tokens / 1000,
budget_part,
activity_part,
);
let proc_indicator = if self.running_processes > 0 {
format!(" {}proc", self.running_processes)
} else {
String::new()
};
let reason_indicator = if self.reasoning_effort != "none" {
format!(" reason:{}", self.reasoning_effort)
} else {
String::new()
};
let right_legend = format!(
"{}{} ^P:pause ^D:debug ^R:reason ^K:kill | {} ",
reason_indicator,
proc_indicator,
self.status.model,
);
// Pad the middle to fill the status bar
let total_width = status_area.width as usize;
let used = left_status.len() + right_legend.len();
let padding = if total_width > used {
" ".repeat(total_width - used)
} else {
" ".to_string()
};
let status = Paragraph::new(Line::from(vec![
Span::styled(&left_status, Style::default().fg(Color::White).bg(Color::DarkGray)),
Span::styled(padding, Style::default().bg(Color::DarkGray)),
Span::styled(
right_legend,
Style::default().fg(Color::DarkGray).bg(Color::Gray),
),
]));
frame.render_widget(status, status_area);
}
/// Read the live context state from the shared lock.
fn read_context_state(&self) -> Vec<crate::ui_channel::ContextSection> {
self.shared_context.read().map_or_else(|_| Vec::new(), |s| s.clone())
}
/// Draw the debug screen — full-screen overlay with context and runtime info.
/// Count total selectable items in the context state tree.
fn debug_item_count(&self, context_state: &[crate::ui_channel::ContextSection]) -> usize {
fn count_section(section: &crate::ui_channel::ContextSection, expanded: &std::collections::HashSet<usize>, idx: &mut usize) -> usize {
let my_idx = *idx;
*idx += 1;
let mut total = 1;
if expanded.contains(&my_idx) {
for child in &section.children {
total += count_section(child, expanded, idx);
}
}
total
}
let mut idx = 0;
let mut total = 0;
for section in context_state {
total += count_section(section, &self.debug_expanded, &mut idx);
}
total
}
/// Render a context section as a tree node with optional children.
fn render_debug_section(
&self,
section: &crate::ui_channel::ContextSection,
depth: usize,
start_idx: usize,
lines: &mut Vec<Line>,
idx: &mut usize,
) {
let my_idx = *idx;
let selected = self.debug_selected == Some(my_idx);
let expanded = self.debug_expanded.contains(&my_idx);
let has_children = !section.children.is_empty();
let has_content = !section.content.is_empty();
let expandable = has_children || has_content;
let indent = " ".repeat(depth + 1);
let marker = if !expandable {
" "
} else if expanded {
""
} else {
""
};
let label = format!("{}{} {:30} {:>6} tokens", indent, marker, section.name, section.tokens);
let style = if selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::styled(label, style));
*idx += 1;
if expanded {
if has_children {
for child in &section.children {
self.render_debug_section(child, depth + 1, start_idx, lines, idx);
}
} else if has_content {
let content_indent = format!("{}", " ".repeat(depth + 1));
let content_lines: Vec<&str> = section.content.lines().collect();
let show = content_lines.len().min(50);
for line in &content_lines[..show] {
lines.push(Line::styled(
format!("{}{}", content_indent, line),
Style::default().fg(Color::DarkGray),
));
}
if content_lines.len() > 50 {
lines.push(Line::styled(
format!("{}... ({} more lines)", content_indent, content_lines.len() - 50),
Style::default().fg(Color::DarkGray),
));
}
}
}
}
fn draw_debug(&self, frame: &mut Frame, size: Rect) {
let mut lines: Vec<Line> = Vec::new();
let section = Style::default().fg(Color::Yellow);
lines.push(Line::styled(
" Debug (Ctrl+D or Esc to close, arrows/PgUp/PgDn to scroll)",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
));
lines.push(Line::raw(""));
// Model
lines.push(Line::styled("── Model ──", section));
let model_display = self.context_info.as_ref()
.map_or_else(|| self.status.model.clone(), |i| i.model.clone());
lines.push(Line::raw(format!(" Current: {}", model_display)));
if let Some(ref info) = self.context_info {
lines.push(Line::raw(format!(" Backend: {}", info.backend)));
lines.push(Line::raw(format!(" Prompt: {}", info.prompt_file)));
lines.push(Line::raw(format!(" Available: {}", info.available_models.join(", "))));
}
lines.push(Line::raw(""));
// Context state
lines.push(Line::styled("── Context State ──", section));
lines.push(Line::raw(format!(" Prompt tokens: {}K", self.status.prompt_tokens / 1000)));
if !self.status.context_budget.is_empty() {
lines.push(Line::raw(format!(" Budget: {}", self.status.context_budget)));
}
let context_state = self.read_context_state();
if !context_state.is_empty() {
let total: usize = context_state.iter().map(|s| s.tokens).sum();
lines.push(Line::raw(""));
lines.push(Line::styled(
" (↑/↓ select, →/Enter expand, ← collapse, PgUp/PgDn scroll)",
Style::default().fg(Color::DarkGray),
));
lines.push(Line::raw(""));
// Flatten tree into indexed entries for selection
let mut flat_idx = 0usize;
for section in &context_state {
self.render_debug_section(section, 0, flat_idx, &mut lines, &mut flat_idx);
}
lines.push(Line::raw(format!(" {:23} {:>6} tokens", "────────", "──────")));
lines.push(Line::raw(format!(" {:23} {:>6} tokens", "Total", total)));
} else if let Some(ref info) = self.context_info {
lines.push(Line::raw(format!(" System prompt: {:>6} chars", info.system_prompt_chars)));
lines.push(Line::raw(format!(" Context message: {:>6} chars", info.context_message_chars)));
}
lines.push(Line::raw(""));
// Runtime
lines.push(Line::styled("── Runtime ──", section));
lines.push(Line::raw(format!(
" DMN: {} ({}/{})",
self.status.dmn_state, self.status.dmn_turns, self.status.dmn_max_turns,
)));
lines.push(Line::raw(format!(" Reasoning: {}", self.reasoning_effort)));
lines.push(Line::raw(format!(" Running processes: {}", self.running_processes)));
lines.push(Line::raw(format!(" Active tools: {}", self.active_tools.len())));
let block = Block::default()
.title(" Debug ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
.scroll((self.debug_scroll, 0));
frame.render_widget(para, size);
}
}
/// Draw a scrollable text pane (free function to avoid borrow issues).
fn draw_pane(
frame: &mut Frame,
area: Rect,
title: &str,
pane: &mut PaneState,
is_active: bool,
) {
let inner_height = area.height.saturating_sub(2); // borders
let inner_width = area.width.saturating_sub(2);
pane.auto_scroll(inner_height, inner_width);
let border_style = if is_active {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.title(format!(" {} ", title))
.borders(Borders::ALL)
.border_style(border_style);
let lines = pane.all_lines();
let paragraph = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
.scroll((pane.scroll, 0));
frame.render_widget(paragraph, area);
}
/// Initialize the terminal for TUI mode.
pub fn init_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
stdout.execute(EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
/// Restore the terminal to normal mode.
pub fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
terminal::disable_raw_mode()?;
terminal.backend_mut().execute(DisableMouseCapture)?;
terminal.backend_mut().execute(LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}