Disables memory scoring, surface, and observe agents when set. Useful for testing with external backends (e.g. OpenRouter) where background agent traffic would be slow and unnecessary. Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
769 lines
29 KiB
Rust
769 lines
29 KiB
Rust
// user/ — User interface layer
|
|
//
|
|
// TUI, UI channel, parsing. The cognitive layer (session state
|
|
// machine, DMN, identity) lives in mind/.
|
|
|
|
pub mod ui_channel;
|
|
pub mod log;
|
|
|
|
pub mod chat;
|
|
pub mod context;
|
|
pub mod subconscious;
|
|
pub mod unconscious;
|
|
pub mod thalamus;
|
|
|
|
// --- TUI infrastructure (moved from tui/mod.rs) ---
|
|
|
|
use crossterm::{
|
|
event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
|
|
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
|
|
ExecutableCommand,
|
|
};
|
|
use ratatui::{
|
|
backend::CrosstermBackend,
|
|
layout::Rect,
|
|
style::{Color, Style},
|
|
text::{Line, Span},
|
|
Frame, Terminal,
|
|
};
|
|
use std::io;
|
|
|
|
use crate::user::ui_channel::{ContextInfo, SharedContextState, StatusInfo, UiMessage};
|
|
|
|
pub(crate) const SCREEN_LEGEND: &str = " F1=interact F2=conscious F3=subconscious F4=unconscious F5=thalamus ";
|
|
|
|
pub(crate) 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
|
|
}
|
|
|
|
pub(crate) fn is_zero_width(ch: char) -> bool {
|
|
matches!(ch,
|
|
'\u{200B}'..='\u{200F}' |
|
|
'\u{2028}'..='\u{202F}' |
|
|
'\u{2060}'..='\u{2069}' |
|
|
'\u{FEFF}'
|
|
)
|
|
}
|
|
|
|
/// Which pane receives scroll keys.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum ActivePane {
|
|
Autonomous,
|
|
Conversation,
|
|
Tools,
|
|
}
|
|
|
|
const MAX_PANE_LINES: usize = 10_000;
|
|
|
|
/// Turn marker for the conversation pane gutter.
|
|
#[derive(Clone, Copy, PartialEq, Default)]
|
|
pub(crate) enum Marker {
|
|
#[default]
|
|
None,
|
|
User,
|
|
Assistant,
|
|
}
|
|
|
|
pub(crate) struct PaneState {
|
|
pub(crate) lines: Vec<Line<'static>>,
|
|
pub(crate) markers: Vec<Marker>,
|
|
pub(crate) current_line: String,
|
|
pub(crate) current_color: Color,
|
|
pub(crate) md_buffer: String,
|
|
pub(crate) use_markdown: bool,
|
|
pub(crate) pending_marker: Marker,
|
|
pub(crate) scroll: u16,
|
|
pub(crate) pinned: bool,
|
|
pub(crate) last_total_lines: u16,
|
|
pub(crate) last_height: u16,
|
|
}
|
|
|
|
impl PaneState {
|
|
fn new(use_markdown: bool) -> Self {
|
|
Self {
|
|
lines: Vec::new(), markers: Vec::new(),
|
|
current_line: String::new(), current_color: Color::Reset,
|
|
md_buffer: String::new(), use_markdown,
|
|
pending_marker: Marker::None, scroll: 0, pinned: false,
|
|
last_total_lines: 0, last_height: 20,
|
|
}
|
|
}
|
|
|
|
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 = self.scroll.saturating_sub(excess as u16);
|
|
}
|
|
}
|
|
|
|
fn append_text(&mut self, text: &str) {
|
|
let clean = strip_ansi(text);
|
|
if self.use_markdown {
|
|
self.md_buffer.push_str(&clean);
|
|
} else {
|
|
for ch in clean.chars() {
|
|
if ch == '\n' {
|
|
let line = std::mem::take(&mut self.current_line);
|
|
self.lines.push(Line::styled(line, Style::default().fg(self.current_color)));
|
|
self.markers.push(Marker::None);
|
|
} else if ch == '\t' {
|
|
self.current_line.push_str(" ");
|
|
} else if ch.is_control() || is_zero_width(ch) {
|
|
} else {
|
|
self.current_line.push(ch);
|
|
}
|
|
}
|
|
}
|
|
self.evict();
|
|
}
|
|
|
|
pub(crate) 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 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; }
|
|
}
|
|
|
|
pub(crate) fn all_lines(&self) -> Vec<Line<'static>> {
|
|
let (lines, _) = self.all_lines_with_markers();
|
|
lines
|
|
}
|
|
|
|
pub(crate) 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)
|
|
}
|
|
}
|
|
|
|
pub(crate) 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
|
|
}
|
|
|
|
pub(crate) 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()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum Screen {
|
|
Interact, Conscious, Subconscious, Unconscious, Thalamus,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum HotkeyAction {
|
|
CycleReasoning, KillProcess, Interrupt, CycleAutonomy,
|
|
/// Adjust a sampling parameter: (param_index, delta)
|
|
/// 0=temperature, 1=top_p, 2=top_k
|
|
AdjustSampling(usize, f32),
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct IdleInfo {
|
|
pub user_present: bool,
|
|
pub since_activity: f64,
|
|
pub activity_ewma: f64,
|
|
pub block_reason: String,
|
|
pub dreaming: bool,
|
|
pub sleeping: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct ChannelStatus {
|
|
pub name: String,
|
|
pub connected: bool,
|
|
pub unread: u32,
|
|
}
|
|
|
|
pub struct App {
|
|
pub(crate) autonomous: PaneState,
|
|
pub(crate) conversation: PaneState,
|
|
pub(crate) tools: PaneState,
|
|
pub(crate) status: StatusInfo,
|
|
pub(crate) activity: String,
|
|
pub(crate) turn_started: Option<std::time::Instant>,
|
|
pub(crate) call_started: Option<std::time::Instant>,
|
|
pub(crate) call_timeout_secs: u64,
|
|
pub(crate) needs_assistant_marker: bool,
|
|
pub running_processes: u32,
|
|
pub reasoning_effort: String,
|
|
pub temperature: f32,
|
|
pub top_p: f32,
|
|
pub top_k: u32,
|
|
pub(crate) active_tools: crate::user::ui_channel::SharedActiveTools,
|
|
pub(crate) active_pane: ActivePane,
|
|
pub textarea: tui_textarea::TextArea<'static>,
|
|
input_history: Vec<String>,
|
|
history_index: Option<usize>,
|
|
pub should_quit: bool,
|
|
pub submitted: Vec<String>,
|
|
pub hotkey_actions: Vec<HotkeyAction>,
|
|
pub(crate) pane_areas: [Rect; 3],
|
|
pub screen: Screen,
|
|
pub(crate) debug_scroll: u16,
|
|
pub(crate) debug_selected: Option<usize>,
|
|
pub(crate) debug_expanded: std::collections::HashSet<usize>,
|
|
pub(crate) context_info: Option<ContextInfo>,
|
|
pub(crate) shared_context: SharedContextState,
|
|
pub(crate) agent_selected: usize,
|
|
pub(crate) agent_log_view: bool,
|
|
pub(crate) agent_state: Vec<crate::subconscious::subconscious::AgentSnapshot>,
|
|
pub(crate) channel_status: Vec<ChannelStatus>,
|
|
pub(crate) idle_info: Option<IdleInfo>,
|
|
/// Thalamus screen: selected sampling param (0=temp, 1=top_p, 2=top_k).
|
|
pub(crate) sampling_selected: usize,
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(model: String, shared_context: SharedContextState, active_tools: crate::user::ui_channel::SharedActiveTools) -> Self {
|
|
Self {
|
|
autonomous: PaneState::new(true),
|
|
conversation: PaneState::new(true),
|
|
tools: PaneState::new(false),
|
|
status: StatusInfo {
|
|
dmn_state: "resting".into(), dmn_turns: 0, dmn_max_turns: 20,
|
|
prompt_tokens: 0, completion_tokens: 0, model,
|
|
turn_tools: 0, context_budget: String::new(),
|
|
},
|
|
activity: String::new(),
|
|
turn_started: None, call_started: None, call_timeout_secs: 60,
|
|
needs_assistant_marker: false, running_processes: 0,
|
|
reasoning_effort: "none".to_string(),
|
|
temperature: 0.6,
|
|
top_p: 0.95,
|
|
top_k: 20,
|
|
active_tools, active_pane: ActivePane::Conversation,
|
|
textarea: new_textarea(vec![String::new()]),
|
|
input_history: Vec::new(), history_index: None,
|
|
should_quit: false, submitted: Vec::new(), hotkey_actions: Vec::new(),
|
|
pane_areas: [Rect::default(); 3],
|
|
screen: Screen::Interact,
|
|
debug_scroll: 0, debug_selected: None,
|
|
debug_expanded: std::collections::HashSet::new(),
|
|
context_info: None, shared_context,
|
|
agent_selected: 0, agent_log_view: false, agent_state: Vec::new(),
|
|
channel_status: Vec::new(), idle_info: None, sampling_selected: 0,
|
|
}
|
|
}
|
|
|
|
pub fn drain_messages(&mut self, rx: &mut crate::user::ui_channel::UiReceiver) -> bool {
|
|
let mut any = false;
|
|
while let Ok(msg) = rx.try_recv() {
|
|
self.handle_ui_message(msg);
|
|
any = true;
|
|
}
|
|
any
|
|
}
|
|
|
|
pub fn handle_ui_message(&mut self, msg: UiMessage) {
|
|
use crate::user::ui_channel::StreamTarget;
|
|
match msg {
|
|
UiMessage::TextDelta(text, target) => match target {
|
|
StreamTarget::Conversation => {
|
|
if self.needs_assistant_marker {
|
|
self.conversation.pending_marker = Marker::Assistant;
|
|
self.needs_assistant_marker = false;
|
|
}
|
|
self.conversation.current_color = Color::Reset;
|
|
self.conversation.append_text(&text);
|
|
}
|
|
StreamTarget::Autonomous => {
|
|
self.autonomous.current_color = Color::Reset;
|
|
self.autonomous.append_text(&text);
|
|
}
|
|
},
|
|
UiMessage::UserInput(text) => {
|
|
self.conversation.push_line_with_marker(text, Color::Cyan, Marker::User);
|
|
self.turn_started = Some(std::time::Instant::now());
|
|
self.needs_assistant_marker = true;
|
|
self.status.turn_tools = 0;
|
|
}
|
|
UiMessage::ToolCall { name, args_summary } => {
|
|
self.status.turn_tools += 1;
|
|
let line = if args_summary.is_empty() { format!("[{}]", name) }
|
|
else { format!("[{}] {}", name, args_summary) };
|
|
self.tools.push_line(line, Color::Yellow);
|
|
}
|
|
UiMessage::ToolResult { name: _, result } => {
|
|
for line in result.lines() {
|
|
self.tools.push_line(format!(" {}", line), Color::DarkGray);
|
|
}
|
|
self.tools.push_line(String::new(), Color::Reset);
|
|
}
|
|
UiMessage::DmnAnnotation(text) => {
|
|
self.autonomous.push_line(text, Color::Yellow);
|
|
self.turn_started = Some(std::time::Instant::now());
|
|
self.needs_assistant_marker = true;
|
|
self.status.turn_tools = 0;
|
|
}
|
|
UiMessage::StatusUpdate(info) => {
|
|
if !info.dmn_state.is_empty() {
|
|
self.status.dmn_state = info.dmn_state;
|
|
self.status.dmn_turns = info.dmn_turns;
|
|
self.status.dmn_max_turns = info.dmn_max_turns;
|
|
}
|
|
if info.prompt_tokens > 0 { self.status.prompt_tokens = info.prompt_tokens; }
|
|
if !info.model.is_empty() { self.status.model = info.model; }
|
|
if !info.context_budget.is_empty() { self.status.context_budget = info.context_budget; }
|
|
}
|
|
UiMessage::Activity(text) => {
|
|
if text.is_empty() {
|
|
self.call_started = None;
|
|
} else if self.activity.is_empty() || self.call_started.is_none() {
|
|
self.call_started = Some(std::time::Instant::now());
|
|
self.call_timeout_secs = crate::config::get().api_stream_timeout_secs;
|
|
}
|
|
self.activity = text;
|
|
}
|
|
UiMessage::Reasoning(text) => {
|
|
self.autonomous.current_color = Color::DarkGray;
|
|
self.autonomous.append_text(&text);
|
|
}
|
|
UiMessage::ToolStarted { .. } | UiMessage::ToolFinished { .. } => {}
|
|
UiMessage::Debug(text) => {
|
|
self.tools.push_line(format!("[debug] {}", text), Color::DarkGray);
|
|
}
|
|
UiMessage::Info(text) => {
|
|
self.conversation.push_line(text, Color::Cyan);
|
|
}
|
|
UiMessage::ContextInfoUpdate(info) => { self.context_info = Some(info); }
|
|
UiMessage::AgentUpdate(agents) => { self.agent_state = agents; }
|
|
}
|
|
}
|
|
|
|
pub fn handle_key(&mut self, key: KeyEvent) {
|
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
match key.code {
|
|
KeyCode::Char('c') => { self.should_quit = true; return; }
|
|
KeyCode::Char('r') => { self.hotkey_actions.push(HotkeyAction::CycleReasoning); return; }
|
|
KeyCode::Char('k') => { self.hotkey_actions.push(HotkeyAction::KillProcess); return; }
|
|
KeyCode::Char('p') => { self.hotkey_actions.push(HotkeyAction::CycleAutonomy); return; }
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::F(1) => { self.set_screen(Screen::Interact); return; }
|
|
KeyCode::F(2) => { self.set_screen(Screen::Conscious); return; }
|
|
KeyCode::F(3) => { self.set_screen(Screen::Subconscious); return; }
|
|
KeyCode::F(4) => { self.set_screen(Screen::Unconscious); return; }
|
|
KeyCode::F(5) => { self.set_screen(Screen::Thalamus); return; }
|
|
_ => {}
|
|
}
|
|
|
|
match self.screen {
|
|
Screen::Subconscious => {
|
|
match key.code {
|
|
KeyCode::Up => { self.agent_selected = self.agent_selected.saturating_sub(1); self.debug_scroll = 0; return; }
|
|
KeyCode::Down => { self.agent_selected = (self.agent_selected + 1).min(self.agent_state.len().saturating_sub(1)); self.debug_scroll = 0; return; }
|
|
KeyCode::Enter | KeyCode::Right => { self.agent_log_view = true; self.debug_scroll = 0; return; }
|
|
KeyCode::Left | KeyCode::Esc => {
|
|
if self.agent_log_view { self.agent_log_view = false; self.debug_scroll = 0; }
|
|
else { self.screen = Screen::Interact; }
|
|
return;
|
|
}
|
|
KeyCode::PageUp => { self.debug_scroll = self.debug_scroll.saturating_sub(10); return; }
|
|
KeyCode::PageDown => { self.debug_scroll += 10; return; }
|
|
_ => {}
|
|
}
|
|
}
|
|
Screen::Conscious => {
|
|
let cs = self.read_context_state();
|
|
let n = self.debug_item_count(&cs);
|
|
match key.code {
|
|
KeyCode::Up => {
|
|
if n > 0 { self.debug_selected = Some(match self.debug_selected { None => n - 1, Some(0) => 0, Some(i) => i - 1 }); self.scroll_to_selected(n); }
|
|
return;
|
|
}
|
|
KeyCode::Down => {
|
|
if n > 0 { self.debug_selected = Some(match self.debug_selected { None => 0, Some(i) if i >= n - 1 => n - 1, Some(i) => i + 1 }); self.scroll_to_selected(n); }
|
|
return;
|
|
}
|
|
KeyCode::PageUp => {
|
|
if n > 0 { self.debug_selected = Some(match self.debug_selected { None => 0, Some(i) => i.saturating_sub(20) }); self.scroll_to_selected(n); }
|
|
return;
|
|
}
|
|
KeyCode::PageDown => {
|
|
if n > 0 { self.debug_selected = Some(match self.debug_selected { None => 0, Some(i) => (i + 20).min(n - 1) }); self.scroll_to_selected(n); }
|
|
return;
|
|
}
|
|
KeyCode::Right | KeyCode::Enter => { if let Some(idx) = self.debug_selected { self.debug_expanded.insert(idx); } return; }
|
|
KeyCode::Left => { if let Some(idx) = self.debug_selected { self.debug_expanded.remove(&idx); } return; }
|
|
KeyCode::Esc => { self.screen = Screen::Interact; return; }
|
|
_ => {}
|
|
}
|
|
}
|
|
Screen::Unconscious => {
|
|
match key.code {
|
|
KeyCode::PageUp => { self.debug_scroll = self.debug_scroll.saturating_sub(10); return; }
|
|
KeyCode::PageDown => { self.debug_scroll += 10; return; }
|
|
KeyCode::Esc => { self.screen = Screen::Interact; return; }
|
|
_ => {}
|
|
}
|
|
}
|
|
Screen::Thalamus => {
|
|
match key.code {
|
|
KeyCode::Up => { self.sampling_selected = self.sampling_selected.saturating_sub(1); return; }
|
|
KeyCode::Down => { self.sampling_selected = (self.sampling_selected + 1).min(2); return; }
|
|
KeyCode::Right => {
|
|
let delta = match self.sampling_selected {
|
|
0 => 0.05, // temperature
|
|
1 => 0.05, // top_p
|
|
2 => 5.0, // top_k
|
|
_ => 0.0,
|
|
};
|
|
self.hotkey_actions.push(HotkeyAction::AdjustSampling(self.sampling_selected, delta));
|
|
return;
|
|
}
|
|
KeyCode::Left => {
|
|
let delta = match self.sampling_selected {
|
|
0 => -0.05,
|
|
1 => -0.05,
|
|
2 => -5.0,
|
|
_ => 0.0,
|
|
};
|
|
self.hotkey_actions.push(HotkeyAction::AdjustSampling(self.sampling_selected, delta));
|
|
return;
|
|
}
|
|
KeyCode::Esc => { self.screen = Screen::Interact; return; }
|
|
_ => {}
|
|
}
|
|
}
|
|
Screen::Interact => {}
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Esc => { self.hotkey_actions.push(HotkeyAction::Interrupt); }
|
|
KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::SHIFT) => {
|
|
let input: String = self.textarea.lines().join("\n");
|
|
if !input.is_empty() {
|
|
if self.input_history.last().map_or(true, |h| h != &input) { self.input_history.push(input.clone()); }
|
|
self.history_index = None;
|
|
self.submitted.push(input);
|
|
self.textarea = new_textarea(vec![String::new()]);
|
|
}
|
|
}
|
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => self.scroll_active_up(3),
|
|
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => self.scroll_active_down(3),
|
|
KeyCode::Up => {
|
|
if !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); }
|
|
}
|
|
}
|
|
|
|
fn scroll_active_up(&mut self, n: u16) {
|
|
match self.active_pane {
|
|
ActivePane::Autonomous => self.autonomous.scroll_up(n),
|
|
ActivePane::Conversation => self.conversation.scroll_up(n),
|
|
ActivePane::Tools => self.tools.scroll_up(n),
|
|
}
|
|
}
|
|
|
|
fn scroll_active_down(&mut self, n: u16) {
|
|
match self.active_pane {
|
|
ActivePane::Autonomous => self.autonomous.scroll_down(n),
|
|
ActivePane::Conversation => self.conversation.scroll_down(n),
|
|
ActivePane::Tools => self.tools.scroll_down(n),
|
|
}
|
|
}
|
|
|
|
pub fn handle_resize(&mut self, _width: u16, _height: u16) {}
|
|
|
|
pub fn handle_mouse(&mut self, mouse: MouseEvent) {
|
|
match mouse.kind {
|
|
MouseEventKind::ScrollUp => self.scroll_active_up(3),
|
|
MouseEventKind::ScrollDown => self.scroll_active_down(3),
|
|
MouseEventKind::Down(MouseButton::Left) => {
|
|
let (x, y) = (mouse.column, mouse.row);
|
|
for (i, area) in self.pane_areas.iter().enumerate() {
|
|
if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height {
|
|
self.active_pane = match i { 0 => ActivePane::Autonomous, 1 => ActivePane::Conversation, _ => ActivePane::Tools };
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
pub fn draw(&mut self, frame: &mut Frame) {
|
|
let size = frame.area();
|
|
match self.screen {
|
|
Screen::Conscious => { self.draw_debug(frame, size); return; }
|
|
Screen::Subconscious => { self.draw_agents(frame, size); return; }
|
|
Screen::Unconscious => { self.draw_unconscious(frame, size); return; }
|
|
Screen::Thalamus => { self.draw_thalamus(frame, size); return; }
|
|
Screen::Interact => {}
|
|
}
|
|
self.draw_main(frame, size);
|
|
}
|
|
|
|
pub fn set_channel_status(&mut self, channels: Vec<(String, bool, u32)>) {
|
|
self.channel_status = channels.into_iter()
|
|
.map(|(name, connected, unread)| ChannelStatus { name, connected, unread })
|
|
.collect();
|
|
}
|
|
|
|
pub fn update_idle(&mut self, state: &crate::thalamus::idle::State) {
|
|
self.idle_info = Some(IdleInfo {
|
|
user_present: state.user_present(), since_activity: state.since_activity(),
|
|
activity_ewma: state.activity_ewma, block_reason: state.block_reason().to_string(),
|
|
dreaming: state.dreaming, sleeping: state.sleep_until.is_some(),
|
|
});
|
|
}
|
|
|
|
pub(crate) fn set_screen(&mut self, screen: Screen) {
|
|
self.screen = screen;
|
|
self.debug_scroll = 0;
|
|
}
|
|
}
|
|
|
|
pub fn init_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
|
terminal::enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
stdout.execute(EnterAlternateScreen)?;
|
|
stdout.execute(EnableMouseCapture)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
Terminal::new(backend)
|
|
}
|
|
|
|
pub fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
|
terminal::disable_raw_mode()?;
|
|
terminal.backend_mut().execute(DisableMouseCapture)?;
|
|
terminal.backend_mut().execute(LeaveAlternateScreen)?;
|
|
terminal.show_cursor()
|
|
}
|
|
|
|
// --- CLI ---
|
|
|
|
use clap::{Parser, Subcommand};
|
|
use std::path::PathBuf;
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(name = "consciousness", about = "Substrate-independent AI agent")]
|
|
pub struct CliArgs {
|
|
/// Select active backend ("anthropic" or "openrouter")
|
|
#[arg(long)]
|
|
pub backend: Option<String>,
|
|
|
|
/// Model override
|
|
#[arg(short, long)]
|
|
pub model: Option<String>,
|
|
|
|
/// API key override
|
|
#[arg(long)]
|
|
pub api_key: Option<String>,
|
|
|
|
/// Base URL override
|
|
#[arg(long)]
|
|
pub api_base: Option<String>,
|
|
|
|
/// Enable debug logging
|
|
#[arg(long)]
|
|
pub debug: bool,
|
|
|
|
/// Print effective config with provenance and exit
|
|
#[arg(long)]
|
|
pub show_config: bool,
|
|
|
|
/// Override all prompt assembly with this file
|
|
#[arg(long)]
|
|
pub system_prompt_file: Option<PathBuf>,
|
|
|
|
/// Project memory directory
|
|
#[arg(long)]
|
|
pub memory_project: Option<PathBuf>,
|
|
|
|
/// Max consecutive DMN turns
|
|
#[arg(long)]
|
|
pub dmn_max_turns: Option<u32>,
|
|
|
|
/// Disable background agents (surface, observe, scoring)
|
|
#[arg(long)]
|
|
pub no_agents: bool,
|
|
|
|
#[command(subcommand)]
|
|
pub command: Option<SubCmd>,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
pub enum SubCmd {
|
|
/// Print new output since last read and exit
|
|
Read {
|
|
/// Stream output continuously instead of exiting
|
|
#[arg(short, long)]
|
|
follow: bool,
|
|
/// Block until a complete response is received, then exit
|
|
#[arg(long)]
|
|
block: bool,
|
|
},
|
|
/// Send a message to the running agent
|
|
Write {
|
|
/// The message to send
|
|
message: Vec<String>,
|
|
},
|
|
}
|
|
|
|
#[tokio::main]
|
|
pub async fn main() {
|
|
let cli = CliArgs::parse();
|
|
|
|
match &cli.command {
|
|
Some(SubCmd::Read { follow, block }) => {
|
|
if let Err(e) = crate::mind::observe::cmd_read_inner(*follow, *block, cli.debug).await {
|
|
eprintln!("{:#}", e);
|
|
std::process::exit(1);
|
|
}
|
|
return;
|
|
}
|
|
Some(SubCmd::Write { message }) => {
|
|
let msg = message.join(" ");
|
|
if msg.is_empty() {
|
|
eprintln!("Usage: consciousness write <message>");
|
|
std::process::exit(1);
|
|
}
|
|
if let Err(e) = crate::mind::observe::cmd_write(&msg, cli.debug).await {
|
|
eprintln!("{:#}", e);
|
|
std::process::exit(1);
|
|
}
|
|
return;
|
|
}
|
|
None => {}
|
|
}
|
|
|
|
if cli.show_config {
|
|
match crate::config::load_app(&cli) {
|
|
Ok((app, figment)) => crate::config::show_config(&app, &figment),
|
|
Err(e) => {
|
|
eprintln!("Error loading config: {:#}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if let Err(e) = crate::mind::run(cli).await {
|
|
let _ = crossterm::terminal::disable_raw_mode();
|
|
let _ = crossterm::execute!(
|
|
std::io::stdout(),
|
|
crossterm::terminal::LeaveAlternateScreen
|
|
);
|
|
eprintln!("Error: {:#}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|