consciousness/src/user/chat.rs
Kent Overstreet 4225294d16 replace try_lock() with lock_blocking() across UI thread
Add lock_blocking() to TrackedMutex: blocks current thread using
block_in_place + futures::executor::block_on, safe for sync contexts.

Replace all try_lock() calls with lock_blocking() in slash commands,
UI rendering, and status reads. Lock hold times are fast enough that
blocking briefly is fine, and this eliminates the spurious 'lock
unavailable' paths that were never actually hit.

Kept rx_mutex.try_lock() in mod.rs (std::sync::Mutex for stderr rx).
2026-04-25 15:35:14 -04:00

1161 lines
44 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, _| {
{ let mut ag = s.agent.state.lock_blocking(); ag.notify("saved"); }
} },
SlashCommand { name: "/model", help: "Show/switch model (/model <name>)",
handler: |s, arg| {
if arg.is_empty() {
{ let mut ag = s.agent.state.lock_blocking();
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();
{ let mut ag = s.agent.state.lock_blocking();
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;
{ let mut ag = s.agent.state.lock_blocking(); 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;
{ let mut ag = s.agent.state.lock_blocking(); 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;
{ let mut ag = s.agent.state.lock_blocking(); 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,
);
agent.state.lock().await.notify(format!("switched to {}", resolved.model_id));
}
fn notify_help(agent: &std::sync::Arc<crate::agent::Agent>) {
{ let mut ag = agent.state.lock_blocking();
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,
}
impl Marker {
fn gutter_span(self) -> Option<Span<'static>> {
match self {
Marker::User => Some(Span::styled("", Style::default().fg(Color::Cyan))),
Marker::Assistant => Some(Span::styled("", Style::default().fg(Color::Magenta))),
Marker::None => None,
}
}
}
/// A line paired with a gutter marker, for use with ScrollPane.
struct MarkedLine {
line: Line<'static>,
marker: Marker,
}
impl super::scroll_pane::ScrollItem for MarkedLine {
fn content(&self) -> ratatui::text::Text<'_> {
ratatui::text::Text::from(self.line.clone())
}
fn gutter(&self) -> Option<Span<'_>> {
self.marker.gutter_span()
}
}
#[derive(PartialEq)]
enum PaneTarget {
Conversation,
ConversationAssistant,
Tools,
ToolResult,
Autonomous,
}
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) {
if self.lines.pop().is_some() {
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],
// 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],
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::Log(_) | NodeBody::Dmn(_) => vec![],
NodeBody::Thinking(_) => {
if text.is_empty() { vec![] }
else { vec![(PaneTarget::Autonomous, text, Marker::None)] }
}
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)] }
}
NodeBody::Image { orig_height, orig_width, .. } => {
vec![(PaneTarget::Conversation,
format!("[image {}x{}]", orig_width, orig_height),
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);
}
}
PaneTarget::Autonomous => {
self.autonomous.current_color = Color::Gray;
self.autonomous.append_text(&text);
self.autonomous.pending_marker = marker;
self.autonomous.flush_pending();
}
}
}
}
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(),
PaneTarget::Autonomous
=> self.autonomous.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 = self.agent.state.lock_blocking();
let generation = st.generation;
drop(st);
let ctx = self.agent.context.lock_blocking();
(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 {
{ let mut ag = self.agent.state.lock_blocking();
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.lock_blocking();
let tool_lines = st_guard.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,
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 !st_guard.active_tools.is_empty() {
let tool_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::DIM);
let tool_text: Vec<Line> = st_guard.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
{ let mut st = self.agent.state.lock_blocking();
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);
}
{ let ctx = self.agent.context.lock_blocking();
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::ScrollPane;
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 (lines, markers) = pane.all_lines_with_markers();
// Apply selection highlighting
let items: Vec<MarkedLine> = if let Some(ref sel) = pane.selection {
let (sl, sc, el, ec) = sel.range();
lines.into_iter().zip(markers).enumerate().map(|(i, (line, marker))| {
let line = 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
}
} else {
line
};
MarkedLine { line, marker }
}).collect()
} else {
lines.into_iter().zip(markers).map(|(line, marker)| MarkedLine { line, marker }).collect()
};
let widget = ScrollPane::new(&items)
.block(block)
.gutter_width(2)
.pin_to_bottom(true);
frame.render_stateful_widget(widget, area, &mut pane.scroll);
}
/// 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::ScrollPane;
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 widget = ScrollPane::new(&lines)
.block(block)
.pin_to_bottom(true);
frame.render_stateful_widget(widget, area, &mut pane.scroll);
}