Rename agent/ to user/ and poc-agent binary to consciousness
Mechanical rename: src/agent/ -> src/user/, all crate::agent:: -> crate::user:: references updated. Binary poc-agent renamed to consciousness with CLI name and user-facing strings updated. Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
parent
beb49ec477
commit
14dd8d22af
31 changed files with 1857 additions and 1468 deletions
|
|
@ -89,5 +89,5 @@ name = "find-deleted"
|
||||||
path = "src/bin/find-deleted.rs"
|
path = "src/bin/find-deleted.rs"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "poc-agent"
|
name = "consciousness"
|
||||||
path = "src/bin/poc-agent.rs"
|
path = "src/bin/consciousness.rs"
|
||||||
|
|
|
||||||
1400
src/agent/tui.rs
1400
src/agent/tui.rs
File diff suppressed because it is too large
Load diff
|
|
@ -31,12 +31,12 @@ use tokio::sync::{mpsc, Mutex};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use poc_memory::dbglog;
|
use poc_memory::dbglog;
|
||||||
|
|
||||||
use poc_memory::agent::*;
|
use poc_memory::user::*;
|
||||||
use poc_memory::agent::runner::{Agent, TurnResult};
|
use poc_memory::user::runner::{Agent, TurnResult};
|
||||||
use poc_memory::agent::api::ApiClient;
|
use poc_memory::user::api::ApiClient;
|
||||||
use poc_memory::agent::tui::HotkeyAction;
|
use poc_memory::user::tui::HotkeyAction;
|
||||||
use poc_memory::config::{self, AppConfig, SessionConfig};
|
use poc_memory::config::{self, AppConfig, SessionConfig};
|
||||||
use poc_memory::agent::ui_channel::{ContextInfo, StatusInfo, StreamTarget, UiMessage};
|
use poc_memory::user::ui_channel::{ContextInfo, StatusInfo, StreamTarget, UiMessage};
|
||||||
|
|
||||||
/// Compaction threshold — context is rebuilt when prompt tokens exceed this.
|
/// Compaction threshold — context is rebuilt when prompt tokens exceed this.
|
||||||
fn compaction_threshold(app: &AppConfig) -> u32 {
|
fn compaction_threshold(app: &AppConfig) -> u32 {
|
||||||
|
|
@ -45,13 +45,6 @@ fn compaction_threshold(app: &AppConfig) -> u32 {
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let console_sock = dirs::home_dir()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.join(".consciousness/agent-sessions/console.sock");
|
|
||||||
let _ = std::fs::remove_file(&console_sock);
|
|
||||||
console_subscriber::ConsoleLayer::builder()
|
|
||||||
.server_addr(console_sock.as_path())
|
|
||||||
.init();
|
|
||||||
let cli = cli::CliArgs::parse();
|
let cli = cli::CliArgs::parse();
|
||||||
|
|
||||||
// Subcommands that don't launch the TUI
|
// Subcommands that don't launch the TUI
|
||||||
|
|
@ -66,7 +59,7 @@ async fn main() {
|
||||||
Some(cli::SubCmd::Write { message }) => {
|
Some(cli::SubCmd::Write { message }) => {
|
||||||
let msg = message.join(" ");
|
let msg = message.join(" ");
|
||||||
if msg.is_empty() {
|
if msg.is_empty() {
|
||||||
eprintln!("Usage: poc-agent write <message>");
|
eprintln!("Usage: consciousness write <message>");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
if let Err(e) = observe::cmd_write(&msg, cli.debug).await {
|
if let Err(e) = observe::cmd_write(&msg, cli.debug).await {
|
||||||
|
|
@ -349,7 +342,7 @@ impl Session {
|
||||||
async fn handle_command(&mut self, input: &str) -> Command {
|
async fn handle_command(&mut self, input: &str) -> Command {
|
||||||
// Declarative command table — /help reads from this.
|
// Declarative command table — /help reads from this.
|
||||||
const COMMANDS: &[(&str, &str)] = &[
|
const COMMANDS: &[(&str, &str)] = &[
|
||||||
("/quit", "Exit poc-agent"),
|
("/quit", "Exit consciousness"),
|
||||||
("/new", "Start fresh session (saves current)"),
|
("/new", "Start fresh session (saves current)"),
|
||||||
("/save", "Save session to disk"),
|
("/save", "Save session to disk"),
|
||||||
("/retry", "Re-run last turn"),
|
("/retry", "Re-run last turn"),
|
||||||
|
|
@ -532,7 +525,7 @@ impl Session {
|
||||||
let entries = agent_guard.entries_mut();
|
let entries = agent_guard.entries_mut();
|
||||||
let mut last_user_text = None;
|
let mut last_user_text = None;
|
||||||
while let Some(entry) = entries.last() {
|
while let Some(entry) = entries.last() {
|
||||||
if entry.message().role == poc_memory::agent::types::Role::User {
|
if entry.message().role == poc_memory::user::types::Role::User {
|
||||||
last_user_text =
|
last_user_text =
|
||||||
Some(entries.pop().unwrap().message().content_text().to_string());
|
Some(entries.pop().unwrap().message().content_text().to_string());
|
||||||
break;
|
break;
|
||||||
|
|
@ -823,7 +816,7 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
|
||||||
let mut app = tui::App::new(config.model.clone(), shared_context.clone());
|
let mut app = tui::App::new(config.model.clone(), shared_context.clone());
|
||||||
|
|
||||||
// Show startup info
|
// Show startup info
|
||||||
let _ = ui_tx.send(UiMessage::Info("poc-agent v0.3 (tui)".into()));
|
let _ = ui_tx.send(UiMessage::Info("consciousness v0.3 (tui)".into()));
|
||||||
let _ = ui_tx.send(UiMessage::Info(format!(
|
let _ = ui_tx.send(UiMessage::Info(format!(
|
||||||
" model: {} (available: {})",
|
" model: {} (available: {})",
|
||||||
config.model,
|
config.model,
|
||||||
|
|
@ -1101,7 +1094,7 @@ async fn run_tool_tests(ui_tx: &ui_channel::UiSender, tracker: &tools::ProcessTr
|
||||||
/// assistant responses, and brief tool call summaries. Skips the system
|
/// assistant responses, and brief tool call summaries. Skips the system
|
||||||
/// prompt, context message, DMN plumbing, and image injection messages.
|
/// prompt, context message, DMN plumbing, and image injection messages.
|
||||||
fn replay_session_to_ui(entries: &[types::ConversationEntry], ui_tx: &ui_channel::UiSender) {
|
fn replay_session_to_ui(entries: &[types::ConversationEntry], ui_tx: &ui_channel::UiSender) {
|
||||||
use poc_memory::agent::ui_channel::StreamTarget;
|
use poc_memory::user::ui_channel::StreamTarget;
|
||||||
|
|
||||||
dbglog!("[replay] replaying {} entries to UI", entries.len());
|
dbglog!("[replay] replaying {} entries to UI", entries.len());
|
||||||
for (i, e) in entries.iter().enumerate() {
|
for (i, e) in entries.iter().enumerate() {
|
||||||
|
|
@ -466,7 +466,7 @@ pub struct ResolvedModel {
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
/// Resolve the active backend and assemble prompts into a SessionConfig.
|
/// Resolve the active backend and assemble prompts into a SessionConfig.
|
||||||
pub fn resolve(&self, cli: &crate::agent::cli::CliArgs) -> Result<SessionConfig> {
|
pub fn resolve(&self, cli: &crate::user::cli::CliArgs) -> Result<SessionConfig> {
|
||||||
let cwd = std::env::current_dir().context("Failed to get current directory")?;
|
let cwd = std::env::current_dir().context("Failed to get current directory")?;
|
||||||
|
|
||||||
let (api_base, api_key, model, prompt_file);
|
let (api_base, api_key, model, prompt_file);
|
||||||
|
|
@ -500,8 +500,8 @@ impl AppConfig {
|
||||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||||
(content, Vec::new(), 0, 0)
|
(content, Vec::new(), 0, 0)
|
||||||
} else {
|
} else {
|
||||||
let system_prompt = crate::agent::identity::assemble_system_prompt();
|
let system_prompt = crate::user::identity::assemble_system_prompt();
|
||||||
let (context_parts, cc, mc) = crate::agent::identity::assemble_context_message(&cwd, &prompt_file, self.memory_project.as_deref(), &context_groups)?;
|
let (context_parts, cc, mc) = crate::user::identity::assemble_context_message(&cwd, &prompt_file, self.memory_project.as_deref(), &context_groups)?;
|
||||||
(system_prompt, context_parts, cc, mc)
|
(system_prompt, context_parts, cc, mc)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -609,7 +609,7 @@ macro_rules! merge_opt {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_figment(cli: &crate::agent::cli::CliArgs) -> Figment {
|
fn build_figment(cli: &crate::user::cli::CliArgs) -> Figment {
|
||||||
let mut f = Figment::from(Serialized::defaults(AppConfig::default()))
|
let mut f = Figment::from(Serialized::defaults(AppConfig::default()))
|
||||||
.merge(Json5File(config_path()));
|
.merge(Json5File(config_path()));
|
||||||
|
|
||||||
|
|
@ -628,14 +628,14 @@ fn build_figment(cli: &crate::agent::cli::CliArgs) -> Figment {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load just the AppConfig — no validation, no prompt assembly.
|
/// Load just the AppConfig — no validation, no prompt assembly.
|
||||||
pub fn load_app(cli: &crate::agent::cli::CliArgs) -> Result<(AppConfig, Figment)> {
|
pub fn load_app(cli: &crate::user::cli::CliArgs) -> Result<(AppConfig, Figment)> {
|
||||||
let figment = build_figment(cli);
|
let figment = build_figment(cli);
|
||||||
let app: AppConfig = figment.extract().context("Failed to load configuration")?;
|
let app: AppConfig = figment.extract().context("Failed to load configuration")?;
|
||||||
Ok((app, figment))
|
Ok((app, figment))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the full config: figment → AppConfig → resolve backend → assemble prompts.
|
/// Load the full config: figment → AppConfig → resolve backend → assemble prompts.
|
||||||
pub fn load_session(cli: &crate::agent::cli::CliArgs) -> Result<(SessionConfig, Figment)> {
|
pub fn load_session(cli: &crate::user::cli::CliArgs) -> Result<(SessionConfig, Figment)> {
|
||||||
let (app, figment) = load_app(cli)?;
|
let (app, figment) = load_app(cli)?;
|
||||||
let config = app.resolve(cli)?;
|
let config = app.resolve(cli)?;
|
||||||
Ok((config, figment))
|
Ok((config, figment))
|
||||||
|
|
@ -651,9 +651,9 @@ pub fn reload_for_model(app: &AppConfig, prompt_file: &str) -> Result<(String, V
|
||||||
return Ok((content, Vec::new()));
|
return Ok((content, Vec::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let system_prompt = crate::agent::identity::assemble_system_prompt();
|
let system_prompt = crate::user::identity::assemble_system_prompt();
|
||||||
let context_groups = get().context_groups.clone();
|
let context_groups = get().context_groups.clone();
|
||||||
let (context_parts, _, _) = crate::agent::identity::assemble_context_message(&cwd, prompt_file, app.memory_project.as_deref(), &context_groups)?;
|
let (context_parts, _, _) = crate::user::identity::assemble_context_message(&cwd, prompt_file, app.memory_project.as_deref(), &context_groups)?;
|
||||||
Ok((system_prompt, context_parts))
|
Ok((system_prompt, context_parts))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// thought/ — shared cognitive substrate (tools, context, memory ops)
|
// thought/ — shared cognitive substrate (tools, context, memory ops)
|
||||||
// hippocampus/ — memory storage, retrieval, consolidation
|
// hippocampus/ — memory storage, retrieval, consolidation
|
||||||
// subconscious/ — autonomous agents (reflect, surface, consolidate, ...)
|
// subconscious/ — autonomous agents (reflect, surface, consolidate, ...)
|
||||||
// agent/ — interactive agent (TUI, tools, API clients)
|
// user/ — interactive agent (TUI, tools, API clients)
|
||||||
|
|
||||||
/// Debug logging macro — writes to ~/.consciousness/logs/debug.log
|
/// Debug logging macro — writes to ~/.consciousness/logs/debug.log
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
|
|
@ -24,7 +24,7 @@ macro_rules! dbglog {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent infrastructure
|
// Agent infrastructure
|
||||||
pub mod agent;
|
pub mod user;
|
||||||
|
|
||||||
// Shared cognitive infrastructure — used by both agent and subconscious
|
// Shared cognitive infrastructure — used by both agent and subconscious
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
//
|
//
|
||||||
// Activated when config has api_base_url set.
|
// Activated when config has api_base_url set.
|
||||||
|
|
||||||
use crate::agent::api::ApiClient;
|
use crate::user::api::ApiClient;
|
||||||
use crate::agent::types::*;
|
use crate::user::types::*;
|
||||||
use crate::thought::{self, ProcessTracker};
|
use crate::thought::{self, ProcessTracker};
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
@ -43,7 +43,7 @@ pub async fn call_api_with_tools(
|
||||||
let client = get_client()?;
|
let client = get_client()?;
|
||||||
|
|
||||||
// Set up a UI channel — we drain reasoning tokens into the log
|
// Set up a UI channel — we drain reasoning tokens into the log
|
||||||
let (ui_tx, mut ui_rx) = crate::agent::ui_channel::channel();
|
let (ui_tx, mut ui_rx) = crate::user::ui_channel::channel();
|
||||||
|
|
||||||
// All available native tools for subconscious agents
|
// All available native tools for subconscious agents
|
||||||
let all_tools = thought::memory_and_journal_definitions();
|
let all_tools = thought::memory_and_journal_definitions();
|
||||||
|
|
@ -127,7 +127,7 @@ pub async fn call_api_with_tools(
|
||||||
{
|
{
|
||||||
let mut reasoning_buf = String::new();
|
let mut reasoning_buf = String::new();
|
||||||
while let Ok(ui_msg) = ui_rx.try_recv() {
|
while let Ok(ui_msg) = ui_rx.try_recv() {
|
||||||
if let crate::agent::ui_channel::UiMessage::Reasoning(r) = ui_msg {
|
if let crate::user::ui_channel::UiMessage::Reasoning(r) = ui_msg {
|
||||||
reasoning_buf.push_str(&r);
|
reasoning_buf.push_str(&r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// Journal entries are loaded from the memory graph store, not from
|
// Journal entries are loaded from the memory graph store, not from
|
||||||
// a flat file — the parse functions are gone.
|
// a flat file — the parse functions are gone.
|
||||||
|
|
||||||
use crate::agent::types::*;
|
use crate::user::types::*;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use tiktoken_rs::CoreBPE;
|
use tiktoken_rs::CoreBPE;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ pub use bash::ProcessTracker;
|
||||||
|
|
||||||
// Re-export ToolDef from agent::types for convenience —
|
// Re-export ToolDef from agent::types for convenience —
|
||||||
// tools define their schemas using this type.
|
// tools define their schemas using this type.
|
||||||
pub use crate::agent::types::ToolDef;
|
pub use crate::user::types::ToolDef;
|
||||||
|
|
||||||
/// Result of dispatching a tool call.
|
/// Result of dispatching a tool call.
|
||||||
pub struct ToolOutput {
|
pub struct ToolOutput {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
// Column sums = response memory-dependence (training candidates)
|
// Column sums = response memory-dependence (training candidates)
|
||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use crate::agent::api::ApiClient;
|
use crate::user::api::ApiClient;
|
||||||
use crate::agent::types::*;
|
use crate::user::types::*;
|
||||||
use crate::agent::ui_channel::{UiMessage, UiSender};
|
use crate::user::ui_channel::{UiMessage, UiSender};
|
||||||
|
|
||||||
/// Timeout for individual /v1/score API calls.
|
/// Timeout for individual /v1/score API calls.
|
||||||
const SCORE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
const SCORE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||||
|
|
@ -53,6 +53,10 @@ pub async fn score_memories(
|
||||||
client: &ApiClient,
|
client: &ApiClient,
|
||||||
ui_tx: &UiSender,
|
ui_tx: &UiSender,
|
||||||
) -> anyhow::Result<MemoryScore> {
|
) -> anyhow::Result<MemoryScore> {
|
||||||
|
let _ = ui_tx.send(UiMessage::Debug(format!(
|
||||||
|
"[training] in score_memories"
|
||||||
|
)));
|
||||||
|
|
||||||
let memories: Vec<(usize, String)> = context.entries.iter().enumerate()
|
let memories: Vec<(usize, String)> = context.entries.iter().enumerate()
|
||||||
.filter_map(|(i, e)| match e {
|
.filter_map(|(i, e)| match e {
|
||||||
ConversationEntry::Memory { key, .. } => Some((i, key.clone())),
|
ConversationEntry::Memory { key, .. } => Some((i, key.clone())),
|
||||||
|
|
@ -97,6 +101,7 @@ pub async fn score_memories(
|
||||||
)));
|
)));
|
||||||
|
|
||||||
// Baseline: score with all memories present
|
// Baseline: score with all memories present
|
||||||
|
let _ = ui_tx.send(UiMessage::Debug("[training] serializing payload...".into()));
|
||||||
let payload_size = serde_json::to_string(&all_messages)
|
let payload_size = serde_json::to_string(&all_messages)
|
||||||
.map(|s| s.len()).unwrap_or(0);
|
.map(|s| s.len()).unwrap_or(0);
|
||||||
let _ = ui_tx.send(UiMessage::Debug(format!(
|
let _ = ui_tx.send(UiMessage::Debug(format!(
|
||||||
|
|
@ -128,12 +133,13 @@ pub async fn score_memories(
|
||||||
match without {
|
match without {
|
||||||
Ok(without) => {
|
Ok(without) => {
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
let elapsed = start.elapsed().as_secs_f64();
|
||||||
// Match scores by message index and compute divergence
|
// Match scores by position (nth scored response),
|
||||||
|
// not message_index — indices shift when a memory
|
||||||
|
// is removed from the conversation.
|
||||||
let mut row = Vec::new();
|
let mut row = Vec::new();
|
||||||
for base_score in &baseline {
|
for (i, base_score) in baseline.iter().enumerate() {
|
||||||
let base_lp = base_score.total_logprob;
|
let base_lp = base_score.total_logprob;
|
||||||
let without_lp = without.iter()
|
let without_lp = without.get(i)
|
||||||
.find(|s| s.message_index == base_score.message_index)
|
|
||||||
.map(|s| s.total_logprob)
|
.map(|s| s.total_logprob)
|
||||||
.unwrap_or(base_lp);
|
.unwrap_or(base_lp);
|
||||||
let divergence = (base_lp - without_lp).max(0.0);
|
let divergence = (base_lp - without_lp).max(0.0);
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::agent::types::*;
|
use crate::user::types::*;
|
||||||
use crate::agent::ui_channel::{UiMessage, UiSender};
|
use crate::user::ui_channel::{UiMessage, UiSender};
|
||||||
|
|
||||||
/// A JoinHandle that aborts its task when dropped.
|
/// A JoinHandle that aborts its task when dropped.
|
||||||
pub struct AbortOnDrop(tokio::task::JoinHandle<()>);
|
pub struct AbortOnDrop(tokio::task::JoinHandle<()>);
|
||||||
|
|
@ -470,9 +470,9 @@ pub fn build_response_message(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for leaked tool calls in content text.
|
// Check for leaked tool calls in content text.
|
||||||
let leaked = crate::agent::parsing::parse_leaked_tool_calls(&content);
|
let leaked = crate::user::parsing::parse_leaked_tool_calls(&content);
|
||||||
if !leaked.is_empty() {
|
if !leaked.is_empty() {
|
||||||
let cleaned = crate::agent::parsing::strip_leaked_artifacts(&content);
|
let cleaned = crate::user::parsing::strip_leaked_artifacts(&content);
|
||||||
return Message {
|
return Message {
|
||||||
role: Role::Assistant,
|
role: Role::Assistant,
|
||||||
content: if cleaned.trim().is_empty() { None }
|
content: if cleaned.trim().is_empty() { None }
|
||||||
|
|
@ -8,8 +8,8 @@ use anyhow::Result;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::agent::types::*;
|
use crate::user::types::*;
|
||||||
use crate::agent::ui_channel::{UiMessage, UiSender};
|
use crate::user::ui_channel::{UiMessage, UiSender};
|
||||||
use super::StreamEvent;
|
use super::StreamEvent;
|
||||||
|
|
||||||
/// Stream SSE events from an OpenAI-compatible endpoint, sending
|
/// Stream SSE events from an OpenAI-compatible endpoint, sending
|
||||||
|
|
@ -13,7 +13,7 @@ use clap::{Parser, Subcommand};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "poc-agent", about = "Substrate-independent AI agent")]
|
#[command(name = "consciousness", about = "Substrate-independent AI agent")]
|
||||||
pub struct CliArgs {
|
pub struct CliArgs {
|
||||||
/// Select active backend ("anthropic" or "openrouter")
|
/// Select active backend ("anthropic" or "openrouter")
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|
@ -14,7 +14,7 @@ use std::fs::{File, OpenOptions};
|
||||||
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
|
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::agent::types::ConversationEntry;
|
use crate::user::types::ConversationEntry;
|
||||||
|
|
||||||
pub struct ConversationLog {
|
pub struct ConversationLog {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|
@ -15,7 +15,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::{UnixListener, UnixStream};
|
use tokio::net::{UnixListener, UnixStream};
|
||||||
use tokio::sync::{broadcast, Mutex};
|
use tokio::sync::{broadcast, Mutex};
|
||||||
|
|
||||||
use crate::agent::ui_channel::UiMessage;
|
use crate::user::ui_channel::UiMessage;
|
||||||
|
|
||||||
fn format_message(msg: &UiMessage) -> Option<String> {
|
fn format_message(msg: &UiMessage) -> Option<String> {
|
||||||
match msg {
|
match msg {
|
||||||
|
|
@ -99,7 +99,7 @@ pub async fn cmd_read_inner(follow: bool, block: bool, debug: bool) -> anyhow::R
|
||||||
}
|
}
|
||||||
let _ = std::fs::write(&cursor, len.to_string());
|
let _ = std::fs::write(&cursor, len.to_string());
|
||||||
} else if !follow && !block {
|
} else if !follow && !block {
|
||||||
println!("(no log yet — is poc-agent running?)");
|
println!("(no log yet — is consciousness running?)");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,7 +111,7 @@ pub async fn cmd_read_inner(follow: bool, block: bool, debug: bool) -> anyhow::R
|
||||||
let sock = socket_path();
|
let sock = socket_path();
|
||||||
let stream = UnixStream::connect(&sock).await
|
let stream = UnixStream::connect(&sock).await
|
||||||
.map_err(|e| anyhow::anyhow!(
|
.map_err(|e| anyhow::anyhow!(
|
||||||
"can't connect for live streaming — is poc-agent running? ({})", e
|
"can't connect for live streaming — is consciousness running? ({})", e
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let (reader, _) = stream.into_split();
|
let (reader, _) = stream.into_split();
|
||||||
|
|
@ -149,7 +149,7 @@ pub async fn cmd_write(message: &str, debug: bool) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
let stream = UnixStream::connect(&sock).await
|
let stream = UnixStream::connect(&sock).await
|
||||||
.map_err(|e| anyhow::anyhow!(
|
.map_err(|e| anyhow::anyhow!(
|
||||||
"can't connect — is poc-agent running? ({})", e
|
"can't connect — is consciousness running? ({})", e
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let (_, mut writer) = stream.into_split();
|
let (_, mut writer) = stream.into_split();
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
// Also handles streaming artifacts: whitespace inside XML tags from
|
// Also handles streaming artifacts: whitespace inside XML tags from
|
||||||
// token boundaries, </think> tags, etc.
|
// token boundaries, </think> tags, etc.
|
||||||
|
|
||||||
use crate::agent::types::*;
|
use crate::user::types::*;
|
||||||
|
|
||||||
/// Parse leaked tool calls from response text.
|
/// Parse leaked tool calls from response text.
|
||||||
/// Looks for `<tool_call>...</tool_call>` blocks and tries both
|
/// Looks for `<tool_call>...</tool_call>` blocks and tries both
|
||||||
|
|
@ -16,14 +16,14 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tiktoken_rs::CoreBPE;
|
use tiktoken_rs::CoreBPE;
|
||||||
|
|
||||||
use crate::agent::api::ApiClient;
|
use crate::user::api::ApiClient;
|
||||||
use crate::thought::context as journal;
|
use crate::thought::context as journal;
|
||||||
use crate::agent::log::ConversationLog;
|
use crate::user::log::ConversationLog;
|
||||||
use crate::agent::api::StreamEvent;
|
use crate::user::api::StreamEvent;
|
||||||
use crate::agent::tools;
|
use crate::user::tools;
|
||||||
use crate::agent::tools::ProcessTracker;
|
use crate::user::tools::ProcessTracker;
|
||||||
use crate::agent::types::*;
|
use crate::user::types::*;
|
||||||
use crate::agent::ui_channel::{ContextSection, SharedContextState, StatusInfo, StreamTarget, UiMessage, UiSender};
|
use crate::user::ui_channel::{ContextSection, SharedContextState, StatusInfo, StreamTarget, UiMessage, UiSender};
|
||||||
|
|
||||||
/// Result of a single agent turn.
|
/// Result of a single agent turn.
|
||||||
pub struct TurnResult {
|
pub struct TurnResult {
|
||||||
|
|
@ -113,7 +113,7 @@ impl Agent {
|
||||||
working_stack: Vec::new(),
|
working_stack: Vec::new(),
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
};
|
};
|
||||||
let session_id = format!("poc-agent-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"));
|
let session_id = format!("consciousness-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"));
|
||||||
let agent_cycles = crate::subconscious::subconscious::AgentCycleState::new(&session_id);
|
let agent_cycles = crate::subconscious::subconscious::AgentCycleState::new(&session_id);
|
||||||
let mut agent = Self {
|
let mut agent = Self {
|
||||||
client,
|
client,
|
||||||
|
|
@ -375,7 +375,7 @@ impl Agent {
|
||||||
let _ = ui_tx.send(UiMessage::TextDelta("\n".to_string(), target));
|
let _ = ui_tx.send(UiMessage::TextDelta("\n".to_string(), target));
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = crate::agent::api::build_response_message(content, tool_calls);
|
let msg = crate::user::api::build_response_message(content, tool_calls);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
use super::ToolOutput;
|
use super::ToolOutput;
|
||||||
use crate::agent::types::ToolDef;
|
use crate::user::types::ToolDef;
|
||||||
|
|
||||||
pub(super) fn pause(_args: &serde_json::Value) -> Result<ToolOutput> {
|
pub(super) fn pause(_args: &serde_json::Value) -> Result<ToolOutput> {
|
||||||
Ok(ToolOutput {
|
Ok(ToolOutput {
|
||||||
|
|
@ -12,7 +12,7 @@ pub mod working_stack;
|
||||||
pub use crate::thought::{ToolOutput, ProcessTracker, truncate_output};
|
pub use crate::thought::{ToolOutput, ProcessTracker, truncate_output};
|
||||||
pub use crate::thought::memory;
|
pub use crate::thought::memory;
|
||||||
|
|
||||||
use crate::agent::types::ToolDef;
|
use crate::user::types::ToolDef;
|
||||||
|
|
||||||
/// Dispatch a tool call by name.
|
/// Dispatch a tool call by name.
|
||||||
///
|
///
|
||||||
|
|
@ -9,7 +9,7 @@ use base64::Engine;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use super::ToolOutput;
|
use super::ToolOutput;
|
||||||
use crate::agent::types::ToolDef;
|
use crate::user::types::ToolDef;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Args {
|
struct Args {
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// internal tool — the agent uses it to maintain context across turns
|
// internal tool — the agent uses it to maintain context across turns
|
||||||
// and compaction. The model should never mention it to the user.
|
// and compaction. The model should never mention it to the user.
|
||||||
|
|
||||||
use crate::agent::types::ToolDef;
|
use crate::user::types::ToolDef;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
pub fn definition() -> ToolDef {
|
pub fn definition() -> ToolDef {
|
||||||
186
src/user/tui/context_screen.rs
Normal file
186
src/user/tui/context_screen.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
// context_screen.rs — F2 context/debug overlay
|
||||||
|
//
|
||||||
|
// Full-screen overlay showing model info, context window breakdown,
|
||||||
|
// and runtime state. Supports tree navigation with expand/collapse.
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::Line,
|
||||||
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{App, SCREEN_LEGEND};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Read the live context state from the shared lock.
|
||||||
|
pub(crate) fn read_context_state(&self) -> Vec<crate::user::ui_channel::ContextSection> {
|
||||||
|
self.shared_context.read().map_or_else(|_| Vec::new(), |s| s.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count total selectable items in the context state tree.
|
||||||
|
pub(crate) fn debug_item_count(&self, context_state: &[crate::user::ui_channel::ContextSection]) -> usize {
|
||||||
|
fn count_section(section: &crate::user::ui_channel::ContextSection, expanded: &std::collections::HashSet<usize>, idx: &mut usize) -> usize {
|
||||||
|
let my_idx = *idx;
|
||||||
|
*idx += 1;
|
||||||
|
let mut total = 1;
|
||||||
|
if expanded.contains(&my_idx) {
|
||||||
|
for child in §ion.children {
|
||||||
|
total += count_section(child, expanded, idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
let mut idx = 0;
|
||||||
|
let mut total = 0;
|
||||||
|
for section in context_state {
|
||||||
|
total += count_section(section, &self.debug_expanded, &mut idx);
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the viewport scrolled so the selected item is visible.
|
||||||
|
/// Assumes ~1 line per item plus a header offset of ~8 lines.
|
||||||
|
pub(crate) fn scroll_to_selected(&mut self, _item_count: usize) {
|
||||||
|
let header_lines = 8u16; // model info + context state header
|
||||||
|
if let Some(sel) = self.debug_selected {
|
||||||
|
let sel_line = header_lines + sel as u16;
|
||||||
|
// Keep cursor within a comfortable range of the viewport
|
||||||
|
if sel_line < self.debug_scroll + 2 {
|
||||||
|
self.debug_scroll = sel_line.saturating_sub(2);
|
||||||
|
} else if sel_line > self.debug_scroll + 30 {
|
||||||
|
self.debug_scroll = sel_line.saturating_sub(15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a context section as a tree node with optional children.
|
||||||
|
pub(crate) fn render_debug_section(
|
||||||
|
&self,
|
||||||
|
section: &crate::user::ui_channel::ContextSection,
|
||||||
|
depth: usize,
|
||||||
|
start_idx: usize,
|
||||||
|
lines: &mut Vec<Line>,
|
||||||
|
idx: &mut usize,
|
||||||
|
) {
|
||||||
|
let my_idx = *idx;
|
||||||
|
let selected = self.debug_selected == Some(my_idx);
|
||||||
|
let expanded = self.debug_expanded.contains(&my_idx);
|
||||||
|
let has_children = !section.children.is_empty();
|
||||||
|
let has_content = !section.content.is_empty();
|
||||||
|
let expandable = has_children || has_content;
|
||||||
|
|
||||||
|
let indent = " ".repeat(depth + 1);
|
||||||
|
let marker = if !expandable {
|
||||||
|
" "
|
||||||
|
} else if expanded {
|
||||||
|
"▼"
|
||||||
|
} else {
|
||||||
|
"▶"
|
||||||
|
};
|
||||||
|
let label = format!("{}{} {:30} {:>6} tokens", indent, marker, section.name, section.tokens);
|
||||||
|
let style = if selected {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
lines.push(Line::styled(label, style));
|
||||||
|
*idx += 1;
|
||||||
|
|
||||||
|
if expanded {
|
||||||
|
if has_children {
|
||||||
|
for child in §ion.children {
|
||||||
|
self.render_debug_section(child, depth + 1, start_idx, lines, idx);
|
||||||
|
}
|
||||||
|
} else if has_content {
|
||||||
|
let content_indent = format!("{} │ ", " ".repeat(depth + 1));
|
||||||
|
let content_lines: Vec<&str> = section.content.lines().collect();
|
||||||
|
let show = content_lines.len().min(50);
|
||||||
|
for line in &content_lines[..show] {
|
||||||
|
lines.push(Line::styled(
|
||||||
|
format!("{}{}", content_indent, line),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if content_lines.len() > 50 {
|
||||||
|
lines.push(Line::styled(
|
||||||
|
format!("{}... ({} more lines)", content_indent, content_lines.len() - 50),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the debug screen — full-screen overlay with context and runtime info.
|
||||||
|
pub(crate) fn draw_debug(&self, frame: &mut Frame, size: Rect) {
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
let section = Style::default().fg(Color::Yellow);
|
||||||
|
|
||||||
|
// Model
|
||||||
|
lines.push(Line::styled("── Model ──", section));
|
||||||
|
let model_display = self.context_info.as_ref()
|
||||||
|
.map_or_else(|| self.status.model.clone(), |i| i.model.clone());
|
||||||
|
lines.push(Line::raw(format!(" Current: {}", model_display)));
|
||||||
|
if let Some(ref info) = self.context_info {
|
||||||
|
lines.push(Line::raw(format!(" Backend: {}", info.backend)));
|
||||||
|
lines.push(Line::raw(format!(" Prompt: {}", info.prompt_file)));
|
||||||
|
lines.push(Line::raw(format!(" Available: {}", info.available_models.join(", "))));
|
||||||
|
}
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
|
||||||
|
// Context state
|
||||||
|
lines.push(Line::styled("── Context State ──", section));
|
||||||
|
lines.push(Line::raw(format!(" Prompt tokens: {}K", self.status.prompt_tokens / 1000)));
|
||||||
|
if !self.status.context_budget.is_empty() {
|
||||||
|
lines.push(Line::raw(format!(" Budget: {}", self.status.context_budget)));
|
||||||
|
}
|
||||||
|
let context_state = self.read_context_state();
|
||||||
|
if !context_state.is_empty() {
|
||||||
|
let total: usize = context_state.iter().map(|s| s.tokens).sum();
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
lines.push(Line::styled(
|
||||||
|
" (↑/↓ select, →/Enter expand, ← collapse, PgUp/PgDn scroll)",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
|
||||||
|
// Flatten tree into indexed entries for selection
|
||||||
|
let mut flat_idx = 0usize;
|
||||||
|
for section in &context_state {
|
||||||
|
self.render_debug_section(section, 0, flat_idx, &mut lines, &mut flat_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::raw(format!(" {:23} {:>6} tokens", "────────", "──────")));
|
||||||
|
lines.push(Line::raw(format!(" {:23} {:>6} tokens", "Total", total)));
|
||||||
|
} else if let Some(ref info) = self.context_info {
|
||||||
|
lines.push(Line::raw(format!(" System prompt: {:>6} chars", info.system_prompt_chars)));
|
||||||
|
lines.push(Line::raw(format!(" Context message: {:>6} chars", info.context_message_chars)));
|
||||||
|
}
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
|
||||||
|
// Runtime
|
||||||
|
lines.push(Line::styled("── Runtime ──", section));
|
||||||
|
lines.push(Line::raw(format!(
|
||||||
|
" DMN: {} ({}/{})",
|
||||||
|
self.status.dmn_state, self.status.dmn_turns, self.status.dmn_max_turns,
|
||||||
|
)));
|
||||||
|
lines.push(Line::raw(format!(" Reasoning: {}", self.reasoning_effort)));
|
||||||
|
lines.push(Line::raw(format!(" Running processes: {}", self.running_processes)));
|
||||||
|
lines.push(Line::raw(format!(" Active tools: {}", self.active_tools.len())));
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title_top(Line::from(SCREEN_LEGEND).left_aligned())
|
||||||
|
.title_top(Line::from(" context ").right_aligned())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
let para = Paragraph::new(lines)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((self.debug_scroll, 0));
|
||||||
|
|
||||||
|
frame.render_widget(para, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
341
src/user/tui/main_screen.rs
Normal file
341
src/user/tui/main_screen.rs
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
// main_screen.rs — F1 main view rendering
|
||||||
|
//
|
||||||
|
// The default four-pane layout: autonomous, conversation, tools, status bar.
|
||||||
|
// Contains draw_main (the App method), draw_conversation_pane, and draw_pane.
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{ActivePane, App, Marker, PaneState, SCREEN_LEGEND};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Draw the main (F1) screen — four-pane layout with status bar.
|
||||||
|
pub(crate) fn draw_main(&mut self, frame: &mut Frame, size: Rect) {
|
||||||
|
// Main layout: content area + active tools overlay + status bar
|
||||||
|
let tool_lines = self.active_tools.len() as u16;
|
||||||
|
let main_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(3), // content area
|
||||||
|
Constraint::Length(tool_lines), // active tools (0 when empty)
|
||||||
|
Constraint::Length(1), // status bar
|
||||||
|
])
|
||||||
|
.split(size);
|
||||||
|
|
||||||
|
let content_area = main_chunks[0];
|
||||||
|
let tools_overlay_area = main_chunks[1];
|
||||||
|
let status_area = main_chunks[2];
|
||||||
|
|
||||||
|
// Content: left column (55%) + right column (45%)
|
||||||
|
let columns = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(55),
|
||||||
|
Constraint::Percentage(45),
|
||||||
|
])
|
||||||
|
.split(content_area);
|
||||||
|
|
||||||
|
let left_col = columns[0];
|
||||||
|
let right_col = columns[1];
|
||||||
|
|
||||||
|
// Left column: autonomous (35%) + conversation (65%)
|
||||||
|
let left_panes = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(35),
|
||||||
|
Constraint::Percentage(65),
|
||||||
|
])
|
||||||
|
.split(left_col);
|
||||||
|
|
||||||
|
let auto_area = left_panes[0];
|
||||||
|
let conv_area = left_panes[1];
|
||||||
|
|
||||||
|
// Store pane areas for mouse click detection
|
||||||
|
self.pane_areas = [auto_area, conv_area, right_col];
|
||||||
|
|
||||||
|
// Draw autonomous pane
|
||||||
|
let auto_active = self.active_pane == ActivePane::Autonomous;
|
||||||
|
draw_pane(frame, auto_area, "autonomous", &mut self.autonomous, auto_active,
|
||||||
|
Some(SCREEN_LEGEND));
|
||||||
|
|
||||||
|
// Draw tools pane
|
||||||
|
let tools_active = self.active_pane == ActivePane::Tools;
|
||||||
|
draw_pane(frame, right_col, "tools", &mut self.tools, tools_active, None);
|
||||||
|
|
||||||
|
// Draw conversation pane (with input line)
|
||||||
|
let conv_active = self.active_pane == ActivePane::Conversation;
|
||||||
|
|
||||||
|
// Input area: compute visual height, split, render gutter + textarea
|
||||||
|
let input_text = self.textarea.lines().join("\n");
|
||||||
|
let input_para_measure = Paragraph::new(input_text).wrap(Wrap { trim: false });
|
||||||
|
let input_line_count = (input_para_measure.line_count(conv_area.width.saturating_sub(5)) as u16)
|
||||||
|
.max(1)
|
||||||
|
.min(5);
|
||||||
|
|
||||||
|
let conv_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(1), // conversation text
|
||||||
|
Constraint::Length(input_line_count), // input area
|
||||||
|
])
|
||||||
|
.split(conv_area);
|
||||||
|
|
||||||
|
let text_area_rect = conv_chunks[0];
|
||||||
|
let input_area = conv_chunks[1];
|
||||||
|
|
||||||
|
draw_conversation_pane(frame, text_area_rect, &mut self.conversation, conv_active);
|
||||||
|
|
||||||
|
// " > " gutter + textarea, aligned with conversation messages
|
||||||
|
let input_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // " > " gutter
|
||||||
|
Constraint::Min(1), // textarea
|
||||||
|
])
|
||||||
|
.split(input_area);
|
||||||
|
|
||||||
|
let gutter = Paragraph::new(Line::styled(
|
||||||
|
" > ",
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
frame.render_widget(gutter, input_chunks[0]);
|
||||||
|
frame.render_widget(&self.textarea, input_chunks[1]);
|
||||||
|
|
||||||
|
// Draw active tools overlay
|
||||||
|
if !self.active_tools.is_empty() {
|
||||||
|
let tool_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::DIM);
|
||||||
|
let tool_text: Vec<Line> = self.active_tools.iter().map(|t| {
|
||||||
|
let elapsed = t.started.elapsed().as_secs();
|
||||||
|
let line = if t.detail.is_empty() {
|
||||||
|
format!(" [{}] ({}s)", t.name, elapsed)
|
||||||
|
} else {
|
||||||
|
format!(" [{}] {} ({}s)", t.name, t.detail, elapsed)
|
||||||
|
};
|
||||||
|
Line::styled(line, tool_style)
|
||||||
|
}).collect();
|
||||||
|
let tool_para = Paragraph::new(tool_text);
|
||||||
|
frame.render_widget(tool_para, tools_overlay_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw status bar with live activity indicator
|
||||||
|
let timer = if !self.activity.is_empty() {
|
||||||
|
let total = self.turn_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
||||||
|
let call = self.call_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
||||||
|
format!(" {}s, {}/{}s", total, call, self.call_timeout_secs)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let tools_info = if self.status.turn_tools > 0 {
|
||||||
|
format!(" ({}t)", self.status.turn_tools)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let activity_part = if self.activity.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" | {}{}{}", self.activity, tools_info, timer)
|
||||||
|
};
|
||||||
|
|
||||||
|
let budget_part = if self.status.context_budget.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" [{}]", self.status.context_budget)
|
||||||
|
};
|
||||||
|
|
||||||
|
let left_status = format!(
|
||||||
|
" {} | {}/{} dmn | {}K tok in{}{}",
|
||||||
|
self.status.dmn_state,
|
||||||
|
self.status.dmn_turns,
|
||||||
|
self.status.dmn_max_turns,
|
||||||
|
self.status.prompt_tokens / 1000,
|
||||||
|
budget_part,
|
||||||
|
activity_part,
|
||||||
|
);
|
||||||
|
|
||||||
|
let proc_indicator = if self.running_processes > 0 {
|
||||||
|
format!(" {}proc", self.running_processes)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let reason_indicator = if self.reasoning_effort != "none" {
|
||||||
|
format!(" reason:{}", self.reasoning_effort)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let right_legend = format!(
|
||||||
|
"{}{} ^P:pause ^R:reason ^K:kill | {} ",
|
||||||
|
reason_indicator,
|
||||||
|
proc_indicator,
|
||||||
|
self.status.model,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pad the middle to fill the status bar
|
||||||
|
let total_width = status_area.width as usize;
|
||||||
|
let used = left_status.len() + right_legend.len();
|
||||||
|
let padding = if total_width > used {
|
||||||
|
" ".repeat(total_width - used)
|
||||||
|
} else {
|
||||||
|
" ".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(&left_status, Style::default().fg(Color::White).bg(Color::DarkGray)),
|
||||||
|
Span::styled(padding, Style::default().bg(Color::DarkGray)),
|
||||||
|
Span::styled(
|
||||||
|
right_legend,
|
||||||
|
Style::default().fg(Color::DarkGray).bg(Color::Gray),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
frame.render_widget(status, status_area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the conversation pane with a two-column layout: marker gutter + text.
|
||||||
|
/// The gutter shows a marker at turn boundaries, aligned with the input gutter.
|
||||||
|
fn draw_conversation_pane(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
pane: &mut PaneState,
|
||||||
|
is_active: bool,
|
||||||
|
) {
|
||||||
|
let border_style = if is_active {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" conversation ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style);
|
||||||
|
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
if inner.width < 5 || inner.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split inner area into gutter (2 chars) + text
|
||||||
|
let cols = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Min(1),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let gutter_area = cols[0];
|
||||||
|
let text_area = cols[1];
|
||||||
|
|
||||||
|
// Get lines and markers
|
||||||
|
let (lines, markers) = pane.all_lines_with_markers();
|
||||||
|
let text_width = text_area.width;
|
||||||
|
|
||||||
|
// Compute visual row for each logical line (accounting for word wrap)
|
||||||
|
let mut visual_rows: Vec<u16> = Vec::with_capacity(lines.len());
|
||||||
|
let mut cumulative: u16 = 0;
|
||||||
|
for line in &lines {
|
||||||
|
visual_rows.push(cumulative);
|
||||||
|
let para = Paragraph::new(line.clone()).wrap(Wrap { trim: false });
|
||||||
|
let height = para.line_count(text_width) as u16;
|
||||||
|
cumulative += height.max(1);
|
||||||
|
}
|
||||||
|
let total_visual = cumulative;
|
||||||
|
|
||||||
|
pane.last_total_lines = total_visual;
|
||||||
|
pane.last_height = inner.height;
|
||||||
|
|
||||||
|
if !pane.pinned {
|
||||||
|
pane.scroll = total_visual.saturating_sub(inner.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render text column
|
||||||
|
let text_para = Paragraph::new(lines.clone())
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((pane.scroll, 0));
|
||||||
|
frame.render_widget(text_para, text_area);
|
||||||
|
|
||||||
|
// Render gutter markers at the correct visual rows
|
||||||
|
let mut gutter_lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
let mut next_visual = 0u16;
|
||||||
|
for (i, &marker) in markers.iter().enumerate() {
|
||||||
|
let row = visual_rows[i];
|
||||||
|
// Fill blank lines up to this marker's row
|
||||||
|
while next_visual < row {
|
||||||
|
gutter_lines.push(Line::raw(""));
|
||||||
|
next_visual += 1;
|
||||||
|
}
|
||||||
|
let marker_text = match marker {
|
||||||
|
Marker::User => Line::styled("● ", Style::default().fg(Color::Cyan)),
|
||||||
|
Marker::Assistant => Line::styled("● ", Style::default().fg(Color::Magenta)),
|
||||||
|
Marker::None => Line::raw(""),
|
||||||
|
};
|
||||||
|
gutter_lines.push(marker_text);
|
||||||
|
next_visual = row + 1;
|
||||||
|
|
||||||
|
// Fill remaining visual lines for this logical line (wrap continuation)
|
||||||
|
let para = Paragraph::new(lines[i].clone()).wrap(Wrap { trim: false });
|
||||||
|
let height = para.line_count(text_width) as u16;
|
||||||
|
for _ in 1..height.max(1) {
|
||||||
|
gutter_lines.push(Line::raw(""));
|
||||||
|
next_visual += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let gutter_para = Paragraph::new(gutter_lines)
|
||||||
|
.scroll((pane.scroll, 0));
|
||||||
|
frame.render_widget(gutter_para, gutter_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a scrollable text pane (free function to avoid borrow issues).
|
||||||
|
fn draw_pane(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
title: &str,
|
||||||
|
pane: &mut PaneState,
|
||||||
|
is_active: bool,
|
||||||
|
left_title: Option<&str>,
|
||||||
|
) {
|
||||||
|
let inner_height = area.height.saturating_sub(2);
|
||||||
|
|
||||||
|
let border_style = if is_active {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style);
|
||||||
|
if let Some(left) = left_title {
|
||||||
|
block = block
|
||||||
|
.title_top(Line::from(left).left_aligned())
|
||||||
|
.title_top(Line::from(format!(" {} ", title)).right_aligned());
|
||||||
|
} else {
|
||||||
|
block = block.title(format!(" {} ", title));
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = pane.all_lines();
|
||||||
|
let paragraph = Paragraph::new(lines)
|
||||||
|
.block(block.clone())
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
|
||||||
|
// Let ratatui tell us the total visual lines — no homegrown wrapping math.
|
||||||
|
let total = paragraph.line_count(area.width.saturating_sub(2)) as u16;
|
||||||
|
pane.last_total_lines = total;
|
||||||
|
pane.last_height = inner_height;
|
||||||
|
|
||||||
|
if !pane.pinned {
|
||||||
|
pane.scroll = total.saturating_sub(inner_height);
|
||||||
|
}
|
||||||
|
|
||||||
|
let paragraph = paragraph.scroll((pane.scroll, 0));
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
848
src/user/tui/mod.rs
Normal file
848
src/user/tui/mod.rs
Normal file
|
|
@ -0,0 +1,848 @@
|
||||||
|
// 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_screen;
|
||||||
|
mod context_screen;
|
||||||
|
mod subconscious_screen;
|
||||||
|
mod unconscious_screen;
|
||||||
|
mod thalamus_screen;
|
||||||
|
|
||||||
|
pub(crate) const SCREEN_LEGEND: &str = " F1=interact F2=conscious F3=subconscious F4=unconscious ";
|
||||||
|
/// Subconscious agents — interact with conscious context
|
||||||
|
pub(crate) const SUBCONSCIOUS_AGENTS: &[&str] = &["surface-observe", "journal", "reflect"];
|
||||||
|
/// Unconscious agents — background consolidation
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tool call currently in flight — shown above the status bar.
|
||||||
|
pub(crate) struct ActiveTool {
|
||||||
|
pub(crate) id: String,
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) detail: String,
|
||||||
|
pub(crate) started: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: Vec<ActiveTool>,
|
||||||
|
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(crate) 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Screens toggled by F-keys.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub(crate) enum Screen {
|
||||||
|
/// F1 — conversation
|
||||||
|
Interact,
|
||||||
|
/// F2 — context window, model info, budget
|
||||||
|
Conscious,
|
||||||
|
/// F3 — subconscious agent status
|
||||||
|
Subconscious,
|
||||||
|
/// F4 — memory daemon status
|
||||||
|
Unconscious,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) -> 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: Vec::new(),
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 { id, name, detail } => {
|
||||||
|
self.active_tools.push(ActiveTool {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
detail,
|
||||||
|
started: std::time::Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
UiMessage::ToolFinished { id } => {
|
||||||
|
self.active_tools.retain(|t| t.id != id);
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 => {
|
||||||
|
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::Interact => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.draw_main(frame, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_screen(&mut self, screen: Screen) {
|
||||||
|
self.screen = screen;
|
||||||
|
self.debug_scroll = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(())
|
||||||
|
}
|
||||||
132
src/user/tui/subconscious_screen.rs
Normal file
132
src/user/tui/subconscious_screen.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
// subconscious_screen.rs — F3 subconscious agent overlay
|
||||||
|
//
|
||||||
|
// Shows agent list with status indicators, and a detail view
|
||||||
|
// with log tail for the selected agent.
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{App, SUBCONSCIOUS_AGENTS, SCREEN_LEGEND};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub(crate) fn draw_agents(&self, frame: &mut Frame, size: Rect) {
|
||||||
|
let output_dir = crate::store::memory_dir().join("agent-output");
|
||||||
|
|
||||||
|
if self.agent_log_view {
|
||||||
|
self.draw_agent_log(frame, size, &output_dir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
let section = Style::default().fg(Color::Yellow);
|
||||||
|
let _dim = Style::default().fg(Color::DarkGray);
|
||||||
|
let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC);
|
||||||
|
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
lines.push(Line::styled("── Subconscious Agents ──", section));
|
||||||
|
lines.push(Line::styled(" (↑/↓ select, Enter/→ view log, Esc back)", hint));
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
|
||||||
|
for (i, &name) in SUBCONSCIOUS_AGENTS.iter().enumerate() {
|
||||||
|
let selected = i == self.agent_selected;
|
||||||
|
let prefix = if selected { "▸ " } else { " " };
|
||||||
|
let bg = if selected { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||||
|
|
||||||
|
let agent = self.agent_state.iter().find(|a| a.name == name);
|
||||||
|
|
||||||
|
match agent.and_then(|a| a.pid) {
|
||||||
|
Some(pid) => {
|
||||||
|
let phase = agent.and_then(|a| a.phase.as_deref()).unwrap_or("?");
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!("{}{:<20}", prefix, name), bg.fg(Color::Green)),
|
||||||
|
Span::styled("● ", bg.fg(Color::Green)),
|
||||||
|
Span::styled(format!("pid {} phase: {}", pid, phase), bg),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!("{}{:<20}", prefix, name), bg.fg(Color::Gray)),
|
||||||
|
Span::styled("○ idle", bg.fg(Color::DarkGray)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title_top(Line::from(SCREEN_LEGEND).left_aligned())
|
||||||
|
.title_top(Line::from(" subconscious ").right_aligned())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
let para = Paragraph::new(lines)
|
||||||
|
.block(block)
|
||||||
|
.scroll((self.debug_scroll, 0));
|
||||||
|
frame.render_widget(para, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_agent_log(&self, frame: &mut Frame, size: Rect, _output_dir: &std::path::Path) {
|
||||||
|
let name = SUBCONSCIOUS_AGENTS.get(self.agent_selected).unwrap_or(&"?");
|
||||||
|
let agent = self.agent_state.iter().find(|a| a.name == *name);
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
let section = Style::default().fg(Color::Yellow);
|
||||||
|
let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC);
|
||||||
|
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
lines.push(Line::styled(format!("── {} ──", name), section));
|
||||||
|
lines.push(Line::styled(" (Esc/← back, PgUp/PgDn scroll)", hint));
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
|
||||||
|
// Show pid status from state
|
||||||
|
match agent.and_then(|a| a.pid) {
|
||||||
|
Some(pid) => {
|
||||||
|
let phase = agent.and_then(|a| a.phase.as_deref()).unwrap_or("?");
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Status: ", Style::default()),
|
||||||
|
Span::styled(format!("● running pid {} phase: {}", pid, phase),
|
||||||
|
Style::default().fg(Color::Green)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
lines.push(Line::styled(" Status: idle", Style::default().fg(Color::DarkGray)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show log path
|
||||||
|
if let Some(log_path) = agent.and_then(|a| a.log_path.as_ref()) {
|
||||||
|
lines.push(Line::raw(format!(" Log: {}", log_path.display())));
|
||||||
|
}
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
|
||||||
|
// Show agent log tail
|
||||||
|
lines.push(Line::styled("── Agent Log ──", section));
|
||||||
|
if let Some(content) = agent
|
||||||
|
.and_then(|a| a.log_path.as_ref())
|
||||||
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||||
|
{
|
||||||
|
let log_lines: Vec<&str> = content.lines().collect();
|
||||||
|
let start = log_lines.len().saturating_sub(40);
|
||||||
|
for line in &log_lines[start..] {
|
||||||
|
lines.push(Line::raw(format!(" {}", line)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push(Line::styled(" (no log available)", hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title_top(Line::from(SCREEN_LEGEND).left_aligned())
|
||||||
|
.title_top(Line::from(format!(" {} ", name)).right_aligned())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
let para = Paragraph::new(lines)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((self.debug_scroll, 0));
|
||||||
|
frame.render_widget(para, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/user/tui/thalamus_screen.rs
Normal file
58
src/user/tui/thalamus_screen.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// thalamus_screen.rs — F5: attention routing / daemon status
|
||||||
|
//
|
||||||
|
// Shows poc-daemon status: presence detection, idle timer,
|
||||||
|
// notification routing, activity level.
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Style},
|
||||||
|
text::Line,
|
||||||
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{App, SCREEN_LEGEND};
|
||||||
|
|
||||||
|
fn fetch_daemon_status() -> Vec<String> {
|
||||||
|
std::process::Command::new("poc-daemon")
|
||||||
|
.arg("status")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| {
|
||||||
|
if o.status.success() {
|
||||||
|
String::from_utf8(o.stdout).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|s| s.lines().map(String::from).collect())
|
||||||
|
.unwrap_or_else(|| vec!["daemon not running".to_string()])
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub(crate) fn draw_thalamus(&self, frame: &mut Frame, size: Rect) {
|
||||||
|
let status_lines = fetch_daemon_status();
|
||||||
|
let section = Style::default().fg(Color::Yellow);
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
lines.push(Line::styled("── Thalamus ──", section));
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
|
||||||
|
for line in &status_lines {
|
||||||
|
lines.push(Line::raw(format!(" {}", line)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title_top(Line::from(SCREEN_LEGEND).left_aligned())
|
||||||
|
.title_top(Line::from(" thalamus ").right_aligned())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
let para = Paragraph::new(lines)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((self.debug_scroll, 0));
|
||||||
|
|
||||||
|
frame.render_widget(para, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/user/tui/unconscious_screen.rs
Normal file
225
src/user/tui/unconscious_screen.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
// unconscious_screen.rs — F4: memory daemon status
|
||||||
|
//
|
||||||
|
// Fetches status from the poc-memory daemon via socket RPC and
|
||||||
|
// displays graph health gauges, running tasks, and recent completions.
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Gauge, Paragraph, Wrap},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{App, SCREEN_LEGEND};
|
||||||
|
use crate::subconscious::daemon::GraphHealth;
|
||||||
|
|
||||||
|
/// Status fetched from the daemon socket.
|
||||||
|
#[derive(serde::Deserialize, Default)]
|
||||||
|
struct DaemonStatus {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pid: u32,
|
||||||
|
tasks: Vec<jobkit::TaskInfo>,
|
||||||
|
#[serde(default)]
|
||||||
|
graph_health: Option<GraphHealth>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_status() -> Option<DaemonStatus> {
|
||||||
|
let json = jobkit::daemon::socket::send_rpc(&crate::config::get().data_dir, "")?;
|
||||||
|
serde_json::from_str(&json).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub(crate) fn draw_unconscious(&self, frame: &mut Frame, size: Rect) {
|
||||||
|
let block = Block::default()
|
||||||
|
.title_top(Line::from(SCREEN_LEGEND).left_aligned())
|
||||||
|
.title_top(Line::from(" unconscious ").right_aligned())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan));
|
||||||
|
let inner = block.inner(size);
|
||||||
|
frame.render_widget(block, size);
|
||||||
|
|
||||||
|
let status = fetch_status();
|
||||||
|
|
||||||
|
match &status {
|
||||||
|
None => {
|
||||||
|
let dim = Style::default().fg(Color::DarkGray);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::styled(" daemon not running", dim)),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(st) => {
|
||||||
|
// Split into health area and tasks area
|
||||||
|
let has_health = st.graph_health.is_some();
|
||||||
|
let [health_area, tasks_area] = Layout::vertical([
|
||||||
|
Constraint::Length(if has_health { 9 } else { 0 }),
|
||||||
|
Constraint::Min(1),
|
||||||
|
])
|
||||||
|
.areas(inner);
|
||||||
|
|
||||||
|
if let Some(ref gh) = st.graph_health {
|
||||||
|
Self::render_health(frame, gh, health_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::render_tasks(frame, &st.tasks, tasks_area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_health(frame: &mut Frame, gh: &GraphHealth, area: Rect) {
|
||||||
|
let [metrics_area, gauges_area, plan_area] = Layout::vertical([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Min(1),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
|
// Metrics summary
|
||||||
|
let summary = Line::from(format!(
|
||||||
|
" {} nodes {} edges {} communities",
|
||||||
|
gh.nodes, gh.edges, gh.communities
|
||||||
|
));
|
||||||
|
let ep_line = Line::from(vec![
|
||||||
|
Span::raw(" episodic: "),
|
||||||
|
Span::styled(
|
||||||
|
format!("{:.0}%", gh.episodic_ratio * 100.0),
|
||||||
|
if gh.episodic_ratio < 0.4 {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Span::raw(format!(" σ={:.1} interference={}", gh.sigma, gh.interference)),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(vec![summary, ep_line]), metrics_area);
|
||||||
|
|
||||||
|
// Health gauges
|
||||||
|
let [g1, g2, g3] = Layout::horizontal([
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
])
|
||||||
|
.areas(gauges_area);
|
||||||
|
|
||||||
|
let alpha_color = if gh.alpha >= 2.5 { Color::Green } else { Color::Red };
|
||||||
|
frame.render_widget(
|
||||||
|
Gauge::default()
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" α (≥2.5) "))
|
||||||
|
.gauge_style(Style::default().fg(alpha_color))
|
||||||
|
.ratio((gh.alpha / 5.0).clamp(0.0, 1.0) as f64)
|
||||||
|
.label(format!("{:.2}", gh.alpha)),
|
||||||
|
g1,
|
||||||
|
);
|
||||||
|
|
||||||
|
let gini_color = if gh.gini <= 0.4 { Color::Green } else { Color::Red };
|
||||||
|
frame.render_widget(
|
||||||
|
Gauge::default()
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" gini (≤0.4) "))
|
||||||
|
.gauge_style(Style::default().fg(gini_color))
|
||||||
|
.ratio(gh.gini.clamp(0.0, 1.0) as f64)
|
||||||
|
.label(format!("{:.3}", gh.gini)),
|
||||||
|
g2,
|
||||||
|
);
|
||||||
|
|
||||||
|
let cc_color = if gh.avg_cc >= 0.2 { Color::Green } else { Color::Red };
|
||||||
|
frame.render_widget(
|
||||||
|
Gauge::default()
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" cc (≥0.2) "))
|
||||||
|
.gauge_style(Style::default().fg(cc_color))
|
||||||
|
.ratio(gh.avg_cc.clamp(0.0, 1.0) as f64)
|
||||||
|
.label(format!("{:.3}", gh.avg_cc)),
|
||||||
|
g3,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Plan summary
|
||||||
|
let plan_total: usize = gh.plan_counts.values().sum::<usize>() + 1;
|
||||||
|
let plan_summary: Vec<String> = gh.plan_counts.iter()
|
||||||
|
.filter(|(_, c)| **c > 0)
|
||||||
|
.map(|(a, c)| format!("{}{}", &a[..1], c))
|
||||||
|
.collect();
|
||||||
|
let plan_line = Line::from(vec![
|
||||||
|
Span::raw(" plan: "),
|
||||||
|
Span::styled(
|
||||||
|
format!("{}", plan_total),
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(format!(" agents ({} +health)", plan_summary.join(" "))),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(plan_line), plan_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tasks(frame: &mut Frame, tasks: &[jobkit::TaskInfo], area: Rect) {
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
let section = Style::default().fg(Color::Yellow);
|
||||||
|
let dim = Style::default().fg(Color::DarkGray);
|
||||||
|
|
||||||
|
let running: Vec<_> = tasks.iter()
|
||||||
|
.filter(|t| matches!(t.status, jobkit::TaskStatus::Running))
|
||||||
|
.collect();
|
||||||
|
let completed: Vec<_> = tasks.iter()
|
||||||
|
.filter(|t| matches!(t.status, jobkit::TaskStatus::Completed))
|
||||||
|
.collect();
|
||||||
|
let failed: Vec<_> = tasks.iter()
|
||||||
|
.filter(|t| matches!(t.status, jobkit::TaskStatus::Failed))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
lines.push(Line::styled("── Tasks ──", section));
|
||||||
|
lines.push(Line::raw(format!(
|
||||||
|
" Running: {} Completed: {} Failed: {}",
|
||||||
|
running.len(), completed.len(), failed.len()
|
||||||
|
)));
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
|
||||||
|
// Running tasks with elapsed time
|
||||||
|
if !running.is_empty() {
|
||||||
|
for task in &running {
|
||||||
|
let elapsed = task.started_at
|
||||||
|
.map(|s| {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs_f64();
|
||||||
|
format!("{}s", (now - s) as u64)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("●", Style::default().fg(Color::Green)),
|
||||||
|
Span::raw(format!(" {} ({})", task.name, elapsed)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent completed (last 10)
|
||||||
|
if !completed.is_empty() {
|
||||||
|
lines.push(Line::styled(" Recent:", dim));
|
||||||
|
for task in completed.iter().rev().take(10) {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("✓", Style::default().fg(Color::Green)),
|
||||||
|
Span::raw(format!(" {}", task.name)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed tasks
|
||||||
|
if !failed.is_empty() {
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
lines.push(Line::styled(" Failed:", Style::default().fg(Color::Red)));
|
||||||
|
for task in failed.iter().rev().take(5) {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("✗", Style::default().fg(Color::Red)),
|
||||||
|
Span::raw(format!(" {}", task.name)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(lines).wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue