2026-03-18 22:44:52 -04:00
|
|
|
// 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.
|
|
|
|
|
|
2026-03-19 01:09:55 -04:00
|
|
|
use unicode_width::UnicodeWidthChar;
|
2026-03-18 22:44:52 -04:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-19 00:30:45 -04:00
|
|
|
// Use unicode display width to match ratatui's Wrap behavior
|
|
|
|
|
let w = ratatui::text::Line::raw(line).width();
|
|
|
|
|
((w + width - 1) / width).max(1)
|
2026-03-18 22:44:52 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 01:09:55 -04:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 22:44:52 -04:00
|
|
|
/// 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;
|
|
|
|
|
|
2026-03-19 01:09:55 -04:00
|
|
|
// Calculate input height using word wrap (matches ratatui Wrap behavior)
|
2026-03-18 22:44:52 -04:00
|
|
|
let prompt = "you> ";
|
|
|
|
|
let full_input = format!("{}{}", prompt, &self.input);
|
|
|
|
|
let input_width = conv_area.width as usize;
|
2026-03-19 01:09:55 -04:00
|
|
|
let input_height = word_wrap_breaks(&full_input, input_width).len()
|
2026-03-18 22:44:52 -04:00
|
|
|
.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);
|
|
|
|
|
|
2026-03-19 01:09:55 -04:00
|
|
|
// 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;
|
|
|
|
|
|
2026-03-18 22:44:52 -04:00
|
|
|
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 §ion.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 §ion.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(())
|
|
|
|
|
}
|