feat: centralize default constants and refactor overlay enter handling in TUI
This commit is contained in:
@@ -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;
|
||||
@@ -6,6 +6,7 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::core::{ChatMessage, ToolCallResult, UsageStats};
|
||||
|
||||
pub mod defaults;
|
||||
pub mod prompt;
|
||||
pub mod progress;
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ pub use error::DomainError;
|
||||
// Agent module top-level items (TurnEvent, SessionRuntime, etc.)
|
||||
pub use agent::*;
|
||||
// Sub-module items need explicit re-exports
|
||||
pub use agent::defaults::*;
|
||||
pub use agent::progress::AgentProgress;
|
||||
pub use agent::prompt::{compaction_prompt, main_agent_prompt, subagent_directive};
|
||||
pub use workflow::*;
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
//! 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.
|
||||
///
|
||||
/// Tiers are cumulative: `Write` includes everything in `Read`, and `Full`
|
||||
|
||||
@@ -144,15 +144,15 @@ fn run_api_server(port: u16) -> anyhow::Result<()> {
|
||||
"JWT_SECRET environment variable not set; using insecure default. \
|
||||
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(
|
||||
store.base_dir.clone(),
|
||||
jwt_secret,
|
||||
"",
|
||||
"deepseek-v4-flash-free",
|
||||
Some("https://opencode.ai/zen/v1".to_string()),
|
||||
zesdex_domain::agent::defaults::DEFAULT_MODEL,
|
||||
Some(zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()),
|
||||
);
|
||||
let app = zesdex_api::build_router(state);
|
||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
||||
|
||||
@@ -35,7 +35,6 @@ pub mod lsp;
|
||||
pub mod mcp;
|
||||
pub mod middleware;
|
||||
pub mod persistence;
|
||||
pub mod review;
|
||||
pub mod subagent;
|
||||
pub mod tools;
|
||||
pub mod utils;
|
||||
|
||||
@@ -11,8 +11,8 @@ use zesdex_domain::core::{
|
||||
};
|
||||
use zesdex_application::ports::ProviderService;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
|
||||
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
|
||||
use zesdex_domain::agent::defaults::{DEFAULT_API_BASE, DEFAULT_MODEL};
|
||||
const DEFAULT_BASE_URL: &str = DEFAULT_API_BASE;
|
||||
pub const DEFAULT_API_KEY: &str = "";
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
let base_url = api_base.unwrap_or_else(|| {
|
||||
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));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,9 +5,5 @@ pub mod auto;
|
||||
pub mod context;
|
||||
pub mod division;
|
||||
pub mod engine;
|
||||
pub mod event;
|
||||
pub mod gating;
|
||||
pub mod provider;
|
||||
pub mod spawn;
|
||||
pub mod tools;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -67,7 +67,7 @@ impl SubagentProvider {
|
||||
///
|
||||
/// Flow: reads `settings.provider` and `settings.model` → if model is empty,
|
||||
/// 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]
|
||||
pub fn resolve_subagent_provider(
|
||||
settings: &zesdex_domain::cms::Settings,
|
||||
@@ -82,7 +82,7 @@ pub fn resolve_subagent_provider(
|
||||
.providers
|
||||
.get(&provider)
|
||||
.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 {
|
||||
model
|
||||
};
|
||||
|
||||
@@ -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
|
||||
.get(&provider)
|
||||
.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);
|
||||
|
||||
|
||||
@@ -45,8 +45,9 @@ impl Tool for PlanEnter {
|
||||
let plan_path = ctx.session_dir.join("PLAN.md");
|
||||
let _ = std::fs::write(&plan_path, &plan_text);
|
||||
if let Some(events) = &ctx.turn_events {
|
||||
let mut q = events.lock().unwrap();
|
||||
q.push_back(crate::TurnEvent::PlanUpdate(plan_text.clone()));
|
||||
if let Ok(mut q) = events.lock() {
|
||||
q.push_back(crate::TurnEvent::PlanUpdate(plan_text.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
@@ -103,8 +104,9 @@ impl Tool for PlanReady {
|
||||
let plan_path = ctx.session_dir.join("PLAN.md");
|
||||
let _ = std::fs::write(&plan_path, &plan_content);
|
||||
if let Some(events) = &ctx.turn_events {
|
||||
let mut q = events.lock().unwrap();
|
||||
q.push_back(crate::TurnEvent::PlanUpdate(plan_content.clone()));
|
||||
if let Ok(mut q) = events.lock() {
|
||||
q.push_back(crate::TurnEvent::PlanUpdate(plan_content.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("Plan saved to {filename}. Starting execution."))
|
||||
|
||||
@@ -79,7 +79,7 @@ impl Tool for SpawnAgents {
|
||||
.providers
|
||||
.get(&provider)
|
||||
.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);
|
||||
|
||||
@@ -196,7 +196,7 @@ impl Tool for SpawnPipeline {
|
||||
.providers
|
||||
.get(&provider)
|
||||
.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);
|
||||
|
||||
|
||||
@@ -68,8 +68,9 @@ impl Tool for Todofinish {
|
||||
content = new_lines.join("\n") + "\n";
|
||||
let _ = std::fs::write(&todo_path, &content);
|
||||
if let Some(events) = &ctx.turn_events {
|
||||
let mut q = events.lock().unwrap();
|
||||
q.push_back(crate::TurnEvent::TodoUpdate(content));
|
||||
if let Ok(mut q) = events.lock() {
|
||||
q.push_back(crate::TurnEvent::TodoUpdate(content));
|
||||
}
|
||||
}
|
||||
info!(item, "TODO item completed and written back");
|
||||
} else {
|
||||
|
||||
@@ -62,8 +62,9 @@ impl Tool for Todowrite {
|
||||
let _ = std::fs::write(&todo_path, &content);
|
||||
|
||||
if let Some(events) = &ctx.turn_events {
|
||||
let mut q = events.lock().unwrap();
|
||||
q.push_back(crate::TurnEvent::TodoUpdate(content));
|
||||
if let Ok(mut q) = events.lock() {
|
||||
q.push_back(crate::TurnEvent::TodoUpdate(content));
|
||||
}
|
||||
}
|
||||
|
||||
info!(item, priority, "TODO item added");
|
||||
|
||||
@@ -62,7 +62,7 @@ impl Tool for WorkflowRun {
|
||||
|
||||
let llm_client = LlmClient::new(
|
||||
crate::llm::provider::DEFAULT_API_KEY.to_string(),
|
||||
"deepseek-v4-flash-free".to_string(),
|
||||
zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string(),
|
||||
None,
|
||||
);
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
|
||||
@@ -41,7 +41,7 @@ pub async fn execute_primitive(directive: &str, tool_ctx: &ToolCtx) -> Result<St
|
||||
.providers
|
||||
.get(&provider)
|
||||
.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);
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ pub async fn execute_cycle(
|
||||
.providers
|
||||
.get(&provider)
|
||||
.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);
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ use crate::state::create_session;
|
||||
/// save settings, and release the lock.
|
||||
pub fn run_daemon() -> Result<()> {
|
||||
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");
|
||||
std::fs::create_dir_all(&run_dir)?;
|
||||
|
||||
@@ -15,6 +15,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::controller::command::{apply_command, parse_command};
|
||||
use crate::controller::overlay_enter::handle_overlay_enter;
|
||||
use crate::state::{AutocompleteKind, Overlay, AppStateRest};
|
||||
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
//! commands into structured `Action` variants.
|
||||
pub mod command;
|
||||
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()
|
||||
}
|
||||
@@ -30,6 +30,9 @@ use crate::view;
|
||||
/// save settings.
|
||||
#[tracing::instrument]
|
||||
pub fn run_single_process() -> Result<()> {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
let _guard = rt.enter();
|
||||
|
||||
// Create session state
|
||||
info!("starting single-process TUI");
|
||||
let (_store, mut state) = create_local_session()?;
|
||||
|
||||
@@ -130,3 +130,28 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user