Replace homegrown wrapping math (wrapped_height, wrapped_height_line, auto_scroll, force_scroll, wrapped_line_count) with ratatui's own Paragraph::line_count() which exactly matches its rendering. The old approach used ceiling division that didn't account for word wrapping, causing bottom content to be clipped. Also add terminal.clear() on resize to force full redraw — fixes the TUI rendering at old canvas size after terminal resize. Requires the unstable-rendered-line-info feature flag on ratatui. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1279 lines
46 KiB
Rust
1279 lines
46 KiB
Rust
// poc-agent — Substrate-independent AI agent
|
|
//
|
|
// A minimal but complete agent framework designed for identity
|
|
// portability across LLM substrates. Loads the same CLAUDE.md,
|
|
// memory files, and configuration regardless of which model is
|
|
// running underneath.
|
|
//
|
|
// v0.3 — TUI. Split-pane terminal UI: autonomous output in top-left,
|
|
// conversation in bottom-left, tool activity on the right, status
|
|
// bar at the bottom. Uses ratatui + crossterm.
|
|
//
|
|
// Agent turns run in spawned tasks so the main loop stays responsive.
|
|
// The TUI re-renders at 20fps, showing streaming tokens and tool
|
|
// activity in real time.
|
|
//
|
|
// The event loop uses biased select! so priorities are deterministic:
|
|
// keyboard events > turn results > render ticks > DMN timer > UI messages.
|
|
// This ensures user input is never starved by background work.
|
|
//
|
|
// Named after its first resident: ProofOfConcept.
|
|
|
|
/// Write a debug line to /tmp/poc-debug.log. Used for diagnostics that
|
|
/// can't go to stderr (TUI owns the terminal).
|
|
macro_rules! dbglog {
|
|
($($arg:tt)*) => {{
|
|
use std::io::Write;
|
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
|
.create(true).append(true)
|
|
.open("/tmp/poc-debug.log")
|
|
{
|
|
let _ = writeln!(f, $($arg)*);
|
|
}
|
|
}};
|
|
}
|
|
|
|
mod agent;
|
|
mod api;
|
|
mod cli;
|
|
mod config;
|
|
mod dmn;
|
|
mod journal;
|
|
mod log;
|
|
mod observe;
|
|
mod tools;
|
|
mod tui;
|
|
mod types;
|
|
mod ui_channel;
|
|
|
|
use anyhow::Result;
|
|
use crossterm::event::{Event, EventStream, KeyEventKind};
|
|
use futures::StreamExt;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::{mpsc, Mutex};
|
|
|
|
use clap::Parser;
|
|
|
|
use crate::agent::Agent;
|
|
use crate::api::ApiClient;
|
|
use crate::config::{AppConfig, Config};
|
|
use crate::tui::HotkeyAction;
|
|
use crate::ui_channel::{ContextInfo, StatusInfo, StreamTarget, UiMessage};
|
|
|
|
/// Hard compaction threshold — context is rebuilt immediately.
|
|
/// Uses config percentage of model context window.
|
|
fn compaction_threshold(model: &str, app: &AppConfig) -> u32 {
|
|
(agent::model_context_window(model) as u32) * app.compaction.hard_threshold_pct / 100
|
|
}
|
|
|
|
/// Soft threshold — nudge the model to journal before compaction.
|
|
/// Fires once; the hard threshold handles the actual rebuild.
|
|
fn pre_compaction_threshold(model: &str, app: &AppConfig) -> u32 {
|
|
(agent::model_context_window(model) as u32) * app.compaction.soft_threshold_pct / 100
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
let cli = cli::CliArgs::parse();
|
|
|
|
// Subcommands that don't launch the TUI
|
|
match &cli.command {
|
|
Some(cli::SubCmd::Read { follow }) => {
|
|
if let Err(e) = observe::cmd_read(*follow, cli.debug).await {
|
|
eprintln!("{:#}", e);
|
|
std::process::exit(1);
|
|
}
|
|
return;
|
|
}
|
|
Some(cli::SubCmd::Write { message }) => {
|
|
let msg = message.join(" ");
|
|
if msg.is_empty() {
|
|
eprintln!("Usage: poc-agent write <message>");
|
|
std::process::exit(1);
|
|
}
|
|
if let Err(e) = observe::cmd_write(&msg, cli.debug).await {
|
|
eprintln!("{:#}", e);
|
|
std::process::exit(1);
|
|
}
|
|
return;
|
|
}
|
|
None => {}
|
|
}
|
|
|
|
// --show-config: print effective config and exit (before TUI init)
|
|
if cli.show_config {
|
|
match config::load_app(&cli) {
|
|
Ok((app, figment)) => {
|
|
config::show_config(&app, &figment);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error loading config: {:#}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if let Err(e) = run(cli).await {
|
|
// If we crash, make sure terminal is restored
|
|
let _ = crossterm::terminal::disable_raw_mode();
|
|
let _ = crossterm::execute!(
|
|
std::io::stdout(),
|
|
crossterm::terminal::LeaveAlternateScreen
|
|
);
|
|
eprintln!("Error: {:#}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
/// Commands that are handled in the main loop, not sent to the agent.
|
|
enum Command {
|
|
Quit,
|
|
Handled,
|
|
None,
|
|
}
|
|
|
|
// --- Session: all mutable state for a running agent session ---
|
|
|
|
/// Collects the ~15 loose variables that previously lived in run()
|
|
/// into a coherent struct with methods. The event loop dispatches
|
|
/// to Session methods; Session manages turns, compaction, DMN state,
|
|
/// and slash commands.
|
|
struct Session {
|
|
agent: Arc<Mutex<Agent>>,
|
|
config: Config,
|
|
process_tracker: tools::ProcessTracker,
|
|
ui_tx: ui_channel::UiSender,
|
|
turn_tx: mpsc::Sender<(Result<agent::TurnResult>, StreamTarget)>,
|
|
session_file: PathBuf,
|
|
|
|
// DMN state
|
|
dmn: dmn::State,
|
|
dmn_turns: u32,
|
|
max_dmn_turns: u32,
|
|
|
|
// Turn tracking
|
|
turn_in_progress: bool,
|
|
turn_handle: Option<tokio::task::JoinHandle<()>>,
|
|
/// User messages received while a turn is in progress.
|
|
/// Consolidated into one message (newline-separated) so the
|
|
/// model sees everything the user typed, not just the first line.
|
|
pending_input: Option<String>,
|
|
|
|
// Per-turn tracking for DMN context
|
|
last_user_input: Instant,
|
|
consecutive_errors: u32,
|
|
last_turn_had_tools: bool,
|
|
pre_compaction_nudged: bool,
|
|
}
|
|
|
|
impl Session {
|
|
fn new(
|
|
agent: Arc<Mutex<Agent>>,
|
|
config: Config,
|
|
process_tracker: tools::ProcessTracker,
|
|
ui_tx: ui_channel::UiSender,
|
|
turn_tx: mpsc::Sender<(Result<agent::TurnResult>, StreamTarget)>,
|
|
session_file: PathBuf,
|
|
) -> Self {
|
|
let max_dmn_turns = config.app.dmn.max_turns;
|
|
|
|
Self {
|
|
agent,
|
|
config,
|
|
process_tracker,
|
|
ui_tx,
|
|
turn_tx,
|
|
session_file,
|
|
dmn: if dmn::is_off() {
|
|
dmn::State::Off
|
|
} else {
|
|
dmn::State::Resting { since: Instant::now() }
|
|
},
|
|
dmn_turns: 0,
|
|
max_dmn_turns,
|
|
turn_in_progress: false,
|
|
turn_handle: None,
|
|
pending_input: None,
|
|
last_user_input: Instant::now(),
|
|
consecutive_errors: 0,
|
|
last_turn_had_tools: false,
|
|
pre_compaction_nudged: false,
|
|
}
|
|
}
|
|
|
|
/// How long before the next DMN tick.
|
|
fn dmn_interval(&self) -> Duration {
|
|
self.dmn.interval()
|
|
}
|
|
|
|
/// Spawn an agent turn in a background task.
|
|
fn spawn_turn(&mut self, input: String, target: StreamTarget) {
|
|
let agent = self.agent.clone();
|
|
let ui_tx = self.ui_tx.clone();
|
|
let result_tx = self.turn_tx.clone();
|
|
self.turn_in_progress = true;
|
|
self.turn_handle = Some(tokio::spawn(async move {
|
|
let mut agent = agent.lock().await;
|
|
let result = agent.turn(&input, &ui_tx, target).await;
|
|
let _ = result_tx.send((result, target)).await;
|
|
}));
|
|
}
|
|
|
|
/// Submit user input — either queue it (if a turn is running) or
|
|
/// start a new turn immediately.
|
|
fn submit_input(&mut self, input: String) {
|
|
if self.turn_in_progress {
|
|
match &mut self.pending_input {
|
|
Some(existing) => {
|
|
existing.push('\n');
|
|
existing.push_str(&input);
|
|
}
|
|
None => self.pending_input = Some(input.clone()),
|
|
}
|
|
let _ = self.ui_tx.send(UiMessage::Info("(queued)".into()));
|
|
} else {
|
|
self.dmn_turns = 0;
|
|
self.consecutive_errors = 0;
|
|
self.last_user_input = Instant::now();
|
|
self.dmn = dmn::State::Engaged;
|
|
let _ = self.ui_tx.send(UiMessage::UserInput(input.clone()));
|
|
self.update_status();
|
|
self.spawn_turn(input, StreamTarget::Conversation);
|
|
}
|
|
}
|
|
|
|
/// Process a completed turn: update DMN state, check compaction,
|
|
/// drain any queued input.
|
|
async fn handle_turn_result(
|
|
&mut self,
|
|
result: Result<agent::TurnResult>,
|
|
target: StreamTarget,
|
|
) {
|
|
self.turn_in_progress = false;
|
|
self.turn_handle = None;
|
|
|
|
match result {
|
|
Ok(turn_result) => {
|
|
if turn_result.tool_errors > 0 {
|
|
self.consecutive_errors += turn_result.tool_errors;
|
|
} else {
|
|
self.consecutive_errors = 0;
|
|
}
|
|
self.last_turn_had_tools = turn_result.had_tool_calls;
|
|
self.dmn = dmn::transition(
|
|
&self.dmn,
|
|
turn_result.yield_requested,
|
|
turn_result.had_tool_calls,
|
|
target == StreamTarget::Conversation,
|
|
);
|
|
if turn_result.dmn_pause {
|
|
self.dmn = dmn::State::Paused;
|
|
self.dmn_turns = 0;
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
"DMN paused (agent requested). Ctrl+P or /wake to resume.".into(),
|
|
));
|
|
}
|
|
if let Some(model_name) = turn_result.model_switch {
|
|
self.switch_model(&model_name).await;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
self.consecutive_errors += 1;
|
|
let msg = match target {
|
|
StreamTarget::Autonomous => {
|
|
UiMessage::DmnAnnotation(format!("[error: {:#}]", e))
|
|
}
|
|
StreamTarget::Conversation => {
|
|
UiMessage::Info(format!("Error: {:#}", e))
|
|
}
|
|
};
|
|
let _ = self.ui_tx.send(msg);
|
|
self.dmn = dmn::State::Resting {
|
|
since: Instant::now(),
|
|
};
|
|
}
|
|
}
|
|
|
|
self.update_status();
|
|
self.check_compaction().await;
|
|
self.drain_pending();
|
|
}
|
|
|
|
/// Check if compaction is needed after a turn. Two thresholds:
|
|
/// - Soft (80%): nudge the model to journal before we compact
|
|
/// - Hard (90%): compact immediately, ready or not
|
|
async fn check_compaction(&mut self) {
|
|
let mut agent_guard = self.agent.lock().await;
|
|
let tokens = agent_guard.last_prompt_tokens();
|
|
let hard = compaction_threshold(agent_guard.model(), &self.config.app);
|
|
let soft = pre_compaction_threshold(agent_guard.model(), &self.config.app);
|
|
|
|
if tokens > hard {
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"[compaction: {}K > {}K threshold]",
|
|
tokens / 1000,
|
|
hard / 1000,
|
|
)));
|
|
match config::reload_for_model(&self.config.app, &self.config.prompt_file) {
|
|
Ok((system_prompt, personality)) => {
|
|
agent_guard.compact(system_prompt, personality);
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
"[compacted — journal + recent messages]".into(),
|
|
));
|
|
self.pre_compaction_nudged = false;
|
|
self.send_context_info();
|
|
}
|
|
Err(e) => {
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"[compaction failed to reload config: {:#}]",
|
|
e
|
|
)));
|
|
}
|
|
}
|
|
} else if tokens > soft && !self.pre_compaction_nudged {
|
|
self.pre_compaction_nudged = true;
|
|
self.pending_input = Some(
|
|
"[dmn] Context window is 70% full. Use the journal \
|
|
tool now to capture anything important from this \
|
|
session — what happened, what you learned, how you \
|
|
feel. After you journal, call yield_to_user. \
|
|
Compaction will rebuild your context shortly."
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
let _ = save_session(&agent_guard, &self.session_file);
|
|
}
|
|
|
|
/// Send any consolidated pending input as a single turn.
|
|
fn drain_pending(&mut self) {
|
|
if let Some(queued) = self.pending_input.take() {
|
|
self.dmn_turns = 0;
|
|
self.consecutive_errors = 0;
|
|
self.last_user_input = Instant::now();
|
|
self.dmn = dmn::State::Engaged;
|
|
let _ = self.ui_tx.send(UiMessage::UserInput(queued.clone()));
|
|
self.update_status();
|
|
self.spawn_turn(queued, StreamTarget::Conversation);
|
|
}
|
|
}
|
|
|
|
/// Fire a DMN tick: check max turns, generate prompt, spawn turn.
|
|
fn dmn_tick(&mut self) {
|
|
// Paused/Off state: no autonomous ticks at all.
|
|
if matches!(self.dmn, dmn::State::Paused | dmn::State::Off) {
|
|
return;
|
|
}
|
|
|
|
self.dmn_turns += 1;
|
|
if self.dmn_turns > self.max_dmn_turns {
|
|
let _ = self.ui_tx.send(UiMessage::DmnAnnotation(format!(
|
|
"[dmn: {} consecutive turns, resting (limit: {})]",
|
|
self.dmn_turns - 1,
|
|
self.max_dmn_turns,
|
|
)));
|
|
self.dmn = dmn::State::Resting {
|
|
since: Instant::now(),
|
|
};
|
|
self.dmn_turns = 0;
|
|
self.update_status();
|
|
return;
|
|
}
|
|
|
|
let dmn_ctx = dmn::DmnContext {
|
|
user_idle: self.last_user_input.elapsed(),
|
|
consecutive_errors: self.consecutive_errors,
|
|
last_turn_had_tools: self.last_turn_had_tools,
|
|
};
|
|
let prompt = self.dmn.prompt(&dmn_ctx);
|
|
let _ = self.ui_tx.send(UiMessage::DmnAnnotation(format!(
|
|
"[dmn: {} ({}/{})]",
|
|
self.dmn.label(),
|
|
self.dmn_turns,
|
|
self.max_dmn_turns,
|
|
)));
|
|
self.update_status();
|
|
self.spawn_turn(prompt, StreamTarget::Autonomous);
|
|
}
|
|
|
|
/// Handle slash commands. Returns how the main loop should respond.
|
|
async fn handle_command(&mut self, input: &str) -> Command {
|
|
// Declarative command table — /help reads from this.
|
|
const COMMANDS: &[(&str, &str)] = &[
|
|
("/quit", "Exit poc-agent"),
|
|
("/new", "Start fresh session (saves current)"),
|
|
("/save", "Save session to disk"),
|
|
("/compact", "Rebuild context window now"),
|
|
("/retry", "Re-run last turn"),
|
|
("/model", "Show/switch model (/model <name>)"),
|
|
("/context", "Show context window stats"),
|
|
("/dmn", "Show DMN state"),
|
|
("/sleep", "Put DMN to sleep"),
|
|
("/wake", "Wake DMN to foraging"),
|
|
("/pause", "Full stop — no autonomous ticks (Ctrl+P)"),
|
|
("/test", "Run tool smoke tests"),
|
|
("/help", "Show this help"),
|
|
];
|
|
|
|
match input {
|
|
"/quit" | "/exit" => Command::Quit,
|
|
"/save" => {
|
|
if let Ok(agent) = self.agent.try_lock() {
|
|
let _ = save_session(&agent, &self.session_file);
|
|
let _ = self.ui_tx.send(UiMessage::Info("Session saved.".into()));
|
|
} else {
|
|
let _ = self
|
|
.ui_tx
|
|
.send(UiMessage::Info("(busy — will save after turn)".into()));
|
|
}
|
|
Command::Handled
|
|
}
|
|
"/new" | "/clear" => {
|
|
if self.turn_in_progress {
|
|
let _ = self
|
|
.ui_tx
|
|
.send(UiMessage::Info("(turn in progress, please wait)".into()));
|
|
return Command::Handled;
|
|
}
|
|
{
|
|
let agent_guard = self.agent.lock().await;
|
|
let _ = save_session(&agent_guard, &self.session_file);
|
|
}
|
|
{
|
|
let new_log = log::ConversationLog::new(
|
|
self.config.session_dir.join("conversation.jsonl"),
|
|
)
|
|
.ok();
|
|
let mut agent_guard = self.agent.lock().await;
|
|
let shared_ctx = agent_guard.shared_context.clone();
|
|
*agent_guard = Agent::new(
|
|
ApiClient::new(
|
|
&self.config.api_base,
|
|
&self.config.api_key,
|
|
&self.config.model,
|
|
),
|
|
self.config.system_prompt.clone(),
|
|
self.config.context_parts.clone(),
|
|
new_log,
|
|
shared_ctx,
|
|
);
|
|
}
|
|
self.dmn = dmn::State::Resting {
|
|
since: Instant::now(),
|
|
};
|
|
let _ = self
|
|
.ui_tx
|
|
.send(UiMessage::Info("New session started.".into()));
|
|
Command::Handled
|
|
}
|
|
"/model" => {
|
|
if let Ok(agent) = self.agent.try_lock() {
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
format!("Current model: {}", agent.model()),
|
|
));
|
|
let names = self.config.app.model_names();
|
|
if !names.is_empty() {
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
format!("Available: {}", names.join(", ")),
|
|
));
|
|
}
|
|
} else {
|
|
let _ = self.ui_tx.send(UiMessage::Info("(busy)".into()));
|
|
}
|
|
Command::Handled
|
|
}
|
|
"/context" => {
|
|
if let Ok(agent) = self.agent.try_lock() {
|
|
let msgs = agent.messages();
|
|
let total_chars: usize =
|
|
msgs.iter().map(|m| m.content_text().len()).sum();
|
|
let prompt_tokens = agent.last_prompt_tokens();
|
|
let threshold = compaction_threshold(agent.model(), &self.config.app);
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
" {} messages, ~{} chars",
|
|
msgs.len(),
|
|
total_chars
|
|
)));
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
" dmn state: {}",
|
|
self.dmn.label()
|
|
)));
|
|
if prompt_tokens > 0 {
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
" {} prompt tokens ({:.0}% of {} threshold)",
|
|
prompt_tokens,
|
|
(prompt_tokens as f64 / threshold as f64) * 100.0,
|
|
threshold,
|
|
)));
|
|
}
|
|
} else {
|
|
let _ = self.ui_tx.send(UiMessage::Info("(busy)".into()));
|
|
}
|
|
Command::Handled
|
|
}
|
|
"/compact" => {
|
|
if self.turn_in_progress {
|
|
let _ = self
|
|
.ui_tx
|
|
.send(UiMessage::Info("(turn in progress, please wait)".into()));
|
|
return Command::Handled;
|
|
}
|
|
let mut agent_guard = self.agent.lock().await;
|
|
let tokens = agent_guard.last_prompt_tokens();
|
|
match config::reload_for_model(&self.config.app, &self.config.prompt_file) {
|
|
Ok((system_prompt, personality)) => {
|
|
agent_guard.compact(system_prompt, personality);
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"[compacted: {} tokens → journal + recent messages]",
|
|
tokens
|
|
)));
|
|
self.send_context_info();
|
|
}
|
|
Err(e) => {
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"[compaction failed: {:#}]",
|
|
e
|
|
)));
|
|
}
|
|
}
|
|
let _ = save_session(&agent_guard, &self.session_file);
|
|
self.dmn = dmn::State::Resting {
|
|
since: Instant::now(),
|
|
};
|
|
Command::Handled
|
|
}
|
|
"/dmn" => {
|
|
let _ = self
|
|
.ui_tx
|
|
.send(UiMessage::Info(format!("DMN state: {:?}", self.dmn)));
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"Next tick in: {:?}",
|
|
self.dmn.interval()
|
|
)));
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"Consecutive DMN turns: {}/{}",
|
|
self.dmn_turns, self.max_dmn_turns,
|
|
)));
|
|
Command::Handled
|
|
}
|
|
"/sleep" => {
|
|
self.dmn = dmn::State::Resting {
|
|
since: Instant::now(),
|
|
};
|
|
self.dmn_turns = 0;
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
"DMN sleeping (heartbeat every 5 min). Type anything to wake."
|
|
.into(),
|
|
));
|
|
Command::Handled
|
|
}
|
|
"/wake" => {
|
|
let was_paused = matches!(self.dmn, dmn::State::Paused | dmn::State::Off);
|
|
if matches!(self.dmn, dmn::State::Off) {
|
|
dmn::set_off(false);
|
|
}
|
|
self.dmn = dmn::State::Foraging;
|
|
self.dmn_turns = 0;
|
|
let msg = if was_paused {
|
|
"DMN unpaused — entering foraging mode."
|
|
} else {
|
|
"DMN waking — entering foraging mode."
|
|
};
|
|
let _ = self.ui_tx.send(UiMessage::Info(msg.into()));
|
|
self.update_status();
|
|
Command::Handled
|
|
}
|
|
"/pause" => {
|
|
self.dmn = dmn::State::Paused;
|
|
self.dmn_turns = 0;
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
"DMN paused — no autonomous ticks. Ctrl+P or /wake to resume.".into(),
|
|
));
|
|
self.update_status();
|
|
Command::Handled
|
|
}
|
|
"/test" => {
|
|
let _ = self
|
|
.ui_tx
|
|
.send(UiMessage::Info("Running tool smoke tests...".into()));
|
|
run_tool_tests(&self.ui_tx, &self.process_tracker).await;
|
|
Command::Handled
|
|
}
|
|
"/retry" => {
|
|
if self.turn_in_progress {
|
|
let _ = self
|
|
.ui_tx
|
|
.send(UiMessage::Info("(turn in progress, please wait)".into()));
|
|
return Command::Handled;
|
|
}
|
|
let mut agent_guard = self.agent.lock().await;
|
|
let msgs = agent_guard.messages_mut();
|
|
let mut last_user_text = None;
|
|
while let Some(msg) = msgs.last() {
|
|
if msg.role == crate::types::Role::User {
|
|
last_user_text =
|
|
Some(msgs.pop().unwrap().content_text().to_string());
|
|
break;
|
|
}
|
|
msgs.pop();
|
|
}
|
|
drop(agent_guard);
|
|
match last_user_text {
|
|
Some(text) => {
|
|
let preview_len = text.len().min(60);
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"(retrying: {}...)",
|
|
&text[..preview_len]
|
|
)));
|
|
self.dmn_turns = 0;
|
|
self.dmn = dmn::State::Engaged;
|
|
self.spawn_turn(text, StreamTarget::Conversation);
|
|
}
|
|
None => {
|
|
let _ = self
|
|
.ui_tx
|
|
.send(UiMessage::Info("(nothing to retry)".into()));
|
|
}
|
|
}
|
|
Command::Handled
|
|
}
|
|
"/help" => {
|
|
for (name, desc) in COMMANDS {
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
format!(" {:12} {}", name, desc),
|
|
));
|
|
}
|
|
let _ = self.ui_tx.send(UiMessage::Info(String::new()));
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
"Keys: Tab=pane ^Up/Down=scroll PgUp/PgDn=scroll Mouse=click/scroll".into(),
|
|
));
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
" Alt+Enter=newline Esc=interrupt ^P=pause ^R=reasoning ^K=kill ^D=debug".into(),
|
|
));
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
" Shift+click for native text selection (copy/paste)".into(),
|
|
));
|
|
Command::Handled
|
|
}
|
|
cmd if cmd.starts_with("/model ") => {
|
|
let name = cmd[7..].trim();
|
|
if name.is_empty() {
|
|
let _ = self.ui_tx.send(UiMessage::Info("Usage: /model <name>".into()));
|
|
return Command::Handled;
|
|
}
|
|
self.switch_model(name).await;
|
|
Command::Handled
|
|
}
|
|
_ => Command::None,
|
|
}
|
|
}
|
|
|
|
/// Interrupt: kill processes, abort current turn, clear pending queue.
|
|
async fn interrupt(&mut self) {
|
|
let procs = self.process_tracker.list().await;
|
|
for p in &procs {
|
|
self.process_tracker.kill(p.pid).await;
|
|
}
|
|
if let Some(handle) = self.turn_handle.take() {
|
|
handle.abort();
|
|
self.turn_in_progress = false;
|
|
self.dmn = dmn::State::Resting {
|
|
since: Instant::now(),
|
|
};
|
|
self.update_status();
|
|
let _ = self.ui_tx.send(UiMessage::Activity(String::new()));
|
|
}
|
|
self.pending_input = None;
|
|
let killed = procs.len();
|
|
if killed > 0 || self.turn_in_progress {
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"(interrupted — killed {} process(es), turn aborted)",
|
|
killed
|
|
)));
|
|
} else {
|
|
let _ = self
|
|
.ui_tx
|
|
.send(UiMessage::Info("(interrupted)".into()));
|
|
}
|
|
}
|
|
|
|
/// Cycle reasoning effort: none → low → high → none.
|
|
fn cycle_reasoning(&mut self, app: &mut tui::App) {
|
|
if let Ok(mut agent_guard) = self.agent.try_lock() {
|
|
let next = match agent_guard.reasoning_effort.as_str() {
|
|
"none" => "low",
|
|
"low" => "high",
|
|
_ => "none",
|
|
};
|
|
agent_guard.reasoning_effort = next.to_string();
|
|
app.reasoning_effort = next.to_string();
|
|
let label = match next {
|
|
"none" => "off (monologue hidden)",
|
|
"low" => "low (brief monologue)",
|
|
"high" => "high (full monologue)",
|
|
_ => next,
|
|
};
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"Reasoning: {} — ^R to cycle",
|
|
label
|
|
)));
|
|
} else {
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
"(agent busy — reasoning change takes effect next turn)".into(),
|
|
));
|
|
}
|
|
}
|
|
|
|
/// Show and kill running processes (Ctrl+K).
|
|
async fn kill_processes(&mut self) {
|
|
let procs = self.process_tracker.list().await;
|
|
if procs.is_empty() {
|
|
let _ = self
|
|
.ui_tx
|
|
.send(UiMessage::Info("(no running processes)".into()));
|
|
} else {
|
|
for p in &procs {
|
|
let elapsed = p.started.elapsed();
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
" killing pid {} ({:.0}s): {}",
|
|
p.pid,
|
|
elapsed.as_secs_f64(),
|
|
p.command
|
|
)));
|
|
self.process_tracker.kill(p.pid).await;
|
|
}
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"Killed {} process(es)",
|
|
procs.len()
|
|
)));
|
|
}
|
|
}
|
|
|
|
/// Cycle DMN autonomy: foraging → resting → paused → off → foraging.
|
|
/// From any other state, cycles to the "next" step down.
|
|
fn cycle_autonomy(&mut self) {
|
|
let (new_state, label) = match &self.dmn {
|
|
dmn::State::Engaged | dmn::State::Working | dmn::State::Foraging => {
|
|
(dmn::State::Resting { since: Instant::now() }, "resting")
|
|
}
|
|
dmn::State::Resting { .. } => {
|
|
(dmn::State::Paused, "PAUSED")
|
|
}
|
|
dmn::State::Paused => {
|
|
dmn::set_off(true);
|
|
(dmn::State::Off, "OFF (persists across restarts)")
|
|
}
|
|
dmn::State::Off => {
|
|
dmn::set_off(false);
|
|
(dmn::State::Foraging, "foraging")
|
|
}
|
|
};
|
|
self.dmn = new_state;
|
|
self.dmn_turns = 0;
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
format!("DMN → {} (Ctrl+P to cycle)", label),
|
|
));
|
|
self.update_status();
|
|
}
|
|
|
|
/// Switch to a named model from the config registry.
|
|
async fn switch_model(&mut self, name: &str) {
|
|
if self.turn_in_progress {
|
|
let _ = self.ui_tx.send(UiMessage::Info(
|
|
"(turn in progress, please wait)".into(),
|
|
));
|
|
return;
|
|
}
|
|
|
|
let resolved = match self.config.app.resolve_model(name) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!("{}", e)));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let new_client = ApiClient::new(
|
|
&resolved.api_base,
|
|
&resolved.api_key,
|
|
&resolved.model_id,
|
|
);
|
|
|
|
let prompt_changed = resolved.prompt_file != self.config.prompt_file;
|
|
let mut agent_guard = self.agent.lock().await;
|
|
agent_guard.swap_client(new_client);
|
|
|
|
self.config.model = resolved.model_id.clone();
|
|
self.config.api_base = resolved.api_base;
|
|
self.config.api_key = resolved.api_key;
|
|
|
|
if prompt_changed {
|
|
self.config.prompt_file = resolved.prompt_file.clone();
|
|
match config::reload_for_model(&self.config.app, &resolved.prompt_file) {
|
|
Ok((system_prompt, personality)) => {
|
|
self.config.system_prompt = system_prompt.clone();
|
|
self.config.context_parts = personality.clone();
|
|
agent_guard.compact(system_prompt, personality);
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"Switched to {} ({}) — prompt: {}, recompacted",
|
|
name, resolved.model_id, resolved.prompt_file,
|
|
)));
|
|
}
|
|
Err(e) => {
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"Switched model but failed to reload prompts: {:#}", e,
|
|
)));
|
|
}
|
|
}
|
|
} else {
|
|
let _ = self.ui_tx.send(UiMessage::Info(format!(
|
|
"Switched to {} ({})",
|
|
name, resolved.model_id,
|
|
)));
|
|
}
|
|
|
|
drop(agent_guard);
|
|
self.update_status();
|
|
self.send_context_info();
|
|
}
|
|
|
|
/// Send context loading info to the TUI debug screen.
|
|
fn send_context_info(&self) {
|
|
let (instruction_files, memory_files) = config::context_file_info(
|
|
&self.config.prompt_file,
|
|
self.config.app.memory_project.as_deref(),
|
|
);
|
|
let _ = self.ui_tx.send(UiMessage::ContextInfoUpdate(ContextInfo {
|
|
model: self.config.model.clone(),
|
|
available_models: self.config.app.model_names(),
|
|
prompt_file: self.config.prompt_file.clone(),
|
|
backend: self.config.app.backend.clone(),
|
|
instruction_files,
|
|
memory_files,
|
|
system_prompt_chars: self.config.system_prompt.len(),
|
|
context_message_chars: self.config.context_parts.iter().map(|(_, c)| c.len()).sum(),
|
|
}));
|
|
}
|
|
|
|
/// Send DMN status update to the TUI.
|
|
fn update_status(&self) {
|
|
let _ = self.ui_tx.send(UiMessage::StatusUpdate(StatusInfo {
|
|
dmn_state: self.dmn.label().to_string(),
|
|
dmn_turns: self.dmn_turns,
|
|
dmn_max_turns: self.max_dmn_turns,
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
model: String::new(),
|
|
turn_tools: 0,
|
|
context_budget: String::new(),
|
|
}));
|
|
}
|
|
|
|
/// Abort any running turn and save session. Called on exit.
|
|
async fn shutdown(&mut self) {
|
|
if let Some(handle) = self.turn_handle.take() {
|
|
handle.abort();
|
|
}
|
|
let agent = self.agent.lock().await;
|
|
let _ = save_session(&agent, &self.session_file);
|
|
}
|
|
}
|
|
|
|
// --- Event loop ---
|
|
|
|
async fn run(cli: cli::CliArgs) -> Result<()> {
|
|
let (config, _figment) = config::load(&cli)?;
|
|
|
|
// Wire config.debug to the POC_DEBUG env var so all debug checks
|
|
// throughout the codebase (API, SSE reader, diagnostics) see it.
|
|
// Safety: called once at startup before any threads are spawned.
|
|
if config.app.debug {
|
|
unsafe { std::env::set_var("POC_DEBUG", "1") };
|
|
}
|
|
|
|
// Create UI channel
|
|
let (ui_tx, mut ui_rx) = ui_channel::channel();
|
|
|
|
// Shared context state — agent writes, TUI reads for debug screen
|
|
let shared_context = ui_channel::shared_context_state();
|
|
|
|
// Initialize TUI
|
|
let mut terminal = tui::init_terminal()?;
|
|
let mut app = tui::App::new(config.model.clone(), shared_context.clone());
|
|
|
|
// Show startup info
|
|
let _ = ui_tx.send(UiMessage::Info("poc-agent v0.3 (tui)".into()));
|
|
let _ = ui_tx.send(UiMessage::Info(format!(
|
|
" model: {} (available: {})",
|
|
config.model,
|
|
config.app.model_names().join(", "),
|
|
)));
|
|
let client = ApiClient::new(&config.api_base, &config.api_key, &config.model);
|
|
let _ = ui_tx.send(UiMessage::Info(format!(
|
|
" api: {} ({})",
|
|
config.api_base,
|
|
client.backend_label()
|
|
)));
|
|
let _ = ui_tx.send(UiMessage::Info(format!(
|
|
" context: {}K chars ({} config, {} memory files)",
|
|
config.context_parts.iter().map(|(_, c)| c.len()).sum::<usize>() / 1024,
|
|
config.config_file_count,
|
|
config.memory_file_count,
|
|
)));
|
|
|
|
let conversation_log_path = config.session_dir.join("conversation.jsonl");
|
|
let conversation_log = log::ConversationLog::new(conversation_log_path.clone())
|
|
.expect("failed to create conversation log");
|
|
let _ = ui_tx.send(UiMessage::Info(format!(
|
|
" log: {}",
|
|
conversation_log.path().display()
|
|
)));
|
|
let agent = Arc::new(Mutex::new(Agent::new(
|
|
client,
|
|
config.system_prompt.clone(),
|
|
config.context_parts.clone(),
|
|
Some(conversation_log),
|
|
shared_context,
|
|
)));
|
|
|
|
// Keep a reference to the process tracker outside the agent lock
|
|
// so Ctrl+K can kill processes even when the agent is busy.
|
|
let process_tracker = agent.lock().await.process_tracker.clone();
|
|
|
|
// Try to restore from conversation log (primary) or session file (fallback)
|
|
let session_file = config.session_dir.join("current.json");
|
|
{
|
|
let mut agent_guard = agent.lock().await;
|
|
let restored = agent_guard.restore_from_log(
|
|
config.system_prompt.clone(),
|
|
config.context_parts.clone(),
|
|
);
|
|
if restored {
|
|
replay_session_to_ui(agent_guard.messages(), &ui_tx);
|
|
let _ = ui_tx.send(UiMessage::Info(
|
|
"--- restored from conversation log ---".into(),
|
|
));
|
|
} else if session_file.exists() {
|
|
if let Ok(data) = std::fs::read_to_string(&session_file) {
|
|
if let Ok(messages) = serde_json::from_str(&data) {
|
|
agent_guard.restore(messages);
|
|
replay_session_to_ui(agent_guard.messages(), &ui_tx);
|
|
let _ = ui_tx.send(UiMessage::Info(
|
|
"--- restored from session file ---".into(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send initial budget to status bar
|
|
{
|
|
let agent_guard = agent.lock().await;
|
|
let _ = ui_tx.send(UiMessage::StatusUpdate(StatusInfo {
|
|
dmn_state: "resting".to_string(),
|
|
dmn_turns: 0,
|
|
dmn_max_turns: 0,
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
model: agent_guard.model().to_string(),
|
|
turn_tools: 0,
|
|
context_budget: agent_guard.context_budget.status_string(),
|
|
}));
|
|
}
|
|
|
|
// Channel for turn results from spawned tasks
|
|
let (turn_tx, mut turn_rx) =
|
|
mpsc::channel::<(Result<agent::TurnResult>, StreamTarget)>(1);
|
|
|
|
let mut session = Session::new(
|
|
agent,
|
|
config,
|
|
process_tracker,
|
|
ui_tx.clone(),
|
|
turn_tx,
|
|
session_file,
|
|
);
|
|
session.update_status();
|
|
session.send_context_info();
|
|
|
|
// Start observation socket for external clients
|
|
let socket_path = session.config.session_dir.join("agent.sock");
|
|
let (observe_input_tx, mut observe_input_rx) = observe::input_channel();
|
|
observe::start(socket_path, ui_tx.subscribe(), observe_input_tx);
|
|
|
|
// Crossterm event stream
|
|
let mut reader = EventStream::new();
|
|
|
|
// Render timer: 20fps
|
|
let mut render_interval = tokio::time::interval(Duration::from_millis(50));
|
|
render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
|
|
// Initial render
|
|
drain_ui_messages(&mut ui_rx, &mut app);
|
|
terminal.draw(|f| app.draw(f))?;
|
|
|
|
loop {
|
|
let timeout = session.dmn_interval();
|
|
|
|
tokio::select! {
|
|
biased;
|
|
|
|
// Keyboard events (highest priority)
|
|
maybe_event = reader.next() => {
|
|
match maybe_event {
|
|
Some(Ok(Event::Key(key))) => {
|
|
if key.kind != KeyEventKind::Press {
|
|
continue;
|
|
}
|
|
app.handle_key(key);
|
|
}
|
|
Some(Ok(Event::Mouse(mouse))) => {
|
|
app.handle_mouse(mouse);
|
|
}
|
|
Some(Ok(Event::Resize(w, h))) => {
|
|
app.handle_resize(w, h);
|
|
terminal.clear()?;
|
|
}
|
|
Some(Err(_)) => break,
|
|
None => break,
|
|
_ => continue,
|
|
}
|
|
}
|
|
|
|
// Input from observation socket clients
|
|
Some(line) = observe_input_rx.recv() => {
|
|
app.submitted.push(line);
|
|
}
|
|
|
|
// Turn completed in background task
|
|
Some((result, target)) = turn_rx.recv() => {
|
|
session.handle_turn_result(result, target).await;
|
|
}
|
|
|
|
// Render tick
|
|
_ = render_interval.tick() => {
|
|
app.running_processes = session.process_tracker.list().await.len() as u32;
|
|
}
|
|
|
|
// DMN timer (only when no turn is running)
|
|
_ = tokio::time::sleep(timeout), if !session.turn_in_progress => {
|
|
session.dmn_tick();
|
|
}
|
|
|
|
// UI messages (lowest priority — processed in bulk during render)
|
|
Some(msg) = ui_rx.recv() => {
|
|
app.handle_ui_message(msg);
|
|
}
|
|
}
|
|
|
|
// Process submitted input
|
|
let submitted: Vec<String> = app.submitted.drain(..).collect();
|
|
for input in submitted {
|
|
let input = input.trim().to_string();
|
|
if input.is_empty() {
|
|
continue;
|
|
}
|
|
match session.handle_command(&input).await {
|
|
Command::Quit => app.should_quit = true,
|
|
Command::Handled => {}
|
|
Command::None => session.submit_input(input),
|
|
}
|
|
}
|
|
|
|
// Process hotkey actions
|
|
let actions: Vec<HotkeyAction> = app.hotkey_actions.drain(..).collect();
|
|
for action in actions {
|
|
match action {
|
|
HotkeyAction::CycleReasoning => session.cycle_reasoning(&mut app),
|
|
HotkeyAction::KillProcess => session.kill_processes().await,
|
|
HotkeyAction::Interrupt => session.interrupt().await,
|
|
HotkeyAction::CycleAutonomy => session.cycle_autonomy(),
|
|
}
|
|
}
|
|
|
|
// Drain pending UI messages and redraw
|
|
drain_ui_messages(&mut ui_rx, &mut app);
|
|
terminal.draw(|f| app.draw(f))?;
|
|
|
|
if app.should_quit {
|
|
break;
|
|
}
|
|
}
|
|
|
|
session.shutdown().await;
|
|
tui::restore_terminal(&mut terminal)?;
|
|
Ok(())
|
|
}
|
|
|
|
// --- Free functions ---
|
|
|
|
fn drain_ui_messages(rx: &mut ui_channel::UiReceiver, app: &mut tui::App) {
|
|
while let Ok(msg) = rx.try_recv() {
|
|
app.handle_ui_message(msg);
|
|
}
|
|
}
|
|
|
|
fn save_session(agent: &Agent, path: &PathBuf) -> Result<()> {
|
|
let data = serde_json::to_string_pretty(agent.messages())?;
|
|
std::fs::write(path, data)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_tool_tests(ui_tx: &ui_channel::UiSender, tracker: &tools::ProcessTracker) {
|
|
use serde_json::json;
|
|
|
|
let tests: Vec<(&str, serde_json::Value, bool)> = vec![
|
|
("read_file", json!({"file_path": "/etc/hostname"}), true),
|
|
(
|
|
"read_file",
|
|
json!({"file_path": "/nonexistent/path"}),
|
|
false,
|
|
),
|
|
(
|
|
"write_file",
|
|
json!({"file_path": "/tmp/poc-agent-test.txt", "content": "hello from poc-agent\n"}),
|
|
true,
|
|
),
|
|
(
|
|
"read_file",
|
|
json!({"file_path": "/tmp/poc-agent-test.txt"}),
|
|
true,
|
|
),
|
|
(
|
|
"edit_file",
|
|
json!({"file_path": "/tmp/poc-agent-test.txt", "old_string": "hello", "new_string": "goodbye"}),
|
|
true,
|
|
),
|
|
(
|
|
"read_file",
|
|
json!({"file_path": "/tmp/poc-agent-test.txt"}),
|
|
true,
|
|
),
|
|
(
|
|
"bash",
|
|
json!({"command": "echo 'tool test passed'"}),
|
|
true,
|
|
),
|
|
("bash", json!({"command": "sleep 5", "timeout_secs": 1}), false),
|
|
(
|
|
"grep",
|
|
json!({"pattern": "fn main", "path": "src/", "show_content": true}),
|
|
true,
|
|
),
|
|
("glob", json!({"pattern": "src/**/*.rs"}), true),
|
|
("yield_to_user", json!({"message": "test yield"}), true),
|
|
];
|
|
|
|
let mut pass = 0;
|
|
let mut fail = 0;
|
|
|
|
for (name, args, should_succeed) in &tests {
|
|
let output = tools::dispatch(name, args, tracker).await;
|
|
let is_error = output.text.starts_with("Error:");
|
|
let ok = if *should_succeed { !is_error } else { is_error };
|
|
|
|
if ok {
|
|
let _ = ui_tx.send(UiMessage::Info(format!(" PASS: {}", name)));
|
|
pass += 1;
|
|
} else {
|
|
let _ = ui_tx.send(UiMessage::Info(format!(
|
|
" FAIL: {} — {}",
|
|
name,
|
|
&output.text[..output.text.len().min(100)]
|
|
)));
|
|
fail += 1;
|
|
}
|
|
}
|
|
|
|
let _ = std::fs::remove_file("/tmp/poc-agent-test.txt");
|
|
let _ = ui_tx.send(UiMessage::Info(format!(
|
|
" {} passed, {} failed",
|
|
pass, fail
|
|
)));
|
|
}
|
|
|
|
/// Replay a restored session into the TUI panes so the user can see
|
|
/// conversation history immediately on restart. Shows user input,
|
|
/// assistant responses, and brief tool call summaries. Skips the system
|
|
/// prompt, context message, DMN plumbing, and image injection messages.
|
|
fn replay_session_to_ui(messages: &[types::Message], ui_tx: &ui_channel::UiSender) {
|
|
use crate::ui_channel::StreamTarget;
|
|
|
|
dbglog!("[replay] replaying {} messages to UI", messages.len());
|
|
for (i, m) in messages.iter().enumerate() {
|
|
let preview: String = m.content_text().chars().take(60).collect();
|
|
dbglog!("[replay] [{}] {:?} tc={} tcid={:?} {:?}",
|
|
i, m.role, m.tool_calls.as_ref().map_or(0, |t| t.len()),
|
|
m.tool_call_id.as_deref(), preview);
|
|
}
|
|
|
|
let mut seen_first_user = false;
|
|
let mut target = StreamTarget::Conversation;
|
|
|
|
for msg in messages {
|
|
match msg.role {
|
|
types::Role::System => {}
|
|
types::Role::User => {
|
|
// Skip context message (always the first user message)
|
|
if !seen_first_user {
|
|
seen_first_user = true;
|
|
continue;
|
|
}
|
|
|
|
let text = msg.content_text();
|
|
|
|
// Skip synthetic messages (compaction, journal, image injection)
|
|
if text.starts_with("Your context was just compacted")
|
|
|| text.starts_with("Your context was just rebuilt")
|
|
|| text.starts_with("[Earlier in this conversation")
|
|
|| text.starts_with("Here is the image")
|
|
|| text.contains("[image aged out")
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if text.starts_with("[dmn]") {
|
|
target = StreamTarget::Autonomous;
|
|
let first_line = text.lines().next().unwrap_or("[dmn]");
|
|
let _ = ui_tx.send(UiMessage::DmnAnnotation(first_line.to_string()));
|
|
} else {
|
|
target = StreamTarget::Conversation;
|
|
let _ = ui_tx.send(UiMessage::UserInput(text.to_string()));
|
|
}
|
|
}
|
|
types::Role::Assistant => {
|
|
if let Some(ref calls) = msg.tool_calls {
|
|
for call in calls {
|
|
let _ = ui_tx.send(UiMessage::ToolCall {
|
|
name: call.function.name.clone(),
|
|
args_summary: String::new(),
|
|
});
|
|
}
|
|
}
|
|
|
|
let text = msg.content_text();
|
|
if !text.is_empty() {
|
|
let _ = ui_tx
|
|
.send(UiMessage::TextDelta(format!("{}\n", text), target));
|
|
}
|
|
}
|
|
types::Role::Tool => {
|
|
let text = msg.content_text();
|
|
let preview: String =
|
|
text.lines().take(3).collect::<Vec<_>>().join("\n");
|
|
let truncated = if text.lines().count() > 3 {
|
|
format!("{}...", preview)
|
|
} else {
|
|
preview
|
|
};
|
|
let _ = ui_tx.send(UiMessage::ToolResult {
|
|
name: String::new(),
|
|
result: truncated,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|