consciousness/src/user/chat.rs
ProofOfConcept ceaa66e30d scroll_pane: extract scroll state from chat.rs
New ScrollPaneState centralizes height caching, scroll offset,
pin-to-bottom, visible range computation, and screen-to-item
coordinate mapping. Replaces the hand-rolled scroll bookkeeping
that was duplicated across draw_conversation_pane and draw_pane.

-170 lines from chat.rs. The scroll_pane module also includes a
ScrollPane StatefulWidget ready to wire up for the next step:
collapsing the draw functions into render_stateful_widget calls.

Co-Authored-By: Kent Overstreet <kent.overstreet@gmail.com>
2026-04-11 01:35:15 -04:00

1187 lines
45 KiB
Rust

// 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,
};
use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseEvent, MouseEventKind, MouseButton};
use super::{App, ScreenView, screen_legend};
use crate::agent::context::{AstNode, NodeBody, Role, Ast};
use crate::mind::MindCommand;
// --- Slash command table ---
type CmdHandler = fn(&InteractScreen, &str);
struct SlashCommand {
name: &'static str,
help: &'static str,
handler: CmdHandler,
}
fn commands() -> Vec<SlashCommand> { vec![
SlashCommand { name: "/quit", help: "Exit consciousness",
handler: |_, _| {} },
SlashCommand { name: "/new", help: "Start fresh session (saves current)",
handler: |s, _| { let _ = s.mind_tx.send(MindCommand::NewSession); } },
SlashCommand { name: "/save", help: "Save session to disk",
handler: |s, _| {
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("saved"); }
} },
SlashCommand { name: "/model", help: "Show/switch model (/model <name>)",
handler: |s, arg| {
if arg.is_empty() {
if let Ok(mut ag) = s.agent.state.try_lock() {
let names = s.agent.app_config.model_names();
let label = if names.is_empty() {
format!("model: {}", s.agent.model())
} else {
format!("model: {} ({})", s.agent.model(), names.join(", "))
};
ag.notify(label);
}
} else {
let agent = s.agent.clone();
let name = arg.to_string();
tokio::spawn(async move {
let _act = crate::agent::start_activity(&agent, format!("switching to {}...", name)).await;
cmd_switch_model(&agent, &name).await;
});
}
} },
SlashCommand { name: "/score", help: "Score memory importance (full matrix)",
handler: |s, _| { let _ = s.mind_tx.send(MindCommand::ScoreFull); } },
SlashCommand { name: "/dmn", help: "Show DMN state",
handler: |s, _| {
let st = s.shared_mind.lock().unwrap();
if let Ok(mut ag) = s.agent.state.try_lock() {
ag.notify(format!("DMN: {:?} ({}/{})", st.dmn, st.dmn_turns, st.max_dmn_turns));
}
} },
SlashCommand { name: "/sleep", help: "Put DMN to sleep",
handler: |s, _| {
let mut st = s.shared_mind.lock().unwrap();
st.dmn = crate::mind::subconscious::State::Resting { since: std::time::Instant::now() };
st.dmn_turns = 0;
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN sleeping"); }
} },
SlashCommand { name: "/wake", help: "Wake DMN to foraging",
handler: |s, _| {
let mut st = s.shared_mind.lock().unwrap();
if matches!(st.dmn, crate::mind::subconscious::State::Off) { crate::mind::subconscious::set_off(false); }
st.dmn = crate::mind::subconscious::State::Foraging;
st.dmn_turns = 0;
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN foraging"); }
} },
SlashCommand { name: "/pause", help: "Full stop — no autonomous ticks (Ctrl+P)",
handler: |s, _| {
let mut st = s.shared_mind.lock().unwrap();
st.dmn = crate::mind::subconscious::State::Paused;
st.dmn_turns = 0;
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN paused"); }
} },
SlashCommand { name: "/help", help: "Show this help",
handler: |s, _| { notify_help(&s.agent); } },
]}
fn dispatch_command(input: &str) -> Option<SlashCommand> {
let cmd_name = input.split_whitespace().next()?;
commands().into_iter().find(|c| c.name == cmd_name)
}
/// Switch model — used by both /model command and tool-initiated switches.
pub async fn cmd_switch_model(
agent: &std::sync::Arc<crate::agent::Agent>,
name: &str,
) {
let resolved = match agent.app_config.resolve_model(name) {
Ok(r) => r,
Err(e) => {
agent.state.lock().await.notify(format!("model error: {}", e));
return;
}
};
let _new_client = crate::agent::api::ApiClient::new(
&resolved.api_base, &resolved.api_key, &resolved.model_id,
);
let prompt_changed = resolved.prompt_file != agent.prompt_file;
if prompt_changed {
agent.compact().await;
agent.state.lock().await.notify(format!("switched to {} (recompacted)", resolved.model_id));
} else {
agent.state.lock().await.notify(format!("switched to {}", resolved.model_id));
}
}
fn notify_help(agent: &std::sync::Arc<crate::agent::Agent>) {
if let Ok(mut ag) = agent.state.try_lock() {
let mut help = String::new();
for cmd in &commands() {
help.push_str(&format!("{:12} {}\n", cmd.name, cmd.help));
}
help.push_str("Keys: Tab ^Up/Down PgUp/Down Mouse Esc ^P ^R ^K");
ag.notify(help);
}
}
/// Turn marker for the conversation pane gutter.
#[derive(Clone, Copy, PartialEq, Default)]
enum Marker {
#[default]
None,
User,
Assistant,
}
#[derive(PartialEq)]
enum PaneTarget {
Conversation,
ConversationAssistant,
Tools,
ToolResult,
}
const MAX_PANE_LINES: usize = 10_000;
/// Which pane receives scroll keys.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActivePane {
Autonomous,
Conversation,
Tools,
}
/// Text selection within a pane. Anchor is where the click started,
/// cursor is where the mouse currently is. They may be in either order.
#[derive(Debug, Clone, PartialEq, Default)]
struct Selection {
anchor_line: usize,
anchor_col: usize,
cursor_line: usize,
cursor_col: usize,
}
impl Selection {
fn new(line: usize, col: usize) -> Self {
Self { anchor_line: line, anchor_col: col, cursor_line: line, cursor_col: col }
}
fn extend(&mut self, line: usize, col: usize) {
self.cursor_line = line;
self.cursor_col = col;
}
/// Normalized range: (start_line, start_col, end_line, end_col)
fn range(&self) -> (usize, usize, usize, usize) {
if (self.anchor_line, self.anchor_col) <= (self.cursor_line, self.cursor_col) {
(self.anchor_line, self.anchor_col, self.cursor_line, self.cursor_col)
} else {
(self.cursor_line, self.cursor_col, self.anchor_line, self.anchor_col)
}
}
fn text(&self, lines: &[Line<'static>]) -> String {
let (start_line, start_col, end_line, end_col) = self.range();
let mut result = String::new();
for (i, line) in lines.iter().enumerate() {
if i < start_line || i > end_line { continue; }
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let sc = if i == start_line { start_col } else { 0 };
let ec = if i == end_line { end_col } else { line_text.len() };
if sc < line_text.len() {
if let Some(selected) = line_text.get(sc..ec.min(line_text.len())) {
if !result.is_empty() {
result.push('\n');
}
result.push_str(selected);
}
}
}
result
}
}
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' {
if chars.peek() == Some(&'[') {
chars.next();
while let Some(&c) = chars.peek() {
if c.is_ascii() && (0x20..=0x3F).contains(&(c as u8)) {
chars.next();
} else {
break;
}
}
if let Some(&c) = chars.peek() {
if c.is_ascii() && (0x40..=0x7E).contains(&(c as u8)) {
chars.next();
}
}
} else if let Some(&c) = chars.peek() {
if c.is_ascii() && (0x40..=0x5F).contains(&(c as u8)) {
chars.next();
}
}
} else {
out.push(ch);
}
}
out
}
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
}
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()
}
struct PaneState {
lines: Vec<Line<'static>>,
markers: Vec<Marker>,
current_line: String,
current_color: Color,
md_buffer: String,
use_markdown: bool,
pending_marker: Marker,
scroll: super::scroll_pane::ScrollPaneState,
selection: Option<Selection>,
}
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: super::scroll_pane::ScrollPaneState::new(),
selection: None,
}
}
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);
self.scroll.invalidate();
}
}
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));
}
}
fn push_line(&mut self, line: String, color: Color) {
self.push_line_with_marker(line, color, Marker::None);
}
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);
self.evict();
} else {
self.current_line.push(ch);
}
}
}
}
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();
}
fn pop_line(&mut self) {
self.lines.pop();
self.markers.pop();
self.scroll.invalidate_from(self.lines.len());
}
fn scroll_up(&mut self, n: u16) {
self.scroll.scroll_up(n);
}
fn scroll_down(&mut self, n: u16) {
self.scroll.scroll_down(n);
}
fn all_lines(&self) -> Vec<Line<'static>> {
let (lines, _) = self.all_lines_with_markers();
lines
}
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)
}
/// Convert mouse coordinates (relative to pane) to line/column position.
fn mouse_to_position(&self, mouse_x: u16, mouse_y: u16) -> Option<(usize, usize)> {
let (lines, _) = self.all_lines_with_markers();
self.scroll.screen_to_item(mouse_x, mouse_y, &lines)
}
/// Set the selection start position.
fn start_selection(&mut self, line: usize, col: usize) {
self.selection = Some(Selection::new(line, col));
}
/// Update the selection end position.
fn extend_selection(&mut self, line: usize, col: usize) {
if let Some(ref mut sel) = self.selection {
sel.extend(line, col);
}
}
/// Get the selected text, or None if nothing is selected.
fn get_selection(&self) -> Option<String> {
let (lines, _) = self.all_lines_with_markers();
self.selection.as_ref().map(|sel| sel.text(&lines))
}
}
pub(crate) struct InteractScreen {
autonomous: PaneState,
conversation: PaneState,
tools: PaneState,
textarea: tui_textarea::TextArea<'static>,
input_history: Vec<String>,
history_index: Option<usize>,
active_pane: ActivePane,
pane_areas: [Rect; 3],
turn_started: Option<std::time::Instant>,
call_started: Option<std::time::Instant>,
call_timeout_secs: u64,
// State sync with agent — double buffer
last_generation: u64,
last_entries: Vec<AstNode>,
pending_display_count: usize,
/// Reference to agent for state sync
agent: std::sync::Arc<crate::agent::Agent>,
shared_mind: std::sync::Arc<crate::mind::SharedMindState>,
mind_tx: tokio::sync::mpsc::UnboundedSender<crate::mind::MindCommand>,
}
impl InteractScreen {
pub fn new(
agent: std::sync::Arc<crate::agent::Agent>,
shared_mind: std::sync::Arc<crate::mind::SharedMindState>,
mind_tx: tokio::sync::mpsc::UnboundedSender<crate::mind::MindCommand>,
) -> Self {
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],
turn_started: None,
call_started: None,
call_timeout_secs: 60,
last_generation: 0,
last_entries: Vec::new(),
pending_display_count: 0,
agent,
shared_mind,
mind_tx,
}
}
fn route_node(node: &AstNode) -> Vec<(PaneTarget, String, Marker)> {
match node {
AstNode::Leaf(leaf) => {
let text = leaf.body().text().to_string();
match leaf.body() {
NodeBody::Memory { .. } | NodeBody::Thinking(_)
| NodeBody::Log(_) | NodeBody::Dmn(_) => vec![],
NodeBody::Content(_) => {
if text.is_empty() || text.starts_with("<system-reminder>") { vec![] }
else { vec![(PaneTarget::Conversation, text, Marker::User)] }
}
NodeBody::ToolCall { name, arguments } => {
let line = format!("[{}] {}", name, arguments.chars().take(80).collect::<String>());
vec![(PaneTarget::Tools, line, Marker::None)]
}
NodeBody::ToolResult(t) => {
if t.is_empty() { vec![] }
else { vec![(PaneTarget::ToolResult, text, Marker::None)] }
}
}
}
AstNode::Branch { role, children, .. } => {
match role {
Role::User => {
let text: String = children.iter()
.filter_map(|c| c.leaf())
.filter(|l| matches!(l.body(), NodeBody::Content(_)))
.map(|l| l.body().text())
.collect::<Vec<_>>()
.join("");
if text.is_empty() || text.starts_with("<system-reminder>") { vec![] }
else { vec![(PaneTarget::Conversation, text, Marker::User)] }
}
Role::Assistant => {
let mut items = Vec::new();
for child in children {
items.extend(Self::route_node(child));
}
// Re-tag content as assistant
for item in &mut items {
if item.0 == PaneTarget::Conversation {
item.0 = PaneTarget::ConversationAssistant;
item.2 = Marker::Assistant;
}
}
items
}
Role::System => vec![],
}
}
}
}
fn push_routed(&mut self, node: &AstNode) {
for (target, text, marker) in Self::route_node(node) {
match target {
PaneTarget::Conversation => {
self.conversation.current_color = Color::Cyan;
self.conversation.append_text(&text);
self.conversation.pending_marker = marker;
self.conversation.flush_pending();
},
PaneTarget::ConversationAssistant => {
self.conversation.current_color = Color::Reset;
self.conversation.append_text(&text);
self.conversation.pending_marker = marker;
self.conversation.flush_pending();
},
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);
}
}
}
}
}
fn pop_routed(&mut self, node: &AstNode) {
for (target, _, _) in Self::route_node(node) {
match target {
PaneTarget::Conversation | PaneTarget::ConversationAssistant
=> self.conversation.pop_line(),
PaneTarget::Tools | PaneTarget::ToolResult
=> self.tools.pop_line(),
}
}
}
fn sync_from_agent(&mut self) {
for _ in 0..self.pending_display_count {
self.conversation.pop_line();
}
self.pending_display_count = 0;
let (generation, entries) = {
let st = match self.agent.state.try_lock() {
Ok(st) => st,
Err(_) => return,
};
let generation = st.generation;
drop(st);
let ctx = match self.agent.context.try_lock() {
Ok(ctx) => ctx,
Err(_) => return,
};
(generation, ctx.conversation().to_vec())
};
// Full reset on generation change
if generation != self.last_generation {
self.conversation = PaneState::new(true);
self.autonomous = PaneState::new(true);
self.tools = PaneState::new(false);
self.last_entries.clear();
}
// Detect changed entries (streaming updates mutate the last entry)
// Walk backwards from the end, pop any that differ
let mut pop_from = self.last_entries.len();
for i in (0..self.last_entries.len()).rev() {
if i >= entries.len() {
pop_from = i;
continue;
}
// Compare token count as a cheap change detector
if self.last_entries[i].tokens() != entries[i].tokens() {
pop_from = i;
} else {
break; // entries before this haven't changed
}
}
while self.last_entries.len() > pop_from {
let popped = self.last_entries.pop().unwrap();
self.pop_routed(&popped);
}
// Push new/changed entries
for node in entries.iter().skip(self.last_entries.len()) {
self.push_routed(node);
self.last_entries.push(node.clone());
}
self.last_generation = generation;
// Display pending input (queued in Mind, not yet accepted)
let mind = self.shared_mind.lock().unwrap();
for input in &mind.input {
self.conversation.push_line_with_marker(
input.clone(), Color::DarkGray, Marker::User,
);
self.pending_display_count += 1;
}
}
/// Dispatch user input — slash commands or conversation.
fn dispatch_input(&self, input: &str, app: &mut App) {
let input = input.trim();
if input.is_empty() { return; }
if input == "/quit" || input == "/exit" {
app.should_quit = true;
return;
}
if input.starts_with('/') {
if let Some(cmd) = dispatch_command(input) {
(cmd.handler)(self, &input[cmd.name.len()..].trim_start());
} else {
if let Ok(mut ag) = self.agent.state.try_lock() {
ag.notify(format!("unknown: {}", input.split_whitespace().next().unwrap_or(input)));
}
}
return;
}
// Regular input → queue to Mind, then wake it
self.shared_mind.lock().unwrap().input.push(input.to_string());
let _ = self.mind_tx.send(MindCommand::None);
}
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),
}
}
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 };
let rel_x = x.saturating_sub(area.x);
let rel_y = y.saturating_sub(area.y);
self.selection_event(i, rel_x, rel_y, true);
break;
}
}
}
MouseEventKind::Drag(MouseButton::Left) => {
let (x, y) = (mouse.column, mouse.row);
let i = match self.active_pane { ActivePane::Autonomous => 0, ActivePane::Conversation => 1, ActivePane::Tools => 2 };
let area = self.pane_areas[i];
if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height {
let rel_x = x.saturating_sub(area.x);
let rel_y = y.saturating_sub(area.y);
self.selection_event(i, rel_x, rel_y, false);
}
}
MouseEventKind::Up(MouseButton::Left) => {
self.copy_selection_to_clipboard();
}
MouseEventKind::Down(MouseButton::Middle) => {
self.paste_from_selection();
}
_ => {}
}
}
/// Copy the current selection to the clipboard via OSC 52.
fn copy_selection_to_clipboard(&self) {
let text = match self.active_pane {
ActivePane::Autonomous => self.autonomous.get_selection(),
ActivePane::Conversation => self.conversation.get_selection(),
ActivePane::Tools => self.tools.get_selection(),
};
if let Some(ref selected_text) = text {
if selected_text.is_empty() { return; }
// OSC 52 clipboard copy
use std::io::Write;
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(selected_text);
let mut stdout = std::io::stdout().lock();
let _ = write!(stdout, "\x1b]52;c;{}\x07", encoded);
let _ = stdout.flush();
}
}
/// Paste from tmux buffer via middle-click.
fn paste_from_selection(&mut self) {
let result = std::process::Command::new("tmux")
.args(["save-buffer", "-"]).output();
if let Ok(output) = result {
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout).into_owned();
if !text.is_empty() {
self.textarea.insert_str(&text);
}
}
}
}
fn pane_mut(&mut self, idx: usize) -> &mut PaneState {
match idx { 0 => &mut self.autonomous, 1 => &mut self.conversation, _ => &mut self.tools }
}
fn selection_event(&mut self, pane_idx: usize, rel_x: u16, rel_y: u16, start: bool) {
let pane = self.pane_mut(pane_idx);
if let Some((line, col)) = pane.mouse_to_position(rel_x, rel_y) {
if start {
pane.start_selection(line, col);
} else {
pane.extend_selection(line, col);
}
}
self.copy_selection_to_clipboard();
}
/// Draw the main (F1) screen — four-pane layout with status bar.
fn draw_main(&mut self, frame: &mut Frame, size: Rect, app: &App) {
// Main layout: content area + active tools overlay + status bar
let st_guard = app.agent.state.try_lock().ok();
let tool_lines = st_guard.as_ref()
.map(|st| st.active_tools.len() as u16).unwrap_or(0);
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,
Some(&screen_legend()));
// 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]);
if let Some(ref st) = st_guard {
if !st.active_tools.is_empty() {
let tool_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::DIM);
let tool_text: Vec<Line> = st.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 timer = if !app.activity.is_empty() {
let elapsed = app.activity_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
format!(" {}s", elapsed)
} else {
String::new()
};
let tools_info = if app.status.turn_tools > 0 {
format!(" ({}t)", app.status.turn_tools)
} else {
String::new()
};
let activity_part = if app.activity.is_empty() {
String::new()
} else {
format!(" | {}{}{}", app.activity, tools_info, timer)
};
let budget_part = if app.status.context_budget.is_empty() {
String::new()
} else {
format!(" [{}]", app.status.context_budget)
};
let left_status = format!(
" {} | {}/{} dmn | {}K tok in{}{}",
app.status.dmn_state,
app.status.dmn_turns,
app.status.dmn_max_turns,
app.status.prompt_tokens / 1000,
budget_part,
activity_part,
);
let proc_indicator = if app.running_processes > 0 {
format!(" {}proc", app.running_processes)
} else {
String::new()
};
let reason_indicator = if app.reasoning_effort != "none" {
format!(" reason:{}", app.reasoning_effort)
} else {
String::new()
};
let right_legend = format!(
"{}{} ^P:pause ^R:reason ^K:kill | {} ",
reason_indicator,
proc_indicator,
app.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);
}
}
impl ScreenView for InteractScreen {
fn label(&self) -> &'static str { "interact" }
fn tick(&mut self, frame: &mut Frame, area: Rect,
events: &[ratatui::crossterm::event::Event], app: &mut App) {
use ratatui::crossterm::event::Event;
// Handle events
for event in events {
match event {
Event::Key(key) if key.kind == ratatui::crossterm::event::KeyEventKind::Press => {
match key.code {
KeyCode::Esc => { let _ = self.mind_tx.send(crate::mind::MindCommand::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;
self.dispatch_input(&input, app);
self.textarea = new_textarea(vec![String::new()]);
}
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT) => {
// Ctrl+Shift+C: copy selection
self.copy_selection_to_clipboard();
}
// Paste: terminal handles Ctrl+Shift+V natively via bracketed paste
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); }
}
}
Event::Mouse(mouse) => { self.handle_mouse(*mouse); }
Event::Paste(text) => {
self.textarea.insert_str(text);
}
_ => {}
}
}
// Sync state from agent
self.sync_from_agent();
// Read status from agent + mind state
if let Ok(mut st) = self.agent.state.try_lock() {
st.expire_activities();
app.status.prompt_tokens = st.last_prompt_tokens;
app.status.model = self.agent.model().to_string();
app.activity = st.activities.last()
.map(|a| a.label.clone())
.unwrap_or_default();
app.activity_started = st.activities.last()
.map(|a| a.started);
}
if let Ok(ctx) = self.agent.context.try_lock() {
let window = crate::agent::context::context_window();
if window > 0 {
let sys = ctx.system().iter().map(|n| n.tokens()).sum::<usize>();
let id = ctx.identity().iter().map(|n| n.tokens()).sum::<usize>();
let jnl = ctx.journal().iter().map(|n| n.tokens()).sum::<usize>();
let mut mem = 0usize;
let mut conv = 0usize;
for n in ctx.conversation() {
let t = n.tokens();
if matches!(n, AstNode::Leaf(l) if matches!(l.body(), NodeBody::Memory { .. })) {
mem += t;
} else {
conv += t;
}
}
let used = sys + id + jnl + mem + conv;
let free = window.saturating_sub(used);
let pct = |n: usize| if n == 0 { 0 } else { ((n * 100) / window).max(1) };
app.status.context_budget = format!(
"sys:{}% id:{}% jnl:{}% mem:{}% conv:{}% free:{}%",
pct(sys), pct(id), pct(jnl), pct(mem), pct(conv), pct(free),
);
}
}
{
let mind = self.shared_mind.lock().unwrap();
app.status.dmn_state = mind.dmn.label().to_string();
app.status.dmn_turns = mind.dmn_turns;
app.status.dmn_max_turns = mind.max_dmn_turns;
}
// Draw
self.draw_main(frame, area, app);
}
}
/// 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,
) {
use super::scroll_pane::visible_range;
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;
}
let cols = Layout::horizontal([Constraint::Length(2), Constraint::Min(1)]).split(inner);
let gutter_area = cols[0];
let text_area = cols[1];
let text_width = text_area.width;
let (lines, markers) = pane.all_lines_with_markers();
pane.scroll.ensure_heights_for_lines(&lines, text_width);
if !pane.scroll.pinned {
pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner.height);
}
pane.scroll.viewport_height = inner.height;
pane.scroll.offset = pane.scroll.offset.min(pane.scroll.total_visual.saturating_sub(inner.height));
let heights = pane.scroll.heights();
let (first, sub_scroll, last) = visible_range(heights, pane.scroll.offset, inner.height);
// Apply selection highlighting to visible lines
let visible_lines: Vec<Line<'static>> = if let Some(ref sel) = pane.selection {
let (sl, sc, el, ec) = sel.range();
(first..last).map(|i| {
let line = &lines[i];
if i >= sl && i <= el {
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let start_col = if i == sl { sc } else { 0 };
let end_col = if i == el { ec } else { line_text.len() };
if start_col < end_col {
let before = if start_col > 0 { &line_text[..start_col] } else { "" };
let selected = &line_text[start_col..end_col];
let after = if end_col < line_text.len() { &line_text[end_col..] } else { "" };
let mut spans = Vec::new();
if !before.is_empty() { spans.push(Span::raw(before.to_string())); }
spans.push(Span::styled(selected.to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)));
if !after.is_empty() { spans.push(Span::raw(after.to_string())); }
Line::from(spans).style(line.style).alignment(line.alignment.unwrap_or(ratatui::layout::Alignment::Left))
} else {
line.clone()
}
} else {
line.clone()
}
}).collect()
} else {
lines[first..last].to_vec()
};
let text_para = Paragraph::new(visible_lines)
.wrap(Wrap { trim: false })
.scroll((sub_scroll, 0));
frame.render_widget(text_para, text_area);
// Build gutter for the visible slice
let mut gutter_lines: Vec<Line<'static>> = Vec::new();
for i in first..last {
gutter_lines.push(match markers[i] {
Marker::User => Line::styled("", Style::default().fg(Color::Cyan)),
Marker::Assistant => Line::styled("", Style::default().fg(Color::Magenta)),
Marker::None => Line::raw(""),
});
for _ in 1..heights[i] {
gutter_lines.push(Line::raw(""));
}
}
let gutter_para = Paragraph::new(gutter_lines).scroll((sub_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>,
) {
use super::scroll_pane::visible_range;
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 text_width = area.width.saturating_sub(2);
let inner_height = area.height.saturating_sub(2);
let lines = pane.all_lines();
pane.scroll.ensure_heights_for_lines(&lines, text_width);
if !pane.scroll.pinned {
pane.scroll.offset = pane.scroll.total_visual.saturating_sub(inner_height);
}
pane.scroll.viewport_height = inner_height;
pane.scroll.offset = pane.scroll.offset.min(pane.scroll.total_visual.saturating_sub(inner_height));
let (first, sub_scroll, last) = visible_range(pane.scroll.heights(), pane.scroll.offset, inner_height);
let paragraph = Paragraph::new(lines[first..last].to_vec())
.block(block.clone())
.wrap(Wrap { trim: false })
.scroll((sub_scroll, 0));
frame.render_widget(paragraph, area);
}