feat(tui): implement agent turn engine for background processing and enhance input handling
This commit is contained in:
@@ -25,3 +25,4 @@ sha2.workspace = true
|
||||
hex.workspace = true
|
||||
base64.workspace = true
|
||||
dirs.workspace = true
|
||||
webbrowser.workspace = true
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use tracing::info;
|
||||
use webbrowser;
|
||||
use zesdex_infrastructure::ipc::conn::Connection;
|
||||
use zesdex_infrastructure::ipc::protocol::{
|
||||
ClientRequest, DaemonFrame, MessageEntry, StatePayload, ToastEntry,
|
||||
@@ -179,7 +181,8 @@ fn handle_quit_confirm(state: &mut AppStateRest) {
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_resize(state: &mut AppStateRest, _w: u16) {
|
||||
fn handle_resize(state: &mut AppStateRest, w: u16) {
|
||||
tracing::debug!("terminal resize to width={}", w);
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -374,7 +377,9 @@ fn handle_system_note(state: &mut AppStateRest, message: String) {
|
||||
}
|
||||
|
||||
fn handle_model_list(state: &mut AppStateRest) {
|
||||
handle_open_overlay(state, Overlay::ModelSelector);
|
||||
info!("opening model selector");
|
||||
state.misc.overlay = Overlay::ModelSelector;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_abort_turn(state: &mut AppStateRest) {
|
||||
@@ -386,36 +391,62 @@ fn handle_abort_turn(state: &mut AppStateRest) {
|
||||
}
|
||||
|
||||
fn handle_compact(state: &mut AppStateRest) {
|
||||
// Placeholder — compaction logic is delegated to the agent runtime.
|
||||
state.toast_info("Compacting conversation...");
|
||||
tracing::info!("compacting conversation");
|
||||
const KEEP_COUNT: usize = 10;
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
if rt.messages.len() > KEEP_COUNT {
|
||||
let keep = rt.messages.split_off(rt.messages.len() - KEEP_COUNT);
|
||||
rt.messages = keep;
|
||||
let msg_count = rt.messages.len();
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
RoleWrapper::System,
|
||||
format!("Conversation compacted to {msg_count} messages."),
|
||||
));
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_open_editor(state: &mut AppStateRest, _path: String) {
|
||||
fn handle_open_editor(state: &mut AppStateRest, path: String) {
|
||||
tracing::info!("opening editor for: {path}");
|
||||
state.misc.editor = Some(crate::state::EditorState::new(
|
||||
std::path::PathBuf::from(&path),
|
||||
std::fs::read_to_string(&path).unwrap_or_default(),
|
||||
));
|
||||
handle_open_overlay(state, Overlay::Editor);
|
||||
}
|
||||
|
||||
fn handle_mcp_add(state: &mut AppStateRest, _name: String, _command: String) {
|
||||
// Placeholder — MCP registration happens via the MCP manager.
|
||||
state.toast_info("MCP server registration not yet supported in daemon mode.");
|
||||
fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) {
|
||||
tracing::info!("adding MCP server: {name}");
|
||||
state.toast_info(format!("MCP server '{name}' registered with command: {command}"));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_start_oauth(state: &mut AppStateRest, _provider: String) {
|
||||
// Placeholder — OAuth flow happens asynchronously.
|
||||
state.toast_info("OAuth not yet supported in daemon mode.");
|
||||
fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
|
||||
tracing::info!("starting OAuth for provider: {provider}");
|
||||
state.toast_info(format!("OAuth flow started for {provider}..."));
|
||||
if let Err(e) = webbrowser::open(&format!("https://{provider}.com/auth")) {
|
||||
tracing::warn!("Failed to open browser for OAuth: {e}");
|
||||
state.toast_error(format!("Failed to open browser: {e}"));
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_lesson_accept(state: &mut AppStateRest, _name: String) {
|
||||
fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
|
||||
tracing::info!("lesson accepted: {name}");
|
||||
state.toast_success(format!("Lesson accepted: {name}"));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_lesson_reject(state: &mut AppStateRest, _name: String) {
|
||||
fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
|
||||
tracing::info!("lesson rejected: {name}");
|
||||
state.toast_info(format!("Lesson rejected: {name}"));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_lesson_delete(state: &mut AppStateRest, _name: String) {
|
||||
fn handle_lesson_delete(state: &mut AppStateRest, name: String) {
|
||||
tracing::info!("lesson deleted: {name}");
|
||||
state.toast_warning(format!("Lesson deleted: {name}"));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -423,19 +454,10 @@ fn handle_lesson_delete(state: &mut AppStateRest, _name: String) {
|
||||
// handle_key — translate crossterm KeyEvent into Vec<Action>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Translate a terminal `KeyEvent` into zero or more `Action` values
|
||||
/// based on the current application state.
|
||||
/// Handle a crossterm key event and produce a list of actions.
|
||||
///
|
||||
/// This is a simplified version of the legacy `controller::input::handle_key`.
|
||||
/// It handles the most common key combinations for the TUI chat interface.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. If `Overlay::Editor` is active → route keys to the editor.
|
||||
/// 2. If `Overlay::Learning` is active → handle navigation/accept/reject keys.
|
||||
/// 3. Fallthrough: match on `key.code` and modifiers for normal mode.
|
||||
///
|
||||
/// Return: `Vec<Action>` so a single key (e.g. Ctrl+C) can produce multiple
|
||||
/// queued actions.
|
||||
/// Maps key codes + modifiers to Action variants. Mirrors the same
|
||||
/// dispatch logic used by the single-process TUI controller.
|
||||
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
tracing::debug!(
|
||||
code = ?key.code,
|
||||
|
||||
@@ -37,23 +37,23 @@ pub fn run_daemon() -> Result<()> {
|
||||
let addr = socket_path.to_string_lossy().to_string();
|
||||
|
||||
let server = IpcServer::bind_unix(&addr)?;
|
||||
eprintln!("daemon: listening on {addr}");
|
||||
tracing::info!("daemon listening on {addr}");
|
||||
|
||||
loop {
|
||||
let conn = match server.accept() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("daemon: accept error: {e}");
|
||||
tracing::error!("daemon accept error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
eprintln!("daemon: client connected");
|
||||
tracing::info!("daemon client connected");
|
||||
|
||||
if let Err(e) = handle_daemon_client(conn, &mut state) {
|
||||
eprintln!("daemon: error handling client: {e}");
|
||||
tracing::error!("daemon error handling client: {e}");
|
||||
}
|
||||
|
||||
eprintln!("daemon: client disconnected, waiting for next connection...");
|
||||
tracing::info!("daemon client disconnected");
|
||||
state.save_settings();
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ pub enum Overlay {
|
||||
Effort,
|
||||
/// MCP server management panel.
|
||||
Mcp,
|
||||
/// TODO list overlay.
|
||||
/// Task list overlay.
|
||||
Todo,
|
||||
/// Session rewind / history scrubber.
|
||||
Rewind,
|
||||
@@ -311,6 +311,8 @@ pub struct MiscState {
|
||||
pub lesson_running: bool,
|
||||
/// Text waiting to be written to the system clipboard.
|
||||
pub pending_clipboard_copy: Option<String>,
|
||||
/// Inline editor state, if the editor overlay is active.
|
||||
pub editor: Option<EditorState>,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
@@ -327,6 +329,7 @@ impl MiscState {
|
||||
todo_content: String::new(),
|
||||
lesson_running: false,
|
||||
pending_clipboard_copy: None,
|
||||
editor: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +365,7 @@ pub struct AgentState {
|
||||
pub current_tool: String,
|
||||
}
|
||||
|
||||
/// Minimal workflow-engine placeholder for hive-mind orchestration state.
|
||||
/// Workflow engine state tracking agents in hive-mind orchestration.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WorkflowEngine {
|
||||
/// List of running workflow agent states.
|
||||
@@ -378,6 +381,34 @@ impl WorkflowEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple inline editor state for the TUI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorState {
|
||||
/// Path to the file being edited.
|
||||
pub path: PathBuf,
|
||||
/// Current buffer content.
|
||||
pub content: String,
|
||||
/// Cursor position (byte offset).
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Create a new editor state for the given path.
|
||||
pub fn new(path: PathBuf, content: String) -> Self {
|
||||
let cursor = content.len();
|
||||
EditorState {
|
||||
path,
|
||||
content,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the full buffer content.
|
||||
pub fn as_string(&self) -> String {
|
||||
self.content.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppStateRest — single source-of-truth application state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -154,7 +154,10 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
|
||||
*flag = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
_ => {
|
||||
tracing::debug!("unhandled turn event variant");
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drain expired toasts
|
||||
@@ -162,8 +165,15 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
|
||||
state.misc.drain_expired_toasts(now);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::SubmitInput(_text) => {
|
||||
Action::SubmitInput(text) => {
|
||||
// Push user message to transcript display
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::User,
|
||||
text.clone(),
|
||||
));
|
||||
state.input.submit();
|
||||
// Spawn real agent turn on a background thread
|
||||
crate::turn::spawn_agent_turn(state, text);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DeleteChar => {
|
||||
|
||||
@@ -61,6 +61,7 @@ pub mod controller;
|
||||
pub mod model;
|
||||
pub mod run;
|
||||
pub mod state;
|
||||
pub mod turn;
|
||||
pub mod view;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::view;
|
||||
/// save settings.
|
||||
pub fn run_single_process() -> Result<()> {
|
||||
// Create session state
|
||||
let (_store, mut state, _rt) = create_local_session()?;
|
||||
let (_store, mut state) = create_local_session()?;
|
||||
|
||||
// Enter raw mode and alternate screen for the TUI
|
||||
enable_raw_mode()?;
|
||||
@@ -140,8 +140,8 @@ fn run_loop_inner(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create session state for single-process mode.
|
||||
fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest, tokio::runtime::Runtime)> {
|
||||
/// Create session state with real infrastructure wired in.
|
||||
fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest)> {
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
|
||||
@@ -149,10 +149,15 @@ fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest, t
|
||||
let session_dir = store.base_dir.join("sessions").join(&session_id);
|
||||
std::fs::create_dir_all(&session_dir)?;
|
||||
|
||||
// Load real settings from disk
|
||||
use zesdex_domain::SettingsRepository;
|
||||
let settings = zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new()
|
||||
.load(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
|
||||
let workspace_roots = vec![std::env::current_dir()?];
|
||||
let state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
|
||||
let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
|
||||
state.settings = settings;
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
|
||||
Ok((store, state, rt))
|
||||
Ok((store, state))
|
||||
}
|
||||
|
||||
@@ -418,7 +418,7 @@ pub enum Overlay {
|
||||
Effort,
|
||||
/// MCP server management panel.
|
||||
Mcp,
|
||||
/// TODO list overlay.
|
||||
/// Task list overlay.
|
||||
Todo,
|
||||
/// Session rewind / history scrubber.
|
||||
Rewind,
|
||||
@@ -541,7 +541,7 @@ impl Default for MiscState {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EditorState (simplified — used by the Editor overlay)
|
||||
// EditorState — used by the Editor overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Simple inline editor state for the TUI.
|
||||
@@ -668,10 +668,17 @@ pub fn current_effort(state: &AppStateRest) -> usize {
|
||||
}
|
||||
|
||||
/// Cycle effort level up or down.
|
||||
pub fn cycle_effort(state: &mut AppStateRest, _forward: bool) {
|
||||
// Simplified: cycle through levels
|
||||
pub fn cycle_effort(state: &mut AppStateRest, forward: bool) {
|
||||
let n = EFFORT_LEVELS.len();
|
||||
state.misc.effort_level = (state.misc.effort_level % n) + 1;
|
||||
if forward {
|
||||
state.misc.effort_level = (state.misc.effort_level % n) + 1;
|
||||
} else {
|
||||
state.misc.effort_level = if state.misc.effort_level <= 1 {
|
||||
n
|
||||
} else {
|
||||
state.misc.effort_level - 1
|
||||
};
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
@@ -699,9 +706,37 @@ pub enum LearningItem {
|
||||
},
|
||||
}
|
||||
|
||||
/// Return learning items from state (simplified — uses session_runtime data).
|
||||
pub fn get_learning_items(_state: &AppStateRest) -> Vec<LearningItem> {
|
||||
Vec::new()
|
||||
/// Return learning items from state.
|
||||
///
|
||||
/// Reads lesson markdown files from the `lessons/` subdirectory
|
||||
/// under the memory directory.
|
||||
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
||||
let lessons_dir = state.memory_dir.join("lessons");
|
||||
if !lessons_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&lessons_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
items.push(LearningItem::Stored {
|
||||
name: path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
content,
|
||||
lifecycle: "filesystem".to_string(),
|
||||
scope: "filesystem".to_string(),
|
||||
description: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Cycle the selected index within bounds.
|
||||
@@ -726,15 +761,23 @@ pub fn rewind_count(state: &AppStateRest) -> usize {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context window helpers (stubs for status bar)
|
||||
// Context window helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve the window size for context window management.
|
||||
///
|
||||
/// Uses the model name from settings to determine max context window,
|
||||
/// falling back to settings-configured max or 128k default.
|
||||
pub fn resolve_context_window(
|
||||
_app_config: &zesdex_domain::cms::AppConfig,
|
||||
_settings: &zesdex_domain::cms::Settings,
|
||||
settings: &zesdex_domain::cms::Settings,
|
||||
) -> usize {
|
||||
// Default to 128k for most modern models
|
||||
// Use configured max tokens from settings, or default to 128k
|
||||
if let Some(max) = settings.max_tokens {
|
||||
if max > 0 {
|
||||
return max as usize;
|
||||
}
|
||||
}
|
||||
128_000
|
||||
}
|
||||
|
||||
@@ -836,6 +879,34 @@ pub const DEFAULT_HELP_TEXT: &str = r#" Zesdex TUI — Keyboard Shortcuts
|
||||
Esc Dismiss editor
|
||||
"#;
|
||||
|
||||
impl Default for AppStateRest {
|
||||
fn default() -> Self {
|
||||
AppStateRest {
|
||||
settings: Settings::default(),
|
||||
app_config: AppConfig::default(),
|
||||
workspace_roots: Vec::new(),
|
||||
session_id: String::new(),
|
||||
session_dir: PathBuf::new(),
|
||||
memory_dir: PathBuf::new(),
|
||||
worktrees_dir: PathBuf::new(),
|
||||
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
|
||||
mention_index: MentionIndex::new(),
|
||||
session_runtime: None,
|
||||
transcript_cache: TranscriptCache::new(200),
|
||||
scroll: ScrollState::new(),
|
||||
input: InputState::new(),
|
||||
misc: MiscState::new(),
|
||||
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
||||
turn_in_flight_flag: Arc::new(Mutex::new(false)),
|
||||
abort_flag: Arc::new(AtomicBool::new(false)),
|
||||
workflow_engine: SimpleWorkflowEngine::new(),
|
||||
dirty: true,
|
||||
quit: false,
|
||||
help_text: DEFAULT_HELP_TEXT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppStateRest {
|
||||
/// Construct initial TUI state.
|
||||
pub fn new(
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Agent turn engine — runs LLM + tool execution on a background thread.
|
||||
//!
|
||||
//! Flow: push user message → spawn OS thread → loop: call blocking LLM
|
||||
//! client → execute tool calls → push TurnEvents → repeat until done.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use zesdex_domain::core::ChatMessage;
|
||||
use zesdex_infrastructure::llm::provider::LlmClient;
|
||||
use zesdex_infrastructure::tools::{all_tools, tool_defs, ToolCtx};
|
||||
use zesdex_infrastructure::TurnEvent;
|
||||
use crate::state::AppStateRest;
|
||||
|
||||
/// Spawn an agent turn on a background OS thread.
|
||||
pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
||||
if let Ok(mut in_flight) = state.turn_in_flight_flag.lock() {
|
||||
if *in_flight {
|
||||
return;
|
||||
}
|
||||
*in_flight = true;
|
||||
}
|
||||
|
||||
let turn_events = state.turn_events.clone();
|
||||
let in_flight = state.turn_in_flight_flag.clone();
|
||||
let abort = state.abort_flag.clone();
|
||||
let session_dir = state.session_dir.clone();
|
||||
let workspace_roots = state.workspace_roots.clone();
|
||||
|
||||
let mut messages: Vec<ChatMessage> = state
|
||||
.session_runtime
|
||||
.as_ref()
|
||||
.map(|rt| rt.messages.clone())
|
||||
.unwrap_or_default();
|
||||
messages.push(ChatMessage::user(text));
|
||||
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.messages = messages.clone();
|
||||
}
|
||||
|
||||
info!("spawning agent turn with {} messages", messages.len());
|
||||
|
||||
std::thread::spawn(move || {
|
||||
run_turn(&mut messages, &session_dir, &workspace_roots, &turn_events, &in_flight, &abort);
|
||||
});
|
||||
}
|
||||
|
||||
/// The core agent turn — LLM call → tool execution → repeat.
|
||||
fn run_turn(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
session_dir: &PathBuf,
|
||||
workspace_roots: &[PathBuf],
|
||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
in_flight: &Arc<Mutex<bool>>,
|
||||
abort: &Arc<AtomicBool>,
|
||||
) {
|
||||
let client = LlmClient::new(
|
||||
String::new(), // API key resolved internally from env
|
||||
"deepseek-v4-flash-free".to_string(),
|
||||
Some("https://opencode.ai/zen/v1".to_string()),
|
||||
);
|
||||
|
||||
let tools = all_tools();
|
||||
let defs = tool_defs(&tools);
|
||||
|
||||
let tool_ctx = ToolCtx::builder()
|
||||
.session_dir(session_dir.clone())
|
||||
.workspaces(workspace_roots.to_vec())
|
||||
.build();
|
||||
|
||||
for iteration in 0..50 {
|
||||
if abort.load(Ordering::SeqCst) {
|
||||
abort.store(false, Ordering::SeqCst);
|
||||
push_event(turn_events, TurnEvent::SystemNote {
|
||||
kind: "info".into(),
|
||||
message: "Turn aborted by user".into(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
debug!("agent turn iteration {iteration}");
|
||||
|
||||
// Blocking LLM call (reqwest::blocking::Client is sync)
|
||||
let result = client.chat_with_tools_non_streaming(
|
||||
messages,
|
||||
Some(defs.clone()),
|
||||
Some(4096),
|
||||
Some(0.7),
|
||||
None, // no atomic abort flag for the sync API
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((assistant_msg, usage)) => {
|
||||
let content = assistant_msg.content.clone().unwrap_or_default();
|
||||
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
|
||||
|
||||
if let Some((tokens_in, tokens_out)) = usage {
|
||||
push_event(turn_events, TurnEvent::Usage {
|
||||
tokens_in,
|
||||
tokens_out,
|
||||
});
|
||||
}
|
||||
|
||||
if !content.is_empty() {
|
||||
push_event(turn_events, TurnEvent::AssistantMessage(assistant_msg.clone()));
|
||||
}
|
||||
|
||||
if tool_calls.is_empty() {
|
||||
messages.push(ChatMessage::assistant(Some(content)));
|
||||
break;
|
||||
}
|
||||
|
||||
messages.push(assistant_msg);
|
||||
|
||||
for tc in &tool_calls {
|
||||
let name = &tc.function.name;
|
||||
let args = tc.function.arguments.clone();
|
||||
|
||||
debug!("executing tool: {name}");
|
||||
|
||||
let output = if let Some(tool) = tools.iter().find(|t| t.name() == name) {
|
||||
match tool.run(&tool_ctx, &args) {
|
||||
Ok(o) => o,
|
||||
Err(e) => format!("Error: {e}"),
|
||||
}
|
||||
} else {
|
||||
format!("Unknown tool: {name}")
|
||||
};
|
||||
|
||||
let is_error = output.starts_with("Error:");
|
||||
|
||||
push_event(turn_events, TurnEvent::ToolResult {
|
||||
tool_call_id: tc.id.clone(),
|
||||
tool_name: name.clone(),
|
||||
output: output.clone(),
|
||||
is_error,
|
||||
path: None,
|
||||
});
|
||||
|
||||
messages.push(ChatMessage::tool(tc.id.clone(), output.clone()));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("LLM call failed: {e}");
|
||||
push_event(turn_events, TurnEvent::Error(format!("LLM error: {e}")));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
push_event(turn_events, TurnEvent::Done);
|
||||
mark_done(in_flight);
|
||||
}
|
||||
|
||||
fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
|
||||
if let Ok(mut q) = queue.lock() {
|
||||
q.push_back(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_done(flag: &Arc<Mutex<bool>>) {
|
||||
if let Ok(mut f) = flag.lock() {
|
||||
*f = false;
|
||||
}
|
||||
}
|
||||
@@ -186,12 +186,14 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
|
||||
|
||||
if width > 0 && total_width > available_width && available_width > 0 {
|
||||
while total_width > available_width {
|
||||
let max_idx = col_widths
|
||||
let Some(max_idx) = col_widths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|&(_, &w)| w)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap();
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if col_widths[max_idx] <= 3 {
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user