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::{
|
2026-04-05 20:08:18 -04:00
|
|
|
App, HotkeyAction, ScreenAction, ScreenView,
|
|
|
|
|
screen_legend,
|
2026-04-05 18:57:54 -04:00
|
|
|
};
|
2026-04-05 22:34:48 -04:00
|
|
|
use crate::mind::StreamTarget;
|
2026-04-05 21:13:48 -04:00
|
|
|
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",
|
2026-04-05 22:18:07 -04:00
|
|
|
handler: |s, _| {
|
|
|
|
|
if let Ok(mut ag) = s.agent.try_lock() { ag.notify("saved"); }
|
|
|
|
|
} },
|
2026-04-05 21:13:48 -04:00
|
|
|
SlashCommand { name: "/retry", help: "Re-run last turn",
|
|
|
|
|
handler: |s, _| {
|
|
|
|
|
let agent = s.agent.clone();
|
|
|
|
|
let mind_tx = s.mind_tx.clone();
|
|
|
|
|
tokio::spawn(async move {
|
2026-04-05 22:18:07 -04:00
|
|
|
let _act = crate::agent::start_activity(&agent, "retrying...").await;
|
2026-04-05 21:13:48 -04:00
|
|
|
let mut ag = agent.lock().await;
|
|
|
|
|
let entries = ag.entries_mut();
|
|
|
|
|
let mut last_user_text = None;
|
|
|
|
|
while let Some(entry) = entries.last() {
|
|
|
|
|
if entry.message().role == crate::agent::api::types::Role::User {
|
|
|
|
|
last_user_text = Some(entries.pop().unwrap().message().content_text().to_string());
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
entries.pop();
|
|
|
|
|
}
|
|
|
|
|
drop(ag);
|
2026-04-05 22:18:07 -04:00
|
|
|
if let Some(text) = last_user_text {
|
|
|
|
|
let _ = mind_tx.send(MindCommand::Turn(text, StreamTarget::Conversation));
|
2026-04-05 21:13:48 -04:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} },
|
|
|
|
|
SlashCommand { name: "/model", help: "Show/switch model (/model <name>)",
|
|
|
|
|
handler: |s, arg| {
|
|
|
|
|
if arg.is_empty() {
|
2026-04-05 22:18:07 -04:00
|
|
|
if let Ok(mut ag) = s.agent.try_lock() {
|
2026-04-05 21:13:48 -04:00
|
|
|
let names = ag.app_config.model_names();
|
2026-04-05 22:18:07 -04:00
|
|
|
let label = if names.is_empty() {
|
|
|
|
|
format!("model: {}", ag.model())
|
|
|
|
|
} else {
|
|
|
|
|
format!("model: {} ({})", ag.model(), names.join(", "))
|
|
|
|
|
};
|
|
|
|
|
ag.notify(label);
|
2026-04-05 21:13:48 -04:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let agent = s.agent.clone();
|
|
|
|
|
let name = arg.to_string();
|
|
|
|
|
tokio::spawn(async move {
|
2026-04-05 22:18:07 -04:00
|
|
|
let _act = crate::agent::start_activity(&agent, format!("switching to {}...", name)).await;
|
|
|
|
|
cmd_switch_model(&agent, &name).await;
|
2026-04-05 21:13:48 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} },
|
|
|
|
|
SlashCommand { name: "/score", help: "Score memory importance",
|
|
|
|
|
handler: |s, _| { let _ = s.mind_tx.send(MindCommand::Score); } },
|
|
|
|
|
SlashCommand { name: "/dmn", help: "Show DMN state",
|
|
|
|
|
handler: |s, _| {
|
|
|
|
|
let st = s.shared_mind.lock().unwrap();
|
2026-04-05 22:18:07 -04:00
|
|
|
if let Ok(mut ag) = s.agent.try_lock() {
|
|
|
|
|
ag.notify(format!("DMN: {:?} ({}/{})", st.dmn, st.dmn_turns, st.max_dmn_turns));
|
|
|
|
|
}
|
2026-04-05 21:13:48 -04:00
|
|
|
} },
|
|
|
|
|
SlashCommand { name: "/sleep", help: "Put DMN to sleep",
|
|
|
|
|
handler: |s, _| {
|
|
|
|
|
let mut st = s.shared_mind.lock().unwrap();
|
|
|
|
|
st.dmn = crate::mind::dmn::State::Resting { since: std::time::Instant::now() };
|
|
|
|
|
st.dmn_turns = 0;
|
2026-04-05 22:18:07 -04:00
|
|
|
if let Ok(mut ag) = s.agent.try_lock() { ag.notify("DMN sleeping"); }
|
2026-04-05 21:13:48 -04:00
|
|
|
} },
|
|
|
|
|
SlashCommand { name: "/wake", help: "Wake DMN to foraging",
|
|
|
|
|
handler: |s, _| {
|
|
|
|
|
let mut st = s.shared_mind.lock().unwrap();
|
|
|
|
|
if matches!(st.dmn, crate::mind::dmn::State::Off) { crate::mind::dmn::set_off(false); }
|
|
|
|
|
st.dmn = crate::mind::dmn::State::Foraging;
|
|
|
|
|
st.dmn_turns = 0;
|
2026-04-05 22:18:07 -04:00
|
|
|
if let Ok(mut ag) = s.agent.try_lock() { ag.notify("DMN foraging"); }
|
2026-04-05 21:13:48 -04:00
|
|
|
} },
|
|
|
|
|
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::dmn::State::Paused;
|
|
|
|
|
st.dmn_turns = 0;
|
2026-04-05 22:18:07 -04:00
|
|
|
if let Ok(mut ag) = s.agent.try_lock() { ag.notify("DMN paused"); }
|
2026-04-05 21:13:48 -04:00
|
|
|
} },
|
|
|
|
|
SlashCommand { name: "/help", help: "Show this help",
|
2026-04-05 22:18:07 -04:00
|
|
|
handler: |s, _| { notify_help(&s.agent); } },
|
2026-04-05 21:13:48 -04:00
|
|
|
]}
|
|
|
|
|
|
|
|
|
|
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<tokio::sync::Mutex<crate::agent::Agent>>,
|
|
|
|
|
name: &str,
|
|
|
|
|
) {
|
|
|
|
|
let resolved = {
|
|
|
|
|
let ag = agent.lock().await;
|
|
|
|
|
match ag.app_config.resolve_model(name) {
|
|
|
|
|
Ok(r) => r,
|
|
|
|
|
Err(e) => {
|
2026-04-05 22:18:07 -04:00
|
|
|
agent.lock().await.notify(format!("model error: {}", e));
|
2026-04-05 21:13:48 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let new_client = crate::agent::api::ApiClient::new(
|
|
|
|
|
&resolved.api_base, &resolved.api_key, &resolved.model_id,
|
|
|
|
|
);
|
|
|
|
|
let prompt_changed = {
|
|
|
|
|
let ag = agent.lock().await;
|
|
|
|
|
resolved.prompt_file != ag.prompt_file
|
|
|
|
|
};
|
|
|
|
|
let mut ag = agent.lock().await;
|
|
|
|
|
ag.swap_client(new_client);
|
|
|
|
|
if prompt_changed {
|
|
|
|
|
ag.prompt_file = resolved.prompt_file.clone();
|
|
|
|
|
ag.compact();
|
2026-04-05 22:18:07 -04:00
|
|
|
ag.notify(format!("switched to {} (recompacted)", resolved.model_id));
|
2026-04-05 21:13:48 -04:00
|
|
|
} else {
|
2026-04-05 22:18:07 -04:00
|
|
|
ag.notify(format!("switched to {}", resolved.model_id));
|
2026-04-05 21:13:48 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 22:18:07 -04:00
|
|
|
fn notify_help(agent: &std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>) {
|
|
|
|
|
if let Ok(mut ag) = agent.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);
|
2026-04-05 21:13:48 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 18:57:54 -04:00
|
|
|
|
2026-04-05 20:08:18 -04:00
|
|
|
/// Turn marker for the conversation pane gutter.
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Default)]
|
|
|
|
|
enum Marker {
|
|
|
|
|
#[default]
|
|
|
|
|
None,
|
|
|
|
|
User,
|
|
|
|
|
Assistant,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 19:27:14 -04:00
|
|
|
enum PaneTarget {
|
|
|
|
|
Conversation,
|
|
|
|
|
ConversationAssistant,
|
|
|
|
|
Tools,
|
|
|
|
|
ToolResult,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 20:08:18 -04:00
|
|
|
const MAX_PANE_LINES: usize = 10_000;
|
|
|
|
|
|
|
|
|
|
/// Which pane receives scroll keys.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
enum ActivePane {
|
|
|
|
|
Autonomous,
|
|
|
|
|
Conversation,
|
|
|
|
|
Tools,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>,
|
2026-04-06 18:49:28 -04:00
|
|
|
/// Cached wrapped height for each line, valid when cached_width matches.
|
|
|
|
|
line_heights: Vec<u16>,
|
|
|
|
|
cached_width: u16,
|
2026-04-05 20:08:18 -04:00
|
|
|
current_line: String,
|
|
|
|
|
current_color: Color,
|
|
|
|
|
md_buffer: String,
|
|
|
|
|
use_markdown: bool,
|
|
|
|
|
pending_marker: Marker,
|
|
|
|
|
scroll: u16,
|
|
|
|
|
pinned: bool,
|
|
|
|
|
last_total_lines: u16,
|
|
|
|
|
last_height: u16,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PaneState {
|
|
|
|
|
fn new(use_markdown: bool) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
lines: Vec::new(), markers: Vec::new(),
|
2026-04-06 18:49:28 -04:00
|
|
|
line_heights: Vec::new(), cached_width: 0,
|
2026-04-05 20:08:18 -04:00
|
|
|
current_line: String::new(), current_color: Color::Reset,
|
|
|
|
|
md_buffer: String::new(), use_markdown,
|
|
|
|
|
pending_marker: Marker::None, scroll: 0, pinned: false,
|
|
|
|
|
last_total_lines: 0, last_height: 20,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-04-06 18:49:28 -04:00
|
|
|
let drain = excess.min(self.line_heights.len());
|
|
|
|
|
self.line_heights.drain(..drain);
|
2026-04-05 20:08:18 -04:00
|
|
|
self.scroll = self.scroll.saturating_sub(excess as u16);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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();
|
2026-04-06 18:49:28 -04:00
|
|
|
self.line_heights.truncate(self.lines.len());
|
2026-04-05 20:08:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn scroll_up(&mut self, n: u16) {
|
|
|
|
|
self.scroll = self.scroll.saturating_sub(n);
|
|
|
|
|
self.pinned = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn scroll_down(&mut self, n: u16) {
|
|
|
|
|
let max = self.last_total_lines.saturating_sub(self.last_height);
|
|
|
|
|
self.scroll = (self.scroll + n).min(max);
|
|
|
|
|
if self.scroll >= max { self.pinned = false; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 18:49:28 -04:00
|
|
|
/// Ensure cached line heights cover all committed lines at the given width.
|
|
|
|
|
fn compute_heights(&mut self, width: u16) {
|
|
|
|
|
if width != self.cached_width {
|
|
|
|
|
self.line_heights.clear();
|
|
|
|
|
self.cached_width = width;
|
|
|
|
|
}
|
|
|
|
|
self.line_heights.truncate(self.lines.len());
|
|
|
|
|
while self.line_heights.len() < self.lines.len() {
|
|
|
|
|
let i = self.line_heights.len();
|
|
|
|
|
let h = Paragraph::new(self.lines[i].clone())
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
.line_count(width) as u16;
|
|
|
|
|
self.line_heights.push(h.max(1));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 20:08:18 -04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:57:54 -04:00
|
|
|
pub(crate) struct InteractScreen {
|
2026-04-05 20:08:18 -04:00
|
|
|
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,
|
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 21:13:48 -04:00
|
|
|
pending_display_count: usize,
|
2026-04-05 19:17:13 -04:00
|
|
|
/// Reference to agent for state sync
|
2026-04-05 21:13:48 -04:00
|
|
|
agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>,
|
|
|
|
|
shared_mind: std::sync::Arc<crate::mind::SharedMindState>,
|
|
|
|
|
mind_tx: tokio::sync::mpsc::UnboundedSender<crate::mind::MindCommand>,
|
2026-04-05 18:57:54 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl InteractScreen {
|
2026-04-05 21:13:48 -04:00
|
|
|
pub fn new(
|
|
|
|
|
agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>,
|
|
|
|
|
shared_mind: std::sync::Arc<crate::mind::SharedMindState>,
|
|
|
|
|
mind_tx: tokio::sync::mpsc::UnboundedSender<crate::mind::MindCommand>,
|
|
|
|
|
) -> 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],
|
|
|
|
|
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 21:13:48 -04:00
|
|
|
pending_display_count: 0,
|
2026-04-05 19:17:13 -04:00
|
|
|
agent,
|
2026-04-05 21:13:48 -04:00
|
|
|
shared_mind,
|
|
|
|
|
mind_tx,
|
2026-04-05 18:57:54 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 19:43:48 -04:00
|
|
|
/// Route an agent entry to pane items.
|
|
|
|
|
/// Returns empty vec for entries that shouldn't be displayed.
|
|
|
|
|
fn route_entry(entry: &crate::agent::context::ConversationEntry) -> Vec<(PaneTarget, String, Marker)> {
|
2026-04-05 19:41:16 -04:00
|
|
|
use crate::agent::api::types::Role;
|
|
|
|
|
use crate::agent::context::ConversationEntry;
|
|
|
|
|
|
|
|
|
|
if let ConversationEntry::Memory { .. } = entry {
|
2026-04-05 19:43:48 -04:00
|
|
|
return vec![];
|
2026-04-05 19:41:16 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let msg = entry.message();
|
|
|
|
|
let text = msg.content_text().to_string();
|
|
|
|
|
|
2026-04-05 19:43:48 -04:00
|
|
|
if text.starts_with("<system-reminder>") {
|
|
|
|
|
return vec![];
|
|
|
|
|
}
|
2026-04-05 19:41:16 -04:00
|
|
|
|
2026-04-05 19:43:48 -04:00
|
|
|
match msg.role {
|
|
|
|
|
Role::User => {
|
|
|
|
|
if text.is_empty() { return vec![]; }
|
|
|
|
|
vec![(PaneTarget::Conversation, text, Marker::User)]
|
|
|
|
|
}
|
2026-04-05 19:41:16 -04:00
|
|
|
Role::Assistant => {
|
2026-04-05 19:43:48 -04:00
|
|
|
let mut items = Vec::new();
|
2026-04-05 19:41:16 -04:00
|
|
|
// 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>());
|
2026-04-05 19:43:48 -04:00
|
|
|
items.push((PaneTarget::Tools, line, Marker::None));
|
2026-04-05 19:41:16 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 19:43:48 -04:00
|
|
|
// Text content → conversation
|
|
|
|
|
if !text.is_empty() {
|
|
|
|
|
items.push((PaneTarget::ConversationAssistant, text, Marker::Assistant));
|
|
|
|
|
}
|
|
|
|
|
items
|
2026-04-05 19:41:16 -04:00
|
|
|
}
|
2026-04-05 19:43:48 -04:00
|
|
|
Role::Tool => {
|
|
|
|
|
if text.is_empty() { return vec![]; }
|
|
|
|
|
vec![(PaneTarget::ToolResult, text, Marker::None)]
|
|
|
|
|
}
|
|
|
|
|
Role::System => vec![],
|
2026-04-05 19:41:16 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 21:13:48 -04:00
|
|
|
/// Sync conversation display from agent entries + pending input.
|
2026-04-05 19:17:13 -04:00
|
|
|
fn sync_from_agent(&mut self) {
|
2026-04-05 21:13:48 -04:00
|
|
|
// Pop previously-displayed pending input
|
|
|
|
|
for _ in 0..self.pending_display_count {
|
|
|
|
|
self.conversation.pop_line();
|
|
|
|
|
}
|
|
|
|
|
self.pending_display_count = 0;
|
|
|
|
|
|
|
|
|
|
// Sync agent entries
|
|
|
|
|
if let Ok(agent) = self.agent.try_lock() {
|
|
|
|
|
let generation = agent.generation;
|
|
|
|
|
let entries = agent.entries();
|
|
|
|
|
|
|
|
|
|
// Phase 1: detect desync and pop
|
|
|
|
|
if generation != self.last_generation {
|
|
|
|
|
self.conversation = PaneState::new(true);
|
|
|
|
|
self.autonomous = PaneState::new(true);
|
|
|
|
|
self.tools = PaneState::new(false);
|
|
|
|
|
self.last_entries.clear();
|
|
|
|
|
} else {
|
|
|
|
|
// 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();
|
|
|
|
|
for (target, _, _) in 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:41:16 -04:00
|
|
|
}
|
2026-04-05 19:35:19 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 19:17:13 -04:00
|
|
|
|
2026-04-05 21:13:48 -04:00
|
|
|
// Phase 2: push new entries
|
|
|
|
|
let start = self.last_entries.len();
|
|
|
|
|
for entry in entries.iter().skip(start) {
|
|
|
|
|
for (target, text, marker) in Self::route_entry(entry) {
|
|
|
|
|
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:35:19 -04:00
|
|
|
}
|
2026-04-05 19:22:31 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 21:13:48 -04:00
|
|
|
self.last_entries.push(entry.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 {
|
2026-04-05 22:18:07 -04:00
|
|
|
if let Ok(mut ag) = self.agent.try_lock() {
|
|
|
|
|
ag.notify(format!("unknown: {}", input.split_whitespace().next().unwrap_or(input)));
|
|
|
|
|
}
|
2026-04-05 19:17:13 -04:00
|
|
|
}
|
2026-04-05 21:13:48 -04:00
|
|
|
return;
|
2026-04-05 19:17:13 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 21:13:48 -04:00
|
|
|
// Regular input → queue to Mind
|
|
|
|
|
self.shared_mind.lock().unwrap().input.push(input.to_string());
|
2026-04-05 19:17:13 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:57:54 -04:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 20:08:18 -04:00
|
|
|
fn handle_mouse(&mut self, mouse: MouseEvent) {
|
2026-04-05 18:57:54 -04:00
|
|
|
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 20:08:18 -04:00
|
|
|
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,
|
2026-04-05 23:04:10 -04:00
|
|
|
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 => {
|
2026-04-05 19:03:06 -04:00
|
|
|
match key.code {
|
2026-04-05 23:04:10 -04:00
|
|
|
KeyCode::Esc => { let _ = self.mind_tx.send(crate::mind::MindCommand::Interrupt); }
|
2026-04-05 19:03:06 -04:00
|
|
|
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;
|
2026-04-05 21:13:48 -04:00
|
|
|
self.dispatch_input(&input, app);
|
|
|
|
|
self.textarea = new_textarea(vec![String::new()]);
|
2026-04-05 19:03:06 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-05 23:04:10 -04:00
|
|
|
_ => { self.textarea.input(*key); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Event::Mouse(mouse) => { self.handle_mouse(*mouse); }
|
|
|
|
|
_ => {}
|
2026-04-05 19:03:06 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 19:17:13 -04:00
|
|
|
// Sync state from agent
|
|
|
|
|
self.sync_from_agent();
|
|
|
|
|
|
Kill StatusUpdate, Activity, DmnAnnotation, ContextInfoUpdate, AgentUpdate
Status bar reads directly from Agent and MindState on each render tick.
Activity is now a field on Agent — set by agent code directly, read by
UI via try_lock. DmnAnnotation, ContextInfoUpdate, AgentUpdate were
already dead (no senders).
UiMessage down to 4 variants: TextDelta, Reasoning, Debug, Info.
Co-Authored-By: Proof of Concept <poc@bcachefs.org>
2026-04-05 21:34:27 -04:00
|
|
|
// Read status from agent + mind state
|
2026-04-05 22:18:07 -04:00
|
|
|
if let Ok(mut agent) = self.agent.try_lock() {
|
|
|
|
|
agent.expire_activities();
|
Kill StatusUpdate, Activity, DmnAnnotation, ContextInfoUpdate, AgentUpdate
Status bar reads directly from Agent and MindState on each render tick.
Activity is now a field on Agent — set by agent code directly, read by
UI via try_lock. DmnAnnotation, ContextInfoUpdate, AgentUpdate were
already dead (no senders).
UiMessage down to 4 variants: TextDelta, Reasoning, Debug, Info.
Co-Authored-By: Proof of Concept <poc@bcachefs.org>
2026-04-05 21:34:27 -04:00
|
|
|
app.status.prompt_tokens = agent.last_prompt_tokens();
|
|
|
|
|
app.status.model = agent.model().to_string();
|
2026-04-06 18:36:33 -04:00
|
|
|
app.status.context_budget = agent.context.budget.status_string();
|
2026-04-05 22:18:07 -04:00
|
|
|
app.activity = agent.activities.last()
|
|
|
|
|
.map(|a| a.label.clone())
|
|
|
|
|
.unwrap_or_default();
|
Kill StatusUpdate, Activity, DmnAnnotation, ContextInfoUpdate, AgentUpdate
Status bar reads directly from Agent and MindState on each render tick.
Activity is now a field on Agent — set by agent code directly, read by
UI via try_lock. DmnAnnotation, ContextInfoUpdate, AgentUpdate were
already dead (no senders).
UiMessage down to 4 variants: TextDelta, Reasoning, Debug, Info.
Co-Authored-By: Proof of Concept <poc@bcachefs.org>
2026-04-05 21:34:27 -04:00
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 19:03:06 -04:00
|
|
|
// Draw
|
|
|
|
|
self.draw_main(frame, area, app);
|
|
|
|
|
}
|
2026-04-05 21:13:48 -04:00
|
|
|
|
2026-04-05 19:03:06 -04:00
|
|
|
}
|
|
|
|
|
|
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.
|
2026-04-06 18:49:28 -04:00
|
|
|
/// Given per-line heights, a scroll offset, and viewport height,
|
|
|
|
|
/// return (first_line, sub_scroll_within_first, last_line_exclusive).
|
|
|
|
|
fn visible_range(heights: &[u16], scroll: u16, viewport: u16) -> (usize, u16, usize) {
|
|
|
|
|
let mut row = 0u16;
|
|
|
|
|
let mut first = 0;
|
|
|
|
|
let mut row_at_first = 0u16;
|
|
|
|
|
for (i, &h) in heights.iter().enumerate() {
|
|
|
|
|
if row + h > scroll {
|
|
|
|
|
first = i;
|
|
|
|
|
row_at_first = row;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
row += h;
|
|
|
|
|
if i == heights.len() - 1 {
|
|
|
|
|
first = heights.len();
|
|
|
|
|
row_at_first = row;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let sub_scroll = scroll.saturating_sub(row_at_first);
|
|
|
|
|
|
|
|
|
|
let mut last = first;
|
|
|
|
|
let mut visible = 0u16;
|
|
|
|
|
for i in first..heights.len() {
|
|
|
|
|
visible += heights[i];
|
|
|
|
|
last = i + 1;
|
|
|
|
|
if visible >= viewport + sub_scroll { break; }
|
|
|
|
|
}
|
|
|
|
|
(first, sub_scroll, last)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 17:25:59 -04:00
|
|
|
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];
|
|
|
|
|
|
|
|
|
|
let text_width = text_area.width;
|
|
|
|
|
|
2026-04-06 18:49:28 -04:00
|
|
|
// Cache committed line heights; compute pending tail on the fly
|
|
|
|
|
pane.compute_heights(text_width);
|
|
|
|
|
let (lines, markers) = pane.all_lines_with_markers();
|
|
|
|
|
|
|
|
|
|
// Build heights: cached for committed lines, computed for pending tail
|
|
|
|
|
let n_committed = pane.line_heights.len();
|
|
|
|
|
let mut heights: Vec<u16> = pane.line_heights.clone();
|
|
|
|
|
for line in lines.iter().skip(n_committed) {
|
|
|
|
|
let h = Paragraph::new(line.clone())
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
.line_count(text_width) as u16;
|
|
|
|
|
heights.push(h.max(1));
|
2026-04-03 17:25:59 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 18:49:28 -04:00
|
|
|
let total_visual: u16 = heights.iter().sum();
|
2026-04-03 17:25:59 -04:00
|
|
|
pane.last_total_lines = total_visual;
|
|
|
|
|
pane.last_height = inner.height;
|
|
|
|
|
|
|
|
|
|
if !pane.pinned {
|
|
|
|
|
pane.scroll = total_visual.saturating_sub(inner.height);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 18:49:28 -04:00
|
|
|
// Find visible line range
|
|
|
|
|
let (first, sub_scroll, last) = visible_range(&heights, pane.scroll, inner.height);
|
|
|
|
|
|
|
|
|
|
// Render only the visible slice — no full-content grapheme walk
|
|
|
|
|
let text_para = Paragraph::new(lines[first..last].to_vec())
|
2026-04-03 17:25:59 -04:00
|
|
|
.wrap(Wrap { trim: false })
|
2026-04-06 18:49:28 -04:00
|
|
|
.scroll((sub_scroll, 0));
|
2026-04-03 17:25:59 -04:00
|
|
|
frame.render_widget(text_para, text_area);
|
|
|
|
|
|
2026-04-06 18:49:28 -04:00
|
|
|
// Build gutter for the visible slice
|
2026-04-03 17:25:59 -04:00
|
|
|
let mut gutter_lines: Vec<Line<'static>> = Vec::new();
|
2026-04-06 18:49:28 -04:00
|
|
|
for i in first..last {
|
|
|
|
|
let marker_text = match markers[i] {
|
2026-04-03 17:25:59 -04:00
|
|
|
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);
|
2026-04-06 18:49:28 -04:00
|
|
|
for _ in 1..heights[i] {
|
2026-04-03 17:25:59 -04:00
|
|
|
gutter_lines.push(Line::raw(""));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let gutter_para = Paragraph::new(gutter_lines)
|
2026-04-06 18:49:28 -04:00
|
|
|
.scroll((sub_scroll, 0));
|
2026-04-03 17:25:59 -04:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 18:49:28 -04:00
|
|
|
let text_width = area.width.saturating_sub(2);
|
|
|
|
|
pane.compute_heights(text_width);
|
2026-04-03 17:25:59 -04:00
|
|
|
let lines = pane.all_lines();
|
|
|
|
|
|
2026-04-06 18:49:28 -04:00
|
|
|
// Build heights: cached for committed, computed for pending tail
|
|
|
|
|
let n_committed = pane.line_heights.len();
|
|
|
|
|
let mut heights: Vec<u16> = pane.line_heights.clone();
|
|
|
|
|
for line in lines.iter().skip(n_committed) {
|
|
|
|
|
let h = Paragraph::new(line.clone())
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
.line_count(text_width) as u16;
|
|
|
|
|
heights.push(h.max(1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let total: u16 = heights.iter().sum();
|
2026-04-03 17:25:59 -04:00
|
|
|
pane.last_total_lines = total;
|
|
|
|
|
pane.last_height = inner_height;
|
|
|
|
|
|
|
|
|
|
if !pane.pinned {
|
|
|
|
|
pane.scroll = total.saturating_sub(inner_height);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 18:49:28 -04:00
|
|
|
let (first, sub_scroll, last) = visible_range(&heights, pane.scroll, inner_height);
|
|
|
|
|
|
|
|
|
|
let paragraph = Paragraph::new(lines[first..last].to_vec())
|
|
|
|
|
.block(block.clone())
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
.scroll((sub_scroll, 0));
|
2026-04-03 17:25:59 -04:00
|
|
|
frame.render_widget(paragraph, area);
|
|
|
|
|
}
|