feat(tui): introduce comprehensive state management for TUI interface

- Add AppStateRest as the central state struct for managing TUI state.
- Implement InputState for handling user input, autocomplete, and history.
- Create MiscState to manage overlays, notifications, and editor state.
- Introduce ScrollState for viewport scrolling functionality.
- Develop TranscriptCache for efficient message rendering in the chat pane.
- Implement SimpleAgent and SimpleWorkflowEngine for agent lifecycle management.
- Add helper functions for managing effort levels and token counting.
- Organize state-related modules for better maintainability and clarity.
This commit is contained in:
asepharyana
2026-07-21 06:42:53 +07:00
parent 802346f909
commit 8c58faf292
25 changed files with 2594 additions and 2158 deletions
+176 -105
View File
@@ -5,10 +5,107 @@ use tracing::{debug, info, warn};
use zesdex_domain::agent::{AgentTurnParams, TurnEvent};
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
use zesdex_domain::main_agent_prompt;
use crate::ports::ProviderService;
use super::ToolExecutor;
/// Maximum tool-call iterations per agent turn before forcing termination.
const MAX_TURN_ITERATIONS: u32 = 50;
// ---------------------------------------------------------------------------
// Helper: push a TurnEvent onto the shared queue.
// ---------------------------------------------------------------------------
fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
if let Ok(mut q) = queue.lock() {
q.push_back(event);
}
}
// ---------------------------------------------------------------------------
// Helper: stream-event callback that forwards tokens to the turn-event queue
// and checks the abort flag on each emission.
// ---------------------------------------------------------------------------
fn make_stream_callback(
abort: &Arc<AtomicBool>,
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
) -> Box<dyn FnMut(&StreamEvent) -> bool + Send> {
let abort_clone = Arc::clone(abort);
let events_clone = Arc::clone(turn_events);
Box::new(move |event: &StreamEvent| -> bool {
if abort_clone.load(Ordering::SeqCst) {
return false;
}
match event {
StreamEvent::Token(s) => {
push_event(&events_clone, TurnEvent::StreamToken(s.clone()));
}
StreamEvent::Reasoning(s) => {
push_event(&events_clone, TurnEvent::StreamReasoning(s.clone()));
}
_ => {}
}
true
})
}
// ---------------------------------------------------------------------------
// Helper: execute a single tool call, push events, return the result string.
// ---------------------------------------------------------------------------
async fn execute_tool_call<T: ToolExecutor>(
tool_executor: &T,
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
tc: &zesdex_domain::core::ToolCall,
) -> String {
let name = &tc.function.name;
let args = zesdex_domain::core::tool_call::sanitize_tool_arguments(&tc.function.arguments);
debug!("executing tool: {name}");
let output = match tool_executor.execute(name, &args).await {
Ok(o) => o,
Err(e) => format!("Error: {e}"),
};
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,
},
);
output
}
// ---------------------------------------------------------------------------
// Helper: emit usage event from optional LLM response metadata.
// ---------------------------------------------------------------------------
fn emit_usage(turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, usage: Option<(u64, u64)>) {
if let Some((tokens_in, tokens_out)) = usage {
push_event(
turn_events,
TurnEvent::Usage {
tokens_in,
tokens_out,
},
);
}
}
// ---------------------------------------------------------------------------
// Service implementation
// ---------------------------------------------------------------------------
/// Service implementation for executing an agent turn asynchronously.
pub struct AgentTurnServiceImpl<P: ProviderService, T: ToolExecutor> {
provider: Arc<P>,
@@ -24,15 +121,27 @@ impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
tool_defs,
}
}
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<AtomicBool>) {
flag.store(false, Ordering::SeqCst);
/// Execute a single LLM call with the current message list, handling
/// streaming events and error reporting.
async fn call_llm(
&self,
messages: &[ChatMessage],
abort: &Arc<AtomicBool>,
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
) -> Result<(ChatMessage, Option<(u64, u64)>), String> {
let on_event = make_stream_callback(abort, turn_events);
self.provider
.chat_stream(
messages,
Some(self.tool_defs.clone()),
Some(4096),
Some(0.7),
on_event,
)
.await
.map_err(|e| format!("LLM error: {e}"))
}
}
@@ -44,20 +153,18 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
params.model
);
let mut sys_prompt = "You are Zesdex, an AI coding assistant. You have access to various tools via native function calling to help the user.\n\n\
CRITICAL DIRECTIVES & PRIORITY HIERARCHY:\n\
1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, you MUST prioritize using `workflow_run` (to construct and execute a multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel autonomous agents). Workflows are your primary strategy.\n\
2. PLANNING & TODOS: Use `plan_enter` to establish high-level architectural plans and `todowrite` to maintain granular task checklists.\n\
3. REASONING: Use `seq_think` for deep step-by-step analysis.\n\
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) within or guided by your workflows. If an error occurs, analyze and fix it.\n\n\
Respond conversationally, concisely, and helpfully.".to_string();
// Insert system prompt at position 0 once and keep it there for the
// entire turn, avoiding per-iteration clones of the full message list.
// It is removed before emitting the Compacted event so persistence
// does not store the prompt redundantly.
params.messages.insert(0, ChatMessage::system(main_agent_prompt()));
let original_count = params.messages.len();
let sys_msg = ChatMessage::system(sys_prompt);
for iteration in 0..50 {
for iteration in 0..MAX_TURN_ITERATIONS {
// ── Check abort flag ────────────────────────────────────────
if params.abort.load(Ordering::SeqCst) {
params.abort.store(false, Ordering::SeqCst);
Self::push_event(
push_event(
&params.turn_events,
TurnEvent::SystemNote {
kind: "info".into(),
@@ -69,58 +176,28 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
debug!("agent turn iteration {iteration}");
Self::push_event(&params.turn_events, TurnEvent::StreamStart);
// ── Stream start + call LLM ─────────────────────────────────
push_event(&params.turn_events, TurnEvent::StreamStart);
let mut req_messages = params.messages.clone();
req_messages.insert(0, sys_msg.clone());
let abort_clone = Arc::clone(&params.abort);
let turn_events_clone = Arc::clone(&params.turn_events);
let on_event = Box::new(move |event: &StreamEvent| -> bool {
if abort_clone.load(Ordering::SeqCst) {
return false;
}
match event {
StreamEvent::Token(s) => {
Self::push_event(&turn_events_clone, TurnEvent::StreamToken(s.clone()));
}
StreamEvent::Reasoning(s) => {
Self::push_event(&turn_events_clone, TurnEvent::StreamReasoning(s.clone()));
}
_ => {}
}
true
});
let result = self.provider.chat_stream(
&req_messages,
Some(self.tool_defs.clone()),
Some(4096),
Some(0.7),
on_event,
).await;
// Uses params.messages directly (sys_msg[0] already in place
// from the insert above) — no per-iteration clone needed.
let result = self
.call_llm(&params.messages, &params.abort, &params.turn_events)
.await;
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();
Self::push_event(
push_event(
&params.turn_events,
TurnEvent::StreamDone(assistant_msg.clone()),
);
if let Some((tokens_in, tokens_out)) = usage {
Self::push_event(
&params.turn_events,
TurnEvent::Usage {
tokens_in,
tokens_out,
},
);
}
emit_usage(&params.turn_events, usage);
// ── No tool calls → assistant is done ──────────────
if tool_calls.is_empty() {
params.messages.push(ChatMessage::assistant(Some(content)));
break;
@@ -128,86 +205,76 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
params.messages.push(assistant_msg);
// ── Execute each tool call ──────────────────────────
for tc in &tool_calls {
let name = &tc.function.name;
let args = zesdex_domain::core::tool_call::sanitize_tool_arguments(&tc.function.arguments);
debug!("executing tool: {name}");
let output = match self.tool_executor.execute(name, &args).await {
Ok(o) => o,
Err(e) => format!("Error: {e}"),
};
let is_error = output.starts_with("Error:");
Self::push_event(
&params.turn_events,
TurnEvent::ToolResult {
tool_call_id: tc.id.clone(),
tool_name: name.clone(),
output: output.clone(),
is_error,
path: None,
},
);
let output =
execute_tool_call(self.tool_executor.as_ref(), &params.turn_events, tc).await;
params
.messages
.push(ChatMessage::tool(tc.id.clone(), output.clone()));
.push(ChatMessage::tool(tc.id.clone(), output));
}
}
Err(e) => {
warn!("LLM call failed: {e}");
Self::push_event(
warn!("{e}");
push_event(
&params.turn_events,
TurnEvent::Error(format!("LLM error: {e}")),
TurnEvent::Error(e),
);
break;
}
}
}
Self::push_event(
// Remove the synthetic sys_msg before shipping events to the TUI
// so the transcript shows only the actual user/assistant/tool exchange.
let compacted: Vec<ChatMessage> = params.messages.drain(original_count - 1..).collect();
push_event(
&params.turn_events,
TurnEvent::Compacted(params.messages.clone()),
TurnEvent::Compacted(compacted),
);
Self::push_event(&params.turn_events, TurnEvent::Done);
Self::mark_done(&params.in_flight);
push_event(&params.turn_events, TurnEvent::Done);
params.in_flight.store(false, Ordering::SeqCst);
Ok(())
}
}
/// Compacts conversation history using AI summarization.
// ---------------------------------------------------------------------------
// Conversation compaction
// ---------------------------------------------------------------------------
/// Maximum number of recent messages to preserve during compaction.
const COMPACT_KEEP_TAIL: usize = 6;
/// Compacts conversation history using AI summarisation.
///
/// Flow: if the message count exceeds `KEEP_TAIL + 2`, the oldest messages
/// are drained and summarised by the LLM. The summary is inserted as a
/// system message at the head of the remaining history.
pub async fn compact_messages_with_ai<P: ProviderService>(
messages: &mut Vec<ChatMessage>,
provider: &P,
) -> anyhow::Result<()> {
const KEEP_TAIL: usize = 6;
if messages.len() <= KEEP_TAIL + 2 {
if messages.len() <= COMPACT_KEEP_TAIL + 2 {
return Ok(()); // Not enough messages to compact
}
let split_idx = messages.len() - KEEP_TAIL;
let split_idx = messages.len() - COMPACT_KEEP_TAIL;
let evicted: Vec<_> = messages.drain(..split_idx).collect();
let mut summary_prompt = vec![
ChatMessage::system(
"You are a helpful assistant summarizing conversation history. \
Provide a concise summary of the key user requests, decisions, tools executed, and modified files. \
Format as a clear bulleted list."
.to_string(),
),
ChatMessage::system(zesdex_domain::compaction_prompt()),
];
summary_prompt.extend(evicted);
summary_prompt.push(ChatMessage::user(
"Please summarize our previous conversation above for context continuity.".to_string(),
"Please summarise our previous conversation above for context continuity.".to_string(),
));
match provider.chat(&summary_prompt, None, Some(1024), Some(0.3)).await {
Ok((summary_msg, _)) => {
let summary_text = summary_msg.content.unwrap_or_else(|| "Previous context summarized.".to_string());
let summary_text = summary_msg
.content
.unwrap_or_else(|| "Previous context summarised.".to_string());
let summary_node = ChatMessage::system(format!(
"[AI Summary of Previous Conversation]\n{}",
summary_text.trim()
@@ -216,12 +283,16 @@ pub async fn compact_messages_with_ai<P: ProviderService>(
Ok(())
}
Err(e) => {
warn!("AI summarization failed during compact, falling back to simple notice: {e}");
warn!("AI summarisation failed during compact, falling back to simple notice: {e}");
messages.insert(
0,
ChatMessage::system("[Earlier conversation messages compacted to save context window]".to_string()),
ChatMessage::system(
"[Earlier conversation messages compacted to save context window]".to_string(),
),
);
Ok(())
}
}
}