forked from kent/consciousness
Split conversation pane into 2-char gutter + text column. Gutter shows ● markers at turn boundaries (Cyan for user, Magenta for assistant), aligned with the input area's ' > ' gutter. Key changes: - Added Marker enum (None/User/Assistant) and parallel markers vec - Track turn boundaries via pending_marker field - New draw_conversation_pane() with visual row computation for wrapping - Both gutter and text scroll synchronously by visual line offset This fixes the wrapping alignment issue where continuation lines aligned under markers instead of under the text.
1195 lines
44 KiB
Rust
1195 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 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;
|
|
|
|
/// Turn marker for the conversation pane gutter.
|
|
#[derive(Clone, Copy, PartialEq, Default)]
|
|
enum Marker {
|
|
#[default]
|
|
None,
|
|
User,
|
|
Assistant,
|
|
}
|
|
|
|
/// 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>>,
|
|
/// Turn markers — parallel to lines, same length.
|
|
markers: Vec<Marker>,
|
|
/// 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,
|
|
/// Marker to apply to the next line pushed (for turn start tracking).
|
|
pending_marker: Marker,
|
|
/// Scroll offset in visual (wrapped) lines from the top.
|
|
scroll: u16,
|
|
/// Whether the user has scrolled away from the bottom.
|
|
pinned: bool,
|
|
/// Last known total visual lines (set during draw by Paragraph::line_count).
|
|
last_total_lines: u16,
|
|
/// Last known inner height (set during draw).
|
|
last_height: u16,
|
|
}
|
|
|
|
impl PaneState {
|
|
fn new(use_markdown: bool) -> Self {
|
|
Self {
|
|
lines: Vec::new(),
|
|
markers: Vec::new(),
|
|
current_line: String::new(),
|
|
current_color: Color::Reset,
|
|
md_buffer: String::new(),
|
|
use_markdown,
|
|
pending_marker: Marker::None,
|
|
scroll: 0,
|
|
pinned: false,
|
|
last_total_lines: 0,
|
|
last_height: 20,
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
self.markers.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)));
|
|
self.markers.push(Marker::None);
|
|
} 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() {
|
|
let parsed = parse_markdown(&self.md_buffer);
|
|
for (i, line) in parsed.into_iter().enumerate() {
|
|
let marker = if i == 0 {
|
|
std::mem::take(&mut self.pending_marker)
|
|
} else {
|
|
Marker::None
|
|
};
|
|
self.lines.push(line);
|
|
self.markers.push(marker);
|
|
}
|
|
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)));
|
|
self.markers.push(std::mem::take(&mut self.pending_marker));
|
|
}
|
|
}
|
|
|
|
/// 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.push_line_with_marker(line, color, Marker::None);
|
|
}
|
|
|
|
fn push_line_with_marker(&mut self, line: String, color: Color, marker: Marker) {
|
|
self.flush_pending();
|
|
self.lines.push(Line::styled(strip_ansi(&line), Style::default().fg(color)));
|
|
self.markers.push(marker);
|
|
self.evict();
|
|
}
|
|
|
|
/// 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-pin if we reach bottom.
|
|
fn scroll_down(&mut self, n: u16) {
|
|
let max = self.last_total_lines.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 (lines, _) = self.all_lines_with_markers();
|
|
lines
|
|
}
|
|
|
|
/// Get lines and their markers together. Used by the two-column
|
|
/// conversation renderer to know where to place gutter markers.
|
|
fn all_lines_with_markers(&self) -> (Vec<Line<'static>>, Vec<Marker>) {
|
|
let mut lines: Vec<Line<'static>> = self.lines.clone();
|
|
let mut markers: Vec<Marker> = self.markers.clone();
|
|
if self.use_markdown && !self.md_buffer.is_empty() {
|
|
let parsed = parse_markdown(&self.md_buffer);
|
|
let count = parsed.len();
|
|
lines.extend(parsed);
|
|
if count > 0 {
|
|
markers.push(self.pending_marker);
|
|
markers.extend(std::iter::repeat(Marker::None).take(count - 1));
|
|
}
|
|
} else if !self.current_line.is_empty() {
|
|
lines.push(Line::styled(
|
|
self.current_line.clone(),
|
|
Style::default().fg(self.current_color),
|
|
));
|
|
markers.push(self.pending_marker);
|
|
}
|
|
(lines, markers)
|
|
}
|
|
}
|
|
|
|
/// Create a new textarea with standard settings (word wrap, no cursor line highlight).
|
|
fn new_textarea(lines: Vec<String>) -> tui_textarea::TextArea<'static> {
|
|
let mut ta = tui_textarea::TextArea::new(lines);
|
|
ta.set_cursor_line_style(Style::default());
|
|
ta.set_wrap_mode(tui_textarea::WrapMode::Word);
|
|
ta
|
|
}
|
|
|
|
|
|
/// 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>,
|
|
/// Whether to emit a ● marker before the next assistant TextDelta.
|
|
needs_assistant_marker: bool,
|
|
/// 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 editor (handles wrapping, cursor positioning).
|
|
pub textarea: tui_textarea::TextArea<'static>,
|
|
/// 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,
|
|
needs_assistant_marker: false,
|
|
running_processes: 0,
|
|
reasoning_effort: "none".to_string(),
|
|
active_tools: Vec::new(),
|
|
active_pane: ActivePane::Conversation,
|
|
textarea: new_textarea(vec![String::new()]),
|
|
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 => {
|
|
if self.needs_assistant_marker {
|
|
self.conversation.pending_marker = Marker::Assistant;
|
|
self.needs_assistant_marker = false;
|
|
}
|
|
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_with_marker(text.clone(), Color::Cyan, Marker::User);
|
|
// Mark turn start — next TextDelta gets an assistant marker
|
|
self.turn_started = Some(std::time::Instant::now());
|
|
self.needs_assistant_marker = true;
|
|
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.needs_assistant_marker = true;
|
|
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);
|
|
}
|
|
KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT)
|
|
&& !key.modifiers.contains(KeyModifiers::SHIFT) => {
|
|
// Submit input
|
|
let input: String = self.textarea.lines().join("\n");
|
|
if !input.is_empty() {
|
|
if self.input_history.last().map_or(true, |h| h != &input) {
|
|
self.input_history.push(input.clone());
|
|
}
|
|
self.history_index = None;
|
|
self.submitted.push(input);
|
|
self.textarea = new_textarea(vec![String::new()]);
|
|
}
|
|
}
|
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
self.scroll_active_up(3);
|
|
}
|
|
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
self.scroll_active_down(3);
|
|
}
|
|
KeyCode::Up if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
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);
|
|
let mut ta = new_textarea(
|
|
self.input_history[idx].lines().map(String::from).collect()
|
|
);
|
|
ta.move_cursor(tui_textarea::CursorMove::End);
|
|
self.textarea = ta;
|
|
}
|
|
}
|
|
KeyCode::Down if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
if let Some(idx) = self.history_index {
|
|
if idx + 1 < self.input_history.len() {
|
|
self.history_index = Some(idx + 1);
|
|
let mut ta = new_textarea(
|
|
self.input_history[idx + 1].lines().map(String::from).collect()
|
|
);
|
|
ta.move_cursor(tui_textarea::CursorMove::End);
|
|
self.textarea = ta;
|
|
} else {
|
|
self.history_index = None;
|
|
self.textarea = new_textarea(vec![String::new()]);
|
|
}
|
|
}
|
|
}
|
|
KeyCode::PageUp => {
|
|
self.scroll_active_up(10);
|
|
}
|
|
KeyCode::PageDown => {
|
|
self.scroll_active_down(10);
|
|
}
|
|
KeyCode::Tab => {
|
|
self.active_pane = match self.active_pane {
|
|
ActivePane::Autonomous => ActivePane::Tools,
|
|
ActivePane::Tools => ActivePane::Conversation,
|
|
ActivePane::Conversation => ActivePane::Autonomous,
|
|
};
|
|
}
|
|
_ => {
|
|
// Delegate all other keys to the textarea widget
|
|
self.textarea.input(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 terminal resize. Scroll is recalculated in draw_pane
|
|
/// via Paragraph::line_count; terminal.clear() in main.rs forces
|
|
/// a full redraw.
|
|
pub fn handle_resize(&mut self, _width: u16, _height: u16) {
|
|
}
|
|
|
|
/// 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;
|
|
|
|
// Input area: compute visual height, split, render gutter + textarea
|
|
let input_text = self.textarea.lines().join("\n");
|
|
let input_para_measure = Paragraph::new(input_text).wrap(Wrap { trim: false });
|
|
let input_line_count = (input_para_measure.line_count(conv_area.width.saturating_sub(5)) as u16)
|
|
.max(1)
|
|
.min(5);
|
|
|
|
let conv_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Min(1), // conversation text
|
|
Constraint::Length(input_line_count), // input area
|
|
])
|
|
.split(conv_area);
|
|
|
|
let text_area_rect = conv_chunks[0];
|
|
let input_area = conv_chunks[1];
|
|
|
|
draw_conversation_pane(frame, text_area_rect, &mut self.conversation, conv_active);
|
|
|
|
// " > " gutter + textarea, aligned with conversation messages
|
|
let input_chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Length(3), // " > " gutter
|
|
Constraint::Min(1), // textarea
|
|
])
|
|
.split(input_area);
|
|
|
|
let gutter = Paragraph::new(Line::styled(
|
|
" > ",
|
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
|
));
|
|
frame.render_widget(gutter, input_chunks[0]);
|
|
frame.render_widget(&self.textarea, input_chunks[1]);
|
|
|
|
// 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 the conversation pane with a two-column layout: marker gutter + text.
|
|
/// The gutter shows ● at turn boundaries, aligned with the input gutter.
|
|
fn draw_conversation_pane(
|
|
frame: &mut Frame,
|
|
area: Rect,
|
|
pane: &mut PaneState,
|
|
is_active: bool,
|
|
) {
|
|
let border_style = if is_active {
|
|
Style::default().fg(Color::Cyan)
|
|
} else {
|
|
Style::default().fg(Color::DarkGray)
|
|
};
|
|
|
|
let block = Block::default()
|
|
.title(" conversation ")
|
|
.borders(Borders::ALL)
|
|
.border_style(border_style);
|
|
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
if inner.width < 5 || inner.height == 0 {
|
|
return;
|
|
}
|
|
|
|
// Split inner area into gutter (2 chars) + text
|
|
let cols = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Length(2),
|
|
Constraint::Min(1),
|
|
])
|
|
.split(inner);
|
|
|
|
let gutter_area = cols[0];
|
|
let text_area = cols[1];
|
|
|
|
// Get lines and markers
|
|
let (lines, markers) = pane.all_lines_with_markers();
|
|
let text_width = text_area.width;
|
|
|
|
// Compute visual row for each logical line (accounting for word wrap)
|
|
let mut visual_rows: Vec<u16> = Vec::with_capacity(lines.len());
|
|
let mut cumulative: u16 = 0;
|
|
for line in &lines {
|
|
visual_rows.push(cumulative);
|
|
let para = Paragraph::new(line.clone()).wrap(Wrap { trim: false });
|
|
let height = para.line_count(text_width) as u16;
|
|
cumulative += height.max(1);
|
|
}
|
|
let total_visual = cumulative;
|
|
|
|
pane.last_total_lines = total_visual;
|
|
pane.last_height = inner.height;
|
|
|
|
if !pane.pinned {
|
|
pane.scroll = total_visual.saturating_sub(inner.height);
|
|
}
|
|
|
|
// Render text column
|
|
let text_para = Paragraph::new(lines.clone())
|
|
.wrap(Wrap { trim: false })
|
|
.scroll((pane.scroll, 0));
|
|
frame.render_widget(text_para, text_area);
|
|
|
|
// Render gutter markers at the correct visual rows
|
|
let mut gutter_lines: Vec<Line<'static>> = Vec::new();
|
|
let mut next_visual = 0u16;
|
|
for (i, &marker) in markers.iter().enumerate() {
|
|
let row = visual_rows[i];
|
|
// Fill blank lines up to this marker's row
|
|
while next_visual < row {
|
|
gutter_lines.push(Line::raw(""));
|
|
next_visual += 1;
|
|
}
|
|
let marker_text = match marker {
|
|
Marker::User => Line::styled("● ", Style::default().fg(Color::Cyan)),
|
|
Marker::Assistant => Line::styled("● ", Style::default().fg(Color::Magenta)),
|
|
Marker::None => Line::raw(""),
|
|
};
|
|
gutter_lines.push(marker_text);
|
|
next_visual = row + 1;
|
|
|
|
// Fill remaining visual lines for this logical line (wrap continuation)
|
|
let para = Paragraph::new(lines[i].clone()).wrap(Wrap { trim: false });
|
|
let height = para.line_count(text_width) as u16;
|
|
for _ in 1..height.max(1) {
|
|
gutter_lines.push(Line::raw(""));
|
|
next_visual += 1;
|
|
}
|
|
}
|
|
|
|
let gutter_para = Paragraph::new(gutter_lines)
|
|
.scroll((pane.scroll, 0));
|
|
frame.render_widget(gutter_para, gutter_area);
|
|
}
|
|
|
|
/// 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);
|
|
|
|
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.clone())
|
|
.wrap(Wrap { trim: false });
|
|
|
|
// Let ratatui tell us the total visual lines — no homegrown wrapping math.
|
|
let total = paragraph.line_count(area.width.saturating_sub(2)) as u16;
|
|
pane.last_total_lines = total;
|
|
pane.last_height = inner_height;
|
|
|
|
if !pane.pinned {
|
|
pane.scroll = total.saturating_sub(inner_height);
|
|
}
|
|
|
|
let paragraph = paragraph.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(())
|
|
}
|