feat(tui): implement agent turn engine for background processing and enhance input handling

This commit is contained in:
asepharyana
2026-07-20 10:55:09 +07:00
parent da2ed6da25
commit 792695b65a
31 changed files with 603 additions and 153 deletions
+12 -2
View File
@@ -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 => {
+1
View File
@@ -61,6 +61,7 @@ pub mod controller;
pub mod model;
pub mod run;
pub mod state;
pub mod turn;
pub mod view;
// ---------------------------------------------------------------------------
+12 -7
View File
@@ -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))
}
+82 -11
View File
@@ -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(
+169
View File
@@ -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;
}
}
+4 -2
View File
@@ -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;
}