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);
+6 -4
View File
@@ -45,8 +45,9 @@ 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!(
@@ -103,8 +104,9 @@ 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."))
+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,8 +68,9 @@ 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 {
@@ -62,8 +62,9 @@ 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");
+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);
}
}