feat: centralize default constants and refactor overlay enter handling in TUI

This commit is contained in:
asepharyana
2026-07-21 07:00:15 +07:00
parent 8c58faf292
commit d615090dcd
34 changed files with 216 additions and 359 deletions
+34
View File
@@ -0,0 +1,34 @@
//! Shared default constants used across the application.
//!
//! Centralising these values eliminates the hardcoded-string duplication
//! that existed when every call site provided its own inline fallback.
//! Consumers should reference these constants rather than repeating
//! the string literals.
/// Default LLM provider API base URL.
pub const DEFAULT_API_BASE: &str = "https://opencode.ai/zen/v1";
/// Default LLM model identifier.
pub const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
/// Fallback JWT secret used only when `JWT_SECRET` env var is unset.
/// In production this MUST be configured via environment variable.
pub const FALLBACK_JWT_SECRET: &str = "dev-secret";
/// Default context window size (128k tokens).
pub const DEFAULT_CONTEXT_WINDOW: usize = 256_000;
/// Maximum tool-call iterations per agent turn.
pub const MAX_TOOL_ITERATIONS: u32 = 50;
/// Maximum subagent tool-call iterations.
pub const MAX_SUBAGENT_ITERATIONS: u32 = 25;
/// Default LLM request max tokens.
pub const DEFAULT_MAX_TOKENS: u32 = 4096;
/// Default temperature for the main agent.
pub const DEFAULT_TEMPERATURE: f64 = 0.7;
/// Default temperature for compaction / summary calls.
pub const DEFAULT_COMPACT_TEMPERATURE: f64 = 0.3;
+1
View File
@@ -6,6 +6,7 @@ use std::path::PathBuf;
use crate::core::{ChatMessage, ToolCallResult, UsageStats}; use crate::core::{ChatMessage, ToolCallResult, UsageStats};
pub mod defaults;
pub mod prompt; pub mod prompt;
pub mod progress; pub mod progress;
+1
View File
@@ -57,6 +57,7 @@ pub use error::DomainError;
// Agent module top-level items (TurnEvent, SessionRuntime, etc.) // Agent module top-level items (TurnEvent, SessionRuntime, etc.)
pub use agent::*; pub use agent::*;
// Sub-module items need explicit re-exports // Sub-module items need explicit re-exports
pub use agent::defaults::*;
pub use agent::progress::AgentProgress; pub use agent::progress::AgentProgress;
pub use agent::prompt::{compaction_prompt, main_agent_prompt, subagent_directive}; pub use agent::prompt::{compaction_prompt, main_agent_prompt, subagent_directive};
pub use workflow::*; pub use workflow::*;
-26
View File
@@ -1,31 +1,5 @@
//! Subagent domain models. //! Subagent domain models.
/// Events emitted by a running subagent.
#[derive(Debug, Clone)]
pub enum SubagentEvent {
Started {
agent_id: String,
directive: String,
},
ToolCall {
agent_id: String,
tool_name: String,
},
ToolResult {
agent_id: String,
tool_name: String,
output: String,
},
Completed {
agent_id: String,
output: String,
},
Failed {
agent_id: String,
error: String,
},
}
/// Access tier for subagent tool permissions. /// Access tier for subagent tool permissions.
/// ///
/// Tiers are cumulative: `Write` includes everything in `Read`, and `Full` /// Tiers are cumulative: `Write` includes everything in `Read`, and `Full`
+3 -3
View File
@@ -144,15 +144,15 @@ fn run_api_server(port: u16) -> anyhow::Result<()> {
"JWT_SECRET environment variable not set; using insecure default. \ "JWT_SECRET environment variable not set; using insecure default. \
Set JWT_SECRET to a secure random value in production." Set JWT_SECRET to a secure random value in production."
); );
"dev-secret".to_string() zesdex_domain::agent::defaults::FALLBACK_JWT_SECRET.to_string()
}); });
let state = zesdex_api::ApiState::new( let state = zesdex_api::ApiState::new(
store.base_dir.clone(), store.base_dir.clone(),
jwt_secret, jwt_secret,
"", "",
"deepseek-v4-flash-free", zesdex_domain::agent::defaults::DEFAULT_MODEL,
Some("https://opencode.ai/zen/v1".to_string()), Some(zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()),
); );
let app = zesdex_api::build_router(state); let app = zesdex_api::build_router(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port)); let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
-1
View File
@@ -35,7 +35,6 @@ pub mod lsp;
pub mod mcp; pub mod mcp;
pub mod middleware; pub mod middleware;
pub mod persistence; pub mod persistence;
pub mod review;
pub mod subagent; pub mod subagent;
pub mod tools; pub mod tools;
pub mod utils; pub mod utils;
+2 -2
View File
@@ -11,8 +11,8 @@ use zesdex_domain::core::{
}; };
use zesdex_application::ports::ProviderService; use zesdex_application::ports::ProviderService;
const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1"; use zesdex_domain::agent::defaults::{DEFAULT_API_BASE, DEFAULT_MODEL};
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free"; const DEFAULT_BASE_URL: &str = DEFAULT_API_BASE;
pub const DEFAULT_API_KEY: &str = ""; pub const DEFAULT_API_KEY: &str = "";
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(600); const REQUEST_TIMEOUT: Duration = Duration::from_secs(600);
-8
View File
@@ -1,8 +0,0 @@
//! Post-edit auto-review subagent — validates file edits and suggests
//! improvements.
pub mod pending;
pub mod probe;
pub mod prompt;
pub mod staleness;
pub mod types;
-43
View File
@@ -1,43 +0,0 @@
//! Pending review queue — tracks files modified by tools that have not
//! yet been reviewed.
use std::collections::VecDeque;
/// A file mutation awaiting review.
#[derive(Debug, Clone)]
pub struct PendingReview {
pub path: String,
pub tool: String,
pub reason: String,
pub content_sha256: String,
}
/// Queue of files modified but not yet reviewed.
#[derive(Debug, Clone, Default)]
pub struct PendingReviewQueue {
entries: VecDeque<PendingReview>,
}
impl PendingReviewQueue {
pub fn new() -> Self {
PendingReviewQueue {
entries: VecDeque::new(),
}
}
pub fn push(&mut self, entry: PendingReview) {
self.entries.push_back(entry);
}
pub fn pop(&mut self) -> Option<PendingReview> {
self.entries.pop_front()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
}
-18
View File
@@ -1,18 +0,0 @@
//! Review probe — diff analysis and file inspection for review purposes.
use similar::{ChangeTag, TextDiff};
/// Compute a simple unified diff between old and new text.
pub fn compute_diff(old: &str, new: &str) -> String {
let diff = TextDiff::from_lines(old, new);
let mut result = String::new();
for change in diff.iter_all_changes() {
let sign = match change.tag() {
ChangeTag::Delete => "-",
ChangeTag::Insert => "+",
ChangeTag::Equal => " ",
};
result.push_str(&format!("{}{}", sign, change.value()));
}
result
}
-25
View File
@@ -1,25 +0,0 @@
//! Review prompt construction — builds the system prompt for the
//! auto-review subagent.
/// Build the review system prompt for the given diff and context.
pub fn build_review_prompt(diff: &str, file_path: &str) -> String {
format!(
"You are a code reviewer. Review the following diff for file '{}':\n\
\n\
Focus on:\n\
1. Correctness does the change introduce bugs?\n\
2. Security does the change introduce vulnerabilities?\n\
3. Style does the change follow best practices?\n\
4. Edge cases are there unhandled edge cases?\n\
\n\
Diff:\n\
```diff\n\
{}\n\
```\n\
\n\
Provide your review as a JSON array of findings with \
'severity' (Info/Warning/Error), 'file', 'line' (optional), \
'message', and 'suggestion' (optional).",
file_path, diff
)
}
@@ -1,15 +0,0 @@
//! Staleness detection for lesson cache entries.
use std::time::{SystemTime, UNIX_EPOCH};
/// How long (in seconds) before a lesson is considered stale.
const STALE_THRESHOLD_SECS: u64 = 86400 * 7; // 7 days
/// Check whether a lesson timestamp is stale.
pub fn is_stale(updated_at: i64) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
now.saturating_sub(updated_at) > STALE_THRESHOLD_SECS as i64
}
-29
View File
@@ -1,29 +0,0 @@
//! Review types — findings, severity, and configuration.
use serde::{Deserialize, Serialize};
/// Severity of a review finding.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReviewSeverity {
Info,
Warning,
Error,
}
/// A single review finding from the auto-review subagent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewFinding {
pub severity: ReviewSeverity,
pub file: String,
pub line: Option<usize>,
pub message: String,
pub suggestion: Option<String>,
}
/// Configuration for the auto-review subagent.
#[derive(Debug, Clone)]
pub struct ReviewConfig {
pub max_lessons_per_run: usize,
pub adaptive_max_skip: u32,
pub enabled: bool,
}
@@ -109,7 +109,7 @@ pub fn spawn_background_review(
// 3. Resolve LLM credentials // 3. Resolve LLM credentials
let base_url = api_base.unwrap_or_else(|| { let base_url = api_base.unwrap_or_else(|| {
std::env::var("OPENAI_API_BASE") std::env::var("OPENAI_API_BASE")
.unwrap_or_else(|_| "https://opencode.ai/zen/v1".to_string()) .unwrap_or_else(|_| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string())
}); });
let client = LlmClient::new(api_key, model, Some(base_url)); let client = LlmClient::new(api_key, model, Some(base_url));
@@ -1,3 +0,0 @@
//! Subagent event types — events emitted during subagent execution.
pub use zesdex_domain::subagent::SubagentEvent;
@@ -1,23 +0,0 @@
//! Subagent gating — decide whether to run review/test/arch agents based
//! on the current context.
use tracing::instrument;
/// Determine whether an auto-review should be triggered after an edit.
///
/// Gating logic:
/// - Returns `false` if there are no edits (`edit_count == 0`).
/// - Returns `false` if `consecutive_empty_reviews >= max_skip` (too many
/// consecutive reviews produced no findings, so skip further reviews).
/// - Otherwise returns `true`.
#[instrument]
pub fn should_review(edit_count: u32, consecutive_empty_reviews: u32, max_skip: u32) -> bool {
if edit_count == 0 {
return false;
}
// Skip review if we've had several consecutive empty reviews
if consecutive_empty_reviews >= max_skip {
return false;
}
true
}
-4
View File
@@ -5,9 +5,5 @@ pub mod auto;
pub mod context; pub mod context;
pub mod division; pub mod division;
pub mod engine; pub mod engine;
pub mod event;
pub mod gating;
pub mod provider; pub mod provider;
pub mod spawn; pub mod spawn;
pub mod tools;
pub mod workspace;
+2 -2
View File
@@ -67,7 +67,7 @@ impl SubagentProvider {
/// ///
/// Flow: reads `settings.provider` and `settings.model` → if model is empty, /// Flow: reads `settings.provider` and `settings.model` → if model is empty,
/// falls back to the provider config's `default_model` → if that is also /// falls back to the provider config's `default_model` → if that is also
/// empty, uses `"deepseek-v4-flash-free"` as the ultimate default. /// empty, uses the domain default model constant.
#[instrument] #[instrument]
pub fn resolve_subagent_provider( pub fn resolve_subagent_provider(
settings: &zesdex_domain::cms::Settings, settings: &zesdex_domain::cms::Settings,
@@ -82,7 +82,7 @@ pub fn resolve_subagent_provider(
.providers .providers
.get(&provider) .get(&provider)
.and_then(|p| p.default_model.clone()) .and_then(|p| p.default_model.clone())
.unwrap_or_else(|| "deepseek-v4-flash-free".to_string()) .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string())
} else { } else {
model model
}; };
-19
View File
@@ -1,19 +0,0 @@
//! Subagent tool helpers — wrap tool execution for subagent use.
use anyhow::Result;
use tracing::instrument;
use crate::tools::{Tool, ToolCtx};
/// Execute a single tool call within a subagent context.
///
/// Delegates directly to the tool's `run` method with the given context and
/// JSON arguments.
#[instrument(skip(tool, ctx, args))]
pub fn execute_tool_call(
tool: &dyn Tool,
ctx: &ToolCtx,
args: &serde_json::Value,
) -> Result<String> {
tool.run(ctx, args)
}
@@ -1,16 +0,0 @@
//! Subagent workspace management — create isolated workspaces for subagents.
use std::path::{Path, PathBuf};
use tracing::instrument;
/// Create an isolated workspace directory for a subagent.
///
/// Creates `{base_dir}/subagent-workspaces/{agent_id}` and all parent
/// directories if they do not already exist.
#[instrument]
pub fn create_subagent_workspace(base_dir: &Path, agent_id: &str) -> anyhow::Result<PathBuf> {
let ws = base_dir.join("subagent-workspaces").join(agent_id);
std::fs::create_dir_all(&ws)?;
Ok(ws)
}
@@ -100,7 +100,7 @@ impl Tool for ParallelDelegate {
.providers .providers
.get(&provider) .get(&provider)
.map(|p| p.api_base.clone()) .map(|p| p.api_base.clone())
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string()); .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string());
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
+4 -2
View File
@@ -45,9 +45,10 @@ impl Tool for PlanEnter {
let plan_path = ctx.session_dir.join("PLAN.md"); let plan_path = ctx.session_dir.join("PLAN.md");
let _ = std::fs::write(&plan_path, &plan_text); let _ = std::fs::write(&plan_path, &plan_text);
if let Some(events) = &ctx.turn_events { if let Some(events) = &ctx.turn_events {
let mut q = events.lock().unwrap(); if let Ok(mut q) = events.lock() {
q.push_back(crate::TurnEvent::PlanUpdate(plan_text.clone())); q.push_back(crate::TurnEvent::PlanUpdate(plan_text.clone()));
} }
}
Ok(format!( Ok(format!(
"Plan entered (length: {} chars). Waiting for approval...", "Plan entered (length: {} chars). Waiting for approval...",
@@ -103,9 +104,10 @@ impl Tool for PlanReady {
let plan_path = ctx.session_dir.join("PLAN.md"); let plan_path = ctx.session_dir.join("PLAN.md");
let _ = std::fs::write(&plan_path, &plan_content); let _ = std::fs::write(&plan_path, &plan_content);
if let Some(events) = &ctx.turn_events { if let Some(events) = &ctx.turn_events {
let mut q = events.lock().unwrap(); if let Ok(mut q) = events.lock() {
q.push_back(crate::TurnEvent::PlanUpdate(plan_content.clone())); q.push_back(crate::TurnEvent::PlanUpdate(plan_content.clone()));
} }
}
Ok(format!("Plan saved to {filename}. Starting execution.")) Ok(format!("Plan saved to {filename}. Starting execution."))
} else { } else {
+2 -2
View File
@@ -79,7 +79,7 @@ impl Tool for SpawnAgents {
.providers .providers
.get(&provider) .get(&provider)
.map(|p| p.api_base.clone()) .map(|p| p.api_base.clone())
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string()); .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string());
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
@@ -196,7 +196,7 @@ impl Tool for SpawnPipeline {
.providers .providers
.get(&provider) .get(&provider)
.map(|p| p.api_base.clone()) .map(|p| p.api_base.clone())
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string()); .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string());
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
@@ -68,9 +68,10 @@ impl Tool for Todofinish {
content = new_lines.join("\n") + "\n"; content = new_lines.join("\n") + "\n";
let _ = std::fs::write(&todo_path, &content); let _ = std::fs::write(&todo_path, &content);
if let Some(events) = &ctx.turn_events { if let Some(events) = &ctx.turn_events {
let mut q = events.lock().unwrap(); if let Ok(mut q) = events.lock() {
q.push_back(crate::TurnEvent::TodoUpdate(content)); q.push_back(crate::TurnEvent::TodoUpdate(content));
} }
}
info!(item, "TODO item completed and written back"); info!(item, "TODO item completed and written back");
} else { } else {
info!(item, "TODO item not found in TODO.md — nothing to mark"); info!(item, "TODO item not found in TODO.md — nothing to mark");
@@ -62,9 +62,10 @@ impl Tool for Todowrite {
let _ = std::fs::write(&todo_path, &content); let _ = std::fs::write(&todo_path, &content);
if let Some(events) = &ctx.turn_events { if let Some(events) = &ctx.turn_events {
let mut q = events.lock().unwrap(); if let Ok(mut q) = events.lock() {
q.push_back(crate::TurnEvent::TodoUpdate(content)); q.push_back(crate::TurnEvent::TodoUpdate(content));
} }
}
info!(item, priority, "TODO item added"); info!(item, priority, "TODO item added");
Ok(format!("[{}] TODO added: {}", priority, item)) Ok(format!("[{}] TODO added: {}", priority, item))
+1 -1
View File
@@ -62,7 +62,7 @@ impl Tool for WorkflowRun {
let llm_client = LlmClient::new( let llm_client = LlmClient::new(
crate::llm::provider::DEFAULT_API_KEY.to_string(), crate::llm::provider::DEFAULT_API_KEY.to_string(),
"deepseek-v4-flash-free".to_string(), zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string(),
None, None,
); );
let rt = tokio::runtime::Runtime::new()?; let rt = tokio::runtime::Runtime::new()?;
@@ -41,7 +41,7 @@ pub async fn execute_primitive(directive: &str, tool_ctx: &ToolCtx) -> Result<St
.providers .providers
.get(&provider) .get(&provider)
.map(|p| p.api_base.clone()) .map(|p| p.api_base.clone())
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string()); .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string());
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
@@ -50,7 +50,7 @@ pub async fn execute_cycle(
.providers .providers
.get(&provider) .get(&provider)
.map(|p| p.api_base.clone()) .map(|p| p.api_base.clone())
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string()); .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string());
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
+2 -1
View File
@@ -29,7 +29,8 @@ use crate::state::create_session;
/// save settings, and release the lock. /// save settings, and release the lock.
pub fn run_daemon() -> Result<()> { pub fn run_daemon() -> Result<()> {
tracing::info!("starting daemon process"); tracing::info!("starting daemon process");
let (store, _session_lock_guard, mut state, _rt) = create_session()?; let (store, _session_lock_guard, mut state, rt) = create_session()?;
let _guard = rt.enter();
let run_dir = store.base_dir.join("run"); let run_dir = store.base_dir.join("run");
std::fs::create_dir_all(&run_dir)?; std::fs::create_dir_all(&run_dir)?;
+1 -106
View File
@@ -15,6 +15,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::action::Action; use crate::action::Action;
use crate::controller::command::{apply_command, parse_command}; use crate::controller::command::{apply_command, parse_command};
use crate::controller::overlay_enter::handle_overlay_enter;
use crate::state::{AutocompleteKind, Overlay, AppStateRest}; use crate::state::{AutocompleteKind, Overlay, AppStateRest};
/// Mark state dirty and return an empty action list. /// Mark state dirty and return an empty action list.
@@ -329,112 +330,6 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
} }
} }
/// Handle pressing Enter while a modal overlay is active.
///
/// Each overlay variant has its own Enter semantics:
/// - `QuitConfirm` → set `quit = true`
/// - `KeyInput` → save API key from buffer
/// - `ModelSelector` → switch provider/model from selected index
/// - `ClearConfirm` → clear transcript cache
/// - `Rewind` → rewind to selected message index
/// - `Bash` / `Settings` / `Todo` / `Mcp` → no-ops (placeholder)
#[tracing::instrument(skip(state))]
fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
debug!(overlay = ?state.misc.overlay, "handle_overlay_enter");
match state.misc.overlay {
Overlay::Bash => {
let command = state.input.buffer.clone();
state.toast_info(format!("Submitting bash command: {command}"));
state.input.buffer.clear();
state.input.cursor = 0;
state.mark_dirty();
Vec::new()
}
Overlay::Settings => {
state.mark_dirty();
Vec::new()
}
Overlay::Todo => {
state.mark_dirty();
Vec::new()
}
Overlay::QuitConfirm => {
state.quit = true;
state.mark_dirty();
Vec::new()
}
Overlay::KeyInput => {
let text = state.input.buffer.clone();
if !text.is_empty() {
state
.settings
.api_keys
.insert(state.settings.provider.clone(), text);
}
state.toast_success("API key saved".to_string());
state.input.buffer.clear();
state.input.cursor = 0;
state.misc.overlay = Overlay::None;
state.save_settings();
state.mark_dirty();
Vec::new()
}
Overlay::Mcp => {
state.toast_info("Connecting MCP...".to_string());
Vec::new()
}
Overlay::Rewind => {
let idx = state.misc.selected_index;
let n = state.transcript_cache.messages.len();
if idx < n {
let rewind_to = n - idx - 1;
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::System,
format!("Rewound to message {rewind_to}"),
));
}
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
Overlay::ModelSelector => {
let providers: Vec<String> = state.app_config.providers.keys().cloned().collect();
if let Some(provider) = providers.get(state.misc.selected_index) {
if let Some(cfg) = state.app_config.providers.get(provider) {
let model = cfg.default_model.clone().unwrap_or_else(|| {
"claude-opus-4-8".to_string()
});
state.settings.provider.clone_from(provider);
state.settings.model.clone_from(&model);
if let Some(ref key) = cfg.default_api_key {
state.settings.api_keys.insert(provider.clone(), key.clone());
} else if let Some(env_key) = cfg
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
{
state.settings.api_keys.insert(provider.clone(), env_key);
}
state.save_settings();
state.toast_success(format!("Switched to {provider} / {model}"));
}
}
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
Overlay::ClearConfirm => {
state.toast_info("Transcript cleared".to_string());
state.transcript_cache.messages.clear();
state.transcript_cache.dirty = true;
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
_ => Vec::new(),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -9,3 +9,4 @@
//! commands into structured `Action` variants. //! commands into structured `Action` variants.
pub mod command; pub mod command;
pub mod input; pub mod input;
pub mod overlay_enter;
@@ -0,0 +1,122 @@
//! Overlay-specific Enter-key handlers.
//!
//! Each overlay variant has its own Enter semantics. Extracted from the
//! monolithic `input.rs` so each handler is self-contained.
use tracing::debug;
use crate::action::Action;
use crate::state::{AppStateRest, Overlay};
/// Handle pressing Enter while a modal overlay is active.
///
/// Each overlay variant has its own Enter semantics:
/// - `QuitConfirm` → set `quit = true`
/// - `KeyInput` → save API key from buffer
/// - `ModelSelector` → switch provider/model from selected index
/// - `ClearConfirm` → clear transcript cache
/// - `Rewind` → rewind to selected message index
/// - `Bash` / `Settings` / `Todo` / `Mcp` → no-ops (placeholder)
#[tracing::instrument(skip(state))]
pub fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
debug!(overlay = ?state.misc.overlay, "handle_overlay_enter");
match state.misc.overlay {
Overlay::Bash => handle_bash_enter(state),
Overlay::Settings | Overlay::Todo => {
state.mark_dirty();
Vec::new()
}
Overlay::QuitConfirm => {
state.quit = true;
state.mark_dirty();
Vec::new()
}
Overlay::KeyInput => handle_keyinput_enter(state),
Overlay::Mcp => {
state.toast_info("Connecting MCP...".to_string());
Vec::new()
}
Overlay::Rewind => handle_rewind_enter(state),
Overlay::ModelSelector => handle_model_selector_enter(state),
Overlay::ClearConfirm => handle_clear_confirm(state),
_ => Vec::new(),
}
}
fn handle_bash_enter(state: &mut AppStateRest) -> Vec<Action> {
let command = state.input.buffer.clone();
state.toast_info(format!("Submitting bash command: {command}"));
state.input.buffer.clear();
state.input.cursor = 0;
state.mark_dirty();
Vec::new()
}
fn handle_keyinput_enter(state: &mut AppStateRest) -> Vec<Action> {
let text = state.input.buffer.clone();
if !text.is_empty() {
state
.settings
.api_keys
.insert(state.settings.provider.clone(), text);
}
state.toast_success("API key saved".to_string());
state.input.buffer.clear();
state.input.cursor = 0;
state.misc.overlay = Overlay::None;
state.save_settings();
state.mark_dirty();
Vec::new()
}
fn handle_rewind_enter(state: &mut AppStateRest) -> Vec<Action> {
let idx = state.misc.selected_index;
let n = state.transcript_cache.messages.len();
if idx < n {
let rewind_to = n - idx - 1;
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::System,
format!("Rewound to message {rewind_to}"),
));
}
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
fn handle_model_selector_enter(state: &mut AppStateRest) -> Vec<Action> {
let providers: Vec<String> = state.app_config.providers.keys().cloned().collect();
if let Some(provider) = providers.get(state.misc.selected_index) {
if let Some(cfg) = state.app_config.providers.get(provider) {
let model = cfg
.default_model
.clone()
.unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string());
state.settings.provider.clone_from(provider);
state.settings.model.clone_from(&model);
if let Some(ref key) = cfg.default_api_key {
state.settings.api_keys.insert(provider.clone(), key.clone());
} else if let Some(env_key) = cfg
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
{
state.settings.api_keys.insert(provider.clone(), env_key);
}
state.save_settings();
state.toast_success(format!("Switched to {provider} / {model}"));
}
}
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
fn handle_clear_confirm(state: &mut AppStateRest) -> Vec<Action> {
state.toast_info("Transcript cleared".to_string());
state.transcript_cache.messages.clear();
state.transcript_cache.dirty = true;
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
+3
View File
@@ -30,6 +30,9 @@ use crate::view;
/// save settings. /// save settings.
#[tracing::instrument] #[tracing::instrument]
pub fn run_single_process() -> Result<()> { pub fn run_single_process() -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
let _guard = rt.enter();
// Create session state // Create session state
info!("starting single-process TUI"); info!("starting single-process TUI");
let (_store, mut state) = create_local_session()?; let (_store, mut state) = create_local_session()?;
+25
View File
@@ -130,3 +130,28 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
let _ = turn_service.run_turn(params).await; let _ = turn_service.run_turn(params).await;
}); });
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spawn_agent_turn_with_tokio_runtime() {
let rt = tokio::runtime::Runtime::new().unwrap();
let _guard = rt.enter();
let temp_dir = std::env::temp_dir().join(format!("zesdex_test_{}", uuid::Uuid::new_v4()));
let session_dir = temp_dir.join("session");
let memory_dir = temp_dir.join("memory");
std::fs::create_dir_all(&session_dir).unwrap();
std::fs::create_dir_all(&memory_dir).unwrap();
let workspace_roots = vec![temp_dir.clone()];
let mut state = AppStateRest::new(workspace_roots, &session_dir, memory_dir);
spawn_agent_turn(&mut state, "hello".to_string());
assert!(state.turn_in_flight());
let _ = std::fs::remove_dir_all(&temp_dir);
}
}