2026-04-03 17:25:59 -04:00
|
|
|
// main_screen.rs — F1 main view rendering
|
|
|
|
|
//
|
|
|
|
|
// The default four-pane layout: autonomous, conversation, tools, status bar.
|
|
|
|
|
// Contains draw_main (the App method), draw_conversation_pane, and draw_pane.
|
|
|
|
|
|
|
|
|
|
use ratatui::{
|
|
|
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
|
|
|
style::{Color, Modifier, Style},
|
|
|
|
|
text::{Line, Span},
|
|
|
|
|
widgets::{Block, Borders, Paragraph, Wrap},
|
|
|
|
|
Frame,
|
2026-04-05 18:57:54 -04:00
|
|
|
crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
|
2026-04-03 17:25:59 -04:00
|
|
|
};
|
|
|
|
|
|
2026-04-05 18:57:54 -04:00
|
|
|
use super::{
|
|
|
|
|
ActivePane, App, HotkeyAction, Marker, PaneState, ScreenAction, ScreenView,
|
|
|
|
|
new_textarea, screen_legend,
|
|
|
|
|
};
|
|
|
|
|
use crate::user::ui_channel::{UiMessage, StreamTarget};
|
|
|
|
|
|
2026-04-05 19:27:14 -04:00
|
|
|
enum PaneTarget {
|
|
|
|
|
Conversation,
|
|
|
|
|
ConversationAssistant,
|
|
|
|
|
Tools,
|
|
|
|
|
ToolResult,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:57:54 -04:00
|
|
|
pub(crate) struct InteractScreen {
|
|
|
|
|
pub(crate) autonomous: PaneState,
|
|
|
|
|
pub(crate) conversation: PaneState,
|
|
|
|
|
pub(crate) tools: PaneState,
|
|
|
|
|
pub(crate) textarea: tui_textarea::TextArea<'static>,
|
|
|
|
|
pub(crate) input_history: Vec<String>,
|
|
|
|
|
pub(crate) history_index: Option<usize>,
|
|
|
|
|
pub(crate) active_pane: ActivePane,
|
|
|
|
|
pub(crate) pane_areas: [Rect; 3],
|
|
|
|
|
pub(crate) needs_assistant_marker: bool,
|
|
|
|
|
pub(crate) turn_started: Option<std::time::Instant>,
|
|
|
|
|
pub(crate) call_started: Option<std::time::Instant>,
|
|
|
|
|
pub(crate) call_timeout_secs: u64,
|
2026-04-05 19:35:19 -04:00
|
|
|
// State sync with agent — double buffer
|
2026-04-05 19:17:13 -04:00
|
|
|
last_generation: u64,
|
2026-04-05 19:41:16 -04:00
|
|
|
last_entries: Vec<crate::agent::context::ConversationEntry>,
|
2026-04-05 19:17:13 -04:00
|
|
|
/// Reference to agent for state sync
|
|
|
|
|
agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>,
|
2026-04-05 18:57:54 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl InteractScreen {
|
2026-04-05 19:17:13 -04:00
|
|
|
pub fn new(agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>) -> Self {
|
2026-04-05 18:57:54 -04:00
|
|
|
Self {
|
|
|
|
|
autonomous: PaneState::new(true),
|
|
|
|
|
conversation: PaneState::new(true),
|
|
|
|
|
tools: PaneState::new(false),
|
|
|
|
|
textarea: new_textarea(vec![String::new()]),
|
|
|
|
|
input_history: Vec::new(),
|
|
|
|
|
history_index: None,
|
|
|
|
|
active_pane: ActivePane::Conversation,
|
|
|
|
|
pane_areas: [Rect::default(); 3],
|
|
|
|
|
needs_assistant_marker: false,
|
|
|
|
|
turn_started: None,
|
|
|
|
|
call_started: None,
|
|
|
|
|
call_timeout_secs: 60,
|
2026-04-05 19:17:13 -04:00
|
|
|
last_generation: 0,
|
2026-04-05 19:41:16 -04:00
|
|
|
last_entries: Vec::new(),
|
2026-04-05 19:17:13 -04:00
|
|
|
agent,
|
2026-04-05 18:57:54 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 19:41:16 -04:00
|
|
|
/// Route an agent entry to the appropriate pane.
|
|
|
|
|
/// Returns None for entries that shouldn't be displayed (memory, system).
|
|
|
|
|
fn route_entry(&mut self, entry: &crate::agent::context::ConversationEntry) -> Option<&mut PaneState> {
|
|
|
|
|
use crate::agent::api::types::Role;
|
|
|
|
|
use crate::agent::context::ConversationEntry;
|
|
|
|
|
|
|
|
|
|
if let ConversationEntry::Memory { .. } = entry {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let msg = entry.message();
|
|
|
|
|
let text = msg.content_text().to_string();
|
|
|
|
|
|
|
|
|
|
match msg.role {
|
|
|
|
|
if text.is_empty() { return None; }
|
|
|
|
|
if text.starts_with("<system-reminder>") { return None; }
|
|
|
|
|
|
|
|
|
|
Role::User => Some(&mut self.conversation),
|
|
|
|
|
Role::Assistant => {
|
|
|
|
|
// Tool calls → tools pane
|
|
|
|
|
if let Some(ref calls) = msg.tool_calls {
|
|
|
|
|
for call in calls {
|
|
|
|
|
let line = format!("[{}] {}",
|
|
|
|
|
call.function.name,
|
|
|
|
|
call.function.arguments.chars().take(80).collect::<String>());
|
|
|
|
|
// TODO: return multiple targets — for now just return first tool call
|
|
|
|
|
return Some((PaneTarget::Tools, line, Marker::None));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Some((PaneTarget::ConversationAssistant, text, Marker::Assistant))
|
|
|
|
|
}
|
|
|
|
|
Role::Tool => Some(&mut self.tools),
|
|
|
|
|
Role::System => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 19:17:13 -04:00
|
|
|
/// Sync conversation display from agent entries.
|
|
|
|
|
fn sync_from_agent(&mut self) {
|
|
|
|
|
let agent = self.agent.blocking_lock();
|
|
|
|
|
let gen = agent.generation;
|
2026-04-05 19:35:19 -04:00
|
|
|
let entries = agent.entries();
|
2026-04-05 19:17:13 -04:00
|
|
|
|
2026-04-05 19:35:19 -04:00
|
|
|
// Phase 1: detect desync and pop
|
|
|
|
|
if gen != self.last_generation {
|
2026-04-05 19:17:13 -04:00
|
|
|
self.conversation = PaneState::new(true);
|
|
|
|
|
self.autonomous = PaneState::new(true);
|
|
|
|
|
self.tools = PaneState::new(false);
|
2026-04-05 19:41:16 -04:00
|
|
|
self.last_entries.clear();
|
2026-04-05 19:35:19 -04:00
|
|
|
} else {
|
2026-04-05 19:41:16 -04:00
|
|
|
// Pop entries from the tail that don't match
|
|
|
|
|
while !self.last_entries.is_empty() {
|
|
|
|
|
let i = self.last_entries.len() - 1;
|
|
|
|
|
if entries.get(i) == Some(&self.last_entries[i]) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
let popped = self.last_entries.pop().unwrap();
|
|
|
|
|
if let Some((target, _, _)) = Self::route_entry(&popped) {
|
|
|
|
|
match target {
|
|
|
|
|
PaneTarget::Conversation | PaneTarget::ConversationAssistant
|
|
|
|
|
=> self.conversation.pop_line(),
|
|
|
|
|
PaneTarget::Tools | PaneTarget::ToolResult
|
|
|
|
|
=> self.tools.pop_line(),
|
|
|
|
|
}
|
2026-04-05 19:35:19 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 19:17:13 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 19:41:16 -04:00
|
|
|
// Phase 2: push new entries
|
|
|
|
|
let start = self.last_entries.len();
|
2026-04-05 19:35:19 -04:00
|
|
|
for entry in entries.iter().skip(start) {
|
2026-04-05 19:41:16 -04:00
|
|
|
if let Some((target, text, marker)) = Self::route_entry(entry) {
|
2026-04-05 19:35:19 -04:00
|
|
|
match target {
|
|
|
|
|
PaneTarget::Conversation => {
|
|
|
|
|
self.conversation.push_line_with_marker(text, Color::Cyan, marker);
|
|
|
|
|
}
|
|
|
|
|
PaneTarget::ConversationAssistant => {
|
|
|
|
|
self.conversation.push_line_with_marker(text, Color::Reset, marker);
|
|
|
|
|
}
|
|
|
|
|
PaneTarget::Tools => {
|
|
|
|
|
self.tools.push_line(text, Color::Yellow);
|
|
|
|
|
}
|
|
|
|
|
PaneTarget::ToolResult => {
|
|
|
|
|
for line in text.lines().take(20) {
|
|
|
|
|
self.tools.push_line(format!(" {}", line), Color::DarkGray);
|
|
|
|
|
}
|
2026-04-05 19:22:31 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 19:17:13 -04:00
|
|
|
}
|
2026-04-05 19:41:16 -04:00
|
|
|
self.last_entries.push(entry.clone());
|
2026-04-05 19:17:13 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.last_generation = gen;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:57:54 -04:00
|
|
|
/// Process a UiMessage — update pane state.
|
|
|
|
|
pub fn handle_ui_message(&mut self, msg: &UiMessage, app: &mut App) {
|
|
|
|
|
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);
|
|
|
|
|
self.turn_started = Some(std::time::Instant::now());
|
|
|
|
|
self.needs_assistant_marker = true;
|
|
|
|
|
app.status.turn_tools = 0;
|
|
|
|
|
}
|
|
|
|
|
UiMessage::ToolCall { name, args_summary } => {
|
|
|
|
|
app.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 { result, .. } => {
|
|
|
|
|
for line in result.lines() {
|
|
|
|
|
self.tools.push_line(format!(" {}", line), Color::DarkGray);
|
|
|
|
|
}
|
|
|
|
|
self.tools.push_line(String::new(), Color::Reset);
|
|
|
|
|
}
|
|
|
|
|
UiMessage::DmnAnnotation(text) => {
|
|
|
|
|
self.autonomous.push_line(text.clone(), Color::Yellow);
|
|
|
|
|
self.turn_started = Some(std::time::Instant::now());
|
|
|
|
|
self.needs_assistant_marker = true;
|
|
|
|
|
app.status.turn_tools = 0;
|
|
|
|
|
}
|
|
|
|
|
UiMessage::StatusUpdate(info) => {
|
|
|
|
|
if !info.dmn_state.is_empty() {
|
|
|
|
|
app.status.dmn_state = info.dmn_state.clone();
|
|
|
|
|
app.status.dmn_turns = info.dmn_turns;
|
|
|
|
|
app.status.dmn_max_turns = info.dmn_max_turns;
|
|
|
|
|
}
|
|
|
|
|
if info.prompt_tokens > 0 { app.status.prompt_tokens = info.prompt_tokens; }
|
|
|
|
|
if !info.model.is_empty() { app.status.model = info.model.clone(); }
|
|
|
|
|
if !info.context_budget.is_empty() { app.status.context_budget = info.context_budget.clone(); }
|
|
|
|
|
}
|
|
|
|
|
UiMessage::Activity(text) => {
|
|
|
|
|
if text.is_empty() {
|
|
|
|
|
self.call_started = None;
|
|
|
|
|
} else if app.activity.is_empty() || self.call_started.is_none() {
|
|
|
|
|
self.call_started = Some(std::time::Instant::now());
|
|
|
|
|
self.call_timeout_secs = crate::config::get().api_stream_timeout_secs;
|
|
|
|
|
}
|
|
|
|
|
app.activity = text.clone();
|
|
|
|
|
}
|
|
|
|
|
UiMessage::Reasoning(text) => {
|
|
|
|
|
self.autonomous.current_color = Color::DarkGray;
|
|
|
|
|
self.autonomous.append_text(text);
|
|
|
|
|
}
|
|
|
|
|
UiMessage::Debug(text) => {
|
|
|
|
|
self.tools.push_line(format!("[debug] {}", text), Color::DarkGray);
|
|
|
|
|
}
|
|
|
|
|
UiMessage::Info(text) => {
|
|
|
|
|
self.conversation.push_line(text.clone(), Color::Cyan);
|
|
|
|
|
}
|
|
|
|
|
UiMessage::ContextInfoUpdate(info) => { app.context_info = Some(info.clone()); }
|
|
|
|
|
UiMessage::AgentUpdate(agents) => { app.agent_state = agents.clone(); }
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-03 17:25:59 -04:00
|
|
|
|
|
|
|
|
/// Draw the main (F1) screen — four-pane layout with status bar.
|
2026-04-05 18:57:54 -04:00
|
|
|
pub(crate) fn draw_main(&mut self, frame: &mut Frame, size: Rect, app: &App) {
|
2026-04-03 17:25:59 -04:00
|
|
|
// Main layout: content area + active tools overlay + status bar
|
2026-04-05 18:57:54 -04:00
|
|
|
let active_tools = app.active_tools.lock().unwrap();
|
2026-04-03 22:57:46 -04:00
|
|
|
let tool_lines = active_tools.len() as u16;
|
2026-04-03 17:25:59 -04:00
|
|
|
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,
|
user: ScreenView trait, overlay screens extracted from App
Convert F2-F5 screens to ScreenView trait with tick() method.
Each screen owns its view state (scroll, selection, expanded).
State persists across screen switches.
- ThalamusScreen: owns sampling_selected, scroll
- ConsciousScreen: owns scroll, selected, expanded
- SubconsciousScreen: owns selected, log_view, scroll
- UnconsciousScreen: owns scroll
Removed from App: Screen enum, debug_scroll, debug_selected,
debug_expanded, agent_selected, agent_log_view, sampling_selected,
set_screen(), per-screen key handling, draw dispatch.
App now only draws the interact (F1) screen. Overlay screens are
drawn by the event loop via ScreenView::tick. F-key routing and
screen instantiation to be wired in event_loop next.
InteractScreen (state-driven, reading from agent entries) is the
next step — will eliminate the input display race condition.
Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
2026-04-05 17:54:40 -04:00
|
|
|
Some(&screen_legend()));
|
2026-04-03 17:25:59 -04:00
|
|
|
|
|
|
|
|
// Draw tools pane
|
|
|
|
|
let tools_active = self.active_pane == ActivePane::Tools;
|
|
|
|
|
draw_pane(frame, right_col, "tools", &mut self.tools, tools_active, None);
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-04-03 22:57:46 -04:00
|
|
|
if !active_tools.is_empty() {
|
2026-04-03 17:25:59 -04:00
|
|
|
let tool_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::DIM);
|
2026-04-03 22:57:46 -04:00
|
|
|
let tool_text: Vec<Line> = active_tools.iter().map(|t| {
|
2026-04-03 17:25:59 -04:00
|
|
|
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
|
2026-04-05 18:57:54 -04:00
|
|
|
let timer = if !app.activity.is_empty() {
|
2026-04-03 17:25:59 -04:00
|
|
|
let total = self.turn_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
|
|
|
|
let call = self.call_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
|
|
|
|
format!(" {}s, {}/{}s", total, call, self.call_timeout_secs)
|
|
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
};
|
2026-04-05 18:57:54 -04:00
|
|
|
let tools_info = if app.status.turn_tools > 0 {
|
|
|
|
|
format!(" ({}t)", app.status.turn_tools)
|
2026-04-03 17:25:59 -04:00
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
};
|
2026-04-05 18:57:54 -04:00
|
|
|
let activity_part = if app.activity.is_empty() {
|
2026-04-03 17:25:59 -04:00
|
|
|
String::new()
|
|
|
|
|
} else {
|
2026-04-05 18:57:54 -04:00
|
|
|
format!(" | {}{}{}", app.activity, tools_info, timer)
|
2026-04-03 17:25:59 -04:00
|
|
|
};
|
|
|
|
|
|
2026-04-05 18:57:54 -04:00
|
|
|
let budget_part = if app.status.context_budget.is_empty() {
|
2026-04-03 17:25:59 -04:00
|
|
|
String::new()
|
|
|
|
|
} else {
|
2026-04-05 18:57:54 -04:00
|
|
|
format!(" [{}]", app.status.context_budget)
|
2026-04-03 17:25:59 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let left_status = format!(
|
|
|
|
|
" {} | {}/{} dmn | {}K tok in{}{}",
|
2026-04-05 18:57:54 -04:00
|
|
|
app.status.dmn_state,
|
|
|
|
|
app.status.dmn_turns,
|
|
|
|
|
app.status.dmn_max_turns,
|
|
|
|
|
app.status.prompt_tokens / 1000,
|
2026-04-03 17:25:59 -04:00
|
|
|
budget_part,
|
|
|
|
|
activity_part,
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-05 18:57:54 -04:00
|
|
|
let proc_indicator = if app.running_processes > 0 {
|
|
|
|
|
format!(" {}proc", app.running_processes)
|
2026-04-03 17:25:59 -04:00
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
};
|
2026-04-05 18:57:54 -04:00
|
|
|
let reason_indicator = if app.reasoning_effort != "none" {
|
|
|
|
|
format!(" reason:{}", app.reasoning_effort)
|
2026-04-03 17:25:59 -04:00
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
};
|
|
|
|
|
let right_legend = format!(
|
|
|
|
|
"{}{} ^P:pause ^R:reason ^K:kill | {} ",
|
|
|
|
|
reason_indicator,
|
|
|
|
|
proc_indicator,
|
2026-04-05 18:57:54 -04:00
|
|
|
app.status.model,
|
2026-04-03 17:25:59 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 19:03:06 -04:00
|
|
|
impl ScreenView for InteractScreen {
|
|
|
|
|
fn label(&self) -> &'static str { "interact" }
|
|
|
|
|
|
|
|
|
|
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
|
|
|
|
key: Option<KeyEvent>, app: &mut App) -> Option<ScreenAction> {
|
|
|
|
|
// Handle keys
|
|
|
|
|
if let Some(key) = key {
|
|
|
|
|
match key.code {
|
|
|
|
|
KeyCode::Esc => return Some(ScreenAction::Hotkey(HotkeyAction::Interrupt)),
|
|
|
|
|
KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::SHIFT) => {
|
|
|
|
|
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;
|
|
|
|
|
// TODO: push to submitted via app or return action
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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 !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 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
_ => { self.textarea.input(key); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 19:17:13 -04:00
|
|
|
// Sync state from agent
|
|
|
|
|
self.sync_from_agent();
|
|
|
|
|
|
2026-04-05 19:03:06 -04:00
|
|
|
// Draw
|
|
|
|
|
self.draw_main(frame, area, app);
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 17:25:59 -04:00
|
|
|
/// Draw the conversation pane with a two-column layout: marker gutter + text.
|
|
|
|
|
/// The gutter shows a marker 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,
|
|
|
|
|
left_title: Option<&str>,
|
|
|
|
|
) {
|
|
|
|
|
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 mut block = Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.border_style(border_style);
|
|
|
|
|
if let Some(left) = left_title {
|
|
|
|
|
block = block
|
|
|
|
|
.title_top(Line::from(left).left_aligned())
|
|
|
|
|
.title_top(Line::from(format!(" {} ", title)).right_aligned());
|
|
|
|
|
} else {
|
|
|
|
|
block = block.title(format!(" {} ", title));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|