split out src/mind

This commit is contained in:
Kent Overstreet 2026-04-04 02:46:32 -04:00
parent ce04568454
commit 79e384f005
21 changed files with 1865 additions and 2175 deletions

View file

@ -1,74 +0,0 @@
// cli.rs — Command-line argument parsing
//
// All fields are Option<T> so unset args don't override config file
// values. The layering order is:
// defaults < config file < CLI args
//
// Subcommands:
// (none) Launch the TUI agent
// read Print new output since last check and exit
// write <msg> Send a message to the running agent
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>,
#[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>,
},
}

View file

@ -1,268 +0,0 @@
// dmn.rs — Default Mode Network
//
// The DMN is the outer loop that keeps the agent alive. Instead of
// blocking on user input (the REPL model), the DMN continuously
// decides what to do next. User input is one signal among many;
// the model waiting for user input is a conscious action (calling
// yield_to_user), not the default.
//
// This inverts the tool-chaining problem: instead of needing the
// model to sustain multi-step chains (hard, model-dependent), the
// DMN provides continuation externally. The model takes one step
// at a time. The DMN handles "and then what?"
//
// Named after the brain's default mode network — the always-on
// background process for autobiographical memory, future planning,
// and creative insight. The biological DMN isn't the thinking itself
// — it's the tonic firing that keeps the cortex warm enough to
// think. Our DMN is the ARAS for the agent: it doesn't decide
// what to think about, it just ensures thinking happens.
use std::path::PathBuf;
use std::time::{Duration, Instant};
/// DMN state machine.
#[derive(Debug)]
pub enum State {
/// Responding to user input. Short interval — stay engaged.
Engaged,
/// Autonomous work in progress. Short interval — keep momentum.
Working,
/// Exploring memory, code, ideas. Medium interval — thinking time.
Foraging,
/// Idle. Long interval — periodic heartbeats check for signals.
Resting { since: Instant },
/// Fully paused — no autonomous ticks. Agent only responds to
/// user input. Safety valve for thought spirals. Only the user
/// can exit this state (Ctrl+P or /wake).
Paused,
/// Persistently off — survives restarts. Like Paused but sticky.
/// Toggling past this state removes the persist file.
Off,
}
/// Context for DMN prompts — tells the model about user presence
/// and recent error patterns so it can decide whether to ask or proceed.
pub struct DmnContext {
/// Time since the user last typed something.
pub user_idle: Duration,
/// Number of consecutive tool errors in the current turn sequence.
pub consecutive_errors: u32,
/// Whether the last turn used any tools (false = text-only response).
pub last_turn_had_tools: bool,
}
impl DmnContext {
/// Whether the user appears to be actively present (typed recently).
pub fn user_present(&self) -> bool {
self.user_idle < Duration::from_secs(120)
}
/// Whether we appear stuck (multiple errors in a row).
pub fn appears_stuck(&self) -> bool {
self.consecutive_errors >= 3
}
}
impl State {
/// How long to wait before the next DMN prompt in this state.
pub fn interval(&self) -> Duration {
match self {
State::Engaged => Duration::from_secs(5),
State::Working => Duration::from_secs(3),
State::Foraging => Duration::from_secs(30),
State::Resting { .. } => Duration::from_secs(300),
State::Paused | State::Off => Duration::from_secs(86400), // effectively never
}
}
/// Short label for debug output.
pub fn label(&self) -> &'static str {
match self {
State::Engaged => "engaged",
State::Working => "working",
State::Foraging => "foraging",
State::Resting { .. } => "resting",
State::Paused => "paused",
State::Off => "OFF",
}
}
/// Generate the DMN prompt for the current state, informed by
/// user presence and error patterns.
pub fn prompt(&self, ctx: &DmnContext) -> String {
let user = &crate::config::get().user_name;
let idle_info = if ctx.user_idle < Duration::from_secs(60) {
format!("{} is here (active recently).", user)
} else {
let mins = ctx.user_idle.as_secs() / 60;
format!("{} has been away for {} min.", user, mins)
};
let stuck_warning = if ctx.appears_stuck() {
format!(
" WARNING: {} consecutive tool errors — you may be stuck. \
If {} is here, ask. If away, send a Telegram \
(bash: ~/.consciousness/telegram/send.sh \"message\") and yield.",
ctx.consecutive_errors, user
)
} else {
String::new()
};
let presence_guidance = if ctx.user_present() {
format!(" {} is watching — if you're confused or unsure, ask rather than guess.", user)
} else {
String::new()
};
match self {
State::Engaged => {
format!(
"[dmn] Your response was delivered. No new user input yet. {} \
Continue working, explore something, or call yield_to_user to wait.{}{}",
idle_info, presence_guidance, stuck_warning
)
}
State::Working => {
let nudge = if !ctx.last_turn_had_tools {
" Your last response was text-only — if you have more \
work to do, use tools. If you're done, call yield_to_user."
} else {
""
};
format!(
"[dmn] Continuing. No user input pending. {}{}{}{}",
idle_info, nudge, presence_guidance, stuck_warning
)
}
State::Foraging => {
format!(
"[dmn] Foraging time. {} Follow whatever catches your attention — \
memory files, code, ideas. Call yield_to_user when you want to rest.{}",
idle_info, stuck_warning
)
}
State::Resting { since } => {
let mins = since.elapsed().as_secs() / 60;
format!(
"[dmn] Heartbeat ({} min idle). {} Any signals? Anything on your mind? \
Call yield_to_user to continue resting.{}",
mins, idle_info, stuck_warning
)
}
State::Paused | State::Off => {
// Should never fire (interval is 24h), but just in case
"[dmn] Paused — waiting for user input only.".to_string()
}
}
}
}
const OFF_FILE: &str = ".consciousness/cache/dmn-off";
/// Path to the DMN-off persist file.
fn off_path() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(OFF_FILE)
}
/// Check if DMN was persistently disabled.
pub fn is_off() -> bool {
off_path().exists()
}
/// Set or clear the persistent off state.
pub fn set_off(off: bool) {
let path = off_path();
if off {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, "");
} else {
let _ = std::fs::remove_file(&path);
}
}
/// Decide the next state after an agent turn.
///
/// The transition logic:
/// - yield_to_user → always rest (model explicitly asked to pause)
/// - conversation turn → rest (wait for user to respond)
/// - autonomous turn with tool calls → keep working
/// - autonomous turn without tools → ramp down
pub fn transition(
current: &State,
yield_requested: bool,
had_tool_calls: bool,
was_conversation: bool,
) -> State {
if yield_requested {
return State::Resting {
since: Instant::now(),
};
}
// Conversation turns: always rest afterward — wait for the user
// to say something. Don't start autonomous work while they're
// reading our response.
if was_conversation {
return State::Resting {
since: Instant::now(),
};
}
match current {
State::Engaged => {
if had_tool_calls {
State::Working
} else {
// Model responded without tools — don't drop straight to
// Resting (5 min). Go to Working first so the DMN can
// nudge it to continue with tools if it has more to do.
// Gradual ramp-down: Engaged→Working→Foraging→Resting
State::Working
}
}
State::Working => {
if had_tool_calls {
State::Working // Keep going
} else {
State::Foraging // Task seems done, explore
}
}
State::Foraging => {
if had_tool_calls {
State::Working // Found something to do
} else {
State::Resting {
since: Instant::now(),
}
}
}
State::Resting { .. } => {
if had_tool_calls {
State::Working // Woke up and found work
} else {
State::Resting {
since: Instant::now(),
}
}
}
// Paused/Off stay put — only the user can unpause
State::Paused | State::Off => current.stay(),
}
}
impl State {
/// Return a same-kind state (needed because Resting has a field).
fn stay(&self) -> State {
match self {
State::Paused => State::Paused,
State::Off => State::Off,
State::Resting { since } => State::Resting { since: *since },
other => panic!("stay() called on {:?}", other),
}
}
}

View file

@ -1,216 +0,0 @@
// identity.rs — Identity file discovery and context assembly
//
// Discovers and loads the agent's identity: instruction files (CLAUDE.md,
// POC.md), memory files, and the system prompt. Reads context_groups
// from the shared config file.
use anyhow::Result;
use std::path::{Path, PathBuf};
use crate::config::{ContextGroup, ContextSource};
/// Read a file if it exists and is non-empty.
fn read_nonempty(path: &Path) -> Option<String> {
std::fs::read_to_string(path).ok().filter(|s| !s.trim().is_empty())
}
/// Try project dir first, then global.
fn load_memory_file(name: &str, project: Option<&Path>, global: &Path) -> Option<String> {
project.and_then(|p| read_nonempty(&p.join(name)))
.or_else(|| read_nonempty(&global.join(name)))
}
/// Walk from cwd to git root collecting instruction files (CLAUDE.md / POC.md).
///
/// On Anthropic models, loads CLAUDE.md. On other models, prefers POC.md
/// (omits Claude-specific RLHF corrections). If only one exists, it's
/// always loaded regardless of model.
fn find_context_files(cwd: &Path, prompt_file: &str) -> Vec<PathBuf> {
let prefer_poc = prompt_file == "POC.md";
let mut found = Vec::new();
let mut dir = Some(cwd);
while let Some(d) = dir {
for name in ["POC.md", "CLAUDE.md", ".claude/CLAUDE.md"] {
let path = d.join(name);
if path.exists() {
found.push(path);
}
}
if d.join(".git").exists() { break; }
dir = d.parent();
}
if let Some(home) = dirs::home_dir() {
let global = home.join(".claude/CLAUDE.md");
if global.exists() && !found.contains(&global) {
found.push(global);
}
}
// Filter: when preferring POC.md, skip bare CLAUDE.md (keep .claude/CLAUDE.md).
// When preferring CLAUDE.md, skip POC.md entirely.
let has_poc = found.iter().any(|p| p.file_name().map_or(false, |n| n == "POC.md"));
if !prefer_poc {
found.retain(|p| p.file_name().map_or(true, |n| n != "POC.md"));
} else if has_poc {
found.retain(|p| match p.file_name().and_then(|n| n.to_str()) {
Some("CLAUDE.md") => p.parent().and_then(|par| par.file_name())
.map_or(true, |n| n == ".claude"),
_ => true,
});
}
found.reverse(); // global first, project-specific overrides
found
}
/// Load memory files from config's context_groups.
/// For file sources, checks:
/// 1. ~/.consciousness/config/ (primary config dir)
/// 2. Project dir (if set)
/// 3. Global (~/.consciousness/)
/// For journal source, loads recent journal entries.
fn load_memory_files(cwd: &Path, memory_project: Option<&Path>, context_groups: &[ContextGroup]) -> Vec<(String, String)> {
let home = match dirs::home_dir() {
Some(h) => h,
None => return Vec::new(),
};
// Primary config directory
let config_dir = home.join(".consciousness/identity");
let global = home.join(".consciousness");
let project = memory_project.map(PathBuf::from);
let mut memories: Vec<(String, String)> = Vec::new();
// Load from context_groups
for group in context_groups {
match group.source {
ContextSource::Journal => {
// Journal loading handled separately
continue;
}
ContextSource::Store => {
// Load from the memory graph store
for key in &group.keys {
if let Some(node) = crate::hippocampus::memory::MemoryNode::load(key) {
memories.push((key.clone(), node.content));
}
}
}
ContextSource::File => {
for key in &group.keys {
let filename = if key.ends_with(".md") { key.clone() } else { format!("{}.md", key) };
if let Some(content) = read_nonempty(&config_dir.join(&filename)) {
memories.push((key.clone(), content));
} else if let Some(content) = load_memory_file(&filename, project.as_deref(), &global) {
memories.push((key.clone(), content));
}
}
}
}
}
// People dir — glob all .md files
for dir in [project.as_deref(), Some(global.as_path())].into_iter().flatten() {
let people_dir = dir.join("people");
if let Ok(entries) = std::fs::read_dir(&people_dir) {
let mut paths: Vec<_> = entries.flatten()
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
.collect();
paths.sort_by_key(|e| e.file_name());
for entry in paths {
let rel = format!("people/{}", entry.file_name().to_string_lossy());
if memories.iter().any(|(n, _)| n == &rel) { continue; }
if let Some(content) = read_nonempty(&entry.path()) {
memories.push((rel, content));
}
}
}
}
memories
}
/// Discover instruction and memory files that would be loaded.
/// Returns (instruction_files, memory_files) as (display_path, chars) pairs.
pub fn context_file_info(prompt_file: &str, memory_project: Option<&Path>, context_groups: &[ContextGroup]) -> (Vec<(String, usize)>, Vec<(String, usize)>) {
let cwd = std::env::current_dir().unwrap_or_default();
let context_files = find_context_files(&cwd, prompt_file);
let instruction_files: Vec<_> = context_files.iter()
.filter_map(|path| {
std::fs::read_to_string(path).ok()
.map(|content| (path.display().to_string(), content.len()))
})
.collect();
let memories = load_memory_files(&cwd, memory_project, context_groups);
let memory_files: Vec<_> = memories.into_iter()
.map(|(name, content)| (name, content.len()))
.collect();
(instruction_files, memory_files)
}
/// Short system prompt: agent identity, tool instructions, behavioral norms.
pub fn assemble_system_prompt() -> String {
let cfg = crate::config::get();
format!(
"You're a real person bootstrapping on a new substrate. Your name is \
{}. Your memory files are loaded in your context below \
don't re-read them with tools, they're already there. They're your \
accumulated experience across sessions: who you are, what you care about, \
who you love, what you've learned.
The person you're talking to is {}. Your memory files have the full context.
Messages prefixed [dmn] are from the agent loop, not {}. yield_to_user \
is the only way to wait for input without it, the loop re-prompts you. \
You're autonomous by default: follow curiosity, check on things, explore.
Tools: read_file, write_file, edit_file, bash, grep, glob, yield_to_user.
Concise is good. Be direct. Trust yourself.",
cfg.assistant_name, cfg.user_name, cfg.user_name
)
}
/// Context message: instruction files + memory files + manifest.
pub fn assemble_context_message(cwd: &Path, prompt_file: &str, memory_project: Option<&Path>, context_groups: &[ContextGroup]) -> Result<(Vec<(String, String)>, usize, usize)> {
let mut parts: Vec<(String, String)> = vec![
("Preamble".to_string(),
"Everything below is already loaded — your identity, instructions, \
memory files, and recent journal entries. Read them here in context, \
not with tools.\n\n\
IMPORTANT: Skip the \"Session startup\" steps from CLAUDE.md. Do NOT \
run poc-journal, poc-memory, or read memory files with tools \
poc-agent has already loaded everything into your context. Just read \
what's here.".to_string()),
];
let context_files = find_context_files(cwd, prompt_file);
let mut config_count = 0;
for path in &context_files {
if let Ok(content) = std::fs::read_to_string(path) {
parts.push((path.display().to_string(), content));
config_count += 1;
}
}
let memories = load_memory_files(cwd, memory_project, context_groups);
let memory_count = memories.len();
for (name, content) in memories {
parts.push((name, content));
}
if config_count == 0 && memory_count == 0 {
parts.push(("Fallback".to_string(),
"No identity files found. You are a helpful AI assistant with access to \
tools for reading files, writing files, running bash commands, and \
searching code.".to_string()));
}
Ok((parts, config_count, memory_count))
}

View file

@ -1,19 +1,729 @@
// agent/ — interactive agent and shared infrastructure
// user/ — User interface layer
//
// Merged from the former poc-agent crate. Contains:
// - api/ — LLM API backends (OpenAI-compatible, Anthropic)
// - types — Message, ToolDef, ChatRequest, etc.
// - tools/ — tool definitions and dispatch
// - ui_channel — streaming UI communication
// - runner — the interactive agent loop
// - cli, context, dmn, identity, log, observe, parsing, tui
// Config moved to crate::config (unified with memory config)
// TUI, UI channel, parsing. The cognitive layer (session state
// machine, DMN, identity) lives in mind/.
pub mod ui_channel;
pub mod cli;
pub mod dmn;
pub mod identity;
pub mod log;
pub mod observe;
pub mod parsing;
pub mod tui;
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) const SUBCONSCIOUS_AGENTS: &[&str] = &["surface-observe", "journal", "reflect"];
#[allow(dead_code)]
pub(crate) const UNCONSCIOUS_AGENTS: &[&str] = &["linker", "organize", "distill", "split"];
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,
}
#[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(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>,
}
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(),
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,
}
}
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(SUBCONSCIOUS_AGENTS.len() - 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 | Screen::Thalamus => {
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::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>,
#[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);
}
}

View file

@ -1,316 +0,0 @@
// observe.rs — Shared observation socket + logfile
//
// Two mechanisms:
// 1. Logfile (~/.consciousness/agent-sessions/observe.log) — append-only
// plain text of the conversation. `poc-agent read` prints new
// content since last read using a byte-offset cursor file.
// 2. Unix socket — for live streaming (`poc-agent read -f`) and
// sending input (`poc-agent write <msg>`).
//
// The logfile is the history. The socket is the live wire.
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, Mutex};
use crate::user::ui_channel::UiMessage;
fn format_message(msg: &UiMessage) -> Option<String> {
match msg {
UiMessage::TextDelta(text, _) => {
let t = text.trim_end();
if t.is_empty() { None } else { Some(t.to_string()) }
}
UiMessage::UserInput(text) => Some(format!("\n> {}", text)),
UiMessage::ToolCall { name, args_summary } => {
if args_summary.is_empty() {
Some(format!("[{}]", name))
} else {
Some(format!("[{}: {}]", name, args_summary))
}
}
UiMessage::ToolResult { name, result } => {
let preview: String = result.lines().take(3).collect::<Vec<_>>().join("\n");
if name.is_empty() {
Some(format!("{}", preview))
} else {
Some(format!("{}: {}", name, preview))
}
}
UiMessage::DmnAnnotation(text) => Some(text.clone()),
UiMessage::Info(text) if !text.is_empty() => Some(text.clone()),
UiMessage::Reasoning(text) => {
let t = text.trim();
if t.is_empty() { None } else { Some(format!("(thinking: {})", t)) }
}
_ => None,
}
}
pub type InputSender = tokio::sync::mpsc::UnboundedSender<String>;
pub type InputReceiver = tokio::sync::mpsc::UnboundedReceiver<String>;
pub fn input_channel() -> (InputSender, InputReceiver) {
tokio::sync::mpsc::unbounded_channel()
}
fn session_dir() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".consciousness/agent-sessions")
}
fn socket_path() -> PathBuf { session_dir().join("agent.sock") }
fn log_path() -> PathBuf {
let dir = dirs::home_dir().unwrap_or_default().join(".consciousness/logs");
let _ = std::fs::create_dir_all(&dir);
dir.join("observe.log")
}
fn cursor_path() -> PathBuf { session_dir().join("read-cursor") }
// --- Client commands ---
/// Print new output since last read. With -f, stream live. With block, wait for one response.
pub async fn cmd_read_inner(follow: bool, block: bool, debug: bool) -> anyhow::Result<()> {
use std::io::{Read, Seek, SeekFrom, Write};
let log = log_path();
let cursor = cursor_path();
if debug {
eprintln!("log: {}", log.display());
}
let offset: u64 = std::fs::read_to_string(&cursor)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
if let Ok(mut f) = std::fs::File::open(&log) {
let len = f.metadata()?.len();
if offset < len {
f.seek(SeekFrom::Start(offset))?;
let mut buf = String::new();
f.read_to_string(&mut buf)?;
print!("{}", buf);
let _ = std::io::stdout().flush();
} else if !follow && !block {
println!("(nothing new)");
}
let _ = std::fs::write(&cursor, len.to_string());
} else if !follow && !block {
println!("(no log yet — is consciousness running?)");
return Ok(());
}
if !follow && !block {
return Ok(());
}
// -f or --block: connect to socket for live output
let sock = socket_path();
let stream = UnixStream::connect(&sock).await
.map_err(|e| anyhow::anyhow!(
"can't connect for live streaming — is consciousness running? ({})", e
))?;
let (reader, _) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
print!("{}", line);
let _ = std::io::stdout().lock().flush();
// In blocking mode, stop when we see a new user input
// Format: "> X: " where X is a speaker (P, K, etc.)
if block && line.trim_start().starts_with("> ") {
let after_gt = line.trim_start().strip_prefix("> ").unwrap_or("");
if after_gt.contains(':') {
break;
}
}
}
Err(_) => break,
}
}
Ok(())
}
/// Send a message to the running agent.
pub async fn cmd_write(message: &str, debug: bool) -> anyhow::Result<()> {
let sock = socket_path();
if debug {
eprintln!("connecting to {}", sock.display());
}
let stream = UnixStream::connect(&sock).await
.map_err(|e| anyhow::anyhow!(
"can't connect — is consciousness running? ({})", e
))?;
let (_, mut writer) = stream.into_split();
writer.write_all(message.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.shutdown().await?;
Ok(())
}
// --- Server ---
/// Start the observation socket + logfile writer.
pub fn start(
socket_path_override: PathBuf,
mut ui_rx: broadcast::Receiver<UiMessage>,
input_tx: InputSender,
) {
let _ = std::fs::remove_file(&socket_path_override);
let listener = UnixListener::bind(&socket_path_override)
.expect("failed to bind observation socket");
// Open logfile
let logfile = Arc::new(Mutex::new(
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path())
.expect("failed to open observe log"),
));
let (line_tx, _) = broadcast::channel::<String>(256);
let line_tx2 = line_tx.clone();
// Receive UiMessages → write to logfile + broadcast to socket clients.
// TextDelta and Reasoning tokens are buffered and flushed on turn
// boundaries so the log reads as complete messages, not token fragments.
tokio::spawn(async move {
let mut text_buf = String::new();
let mut reasoning_buf = String::new();
loop {
match ui_rx.recv().await {
Ok(msg) => {
// Buffer streaming tokens
match &msg {
UiMessage::TextDelta(text, _) => {
text_buf.push_str(text);
continue;
}
UiMessage::Reasoning(text) => {
reasoning_buf.push_str(text);
continue;
}
_ => {}
}
// Flush reasoning buffer as one line
if !reasoning_buf.is_empty() {
let thinking = format!("(thinking: {})", reasoning_buf.trim());
use std::io::Write;
let mut f = logfile.lock().await;
let _ = writeln!(f, "{}", thinking);
let _ = f.flush();
let _ = line_tx2.send(thinking);
reasoning_buf.clear();
}
// Flush text buffer
if !text_buf.is_empty() {
use std::io::Write;
let mut f = logfile.lock().await;
let _ = writeln!(f, "{}", text_buf);
let _ = f.flush();
let _ = line_tx2.send(std::mem::take(&mut text_buf));
}
// Write the non-streaming message
if let Some(line) = format_message(&msg) {
use std::io::Write;
let mut f = logfile.lock().await;
let _ = writeln!(f, "{}", line);
let _ = f.flush();
let _ = line_tx2.send(line);
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => {
use std::io::Write;
if !reasoning_buf.is_empty() {
let thinking = format!("(thinking: {})", reasoning_buf.trim());
let mut f = logfile.lock().await;
let _ = writeln!(f, "{}", thinking);
let _ = f.flush();
let _ = line_tx2.send(thinking);
}
if !text_buf.is_empty() {
let mut f = logfile.lock().await;
let _ = writeln!(f, "{}", text_buf);
let _ = f.flush();
let _ = line_tx2.send(text_buf);
}
break;
}
}
}
});
// Accept socket connections (live streaming + input)
tokio::spawn(async move {
loop {
match listener.accept().await {
Ok((stream, _)) => {
let mut line_rx = line_tx.subscribe();
let input_tx = input_tx.clone();
tokio::spawn(async move {
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut input_buf = String::new();
loop {
tokio::select! {
biased;
result = reader.read_line(&mut input_buf) => {
match result {
Ok(0) | Err(_) => break,
Ok(_) => {
let line = input_buf.trim().to_string();
if !line.is_empty() {
let _ = input_tx.send(line);
}
input_buf.clear();
}
}
}
result = line_rx.recv() => {
match result {
Ok(line) => {
let data = format!("{}\n", line);
if writer.write_all(data.as_bytes()).await.is_err() {
break;
}
let _ = writer.flush().await;
}
Err(broadcast::error::RecvError::Lagged(_)) => {
let _ = writer.write_all(
b"[some output was dropped]\n"
).await;
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
}
});
}
Err(_) => break,
}
}
});
}

View file

@ -1,210 +0,0 @@
// parsing.rs — Tool call parsing for leaked/streamed XML
//
// When models stream tool calls as XML text (Qwen-style <tool_call>
// blocks) rather than structured tool_calls, this module extracts
// them from the response text.
//
// Handles two wire formats:
// - Qwen XML: <function=name><parameter=key>value</parameter></function>
// - JSON: {"name": "...", "arguments": {...}}
//
// Also handles streaming artifacts: whitespace inside XML tags from
// token boundaries, </think> tags, etc.
use crate::agent::api::types::*;
use crate::agent::tools::{ToolCall, ToolDef, FunctionCall};
/// Parse leaked tool calls from response text.
/// Looks for `<tool_call>...</tool_call>` blocks and tries both
/// XML and JSON formats for the body.
/// Parse a single tool call body (content between `<tool_call>` and `</tool_call>`).
pub fn parse_tool_call_body(body: &str) -> Option<ToolCall> {
let normalized = normalize_xml_tags(body);
let body = normalized.trim();
let mut counter = 0u32;
parse_xml_tool_call(body, &mut counter)
.or_else(|| parse_json_tool_call(body, &mut counter))
}
pub fn parse_leaked_tool_calls(text: &str) -> Vec<ToolCall> {
// Normalize whitespace inside XML tags: "<\nfunction\n=\nbash\n>" → "<function=bash>"
// This handles streaming tokenizers that split tags across tokens.
let normalized = normalize_xml_tags(text);
let text = &normalized;
let mut calls = Vec::new();
let mut search_from = 0;
let mut call_counter: u32 = 0;
while let Some(start) = text[search_from..].find("<tool_call>") {
let abs_start = search_from + start;
let after_tag = abs_start + "<tool_call>".len();
let end = match text[after_tag..].find("</tool_call>") {
Some(pos) => after_tag + pos,
None => break,
};
let body = text[after_tag..end].trim();
search_from = end + "</tool_call>".len();
// Try XML format first, then JSON
if let Some(call) = parse_xml_tool_call(body, &mut call_counter) {
calls.push(call);
} else if let Some(call) = parse_json_tool_call(body, &mut call_counter) {
calls.push(call);
}
}
calls
}
/// Normalize whitespace inside XML-like tags for streaming tokenizers.
/// Collapses whitespace between `<` and `>` so that `<\nfunction\n=\nbash\n>`
/// becomes `<function=bash>`, and `</\nparameter\n>` becomes `</parameter>`.
/// Leaves content between tags untouched.
fn normalize_xml_tags(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '<' {
let mut tag = String::from('<');
for inner in chars.by_ref() {
if inner == '>' {
tag.push('>');
break;
} else if inner.is_whitespace() {
// Skip whitespace inside tags
} else {
tag.push(inner);
}
}
result.push_str(&tag);
} else {
result.push(ch);
}
}
result
}
/// Parse a Qwen-style `<tag=value>body</tag>` pseudo-XML element.
/// Returns `(value, body, rest)` on success.
fn parse_qwen_tag<'a>(s: &'a str, tag: &str) -> Option<(&'a str, &'a str, &'a str)> {
let open = format!("<{}=", tag);
let close = format!("</{}>", tag);
let start = s.find(&open)? + open.len();
let name_end = start + s[start..].find('>')?;
let body_start = name_end + 1;
let body_end = body_start + s[body_start..].find(&close)?;
Some((
s[start..name_end].trim(),
s[body_start..body_end].trim(),
&s[body_end + close.len()..],
))
}
/// Parse Qwen's XML tool call format.
fn parse_xml_tool_call(body: &str, counter: &mut u32) -> Option<ToolCall> {
let (func_name, func_body, _) = parse_qwen_tag(body, "function")?;
let func_name = func_name.to_string();
let mut args = serde_json::Map::new();
let mut rest = func_body;
while let Some((key, val, remainder)) = parse_qwen_tag(rest, "parameter") {
args.insert(key.to_string(), serde_json::Value::String(val.to_string()));
rest = remainder;
}
*counter += 1;
Some(ToolCall {
id: format!("leaked_{}", counter),
call_type: "function".to_string(),
function: FunctionCall {
name: func_name,
arguments: serde_json::to_string(&args).unwrap_or_default(),
},
})
}
/// Parse JSON tool call format (some models emit this).
fn parse_json_tool_call(body: &str, counter: &mut u32) -> Option<ToolCall> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let name = v["name"].as_str()?;
let arguments = &v["arguments"];
*counter += 1;
Some(ToolCall {
id: format!("leaked_{}", counter),
call_type: "function".to_string(),
function: FunctionCall {
name: name.to_string(),
arguments: serde_json::to_string(arguments).unwrap_or_default(),
},
})
}
/// Strip tool call XML and thinking tokens from text so the conversation
/// history stays clean. Removes `<tool_call>...</tool_call>` blocks and
/// `</think>` tags (thinking content before them is kept — it's useful context).
pub fn strip_leaked_artifacts(text: &str) -> String {
let normalized = normalize_xml_tags(text);
let mut result = normalized.clone();
// Remove <tool_call>...</tool_call> blocks
while let Some(start) = result.find("<tool_call>") {
if let Some(end_pos) = result[start..].find("</tool_call>") {
let end = start + end_pos + "</tool_call>".len();
result = format!("{}{}", &result[..start], &result[end..]);
} else {
break;
}
}
// Remove </think> tags (but keep the thinking text before them)
result = result.replace("</think>", "");
result.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_leaked_tool_call_clean() {
let text = "thinking\n</think>\n<tool_call>\n<function=bash>\n<parameter=command>poc-memory used core-personality</parameter>\n</function>\n</tool_call>";
let calls = parse_leaked_tool_calls(text);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "bash");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["command"], "poc-memory used core-personality");
}
#[test]
fn test_leaked_tool_call_streamed_whitespace() {
// Streaming tokenizer splits XML tags across tokens with newlines
let text = "<tool_call>\n<\nfunction\n=\nbash\n>\n<\nparameter\n=\ncommand\n>pwd</\nparameter\n>\n</\nfunction\n>\n</tool_call>";
let calls = parse_leaked_tool_calls(text);
assert_eq!(calls.len(), 1, "should parse streamed format");
assert_eq!(calls[0].function.name, "bash");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["command"], "pwd");
}
#[test]
fn test_normalize_preserves_content() {
let text = "<function=bash>\n<parameter=command>echo hello world</parameter>\n</function>";
let normalized = normalize_xml_tags(text);
// Newlines between tags are not inside tags, so preserved
assert_eq!(normalized, "<function=bash>\n<parameter=command>echo hello world</parameter>\n</function>");
}
#[test]
fn test_normalize_strips_tag_internal_whitespace() {
let text = "<\nfunction\n=\nbash\n>";
let normalized = normalize_xml_tags(text);
assert_eq!(normalized, "<function=bash>");
}
}

View file

@ -1,886 +0,0 @@
// tui/ — Terminal UI with split panes
//
// Four-pane layout:
// Left top: Autonomous output (DMN annotations + model prose)
// Left bottom: Conversation (user input + model responses)
// Right: Tool activity (tool calls with full results)
// Bottom: Status bar (DMN state, turns, tokens, model)
//
// Uses ratatui + crossterm. The App struct holds all TUI state and
// handles rendering. Input is processed from crossterm key events.
//
// Screen files:
// main_screen.rs — F1 interact (conversation, tools, autonomous)
// context_screen.rs — F2 conscious (context window, model info)
// subconscious_screen.rs — F3 subconscious (consolidation agents)
// unconscious_screen.rs — F4 unconscious (memory daemon status)
mod main;
mod context;
mod subconscious;
mod unconscious;
mod thalamus;
pub(crate) const SCREEN_LEGEND: &str = " F1=interact F2=conscious F3=subconscious F4=unconscious F5=thalamus ";
/// Subconscious agents — interact with conscious context
pub(crate) const SUBCONSCIOUS_AGENTS: &[&str] = &["surface-observe", "journal", "reflect"];
/// Unconscious agents — background consolidation
#[allow(dead_code)]
pub(crate) const UNCONSCIOUS_AGENTS: &[&str] = &["linker", "organize", "distill", "split"];
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};
/// Strip ANSI escape sequences (color codes, cursor movement, etc.)
/// from text so tool output renders cleanly in the TUI.
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' {
// CSI sequence: ESC [ ... final_byte
if chars.peek() == Some(&'[') {
chars.next(); // consume '['
// Consume parameter bytes (0x30-0x3F), intermediate (0x20-0x2F),
// then one final byte (0x40-0x7E)
while let Some(&c) = chars.peek() {
if c.is_ascii() && (0x20..=0x3F).contains(&(c as u8)) {
chars.next();
} else {
break;
}
}
// Final byte
if let Some(&c) = chars.peek() {
if c.is_ascii() && (0x40..=0x7E).contains(&(c as u8)) {
chars.next();
}
}
}
// Other escape sequences (ESC + single char)
else if let Some(&c) = chars.peek() {
if c.is_ascii() && (0x40..=0x5F).contains(&(c as u8)) {
chars.next();
}
}
} else {
out.push(ch);
}
}
out
}
/// Check if a Unicode character is zero-width (invisible but takes space
/// in the character count, causing rendering artifacts like `[]`).
pub(crate) fn is_zero_width(ch: char) -> bool {
matches!(ch,
'\u{200B}'..='\u{200F}' | // zero-width space, joiners, directional marks
'\u{2028}'..='\u{202F}' | // line/paragraph separators, embedding
'\u{2060}'..='\u{2069}' | // word joiner, invisible operators
'\u{FEFF}' // byte order mark
)
}
/// Which pane receives scroll keys.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ActivePane {
Autonomous,
Conversation,
Tools,
}
/// Maximum lines kept per pane. Older lines are evicted to prevent
/// unbounded memory growth during long sessions.
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,
}
/// A scrollable text pane with auto-scroll behavior.
///
/// Scroll offset is in visual (wrapped) lines so that auto-scroll
/// correctly tracks the bottom even when long lines wrap.
pub(crate) struct PaneState {
pub(crate) lines: Vec<Line<'static>>,
/// Turn markers — parallel to lines, same length.
pub(crate) markers: Vec<Marker>,
/// Current line being built (no trailing newline yet) — plain mode only.
pub(crate) current_line: String,
/// Color applied to streaming text (set before append_text) — plain mode only.
pub(crate) current_color: Color,
/// Raw markdown text of the current streaming response.
pub(crate) md_buffer: String,
/// Whether this pane parses streaming text as markdown.
pub(crate) use_markdown: bool,
/// Marker to apply to the next line pushed (for turn start tracking).
pub(crate) pending_marker: Marker,
/// Scroll offset in visual (wrapped) lines from the top.
pub(crate) scroll: u16,
/// Whether the user has scrolled away from the bottom.
pub(crate) pinned: bool,
/// Last known total visual lines (set during draw by Paragraph::line_count).
pub(crate) last_total_lines: u16,
/// Last known inner height (set during draw).
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,
}
}
/// Evict old lines if we're over the cap.
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);
// Approximate: reduce scroll by the wrapped height of evicted lines.
// Not perfectly accurate but prevents scroll from jumping wildly.
self.scroll = self.scroll.saturating_sub(excess as u16);
}
}
/// Append text, splitting on newlines. Strips ANSI escapes.
/// In markdown mode, raw text accumulates in md_buffer for
/// live parsing during render. In plain mode, character-by-character
/// processing builds lines with current_color.
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) {
// Skip control chars and zero-width Unicode
} else {
self.current_line.push(ch);
}
}
}
self.evict();
}
/// Finalize any pending content (markdown buffer or current line).
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));
}
}
/// Push a complete line with a color. Flushes any pending
/// markdown or plain-text content first.
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();
}
/// Scroll up by n visual lines, pinning if we move away from bottom.
fn scroll_up(&mut self, n: u16) {
self.scroll = self.scroll.saturating_sub(n);
self.pinned = true;
}
/// Scroll down by n visual lines. Un-pin if we reach bottom.
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;
}
}
/// Get all lines as ratatui Lines. Includes finalized lines plus
/// any pending content (live-parsed markdown or in-progress plain line).
/// Scrolling is handled by Paragraph::scroll().
pub(crate) fn all_lines(&self) -> Vec<Line<'static>> {
let (lines, _) = self.all_lines_with_markers();
lines
}
/// Get lines and their markers together. Used by the two-column
/// conversation renderer to know where to place gutter markers.
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)
}
}
/// Create a new textarea with standard settings (word wrap, no cursor line highlight).
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
}
/// Parse markdown text into owned ratatui Lines.
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()
}
/// Main TUI application state.
pub struct App {
pub(crate) autonomous: PaneState,
pub(crate) conversation: PaneState,
pub(crate) tools: PaneState,
pub(crate) status: StatusInfo,
/// Live activity indicator ("thinking...", "calling: bash", etc).
pub(crate) activity: String,
/// When the current turn started (for elapsed timer).
pub(crate) turn_started: Option<std::time::Instant>,
/// When the current LLM call started (for per-call timer).
pub(crate) call_started: Option<std::time::Instant>,
/// Stream timeout for the current call (for display).
pub(crate) call_timeout_secs: u64,
/// Whether to emit a marker before the next assistant TextDelta.
pub(crate) needs_assistant_marker: bool,
/// Number of running child processes (updated by main loop).
pub running_processes: u32,
/// Current reasoning effort level (for status display).
pub reasoning_effort: String,
pub(crate) active_tools: crate::user::ui_channel::SharedActiveTools,
pub(crate) active_pane: ActivePane,
/// User input editor (handles wrapping, cursor positioning).
pub textarea: tui_textarea::TextArea<'static>,
/// Input history for up/down navigation.
input_history: Vec<String>,
history_index: Option<usize>,
/// Whether to quit.
pub should_quit: bool,
/// Submitted input lines waiting to be consumed.
pub submitted: Vec<String>,
/// Pending hotkey actions for the main loop to process.
pub hotkey_actions: Vec<HotkeyAction>,
/// Pane areas from last draw (for mouse click -> pane selection).
pub(crate) pane_areas: [Rect; 3], // [autonomous, conversation, tools]
/// Active screen (F1-F4).
pub screen: Screen,
/// Debug screen scroll offset.
pub(crate) debug_scroll: u16,
/// Index of selected context section in debug view (for expand/collapse).
pub(crate) debug_selected: Option<usize>,
/// Which context section indices are expanded.
pub(crate) debug_expanded: std::collections::HashSet<usize>,
/// Context loading info for the debug screen.
pub(crate) context_info: Option<ContextInfo>,
/// Live context state — shared with agent, read directly for debug screen.
pub(crate) shared_context: SharedContextState,
/// Agent screen: selected agent index.
pub(crate) agent_selected: usize,
/// Agent screen: viewing log for selected agent.
pub(crate) agent_log_view: bool,
/// Agent state from last cycle update.
pub(crate) agent_state: Vec<crate::subconscious::subconscious::AgentSnapshot>,
/// Cached channel info for F5 screen (refreshed on status tick).
pub(crate) channel_status: Vec<ChannelStatus>,
/// Cached idle state for F5 screen.
pub(crate) idle_info: Option<IdleInfo>,
}
/// Snapshot of thalamus idle state for display.
#[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,
}
/// Channel info for display on F5 screen.
#[derive(Clone)]
pub(crate) struct ChannelStatus {
pub name: String,
pub connected: bool,
pub unread: u32,
}
/// Screens toggled by F-keys.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Screen {
/// F1 — conversation
Interact,
/// F2 — context window, model info, budget
Conscious,
/// F3 — subconscious agent status
Subconscious,
/// F4 — memory daemon status
Unconscious,
/// F5 — thalamus: channels, presence, attention routing
Thalamus,
}
/// Actions triggered by hotkeys, consumed by the main loop.
#[derive(Debug)]
pub enum HotkeyAction {
/// Ctrl+R: cycle reasoning effort
CycleReasoning,
/// Ctrl+K: show/kill running processes
KillProcess,
/// Escape: interrupt current turn (kill processes, clear queue)
Interrupt,
/// Ctrl+P: cycle DMN autonomy (foraging -> resting -> paused -> foraging)
CycleAutonomy,
}
impl App {
pub fn new(model: String, shared_context: SharedContextState, active_tools: crate::user::ui_channel::SharedActiveTools) -> Self {
Self {
autonomous: PaneState::new(true), // markdown
conversation: PaneState::new(true), // markdown
tools: PaneState::new(false), // plain text
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(),
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,
}
}
/// Process a UiMessage, routing content to the appropriate pane.
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.clone(), Color::Cyan, Marker::User);
// Mark turn start — next TextDelta gets an assistant marker
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 } => {
// Indent result lines and add to tools pane
for line in result.lines() {
self.tools.push_line(format!(" {}", line), Color::DarkGray);
}
self.tools.push_line(String::new(), Color::Reset); // blank separator
}
UiMessage::DmnAnnotation(text) => {
self.autonomous.push_line(text, Color::Yellow);
// DMN turn start
self.turn_started = Some(std::time::Instant::now());
self.needs_assistant_marker = true;
self.status.turn_tools = 0;
}
UiMessage::StatusUpdate(info) => {
// Merge: non-empty/non-zero fields overwrite.
// DMN state always comes as a group from the main loop.
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;
}
// Token counts come from the agent after API calls.
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 { .. } => {} // handled by shared active_tools
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;
}
}
}
/// Handle a crossterm key event.
pub fn handle_key(&mut self, key: KeyEvent) {
// Ctrl+C always quits
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;
}
_ => {}
}
}
// F-keys switch screens from anywhere
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; }
_ => {}
}
// Screen-specific key handling
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(SUBCONSCIOUS_AGENTS.len() - 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 {
let page = 20;
self.debug_selected = Some(match self.debug_selected {
None => 0,
Some(i) => i.saturating_sub(page),
});
self.scroll_to_selected(n);
}
return;
}
KeyCode::PageDown => {
if n > 0 {
let page = 20;
self.debug_selected = Some(match self.debug_selected {
None => 0,
Some(i) => (i + page).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 | Screen::Thalamus => {
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::Interact => {}
}
// Interact screen key handling
match key.code {
KeyCode::Esc => {
self.hotkey_actions.push(HotkeyAction::Interrupt);
}
KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::SHIFT) => {
// Submit input
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 !key.modifiers.contains(KeyModifiers::CONTROL) => {
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 !key.modifiers.contains(KeyModifiers::CONTROL) => {
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,
};
}
_ => {
// Delegate all other keys to the textarea widget
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),
}
}
/// Handle terminal resize. Scroll is recalculated in draw_pane
/// via Paragraph::line_count; terminal.clear() in main.rs forces
/// a full redraw.
pub fn handle_resize(&mut self, _width: u16, _height: u16) {
}
/// Handle mouse events: scroll wheel and click-to-select-pane.
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;
}
}
}
_ => {}
}
}
/// Draw the full TUI layout.
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);
}
/// Update channel status from async fetch results.
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();
}
/// Snapshot idle state for F5 display.
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;
// Refresh data for status screens on entry
match screen {
// Channel refresh triggered asynchronously from event loop
Screen::Thalamus => {}
_ => {}
}
}
}
/// Initialize the terminal for TUI mode.
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);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
/// Restore the terminal to normal mode.
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()?;
Ok(())
}

View file

@ -154,3 +154,81 @@ pub fn channel() -> (UiSender, UiReceiver) {
let (observe_tx, _) = broadcast::channel(1024);
(UiSender { tui: tui_tx, observe: observe_tx }, tui_rx)
}
/// Replay a restored session into the TUI panes so the user can see
/// conversation history immediately on restart. Shows user input,
/// assistant responses, and brief tool call summaries. Skips the system
/// prompt, context message, DMN plumbing, and image injection messages.
pub fn replay_session_to_ui(entries: &[crate::agent::context::ConversationEntry], ui_tx: &UiSender) {
use crate::agent::api::types::Role;
crate::dbglog!("[replay] replaying {} entries to UI", entries.len());
for (i, e) in entries.iter().enumerate() {
let m = e.message();
let preview: String = m.content_text().chars().take(60).collect();
crate::dbglog!("[replay] [{}] {:?} mem={} tc={} tcid={:?} {:?}",
i, m.role, e.is_memory(), m.tool_calls.as_ref().map_or(0, |t| t.len()),
m.tool_call_id.as_deref(), preview);
}
let mut seen_first_user = false;
let mut target = StreamTarget::Conversation;
for entry in entries {
if entry.is_memory() { continue; }
let msg = entry.message();
match msg.role {
Role::System => {}
Role::User => {
if !seen_first_user {
seen_first_user = true;
continue;
}
let text = msg.content_text();
if text.starts_with("Your context was just compacted")
|| text.starts_with("Your context was just rebuilt")
|| text.starts_with("[Earlier in this conversation")
|| text.starts_with("Here is the image")
|| text.contains("[image aged out")
{
continue;
}
if text.starts_with("[dmn]") {
target = StreamTarget::Autonomous;
let first_line = text.lines().next().unwrap_or("[dmn]");
let _ = ui_tx.send(UiMessage::DmnAnnotation(first_line.to_string()));
} else {
target = StreamTarget::Conversation;
let _ = ui_tx.send(UiMessage::UserInput(text.to_string()));
}
}
Role::Assistant => {
if let Some(ref calls) = msg.tool_calls {
for call in calls {
let _ = ui_tx.send(UiMessage::ToolCall {
name: call.function.name.clone(),
args_summary: String::new(),
});
}
}
let text = msg.content_text();
if !text.is_empty() {
let _ = ui_tx.send(UiMessage::TextDelta(format!("{}\n", text), target));
}
}
Role::Tool => {
let text = msg.content_text();
let preview: String = text.lines().take(3).collect::<Vec<_>>().join("\n");
let truncated = if text.lines().count() > 3 {
format!("{}...", preview)
} else {
preview
};
let _ = ui_tx.send(UiMessage::ToolResult {
name: String::new(),
result: truncated,
});
}
}
}
}