chore: hapus semua source Rust + Cargo.toml/lock + .cargo (247 file .rs, migrasi ke TS/Bun)
This commit is contained in:
1 parent
319198d2f0
commit
c165dff114
250 files changed
-33715
No files matched your search
@@ -1,55 +0,0 @@
|
||||
//! Authentication DTOs — login, register, and token refresh payloads.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request body for `POST /auth/login`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
/// Username or email identifier.
|
||||
pub username: String,
|
||||
/// Plaintext password.
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Request body for `POST /auth/register`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
/// Desired username.
|
||||
pub username: String,
|
||||
/// Plaintext password (will be hashed server-side).
|
||||
pub password: String,
|
||||
/// Optional display name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for `POST /auth/refresh`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RefreshRequest {
|
||||
/// The refresh token issued during login.
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// Response body for auth endpoints (login, register, refresh).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthResponse {
|
||||
/// JWT access token (short-lived, typically 1 hour).
|
||||
pub access_token: String,
|
||||
/// JWT refresh token (long-lived, typically 7 days).
|
||||
pub refresh_token: String,
|
||||
/// Token type (always `"Bearer"`).
|
||||
pub token_type: String,
|
||||
/// Expiry of the access token in seconds.
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
/// Claims exposed in the JWT payload, returned from introspection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClaimsResponse {
|
||||
/// Subject identifier (username).
|
||||
pub sub: String,
|
||||
/// Issued-at timestamp (epoch seconds).
|
||||
pub iat: u64,
|
||||
/// Expiry timestamp (epoch seconds).
|
||||
pub exp: u64,
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
//! Conversation DTOs — message history read/write payloads.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zesdex_domain::core::{ChatMessage, Conversation};
|
||||
|
||||
/// Request body for appending a message to a conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddMessageRequest {
|
||||
/// Message role: `"user"` or `"assistant"`.
|
||||
pub role: String,
|
||||
/// Message content text.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Response body for a single conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversationResponse {
|
||||
/// Session ID this conversation belongs to.
|
||||
pub session_id: String,
|
||||
/// Messages in the conversation.
|
||||
pub messages: Vec<MessageResponse>,
|
||||
/// Total message count.
|
||||
pub message_count: usize,
|
||||
/// Model identifier used for this conversation.
|
||||
pub model: String,
|
||||
/// System prompt in effect.
|
||||
pub system_prompt: String,
|
||||
/// Max tokens configuration.
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Temperature configuration.
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl From<Conversation> for ConversationResponse {
|
||||
fn from(c: Conversation) -> Self {
|
||||
let message_count = c.len();
|
||||
let messages: Vec<MessageResponse> =
|
||||
c.messages.into_iter().map(MessageResponse::from).collect();
|
||||
ConversationResponse {
|
||||
session_id: c.session_id,
|
||||
messages,
|
||||
message_count,
|
||||
model: c.model,
|
||||
system_prompt: c.system_prompt,
|
||||
max_tokens: c.max_tokens,
|
||||
temperature: c.temperature,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single message in a conversation response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageResponse {
|
||||
/// Message role.
|
||||
pub role: String,
|
||||
/// Message content (None for assistant messages with only tool calls).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// Optional tool call information.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCallResponse>>,
|
||||
/// Optional tool call result identifier.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ChatMessage> for MessageResponse {
|
||||
fn from(m: ChatMessage) -> Self {
|
||||
let tool_calls = m.tool_calls.map(|calls| {
|
||||
calls
|
||||
.into_iter()
|
||||
.map(|tc| ToolCallResponse {
|
||||
id: tc.id,
|
||||
function: ToolFunctionResponse {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments.to_string(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
MessageResponse {
|
||||
role: m.role.to_string(),
|
||||
content: m.content,
|
||||
tool_calls,
|
||||
tool_call_id: m.tool_call_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool call reference in a message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallResponse {
|
||||
/// Tool call ID.
|
||||
pub id: String,
|
||||
/// Function details.
|
||||
pub function: ToolFunctionResponse,
|
||||
}
|
||||
|
||||
/// A function invocation in a tool call.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunctionResponse {
|
||||
/// Function name.
|
||||
pub name: String,
|
||||
/// JSON-encoded arguments.
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// Request body for `POST /chat/completions`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatCompletionRequest {
|
||||
/// The session ID to attach this completion to.
|
||||
pub session_id: String,
|
||||
/// Message content (user message).
|
||||
pub message: String,
|
||||
/// Optional model override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// Optional max tokens override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Optional temperature override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
/// Response body for `POST /chat/completions`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatCompletionResponse {
|
||||
/// The assistant's reply.
|
||||
pub reply: String,
|
||||
/// Total prompt tokens consumed.
|
||||
pub prompt_tokens: u64,
|
||||
/// Total completion tokens generated.
|
||||
pub completion_tokens: u64,
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//! Error response DTO — JSON body returned for all API errors.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Standardised error response body.
|
||||
///
|
||||
/// Returned for all non-successful API responses. Contains a human-readable
|
||||
/// `message` and the HTTP status `code` for machine parsing.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
/// Human-readable error description.
|
||||
pub message: String,
|
||||
/// HTTP status code (mirrors the response status).
|
||||
pub code: u16,
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//! Data Transfer Objects for the REST API.
|
||||
//!
|
||||
//! These types define the wire format for request bodies and response bodies.
|
||||
//! They are intentionally independent of domain entities so the API contract
|
||||
//! can evolve without coupling to the domain model.
|
||||
|
||||
pub mod auth;
|
||||
pub mod conversation;
|
||||
pub mod error;
|
||||
pub mod session;
|
||||
@@ -1,57 +0,0 @@
|
||||
//! Session DTOs — create, list, and delete session payloads.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zesdex_domain::auth::Session;
|
||||
|
||||
/// Request body for `POST /sessions`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateSessionRequest {
|
||||
/// Human-readable session title.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Response body for a single session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionResponse {
|
||||
/// Unique session identifier.
|
||||
pub id: String,
|
||||
/// Epoch-millis timestamp of creation.
|
||||
pub created_at: i64,
|
||||
/// Epoch-millis timestamp of last update.
|
||||
pub updated_at: i64,
|
||||
/// Human-readable title.
|
||||
pub title: String,
|
||||
/// Model identifier string.
|
||||
pub model: String,
|
||||
/// Number of messages in this session.
|
||||
pub message_count: u32,
|
||||
/// Whether the session has been archived.
|
||||
pub archived: bool,
|
||||
/// Optional AI-generated summary.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
impl From<Session> for SessionResponse {
|
||||
fn from(s: Session) -> Self {
|
||||
SessionResponse {
|
||||
id: s.id,
|
||||
created_at: s.created_at,
|
||||
updated_at: s.updated_at,
|
||||
title: s.title,
|
||||
model: s.model,
|
||||
message_count: s.message_count,
|
||||
archived: s.archived,
|
||||
summary: s.summary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response body for `GET /sessions`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionListResponse {
|
||||
/// All (non-archived) sessions.
|
||||
pub sessions: Vec<SessionResponse>,
|
||||
/// Total count of sessions returned.
|
||||
pub total: usize,
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
//! Typed API error type with automatic HTTP response conversion.
|
||||
//!
|
||||
//! `ApiError` represents all possible failure modes of the REST API.
|
||||
//! Each variant maps to an appropriate HTTP status code via `IntoResponse`,
|
||||
//! producing a JSON body with a `message` field and an optional `code`.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Handler returns `Result<T, ApiError>` → Axum calls `IntoResponse` →
|
||||
//! HTTP response with appropriate status code and JSON error body.
|
||||
//!
|
||||
//! # Error mapping
|
||||
//!
|
||||
//! - `BadRequest` → 400
|
||||
//! - `Unauthorized` → 401
|
||||
//! - `NotFound` → 404
|
||||
//! - `Conflict` → 409
|
||||
//! - `Internal` → 500 (with `tracing::error!` log)
|
||||
//! - `ChatProxy` → 502 (upstream LLM error)
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::dto::error::ErrorResponse;
|
||||
|
||||
/// Typed API error with HTTP status code mapping.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
/// The request was malformed or contained invalid data.
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
/// Authentication failed or credentials are missing/invalid.
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
/// The requested resource was not found.
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// The request conflicts with the current server state.
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// The client has sent too many requests in a given time window.
|
||||
#[error("Too many requests: {0}")]
|
||||
TooManyRequests(String),
|
||||
|
||||
/// An unexpected internal error occurred.
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
|
||||
/// The upstream LLM provider returned an error.
|
||||
#[error("Chat proxy error: {0}")]
|
||||
ChatProxy(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
/// Convert `ApiError` into an HTTP response with an appropriate status
|
||||
/// code and a structured JSON body.
|
||||
///
|
||||
/// Internal errors are logged at `error` level before returning a generic
|
||||
/// 500 response (to avoid leaking internal details).
|
||||
fn into_response(self) -> Response {
|
||||
let (status, user_message) = match &self {
|
||||
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
|
||||
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
|
||||
ApiError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg.clone()),
|
||||
ApiError::Internal(msg) => {
|
||||
tracing::error!(error = %msg, "Internal server error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"An internal error occurred".to_string(),
|
||||
)
|
||||
}
|
||||
ApiError::ChatProxy(msg) => {
|
||||
tracing::error!(error = %msg, "Chat proxy error");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Upstream LLM error: {msg}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let body = ErrorResponse {
|
||||
message: user_message,
|
||||
code: status.as_u16(),
|
||||
};
|
||||
|
||||
(status, Json(json!(body))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From impls — convert domain/infrastructure errors into ApiError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl From<zesdex_domain::error::DomainError> for ApiError {
|
||||
/// Map domain repository errors to API errors.
|
||||
///
|
||||
/// - `NotFound` → `ApiError::NotFound`
|
||||
/// - `Conflict` → `ApiError::Conflict`
|
||||
/// - `InvalidId` → `ApiError::BadRequest`
|
||||
/// - All others → `ApiError::Internal`
|
||||
fn from(e: zesdex_domain::error::DomainError) -> Self {
|
||||
match e {
|
||||
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
|
||||
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
|
||||
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
|
||||
_ => ApiError::Internal(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<zesdex_domain::auth::ServiceError> for ApiError {
|
||||
/// Map IAM service errors to API errors.
|
||||
fn from(e: zesdex_domain::auth::ServiceError) -> Self {
|
||||
match e {
|
||||
zesdex_domain::auth::ServiceError::Repository(repo_err) => match repo_err {
|
||||
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
|
||||
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
|
||||
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
|
||||
_ => ApiError::Internal(repo_err.to_string()),
|
||||
},
|
||||
zesdex_domain::auth::ServiceError::InvalidConfig(msg) => ApiError::BadRequest(msg),
|
||||
zesdex_domain::auth::ServiceError::StateMismatch => {
|
||||
ApiError::Unauthorized("OAuth state mismatch — possible CSRF attack".into())
|
||||
}
|
||||
zesdex_domain::auth::ServiceError::OAuthProvider(msg) => ApiError::ChatProxy(msg),
|
||||
zesdex_domain::auth::ServiceError::Other(msg) => ApiError::Internal(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<zesdex_domain::cms::ServiceError> for ApiError {
|
||||
/// Map CMS service errors to API errors.
|
||||
fn from(e: zesdex_domain::cms::ServiceError) -> Self {
|
||||
match e {
|
||||
zesdex_domain::cms::ServiceError::Repository(repo_err) => match repo_err {
|
||||
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
|
||||
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
|
||||
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
|
||||
_ => ApiError::Internal(repo_err.to_string()),
|
||||
},
|
||||
zesdex_domain::cms::ServiceError::InvalidInput(msg) => ApiError::BadRequest(msg),
|
||||
zesdex_domain::cms::ServiceError::Other(msg) => ApiError::Internal(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for ApiError {
|
||||
/// Fallback conversion: log the error and return a generic internal error.
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
tracing::error!(error = %e, "Unhandled error");
|
||||
ApiError::Internal(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<jsonwebtoken::errors::Error> for ApiError {
|
||||
fn from(e: jsonwebtoken::errors::Error) -> Self {
|
||||
ApiError::Unauthorized(format!("Invalid token: {e}"))
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
//! Authentication handlers — login, register, and token refresh.
|
||||
//!
|
||||
//! # Endpoints
|
||||
//!
|
||||
//! - `POST /auth/login` — authenticate with username/password, returns JWT
|
||||
//! - `POST /auth/register` — create a new user account
|
||||
//! - `POST /auth/refresh` — exchange a refresh token for a new access token
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Login: validate input → verify password → generate token pair → return.
|
||||
//! Register: validate input → check uniqueness → hash password → persist → login.
|
||||
//! Refresh: decode refresh token → verify → generate new token pair.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
|
||||
use zesdex_application::ports::{PasswordService, TokenService};
|
||||
|
||||
use crate::dto::auth::{AuthResponse, LoginRequest, RefreshRequest, RegisterRequest};
|
||||
use crate::error::ApiError;
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// Login/register brute-force protection: 20 attempts per 10-minute window
|
||||
/// per client IP.
|
||||
const AUTH_RATE_LIMIT_MAX: u32 = 20;
|
||||
const AUTH_RATE_LIMIT_WINDOW_SECS: u64 = 600;
|
||||
|
||||
/// Build the auth sub-router (`/auth/*`).
|
||||
pub fn router() -> Router<Arc<ApiState>> {
|
||||
Router::new()
|
||||
.route("/login", post(login_handler))
|
||||
.route("/register", post(register_handler))
|
||||
.route("/refresh", post(refresh_handler))
|
||||
}
|
||||
|
||||
/// Extract a coarse client identity from the request headers (IP via
|
||||
/// X-Forwarded-For fallback). Used as the rate-limit key.
|
||||
fn client_id(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
/// Enforce the auth rate limit, returning `true` if the request is allowed.
|
||||
fn rate_limited(state: &ApiState, headers: &HeaderMap) -> bool {
|
||||
!state
|
||||
.auth_rate_limiter
|
||||
.check_rate_limit(
|
||||
&client_id(headers),
|
||||
AUTH_RATE_LIMIT_MAX,
|
||||
AUTH_RATE_LIMIT_WINDOW_SECS,
|
||||
)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// POST /auth/login — authenticate and issue JWT tokens.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize `LoginRequest`.
|
||||
/// 2. Load the stored user credentials from the users store.
|
||||
/// 3. Verify the password against the stored hash.
|
||||
/// 4. Generate an access + refresh token pair.
|
||||
/// 5. Return `AuthResponse`.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — missing or empty fields.
|
||||
/// - `401 Unauthorized` — invalid username or password.
|
||||
/// - `500 Internal Server Error` — unexpected failure.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn login_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
// Rate-limit login attempts (brute-force protection).
|
||||
if rate_limited(&state, &headers) {
|
||||
return Err(ApiError::TooManyRequests(
|
||||
"Too many login attempts, try again later".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if req.username.is_empty() || req.password.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Username and password are required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Load the users database from the store
|
||||
let users_path = state.store_base_dir.join("users.json");
|
||||
let users: std::collections::HashMap<String, String> = if users_path.exists() {
|
||||
let content = std::fs::read_to_string(&users_path)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))?
|
||||
} else {
|
||||
return Err(ApiError::Unauthorized(
|
||||
"Invalid username or password".into(),
|
||||
));
|
||||
};
|
||||
|
||||
// Look up the user
|
||||
let stored_hash = users
|
||||
.get(&req.username)
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid username or password".into()))?;
|
||||
|
||||
// Verify password
|
||||
let valid = state
|
||||
.password_service
|
||||
.verify(&req.password, stored_hash)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Password verification failed: {e}")))?;
|
||||
|
||||
if !valid {
|
||||
return Err(ApiError::Unauthorized(
|
||||
"Invalid username or password".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
let (access_token, refresh_token) = state
|
||||
.token_service
|
||||
.generate_tokens(&req.username)
|
||||
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.token_service.access_token_expiry_secs,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /auth/register — create a new user account.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize `RegisterRequest`.
|
||||
/// 2. Check username availability (load users, reject if exists).
|
||||
/// 3. Hash the password using Argon2id.
|
||||
/// 4. Persist the new username + hash.
|
||||
/// 5. Generate an access + refresh token pair.
|
||||
/// 6. Return `AuthResponse`.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — missing or invalid fields.
|
||||
/// - `409 Conflict` — username already taken.
|
||||
/// - `500 Internal Server Error` — unexpected failure.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn register_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
// Rate-limit registration (abuse protection).
|
||||
if rate_limited(&state, &headers) {
|
||||
return Err(ApiError::TooManyRequests(
|
||||
"Too many registration attempts, try again later".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if req.username.is_empty() {
|
||||
return Err(ApiError::BadRequest("Username is required".into()));
|
||||
}
|
||||
if req.password.len() < 6 {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Compute the password hash first (async, no lock held).
|
||||
let hash = state
|
||||
.password_service
|
||||
.hash(&req.password)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Password hashing failed: {e}")))?;
|
||||
|
||||
// Serialize read-modify-write of users.json to avoid losing concurrent
|
||||
// registers (TOCTOU race). The lock is held only across the sync
|
||||
// file operations — never across an `.await` (keeps the future `Send`).
|
||||
let _users_guard = state
|
||||
.users_lock
|
||||
.lock()
|
||||
.map_err(|_| ApiError::Internal("users lock poisoned".into()))?;
|
||||
|
||||
// Load existing users
|
||||
let users_path = state.store_base_dir.join("users.json");
|
||||
let mut users: std::collections::HashMap<String, String> = if users_path.exists() {
|
||||
let content = std::fs::read_to_string(&users_path)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))?
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
// Check uniqueness
|
||||
if users.contains_key(&req.username) {
|
||||
return Err(ApiError::Conflict("Username already exists".into()));
|
||||
}
|
||||
|
||||
// Persist
|
||||
users.insert(req.username.clone(), hash);
|
||||
let content = serde_json::to_string_pretty(&users)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to serialize users: {e}")))?;
|
||||
std::fs::write(&users_path, &content)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to write users: {e}")))?;
|
||||
|
||||
// Generate tokens
|
||||
let (access_token, refresh_token) = state
|
||||
.token_service
|
||||
.generate_tokens(&req.username)
|
||||
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.token_service.access_token_expiry_secs,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /auth/refresh — exchange a refresh token for a new access token.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize `RefreshRequest`.
|
||||
/// 2. Verify the refresh token's signature and extract the subject.
|
||||
/// 3. Generate a fresh access + refresh token pair.
|
||||
/// 4. Return `AuthResponse`.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — missing refresh token.
|
||||
/// - `401 Unauthorized` — invalid or expired refresh token.
|
||||
/// - `500 Internal Server Error` — unexpected failure.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn refresh_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
// Rate-limit refresh attempts.
|
||||
if rate_limited(&state, &headers) {
|
||||
return Err(ApiError::TooManyRequests(
|
||||
"Too many requests, try again later".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if req.refresh_token.is_empty() {
|
||||
return Err(ApiError::BadRequest("Refresh token is required".into()));
|
||||
}
|
||||
|
||||
// Verify the refresh token and extract the subject
|
||||
let sub = state
|
||||
.token_service
|
||||
.verify_refresh_token(&req.refresh_token)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?;
|
||||
|
||||
// Generate a fresh token pair
|
||||
let (access_token, new_refresh_token) = state
|
||||
.token_service
|
||||
.generate_tokens(&sub)
|
||||
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token,
|
||||
refresh_token: new_refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.token_service.access_token_expiry_secs,
|
||||
}))
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
//! LLM chat completion proxy handler.
|
||||
//!
|
||||
//! # Endpoints
|
||||
//!
|
||||
//! - `POST /chat/completions` — proxy a chat completion request to the LLM
|
||||
//! provider, optionally persisting the conversation.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! 1. Deserialize `ChatCompletionRequest`.
|
||||
//! 2. Load the existing conversation for the given session (create if absent).
|
||||
//! 3. Append the user's message to the conversation.
|
||||
//! 4. Call the LLM provider via `LlmClient`.
|
||||
//! 5. Append the assistant's reply to the conversation.
|
||||
//! 6. Persist the updated conversation.
|
||||
//! 7. Return `ChatCompletionResponse`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
|
||||
use zesdex_domain::cms::ConversationService;
|
||||
use zesdex_domain::core::{ChatMessage, Role};
|
||||
|
||||
use crate::dto::conversation::{ChatCompletionRequest, ChatCompletionResponse};
|
||||
use crate::error::ApiError;
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// Build the chat sub-router (`/chat/*`).
|
||||
pub fn router() -> Router<Arc<ApiState>> {
|
||||
Router::new().route("/completions", post(chat_completions_handler))
|
||||
}
|
||||
|
||||
/// POST /chat/completions — proxy to LLM provider.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize the request body.
|
||||
/// 2. Load the conversation for the given `session_id`.
|
||||
/// 3. Append the user's message to the conversation.
|
||||
/// 4. Call the LLM (non-streaming) using `LlmClient`.
|
||||
/// 5. Append the assistant's response.
|
||||
/// 6. Persist the conversation.
|
||||
/// 7. Return the assistant's reply and token usage.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — missing session_id or message.
|
||||
/// - `502 Bad Gateway` — upstream LLM provider error.
|
||||
/// - `500 Internal Server Error` — unexpected failure.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn chat_completions_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Json(req): Json<ChatCompletionRequest>,
|
||||
) -> Result<Json<ChatCompletionResponse>, ApiError> {
|
||||
// Validate input
|
||||
if req.session_id.is_empty() {
|
||||
return Err(ApiError::BadRequest("session_id is required".into()));
|
||||
}
|
||||
if req.message.is_empty() {
|
||||
return Err(ApiError::BadRequest("message is required".into()));
|
||||
}
|
||||
|
||||
// Load or create the conversation
|
||||
let mut conversation = state
|
||||
.conversation_service
|
||||
.load_conversation(&req.session_id)
|
||||
.unwrap_or_else(|_| {
|
||||
// Create a new empty conversation
|
||||
zesdex_domain::core::Conversation {
|
||||
session_id: req.session_id.clone(),
|
||||
messages: Vec::new(),
|
||||
model: req
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| state.llm_client.model.clone()),
|
||||
system_prompt: String::new(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
}
|
||||
});
|
||||
|
||||
// Set model if overridden
|
||||
if let Some(ref model) = req.model {
|
||||
conversation.model.clone_from(model);
|
||||
}
|
||||
|
||||
// Append the user's message
|
||||
let user_msg = ChatMessage {
|
||||
role: Role::User,
|
||||
content: Some(req.message.clone()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
};
|
||||
conversation.push(user_msg.clone());
|
||||
|
||||
// Build message history for the LLM
|
||||
let messages: Vec<ChatMessage> = conversation.messages.clone();
|
||||
|
||||
// Get model from conversation
|
||||
let model = if conversation.model.is_empty() {
|
||||
state.llm_client.model.clone()
|
||||
} else {
|
||||
conversation.model.clone()
|
||||
};
|
||||
|
||||
// Call the LLM provider (non-streaming)
|
||||
//
|
||||
// When the requested model matches the shared client we reuse it to
|
||||
// avoid allocating a new HTTP connection. Otherwise we create a
|
||||
// temporary LlmClient with the requested model — the ownership
|
||||
// lives on the stack via a match binding.
|
||||
//
|
||||
// We use an uninitialized let-binding for `temp_client` so the
|
||||
// compiler can see it's assigned at most once, avoiding
|
||||
// `#[allow(unused_assignments)]`.
|
||||
let temp_client;
|
||||
let llm_client = if model == state.llm_client.model {
|
||||
&state.llm_client
|
||||
} else {
|
||||
temp_client = zesdex_infrastructure::llm::provider::LlmClient::new(
|
||||
state.llm_client.api_key.clone(),
|
||||
model,
|
||||
Some(state.llm_client.base_url.clone()),
|
||||
);
|
||||
&temp_client
|
||||
};
|
||||
|
||||
use zesdex_application::ports::ProviderService;
|
||||
let (response, usage) = llm_client
|
||||
.chat(
|
||||
&messages,
|
||||
None, // No tool definitions for basic chat
|
||||
req.max_tokens,
|
||||
req.temperature,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::ChatProxy(format!("LLM request failed: {e}")))?;
|
||||
|
||||
let (prompt_tokens, completion_tokens) = usage.unwrap_or((0, 0));
|
||||
|
||||
// The response content may be None if only tool calls were returned
|
||||
let reply_text = response.content.unwrap_or_default();
|
||||
|
||||
// Append the assistant's reply
|
||||
let assistant_msg = ChatMessage {
|
||||
role: Role::Assistant,
|
||||
content: Some(reply_text.clone()),
|
||||
tool_calls: response.tool_calls,
|
||||
tool_call_id: response.tool_call_id,
|
||||
name: None,
|
||||
};
|
||||
state
|
||||
.conversation_service
|
||||
.add_message(&mut conversation, assistant_msg)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to persist conversation: {e}")))?;
|
||||
|
||||
Ok(Json(ChatCompletionResponse {
|
||||
reply: reply_text,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
}))
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
//! Conversation message-history handlers.
|
||||
//!
|
||||
//! # Endpoints
|
||||
//!
|
||||
//! - `GET /sessions/:id/conversations` — get conversation for a session
|
||||
//! - `POST /sessions/:id/conversations` — append a message to a session
|
||||
//! - `DELETE /sessions/:id/conversations/:cid` — delete a conversation message
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each handler extracts the session ID from the path, delegates to the
|
||||
//! `ConversationServiceImpl`, and maps results to HTTP responses.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
|
||||
use zesdex_domain::cms::ConversationService;
|
||||
use zesdex_domain::core::ChatMessage;
|
||||
|
||||
use crate::dto::conversation::{AddMessageRequest, ConversationResponse};
|
||||
use crate::error::ApiError;
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// GET /sessions/:id/conversations — fetch the full conversation for a session.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Extract session ID from the path.
|
||||
/// 2. Load the conversation via `ConversationServiceImpl`.
|
||||
/// 3. Return the conversation with all messages.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `404 Not Found` — no conversation exists for this session.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn get_conversation_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ConversationResponse>, ApiError> {
|
||||
if id.is_empty() {
|
||||
return Err(ApiError::BadRequest("Session ID is required".into()));
|
||||
}
|
||||
|
||||
let conversation = state.conversation_service.load_conversation(&id)?;
|
||||
|
||||
Ok(Json(ConversationResponse::from(conversation)))
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/conversations — add a message to a session conversation.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Extract session ID from the path.
|
||||
/// 2. Deserialize `AddMessageRequest`.
|
||||
/// 3. Build a `ChatMessage` from the request.
|
||||
/// 4. Load the conversation, append the message, persist.
|
||||
/// 5. Return the updated conversation.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — invalid message format.
|
||||
/// - `404 Not Found` — session not found.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn add_message_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<AddMessageRequest>,
|
||||
) -> Result<(axum::http::StatusCode, Json<ConversationResponse>), ApiError> {
|
||||
if id.is_empty() {
|
||||
return Err(ApiError::BadRequest("Session ID is required".into()));
|
||||
}
|
||||
if req.content.is_empty() {
|
||||
return Err(ApiError::BadRequest("Message content is required".into()));
|
||||
}
|
||||
|
||||
// Parse role
|
||||
let role = match req.role.to_lowercase().as_str() {
|
||||
"user" => zesdex_domain::core::Role::User,
|
||||
"assistant" => zesdex_domain::core::Role::Assistant,
|
||||
_ => return Err(ApiError::BadRequest(format!("Invalid role: {}", req.role))),
|
||||
};
|
||||
|
||||
let msg = ChatMessage {
|
||||
role,
|
||||
content: Some(req.content),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
};
|
||||
|
||||
// Load conversation and add message
|
||||
let mut conversation = state.conversation_service.load_conversation(&id)?;
|
||||
|
||||
state
|
||||
.conversation_service
|
||||
.add_message(&mut conversation, msg)?;
|
||||
|
||||
Ok((
|
||||
axum::http::StatusCode::OK,
|
||||
Json(ConversationResponse::from(conversation)),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /sessions/:id/conversations/:cid — delete a single message by index.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Extract session ID and message index from the path.
|
||||
/// 2. Load the conversation.
|
||||
/// 3. Remove the message at `cid` (zero-based index).
|
||||
/// 4. Persist the updated conversation.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — session ID is empty or `cid` is not a valid integer.
|
||||
/// - `404 Not Found` — conversation or message index not found.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn delete_message_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path((id, cid)): Path<(String, String)>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
if id.is_empty() {
|
||||
return Err(ApiError::BadRequest("Session ID is required".into()));
|
||||
}
|
||||
|
||||
// Parse the message index from the path
|
||||
let index: usize = cid
|
||||
.parse()
|
||||
.map_err(|_| ApiError::BadRequest(format!("Invalid message index: {cid}")))?;
|
||||
|
||||
// Load conversation and remove the specific message by index
|
||||
let mut conversation = state.conversation_service.load_conversation(&id)?;
|
||||
|
||||
if index >= conversation.messages.len() {
|
||||
return Err(ApiError::NotFound(format!(
|
||||
"Message index {index} out of bounds (max: {})",
|
||||
conversation.messages.len().saturating_sub(1)
|
||||
)));
|
||||
}
|
||||
|
||||
conversation.messages.remove(index);
|
||||
state
|
||||
.conversation_service
|
||||
.save_conversation(&conversation)?;
|
||||
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//! Health-check endpoint.
|
||||
//!
|
||||
//! `GET /health` — returns a simple `{"status": "ok"}` response used by
|
||||
//! load balancers, orchestrators, and monitoring tools to verify the API
|
||||
//! server is running.
|
||||
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Handle `GET /health`.
|
||||
///
|
||||
/// Returns a 200 OK response with `{"status": "ok"}`.
|
||||
///
|
||||
/// This endpoint requires no authentication and has no side effects.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn health() -> Json<Value> {
|
||||
Json(json!({"status": "ok"}))
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//! API route handler modules.
|
||||
//!
|
||||
//! Each sub-module corresponds to a resource group and exposes a `router()`
|
||||
//! function that returns an `axum::Router` scoped to that resource's prefix.
|
||||
|
||||
pub mod auth;
|
||||
pub mod chat;
|
||||
pub mod conversations;
|
||||
pub mod health;
|
||||
pub mod sessions;
|
||||
@@ -1,100 +0,0 @@
|
||||
//! Session management handlers.
|
||||
//!
|
||||
//! # Endpoints
|
||||
//!
|
||||
//! - `GET /sessions` — list all sessions (optionally filtered)
|
||||
//! - `POST /sessions` — create a new session
|
||||
//! - `DELETE /sessions/:id` — archive/close a session
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each handler extracts the shared `ApiState`, delegates to the
|
||||
//! `SessionServiceImpl`, and maps results to HTTP responses with DTOs.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Json, Router};
|
||||
|
||||
use zesdex_domain::auth::{SessionId, SessionService};
|
||||
|
||||
use crate::dto::session::{CreateSessionRequest, SessionListResponse, SessionResponse};
|
||||
use crate::error::ApiError;
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// Build the sessions sub-router (`/sessions/*`).
|
||||
pub fn router() -> Router<Arc<ApiState>> {
|
||||
Router::new()
|
||||
.route("/", get(list_sessions_handler))
|
||||
.route("/", post(create_session_handler))
|
||||
.route("/{id}", delete(delete_session_handler))
|
||||
}
|
||||
|
||||
/// GET /sessions — list all sessions.
|
||||
///
|
||||
/// Returns a list of non-archived sessions sorted by creation time.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn list_sessions_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
) -> Result<Json<SessionListResponse>, ApiError> {
|
||||
let sessions = state.session_service.list_all()?;
|
||||
|
||||
let session_responses: Vec<SessionResponse> =
|
||||
sessions.into_iter().map(SessionResponse::from).collect();
|
||||
let total = session_responses.len();
|
||||
|
||||
Ok(Json(SessionListResponse {
|
||||
sessions: session_responses,
|
||||
total,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /sessions — create a new session.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize `CreateSessionRequest`.
|
||||
/// 2. Delegate to `SessionServiceImpl::create_session`.
|
||||
/// 3. Return the created session as `SessionResponse` with 201 Created.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn create_session_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Json(req): Json<CreateSessionRequest>,
|
||||
) -> Result<(axum::http::StatusCode, Json<SessionResponse>), ApiError> {
|
||||
if req.title.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Session title is required".into()));
|
||||
}
|
||||
|
||||
let session = state.session_service.create_session(&req.title)?;
|
||||
|
||||
Ok((
|
||||
axum::http::StatusCode::CREATED,
|
||||
Json(SessionResponse::from(session)),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /sessions/:id — archive/close a session.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Extract the session ID from the path.
|
||||
/// 2. Validate the ID format.
|
||||
/// 3. Delegate to `SessionServiceImpl::archive_session`.
|
||||
/// 4. Return 204 No Content.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn delete_session_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
if id.is_empty() {
|
||||
return Err(ApiError::BadRequest("Session ID is required".into()));
|
||||
}
|
||||
|
||||
let session_id = SessionId::new(&id)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid session ID: {e}")))?;
|
||||
|
||||
state.session_service.archive_session(session_id)?;
|
||||
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
//! # Zesdex REST API — Axum HTTP server
|
||||
//!
|
||||
//! Provides RESTful endpoints for the Zesdex application, enabling
|
||||
//! web clients, mobile apps, and third-party integrations.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! src/
|
||||
//! ├── lib.rs — Module declarations, re-exports, router builder
|
||||
//! ├── state.rs — ApiState with concrete service implementations
|
||||
//! ├── error.rs — ApiError enum + IntoResponse
|
||||
//! ├── dto/ — Request/response DTOs (serde)
|
||||
//! ├── handlers/ — Axum route handlers
|
||||
//! └── middleware/ — Tower layers (JWT auth, etc.)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Flow
|
||||
//!
|
||||
//! 1. `build_router()` constructs an Axum `Router` with all routes nested.
|
||||
//! 2. Each handler receives `State<Arc<ApiState>>` or direct extractors.
|
||||
//! 3. Handlers delegate to application-layer service implementations.
|
||||
//! 4. Domain/infrastructure errors are mapped to `ApiError` → HTTP status codes.
|
||||
|
||||
pub mod dto;
|
||||
pub mod error;
|
||||
pub mod handlers;
|
||||
pub mod middleware;
|
||||
pub mod state;
|
||||
|
||||
pub use error::ApiError;
|
||||
pub use state::ApiState;
|
||||
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
/// Build the API router with all routes registered.
|
||||
///
|
||||
/// Flow: create CORS layer → build sub-routers for each resource → nest
|
||||
/// them under `/api/v1` → attach shared state → return.
|
||||
///
|
||||
/// ## Arguments
|
||||
/// * `state` — shared application state (wrapped in `Arc` for clone-free sharing)
|
||||
///
|
||||
/// ## Example
|
||||
/// ```ignore
|
||||
/// let state = ApiState::new("/path/to/data");
|
||||
/// let app = build_router(state);
|
||||
/// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
/// axum::serve(listener, app).await.unwrap();
|
||||
/// ```
|
||||
pub fn build_router(state: ApiState) -> Router {
|
||||
let shared_state: Arc<ApiState> = Arc::new(state);
|
||||
|
||||
// CORS layer — permissive for local daemon / development use
|
||||
let cors = CorsLayer::permissive();
|
||||
|
||||
// JWT auth middleware — protects session/chat routes.
|
||||
// Auth (login/register/refresh) and health endpoints stay public.
|
||||
let jwt_auth = middleware::auth::JwtAuthLayer::new(shared_state.clone());
|
||||
|
||||
// Combine all sub-routers under a versioned prefix
|
||||
Router::new()
|
||||
.nest("/api/v1/auth", auth_router())
|
||||
.nest("/api/v1/health", health_router())
|
||||
.nest("/api/v1", protected_router().layer(jwt_auth))
|
||||
.layer(cors)
|
||||
.with_state(shared_state)
|
||||
}
|
||||
|
||||
/// Auth + health sub-routers — publicly accessible (no JWT required).
|
||||
fn auth_router() -> Router<Arc<ApiState>> {
|
||||
handlers::auth::router()
|
||||
}
|
||||
|
||||
fn health_router() -> Router<Arc<ApiState>> {
|
||||
Router::new().route("/", axum::routing::get(handlers::health::health))
|
||||
}
|
||||
|
||||
/// Protected sub-router — sessions + chat, guarded by JWT auth layer.
|
||||
fn protected_router() -> Router<Arc<ApiState>> {
|
||||
use handlers::{chat, conversations, sessions};
|
||||
|
||||
// Sessions router combines session CRUD + nested conversations
|
||||
let sessions_router = Router::new()
|
||||
.route("/", axum::routing::get(sessions::list_sessions_handler))
|
||||
.route("/", axum::routing::post(sessions::create_session_handler))
|
||||
.route(
|
||||
"/{id}",
|
||||
axum::routing::delete(sessions::delete_session_handler),
|
||||
)
|
||||
// Conversations are sub-resources of sessions
|
||||
.route(
|
||||
"/{id}/conversations",
|
||||
axum::routing::get(conversations::get_conversation_handler),
|
||||
)
|
||||
.route(
|
||||
"/{id}/conversations",
|
||||
axum::routing::post(conversations::add_message_handler),
|
||||
)
|
||||
.route(
|
||||
"/{id}/conversations/{cid}",
|
||||
axum::routing::delete(conversations::delete_message_handler),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.nest("/sessions", sessions_router)
|
||||
.nest("/chat", chat::router())
|
||||
}
|
||||
|
||||
// Re-export commonly used types at the crate root for ergonomic access.
|
||||
pub use axum::http::StatusCode;
|
||||
@@ -1,152 +0,0 @@
|
||||
//! JWT authentication middleware for Axum.
|
||||
//!
|
||||
//! Validates the `Authorization: Bearer <token>` header on every protected
|
||||
//! request. Injects the validated subject claim into request extensions for
|
||||
//! downstream handlers to consume.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! ```text
|
||||
//! Request → JwtAuthLayer → extract Bearer token → verify JWT → inject claims
|
||||
//! → inner service → Response
|
||||
//! ```
|
||||
//!
|
||||
//! If the token is missing, expired, or has an invalid signature the request
|
||||
//! is rejected with 401 Unauthorized before reaching any handler.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, Response, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use tower::{Layer, Service};
|
||||
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// Claims extracted from a valid JWT, injected into request extensions.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JwtClaims {
|
||||
/// Subject identifier (username/user ID).
|
||||
pub sub: String,
|
||||
}
|
||||
|
||||
/// Tower Layer that produces `JwtAuthMiddleware` services.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtAuthLayer {
|
||||
/// HMAC secret used to verify JWT signatures (reference into `ApiState`).
|
||||
state: Arc<ApiState>,
|
||||
}
|
||||
|
||||
impl JwtAuthLayer {
|
||||
/// Create a new JWT auth layer with the given shared API state.
|
||||
pub fn new(state: Arc<ApiState>) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for JwtAuthLayer {
|
||||
type Service = JwtAuthMiddleware<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
JwtAuthMiddleware {
|
||||
inner,
|
||||
state: self.state.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tower Service that validates JWT Bearer tokens before forwarding.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtAuthMiddleware<S> {
|
||||
inner: S,
|
||||
state: Arc<ApiState>,
|
||||
}
|
||||
|
||||
impl<S, ReqBody> Service<Request<ReqBody>> for JwtAuthMiddleware<S>
|
||||
where
|
||||
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
|
||||
S::Future: Send + 'static,
|
||||
ReqBody: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future =
|
||||
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
|
||||
// Extract the Authorization header
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let secret = self.state.jwt_secret.clone();
|
||||
|
||||
if let Some(auth_value) = auth_header {
|
||||
// Expect "Bearer <token>"
|
||||
if let Some(token) = auth_value.strip_prefix("Bearer ") {
|
||||
match zesdex_infrastructure::auth::jwt::verify_token(&secret, token) {
|
||||
Ok(claims) => {
|
||||
// Reject refresh tokens on protected routes — only access
|
||||
// tokens are acceptable here.
|
||||
if claims.token_type != zesdex_infrastructure::auth::jwt::TokenType::Access
|
||||
{
|
||||
let response = (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Invalid token",
|
||||
"detail": "refresh tokens are not accepted on protected routes"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return Box::pin(async move { Ok(response) });
|
||||
}
|
||||
// Inject claims as extension for downstream handlers
|
||||
let mut req = req;
|
||||
req.extensions_mut().insert(JwtClaims { sub: claims.sub });
|
||||
let fut = self.inner.call(req);
|
||||
return Box::pin(fut);
|
||||
}
|
||||
Err(e) => {
|
||||
let response = (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Invalid token",
|
||||
"detail": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return Box::pin(async move { Ok(response) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No valid Authorization header
|
||||
let response = (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "Missing or invalid Authorization header"})),
|
||||
)
|
||||
.into_response();
|
||||
Box::pin(async move { Ok(response) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: check if a request has a valid JWT in its Authorization header.
|
||||
///
|
||||
/// Intended for use in middleware layers or route guards that need quick
|
||||
/// authentication verification without extracting the full claims.
|
||||
pub fn is_authenticated(req: &Request<Body>) -> bool {
|
||||
req.extensions().get::<JwtClaims>().is_some()
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
//! Axum middleware layers for the REST API.
|
||||
//!
|
||||
//! Provides tower `Layer` implementations for cross-cutting concerns:
|
||||
//! - `auth` — JWT-based authentication layer
|
||||
|
||||
pub mod auth;
|
||||
@@ -1,311 +0,0 @@
|
||||
//! Shared application state for the REST API server.
|
||||
//!
|
||||
//! `ApiState` holds concrete service implementations wired to infrastructure
|
||||
//! adapters. It is constructed at the composition root and shared across all
|
||||
//! handlers via Axum's `State` extractor (wrapped in `Arc`).
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! 1. `ApiState::new(base_dir)` creates all services with their concrete repos.
|
||||
//! 2. `build_router()` wraps it in `Arc` and passes it to the Axum `Router`.
|
||||
//! 3. Handlers extract `State<Arc<ApiState>>` and delegate to the services.
|
||||
//!
|
||||
//! # Port trait implementations
|
||||
//!
|
||||
//! This module also provides simple wrapper types that implement the
|
||||
//! application-layer port traits using infrastructure functions:
|
||||
//!
|
||||
//! - `Argon2PasswordService` — implements `PasswordService` via
|
||||
//! `infrastructure::auth::password`
|
||||
//! - `JwtTokenService` — implements `TokenService` via
|
||||
//! `infrastructure::auth::jwt`
|
||||
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use zesdex_application::ports::{PasswordService, TokenService};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port trait implementations (wrap infrastructure free functions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Password-hashing service backed by Argon2id (infrastructure).
|
||||
///
|
||||
/// Delegates to `zesdex_infrastructure::auth::password::{hash_password, verify_password}`.
|
||||
#[derive(Clone)]
|
||||
pub struct Argon2PasswordService;
|
||||
|
||||
impl fmt::Debug for Argon2PasswordService {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Argon2PasswordService").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordService for Argon2PasswordService {
|
||||
/// Hash a plaintext password using Argon2id with a random salt.
|
||||
fn hash(&self, password: &str) -> impl Future<Output = anyhow::Result<String>> + Send {
|
||||
zesdex_infrastructure::auth::password::hash_password(password)
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against a stored PHC string.
|
||||
fn verify(
|
||||
&self,
|
||||
password: &str,
|
||||
hash: &str,
|
||||
) -> impl Future<Output = anyhow::Result<bool>> + Send {
|
||||
zesdex_infrastructure::auth::password::verify_password(password, hash)
|
||||
}
|
||||
}
|
||||
|
||||
/// JWT token service backed by HS256 (infrastructure).
|
||||
///
|
||||
/// Delegates to `zesdex_infrastructure::auth::jwt::{create_token, verify_token}`.
|
||||
#[derive(Clone)]
|
||||
pub struct JwtTokenService {
|
||||
/// HMAC secret key used for signing and verification.
|
||||
pub secret: String,
|
||||
/// Token expiry in seconds (default: 3600 = 1 hour).
|
||||
pub access_token_expiry_secs: u64,
|
||||
/// Refresh token expiry in seconds (default: 604800 = 7 days).
|
||||
pub refresh_token_expiry_secs: u64,
|
||||
}
|
||||
|
||||
impl fmt::Debug for JwtTokenService {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("JwtTokenService")
|
||||
.field("access_token_expiry_secs", &self.access_token_expiry_secs)
|
||||
.field("refresh_token_expiry_secs", &self.refresh_token_expiry_secs)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl JwtTokenService {
|
||||
/// Create a new JWT service with the given HMAC secret.
|
||||
pub fn new(secret: impl Into<String>) -> Self {
|
||||
JwtTokenService {
|
||||
secret: secret.into(),
|
||||
access_token_expiry_secs: 3600,
|
||||
refresh_token_expiry_secs: 604800,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenService for JwtTokenService {
|
||||
/// Generate an access + refresh token pair for the given subject.
|
||||
fn generate_tokens(&self, sub: &str) -> anyhow::Result<(String, String)> {
|
||||
use zesdex_infrastructure::auth::jwt::{create_token, JwtClaims, TokenType};
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Access token
|
||||
let access_claims = JwtClaims::new(
|
||||
sub.to_string(),
|
||||
now + self.access_token_expiry_secs,
|
||||
TokenType::Access,
|
||||
None,
|
||||
);
|
||||
let access_token = create_token(&self.secret, access_claims)?;
|
||||
|
||||
// Refresh token (longer-lived)
|
||||
let refresh_claims = JwtClaims::new(
|
||||
sub.to_string(),
|
||||
now + self.refresh_token_expiry_secs,
|
||||
TokenType::Refresh,
|
||||
None,
|
||||
);
|
||||
let refresh_token = create_token(&self.secret, refresh_claims)?;
|
||||
|
||||
Ok((access_token, refresh_token))
|
||||
}
|
||||
|
||||
/// Verify an access token and return the subject claim.
|
||||
fn verify_access_token(&self, token: &str) -> anyhow::Result<String> {
|
||||
use zesdex_infrastructure::auth::jwt::verify_token;
|
||||
|
||||
let claims = verify_token(&self.secret, token)?;
|
||||
Ok(claims.sub)
|
||||
}
|
||||
|
||||
/// Verify a refresh token and return the subject claim.
|
||||
///
|
||||
/// Enforces that the presented token is a **refresh** token (`typ =
|
||||
/// "refresh"`) — an access token presented here is rejected, closing
|
||||
/// the replay-window escalation where a stolen 1-hour access token
|
||||
/// could otherwise be exchanged for a fresh 7-day credential.
|
||||
fn verify_refresh_token(&self, token: &str) -> anyhow::Result<String> {
|
||||
use zesdex_infrastructure::auth::jwt::{verify_token, TokenType};
|
||||
|
||||
let claims = verify_token(&self.secret, token)?;
|
||||
if claims.token_type != TokenType::Refresh {
|
||||
anyhow::bail!("token is not a refresh token");
|
||||
}
|
||||
Ok(claims.sub)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ApiState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared application state for the REST API server.
|
||||
///
|
||||
/// Holds all service implementations, repository instances, and configuration
|
||||
/// needed by the HTTP handlers. Constructed once at startup and shared
|
||||
/// across all requests via `Arc<ApiState>`.
|
||||
///
|
||||
/// `ApiState` does NOT derive `Clone` or `Debug` because the inner service
|
||||
/// types may not implement those traits. It is always wrapped in `Arc`.
|
||||
pub struct ApiState {
|
||||
/// Base directory for all Zesdex data stores (sessions, settings, etc.).
|
||||
pub store_base_dir: PathBuf,
|
||||
/// JWT secret key for token signing/verification.
|
||||
pub jwt_secret: String,
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Service implementations (application-layer use cases)
|
||||
// -----------------------------------------------------------------------
|
||||
/// Session lifecycle management (create, list, archive).
|
||||
pub session_service: zesdex_application::auth::SessionServiceImpl<
|
||||
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository,
|
||||
zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository,
|
||||
>,
|
||||
|
||||
/// Conversation message history CRUD.
|
||||
pub conversation_service: zesdex_application::cms::ConversationServiceImpl<
|
||||
zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository,
|
||||
>,
|
||||
|
||||
/// Settings load/save.
|
||||
pub settings_service: zesdex_application::cms::SettingsServiceImpl<
|
||||
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository,
|
||||
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository,
|
||||
>,
|
||||
|
||||
/// Long-term memory CRUD.
|
||||
pub memory_service: zesdex_application::cms::MemoryServiceImpl<
|
||||
zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository,
|
||||
>,
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Port trait implementations (infrastructure wrappers)
|
||||
// -----------------------------------------------------------------------
|
||||
/// Argon2id password hashing and verification.
|
||||
pub password_service: Argon2PasswordService,
|
||||
|
||||
/// HS256 JWT token generation and verification.
|
||||
pub token_service: JwtTokenService,
|
||||
|
||||
/// Shared sliding-window limiter for auth endpoints (login/register/refresh).
|
||||
pub auth_rate_limiter: zesdex_infrastructure::middleware::rate_limit::RateLimiter,
|
||||
|
||||
/// Serializes read-modify-write of `users.json` so concurrent register
|
||||
/// requests cannot lose writes (TOCTOU race).
|
||||
pub users_lock: Arc<std::sync::Mutex<()>>,
|
||||
|
||||
/// LLM provider client for chat completions.
|
||||
pub llm_client: zesdex_infrastructure::llm::provider::LlmClient,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ApiState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ApiState")
|
||||
.field("store_base_dir", &self.store_base_dir)
|
||||
.field("jwt_secret", &"**redacted**")
|
||||
.field("session_service", &"SessionServiceImpl { .. }")
|
||||
.field("conversation_service", &"ConversationServiceImpl { .. }")
|
||||
.field("settings_service", &"SettingsServiceImpl { .. }")
|
||||
.field("memory_service", &"MemoryServiceImpl { .. }")
|
||||
.field("password_service", &self.password_service)
|
||||
.field("token_service", &self.token_service)
|
||||
.field("llm_client", &"LlmClient { .. }")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiState {
|
||||
/// Construct a new API state with all services wired to their default
|
||||
/// infrastructure implementations.
|
||||
///
|
||||
/// ## Arguments
|
||||
/// * `base_dir` — the Zesdex data store root directory (sessions, settings, etc.)
|
||||
/// * `jwt_secret` — HMAC secret for JWT signing/verification
|
||||
/// * `llm_api_key` — API key for the LLM provider
|
||||
/// * `llm_model` — model identifier string
|
||||
/// * `llm_base_url` — optional custom API base URL
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// Creates concrete repository instances → wraps them in application-layer
|
||||
/// service implementations → stores everything in `ApiState`.
|
||||
pub fn new(
|
||||
base_dir: PathBuf,
|
||||
jwt_secret: impl Into<String>,
|
||||
llm_api_key: impl Into<String>,
|
||||
llm_model: impl Into<String>,
|
||||
llm_base_url: Option<String>,
|
||||
) -> Self {
|
||||
let jwt_secret = jwt_secret.into();
|
||||
let sessions_dir = base_dir.join("sessions");
|
||||
let memory_dir = base_dir.join("memories");
|
||||
|
||||
// IAM repositories
|
||||
let session_repo =
|
||||
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository;
|
||||
let lock_repo =
|
||||
zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository;
|
||||
|
||||
// CMS repositories
|
||||
let conversation_repo =
|
||||
zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository;
|
||||
let settings_repo =
|
||||
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository;
|
||||
let app_config_repo =
|
||||
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository;
|
||||
let memory_repo =
|
||||
zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository;
|
||||
|
||||
// Application-layer services
|
||||
let session_service = zesdex_application::auth::SessionServiceImpl::new(
|
||||
session_repo,
|
||||
lock_repo,
|
||||
base_dir.clone(),
|
||||
);
|
||||
let conversation_service =
|
||||
zesdex_application::cms::ConversationServiceImpl::new(conversation_repo, sessions_dir);
|
||||
let settings_service = zesdex_application::cms::SettingsServiceImpl::new(
|
||||
settings_repo,
|
||||
app_config_repo,
|
||||
base_dir.clone(),
|
||||
);
|
||||
let memory_service =
|
||||
zesdex_application::cms::MemoryServiceImpl::new(memory_repo, memory_dir);
|
||||
|
||||
let token_service = JwtTokenService::new(&jwt_secret);
|
||||
let auth_rate_limiter = zesdex_infrastructure::middleware::rate_limit::RateLimiter::new();
|
||||
let users_lock = Arc::new(std::sync::Mutex::new(()));
|
||||
let llm_client = zesdex_infrastructure::llm::provider::LlmClient::new(
|
||||
llm_api_key.into(),
|
||||
llm_model.into(),
|
||||
llm_base_url,
|
||||
);
|
||||
|
||||
ApiState {
|
||||
store_base_dir: base_dir,
|
||||
jwt_secret,
|
||||
session_service,
|
||||
conversation_service,
|
||||
settings_service,
|
||||
memory_service,
|
||||
password_service: Argon2PasswordService,
|
||||
token_service,
|
||||
auth_rate_limiter,
|
||||
users_lock,
|
||||
llm_client,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
//! Attach mode — TUI-only client that connects to an existing daemon session
|
||||
//! over a Unix socket, forwarding key events and rendering state updates.
|
||||
//!
|
||||
//! Flow: `run_attach(session_id)` resolves the daemon's socket path from
|
||||
//! the store → `setup_attach_client()` connects and enters raw mode →
|
||||
//! enters a render loop: polls for local terminal events (key/resize/paste/
|
||||
//! scroll) → forwards them as `ClientRequest`s to the daemon via IPC →
|
||||
//! receives a `DaemonFrame` reply → `handle_daemon_frame()` /
|
||||
//! `apply_client_update()` applies the state snapshot onto a local
|
||||
//! `AppStateRest` mirror → `draw()` renders the TUI → on quit,
|
||||
//! sends `ClientRequest::Close`, cleans up terminal, and saves settings.
|
||||
//!
|
||||
//! The client has no agent logic — it is a pure render frontend.
|
||||
|
||||
use std::io::{self};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Terminal;
|
||||
use zesdex_domain::SettingsRepository;
|
||||
use zesdex_infrastructure::ipc::client::IpcClient;
|
||||
use zesdex_infrastructure::ipc::protocol::{ClientRequest, DaemonFrame, StatePayload};
|
||||
use zesdex_infrastructure::Toast;
|
||||
use zesdex_infrastructure::ToastKind;
|
||||
|
||||
use crate::key_code::key_code_to_action;
|
||||
use crate::state::{AppStateRest, ChatMessageDisplay, Overlay, RoleWrapper};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply_client_update — apply a StatePayload onto the local AppStateRest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply a `StatePayload` received from the daemon onto the client's
|
||||
/// local `AppStateRest`, so the attach-mode TUI can render it.
|
||||
///
|
||||
/// Flow: copy scalar fields directly → rebuild the transcript cache from
|
||||
/// `MessageEntry`s (mapping role strings back to role variants) →
|
||||
/// resolve the overlay name string to an `Overlay` variant → rebuild
|
||||
/// toasts from `ToastEntry`s.
|
||||
///
|
||||
/// Why: unrecognised role/overlay/toast-kind strings fall back to a safe
|
||||
/// default (`User`, `Overlay::None`, `ToastKind::Info`) rather than
|
||||
/// panicking, so a protocol/version mismatch degrades gracefully.
|
||||
fn apply_client_update(state: &mut AppStateRest, payload: StatePayload) {
|
||||
tracing::debug!("applying state update from daemon");
|
||||
state.session_id = payload.session_id;
|
||||
state.dirty = payload.dirty;
|
||||
|
||||
state.transcript_cache.messages = payload
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|m| ChatMessageDisplay {
|
||||
role: match m.role.as_str() {
|
||||
"Assistant" => RoleWrapper::Assistant,
|
||||
"System" => RoleWrapper::System,
|
||||
"Tool" => RoleWrapper::Tool,
|
||||
_ => RoleWrapper::User,
|
||||
},
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
})
|
||||
.collect();
|
||||
state.transcript_cache.dirty = true;
|
||||
|
||||
state.misc.overlay = match payload.overlay.as_deref() {
|
||||
Some("Help") => Overlay::Help,
|
||||
Some("Settings") => Overlay::Settings,
|
||||
Some("Bash") => Overlay::Bash,
|
||||
Some("QuitConfirm") => Overlay::QuitConfirm,
|
||||
Some("KeyInput") => Overlay::KeyInput,
|
||||
Some("Editor") => Overlay::Editor,
|
||||
Some("Effort") => Overlay::Effort,
|
||||
Some("Mcp") => Overlay::Mcp,
|
||||
Some("Todo") => Overlay::Todo,
|
||||
Some("Rewind") => Overlay::Rewind,
|
||||
Some("Learning") => Overlay::Learning,
|
||||
Some("Usage") => Overlay::Usage,
|
||||
Some("Loading") => Overlay::Loading,
|
||||
Some("ModelSelector") => Overlay::ModelSelector,
|
||||
Some("ClearConfirm") => Overlay::ClearConfirm,
|
||||
_ => Overlay::None,
|
||||
};
|
||||
|
||||
state.misc.toasts = payload
|
||||
.toasts
|
||||
.into_iter()
|
||||
.map(|t| Toast {
|
||||
kind: match t.kind.as_str() {
|
||||
"Success" => ToastKind::Success,
|
||||
"Warning" => ToastKind::Warning,
|
||||
"Error" => ToastKind::Error,
|
||||
"Lesson" => ToastKind::Lesson,
|
||||
_ => ToastKind::Info,
|
||||
},
|
||||
message: t.message,
|
||||
created_at: t.created_at,
|
||||
lifetime_ms: t.lifetime_ms,
|
||||
})
|
||||
.collect();
|
||||
|
||||
state.input.buffer = payload.input_buffer;
|
||||
state.input.cursor = payload.input_cursor;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setup_attach_client — connect to daemon and set up terminal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Set up the IPC client connection, terminal, and initial state for attach mode.
|
||||
///
|
||||
/// Flow: resolve socket path → connect → enable raw/alt mode → create state.
|
||||
///
|
||||
/// Return: (client, terminal, `client_state`) on success.
|
||||
fn setup_attach_client(
|
||||
session_id: &str,
|
||||
) -> Result<(
|
||||
IpcClient,
|
||||
Terminal<CrosstermBackend<io::Stdout>>,
|
||||
AppStateRest,
|
||||
)> {
|
||||
tracing::debug!("setting up attach client for session {session_id}");
|
||||
let store = zesdex_infrastructure::Store::new();
|
||||
let socket_path = store
|
||||
.base_dir
|
||||
.join("run")
|
||||
.join(format!("{session_id}.sock"));
|
||||
let addr = socket_path.to_string_lossy().to_string();
|
||||
let client = IpcClient::connect_unix(&addr)?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
|
||||
execute!(stdout, crossterm::event::EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
|
||||
let workspace_roots = vec![std::env::current_dir()?];
|
||||
let session_dir = store.base_dir.join("sessions").join(session_id);
|
||||
std::fs::create_dir_all(&session_dir)?;
|
||||
let mut client_state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir);
|
||||
client_state.session_id = session_id.to_string();
|
||||
|
||||
Ok((client, terminal, client_state))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handle_daemon_frame — process a single DaemonFrame from the daemon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process a single daemon frame from the IPC channel, updating state accordingly.
|
||||
fn handle_daemon_frame(client_state: &mut AppStateRest, frame: Option<DaemonFrame>) {
|
||||
tracing::debug!("received daemon frame");
|
||||
match frame {
|
||||
Some(DaemonFrame::StateUpdate(payload)) => {
|
||||
apply_client_update(client_state, *payload);
|
||||
}
|
||||
Some(DaemonFrame::StreamToken(_token)) => {}
|
||||
Some(DaemonFrame::SystemNote { kind: _, message }) => {
|
||||
client_state.push_toast(Toast::new(ToastKind::Info, message));
|
||||
}
|
||||
Some(DaemonFrame::ClipboardCopy(text)) => {
|
||||
let _ = zesdex_infrastructure::utils::write_osc52(&mut io::stdout(), &text);
|
||||
client_state.push_toast(Toast::new(
|
||||
ToastKind::Success,
|
||||
"Copied to clipboard".to_string(),
|
||||
));
|
||||
}
|
||||
Some(DaemonFrame::Closed) | None => {
|
||||
client_state.quit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// draw — minimal TUI render function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the current application state onto the terminal.
|
||||
///
|
||||
/// Layout:
|
||||
/// - Top: title bar with session ID and overlay status
|
||||
/// - Middle: transcript message list (scrollable)
|
||||
/// - Bottom: input line with cursor
|
||||
/// - Overlay name shown when an overlay is active
|
||||
fn draw(frame: &mut ratatui::Frame, state: &AppStateRest) {
|
||||
let area = frame.area();
|
||||
|
||||
// Vertical layout: main content + input line
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(1), // main content (transcript + toasts)
|
||||
Constraint::Length(3), // input line
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// ── Main content area ───────────────────────────────────────────────
|
||||
let title = format!(
|
||||
" Zesdex — {} {}",
|
||||
&state.session_id[..state.session_id.len().min(8)],
|
||||
if state.misc.overlay.is_active() {
|
||||
format!("[{}]", state.misc.overlay)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
|
||||
let mut content_lines: Vec<String> = Vec::new();
|
||||
|
||||
// Show toasts at the top if present.
|
||||
for toast in &state.misc.toasts {
|
||||
content_lines.push(format!("[{:?}] {}", toast.kind, toast.message));
|
||||
}
|
||||
|
||||
// Show active overlay name.
|
||||
if state.misc.overlay.is_active() {
|
||||
content_lines.push(String::new());
|
||||
content_lines.push(format!("=== {} ===", state.misc.overlay));
|
||||
content_lines.push(String::new());
|
||||
}
|
||||
|
||||
// Transcript messages.
|
||||
for msg in &state.transcript_cache.messages {
|
||||
let prefix = match msg.role {
|
||||
RoleWrapper::User => "You",
|
||||
RoleWrapper::Assistant => "AI",
|
||||
RoleWrapper::System => "System",
|
||||
RoleWrapper::Tool => "Tool",
|
||||
};
|
||||
content_lines.push(format!("{}: {}", prefix, msg.content));
|
||||
}
|
||||
|
||||
// Scroll offset indicator.
|
||||
if state.scroll.offset > 0 {
|
||||
content_lines.push(format!("--- scrolled up {} lines ---", state.scroll.offset));
|
||||
}
|
||||
|
||||
let content = content_lines.join("\n");
|
||||
|
||||
let main_block = Block::default().title(title).borders(Borders::TOP);
|
||||
let paragraph = Paragraph::new(content)
|
||||
.block(main_block)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((state.scroll.offset as u16, 0));
|
||||
frame.render_widget(paragraph, chunks[0]);
|
||||
|
||||
// ── Input line ──────────────────────────────────────────────────────
|
||||
let input_block = Block::default().borders(Borders::TOP);
|
||||
let input_display = if state.input.buffer.is_empty() {
|
||||
"Type a message...".to_string()
|
||||
} else {
|
||||
state.input.buffer.clone()
|
||||
};
|
||||
let input_paragraph = Paragraph::new(input_display).block(input_block);
|
||||
frame.render_widget(input_paragraph, chunks[1]);
|
||||
|
||||
// Set cursor position for the input line.
|
||||
use ratatui::layout::Position;
|
||||
frame.set_cursor_position(Position::new(
|
||||
chunks[1].x + state.input.cursor as u16 + 1,
|
||||
chunks[1].y + 1,
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// run_attach — main attach-mode entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run zesdex as a TUI-only client attached to an existing daemon session.
|
||||
///
|
||||
/// Flow: connect to the daemon's Unix socket → enter raw mode/alternate
|
||||
/// screen → build a local `AppStateRest` mirror (only used for rendering
|
||||
/// and toast/overlay bookkeeping, not agent logic) → loop: poll for a
|
||||
/// terminal event (key/resize) and forward it as a `ClientRequest`, or
|
||||
/// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and
|
||||
/// apply it via `apply_client_update` → redraw → exit when the daemon
|
||||
/// closes or the user quits (sending `ClientRequest::Close` first).
|
||||
///
|
||||
/// Why: Ctrl+C is intercepted locally to quit the client without going
|
||||
/// through the daemon, since the daemon has no notion of "this client
|
||||
/// wants to leave" beyond the explicit `Close` request.
|
||||
pub fn run_attach(session_id: &str) -> Result<()> {
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
|
||||
|
||||
let (client, mut terminal, mut client_state) = setup_attach_client(session_id)?;
|
||||
let _rt = tokio::runtime::Runtime::new()?;
|
||||
|
||||
loop {
|
||||
if client_state.quit {
|
||||
let _ = client.send(&ClientRequest::Close);
|
||||
break;
|
||||
}
|
||||
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
client_state.misc.drain_expired_toasts(now_ms);
|
||||
|
||||
if crossterm::event::poll(std::time::Duration::from_millis(50))? {
|
||||
match crossterm::event::read()? {
|
||||
Event::Key(key) => {
|
||||
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
|
||||
if key.code == KeyCode::Char('c') && ctrl {
|
||||
client_state.quit = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(key_action) = key_code_to_action(key.code) {
|
||||
client.send(&ClientRequest::KeyPress {
|
||||
key: key_action,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => {
|
||||
client.send(&ClientRequest::Paste(text))?;
|
||||
}
|
||||
Event::Resize(w, h) => {
|
||||
client.send(&ClientRequest::Resize(w, h))?;
|
||||
}
|
||||
Event::Mouse(mouse_event) => {
|
||||
if mouse_event.kind == MouseEventKind::ScrollUp {
|
||||
client.send(&ClientRequest::ScrollUp)?;
|
||||
} else if mouse_event.kind == MouseEventKind::ScrollDown {
|
||||
client.send(&ClientRequest::ScrollDown)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
client.send(&ClientRequest::Tick)?;
|
||||
}
|
||||
|
||||
handle_daemon_frame(&mut client_state, client.receive::<DaemonFrame>()?);
|
||||
|
||||
terminal.draw(|f| {
|
||||
draw(f, &client_state);
|
||||
})?;
|
||||
}
|
||||
|
||||
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
|
||||
let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture);
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
let _ = disable_raw_mode();
|
||||
|
||||
let _ = zesdex_infrastructure::persistence::JsonSettingsRepository::new()
|
||||
.save(&client_state.store_base_dir(), &client_state.settings);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,882 +0,0 @@
|
||||
//! Daemon request handler — processes IPC `ClientRequest` messages,
|
||||
//! applies `Action`s to application state, and pushes state updates back
|
||||
//! to the attached client.
|
||||
//!
|
||||
//! Also defines the [`Action`] enum and the [`apply_action`] dispatcher,
|
||||
//! as well as the [`handle_key`] function that translates `crossterm`
|
||||
//! key events into actions — adapting `controller::input::handle_key`
|
||||
//! from the legacy single-process backend.
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. `handle_daemon_client(conn, state)` loops reading `ClientRequest`s
|
||||
//! 2. Each request is translated into `Action`(s) via `handle_key` /
|
||||
//! direct action invocation
|
||||
//! 3. `apply_action` mutates `AppStateRest` in place
|
||||
//! 4. After each request, `send_daemon_update` pushes a full state
|
||||
//! snapshot back to the client
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use tracing::info;
|
||||
use webbrowser;
|
||||
use zesdex_infrastructure::ipc::conn::Connection;
|
||||
use zesdex_infrastructure::ipc::protocol::{
|
||||
ClientRequest, DaemonFrame, MessageEntry, StatePayload, ToastEntry,
|
||||
};
|
||||
use zesdex_infrastructure::utils::CastOr;
|
||||
|
||||
use crate::key_code::key_action_to_code;
|
||||
use crate::state::{AppStateRest, AutocompleteKind, ChatMessageDisplay, Overlay, RoleWrapper};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single well-typed event that mutates `AppStateRest` when applied via
|
||||
/// [`apply_action`].
|
||||
///
|
||||
/// Produced by `handle_key` (key event → actions) or directly by the
|
||||
/// daemon's IPC handler.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Action {
|
||||
/// Hard exit — immediately terminates the process.
|
||||
ForceQuit,
|
||||
/// Submit a user message to the LLM, starting a new agent turn.
|
||||
SubmitInput(String),
|
||||
/// Delete one character before the cursor in the input buffer.
|
||||
DeleteChar,
|
||||
/// Delete one character after the cursor in the input buffer.
|
||||
DeleteCharRight,
|
||||
/// Move the cursor one position left in the input buffer.
|
||||
CursorLeft,
|
||||
/// Move the cursor one position right in the input buffer.
|
||||
CursorRight,
|
||||
/// Navigate up through command history.
|
||||
HistoryUp,
|
||||
/// Navigate down through command history.
|
||||
HistoryDown,
|
||||
/// Scroll the transcript pane up.
|
||||
ScrollUp,
|
||||
/// Scroll the transcript pane down.
|
||||
ScrollDown,
|
||||
/// Open a named overlay.
|
||||
OpenOverlay(Overlay),
|
||||
/// Close the currently active overlay.
|
||||
CloseOverlay,
|
||||
/// Insert a system-generated note into the transcript.
|
||||
SystemNote {
|
||||
/// Note category: "error", "info", "clear", etc.
|
||||
kind: String,
|
||||
/// The message text to display.
|
||||
message: String,
|
||||
},
|
||||
/// Show the quit-confirmation overlay.
|
||||
QuitConfirm,
|
||||
/// Terminal resize event — carries the new dimensions.
|
||||
Resize(u16, u16),
|
||||
/// Periodic timer tick — drains queued events and runs side jobs.
|
||||
Tick,
|
||||
/// Accept a lesson by name.
|
||||
LessonAccept { name: String },
|
||||
/// Reject a lesson by name.
|
||||
LessonReject { name: String },
|
||||
/// Delete a previously stored lesson by name.
|
||||
LessonDelete { name: String },
|
||||
/// Start the OAuth device-code login flow for a named provider.
|
||||
StartOAuth { provider: String },
|
||||
/// Open the inline file editor for `path`.
|
||||
OpenEditor { path: String },
|
||||
/// Register a new MCP server by name and shell command.
|
||||
McpAdd { name: String, command: String },
|
||||
/// Open the model-picker overlay.
|
||||
ModelList,
|
||||
/// Set the abort flag on the currently running turn.
|
||||
AbortTurn,
|
||||
/// Request AI-summary compaction of the conversation history.
|
||||
Compact,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply_action
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply an `Action` to the application state.
|
||||
///
|
||||
/// Flow: pattern-match the variant → delegate to the corresponding handler
|
||||
/// → handler mutates `state` (input buffer, scroll position, overlay,
|
||||
/// transcript, toasts, dirty flag, etc.).
|
||||
///
|
||||
/// Why: the single chokepoint that turns every typed key and async event
|
||||
/// into a state change.
|
||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
tracing::debug!("apply_action: {:?}", action);
|
||||
match action {
|
||||
// ── Lifecycle ─────────────────────────────────────────────────
|
||||
Action::ForceQuit => handle_force_quit(state),
|
||||
Action::QuitConfirm => handle_quit_confirm(state),
|
||||
Action::Resize(w, _h) => handle_resize(state, w),
|
||||
Action::Tick => handle_tick(state),
|
||||
|
||||
// ── Input / editing ───────────────────────────────────────────
|
||||
Action::SubmitInput(text) => handle_submit_input(state, text),
|
||||
Action::DeleteChar => handle_delete_char(state),
|
||||
Action::DeleteCharRight => handle_delete_char_right(state),
|
||||
Action::CursorLeft => handle_cursor_left(state),
|
||||
Action::CursorRight => handle_cursor_right(state),
|
||||
Action::HistoryUp => handle_history_up(state),
|
||||
Action::HistoryDown => handle_history_down(state),
|
||||
|
||||
// ── Scroll / navigation ───────────────────────────────────────
|
||||
Action::ScrollUp => handle_scroll_up(state),
|
||||
Action::ScrollDown => handle_scroll_down(state),
|
||||
Action::OpenOverlay(overlay) => handle_open_overlay(state, overlay),
|
||||
Action::CloseOverlay => handle_close_overlay(state),
|
||||
|
||||
// ── System / info ─────────────────────────────────────────────
|
||||
Action::SystemNote {
|
||||
kind: _kind,
|
||||
message,
|
||||
} => handle_system_note(state, message),
|
||||
Action::ModelList => handle_model_list(state),
|
||||
Action::AbortTurn => handle_abort_turn(state),
|
||||
Action::Compact => handle_compact(state),
|
||||
|
||||
// ── Editor / MCP / OAuth ──────────────────────────────────────
|
||||
Action::OpenEditor { path } => handle_open_editor(state, path),
|
||||
Action::McpAdd { name, command } => handle_mcp_add(state, name, command),
|
||||
Action::StartOAuth { provider } => handle_start_oauth(state, provider),
|
||||
|
||||
// ── Lessons ───────────────────────────────────────────────────
|
||||
Action::LessonAccept { name } => handle_lesson_accept(state, name),
|
||||
Action::LessonReject { name } => handle_lesson_reject(state, name),
|
||||
Action::LessonDelete { name } => handle_lesson_delete(state, name),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_force_quit(state: &mut AppStateRest) {
|
||||
state.quit = true;
|
||||
}
|
||||
|
||||
fn handle_quit_confirm(state: &mut AppStateRest) {
|
||||
state.misc.overlay = Overlay::QuitConfirm;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_resize(state: &mut AppStateRest, w: u16) {
|
||||
tracing::debug!("terminal resize to width={}", w);
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_tick(state: &mut AppStateRest) {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
state.misc.drain_expired_toasts(now_ms);
|
||||
|
||||
// Drain queued turn events FIRST (while holding the lock), then release
|
||||
// the lock and process events with mutable state access.
|
||||
let drained: Vec<_> = state
|
||||
.turn_events
|
||||
.lock()
|
||||
.map(|mut events| events.drain(..).collect())
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("turn_events mutex poisoned, recovering");
|
||||
e.into_inner().drain(..).collect()
|
||||
});
|
||||
|
||||
for event in drained {
|
||||
use zesdex_infrastructure::TurnEvent;
|
||||
match event {
|
||||
TurnEvent::SystemNote { kind, message } => {
|
||||
if kind == "hive_mind_converged" {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.hive_mind_converged = true;
|
||||
}
|
||||
}
|
||||
handle_system_note(state, message);
|
||||
}
|
||||
TurnEvent::AssistantMessage(_msg) => {
|
||||
// AssistantMessage is handled via StreamDone to avoid duplicates.
|
||||
state.dirty = true;
|
||||
}
|
||||
TurnEvent::StreamToken(_token) => {
|
||||
state.dirty = true;
|
||||
}
|
||||
TurnEvent::StreamDone(msg) => {
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
RoleWrapper::Assistant,
|
||||
msg.content.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
TurnEvent::Error(e) => {
|
||||
state.toast_error(e);
|
||||
}
|
||||
TurnEvent::Usage {
|
||||
tokens_in,
|
||||
tokens_out,
|
||||
} => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
|
||||
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
|
||||
rt.usage.last_tokens_in = tokens_in;
|
||||
rt.usage.last_tokens_out = tokens_out;
|
||||
}
|
||||
}
|
||||
TurnEvent::ReviewUsage {
|
||||
tokens_in,
|
||||
tokens_out,
|
||||
} => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
|
||||
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
|
||||
}
|
||||
}
|
||||
TurnEvent::Done => {
|
||||
if let Ok(mut in_flight) = state.turn_in_flight.lock() {
|
||||
*in_flight = false;
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
TurnEvent::ToolResult { .. } => {
|
||||
// Tool result events are logged but the content is already in
|
||||
// the session runtime messages — no transcript push needed here.
|
||||
state.dirty = true;
|
||||
}
|
||||
_ => {
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.misc.tick_count += 1;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_submit_input(state: &mut AppStateRest, text: String) {
|
||||
// Push the user message to the transcript.
|
||||
state.push_transcript(ChatMessageDisplay::new(RoleWrapper::User, text.clone()));
|
||||
|
||||
// Save the input to history.
|
||||
if !text.is_empty() {
|
||||
state.input.history.push(text.clone());
|
||||
if let Some(ref path) = state.input.history_file {
|
||||
let _ = std::fs::write(path, state.input.history.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// Set up the session runtime for the turn.
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.push_message(zesdex_infrastructure::ChatMessage {
|
||||
role: zesdex_infrastructure::Role::User,
|
||||
content: Some(text.clone()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear the input buffer.
|
||||
state.input.buffer.clear();
|
||||
state.input.cursor = 0;
|
||||
state.input.history_idx = None;
|
||||
state.dirty = true;
|
||||
|
||||
// Resolve LLM provider credentials
|
||||
let provider_name = &state.settings.provider;
|
||||
let provider_cfg = state.app_config.providers.get(provider_name).cloned();
|
||||
let mut api_key = String::new();
|
||||
if let Some(key) = state.settings.api_keys.get(provider_name) {
|
||||
api_key = key.clone();
|
||||
} else if let Some(ref cfg) = provider_cfg {
|
||||
if let Some(ref default_key) = cfg.default_api_key {
|
||||
api_key = default_key.clone();
|
||||
}
|
||||
if api_key.is_empty() {
|
||||
if let Some(ref env_name) = cfg.api_key_env {
|
||||
if let Ok(val) = std::env::var(env_name) {
|
||||
api_key = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let messages = state
|
||||
.session_runtime
|
||||
.as_ref()
|
||||
.map(|rt| rt.messages.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let params = zesdex_domain::agent::AgentTurnParams {
|
||||
messages,
|
||||
session_dir: state.session_dir.clone(),
|
||||
workspace_roots: state.workspace_roots.clone(),
|
||||
turn_events: state.turn_events.clone(),
|
||||
in_flight: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
abort: state.abort_flag.clone(),
|
||||
api_key: api_key.clone(),
|
||||
model: zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config),
|
||||
api_base: provider_cfg.as_ref().map(|cfg| cfg.api_base.clone()),
|
||||
};
|
||||
|
||||
// Capture state for the optional background auto-review so it can run
|
||||
// with the same resolved provider. Done here, before `api_key` /
|
||||
// `provider_cfg` are moved into the client below. The reviewer is
|
||||
// fire-and-forget and skips itself when the workspace has no diff.
|
||||
let review_enabled = state.settings.flags.review_enabled;
|
||||
let review_key = api_key.clone();
|
||||
let review_model =
|
||||
zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config);
|
||||
let review_base = provider_cfg.as_ref().map(|c| c.api_base.clone());
|
||||
let review_ws = params.workspace_roots.clone();
|
||||
let review_events = params.turn_events.clone();
|
||||
|
||||
let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new(
|
||||
api_key,
|
||||
zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config),
|
||||
provider_cfg.map(|cfg| cfg.api_base.clone()),
|
||||
));
|
||||
|
||||
let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder()
|
||||
.session_dir(state.session_dir.clone())
|
||||
.workspaces(state.workspace_roots.clone())
|
||||
.turn_events(state.turn_events.clone())
|
||||
.build();
|
||||
|
||||
let tool_executor = std::sync::Arc::new(
|
||||
zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx),
|
||||
);
|
||||
|
||||
let tools = zesdex_infrastructure::tools::all_tools();
|
||||
let defs = zesdex_infrastructure::tools::tool_defs(&tools);
|
||||
|
||||
let turn_service = zesdex_application::agent::turn_service::AgentTurnServiceImpl::new(
|
||||
client,
|
||||
tool_executor,
|
||||
defs,
|
||||
);
|
||||
|
||||
use zesdex_application::agent::AgentTurnService;
|
||||
tokio::spawn(async move {
|
||||
let _ = turn_service.run_turn(params).await;
|
||||
if review_enabled {
|
||||
zesdex_infrastructure::subagent::auto::engine::spawn_background_review(
|
||||
review_ws,
|
||||
review_events,
|
||||
review_key,
|
||||
review_model,
|
||||
review_base,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_delete_char(state: &mut AppStateRest) {
|
||||
if state.input.cursor > 0 {
|
||||
state.input.buffer.remove(state.input.cursor - 1);
|
||||
state.input.cursor -= 1;
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_delete_char_right(state: &mut AppStateRest) {
|
||||
if state.input.cursor < state.input.buffer.len() {
|
||||
state.input.buffer.remove(state.input.cursor);
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cursor_left(state: &mut AppStateRest) {
|
||||
if state.input.cursor > 0 {
|
||||
state.input.cursor = state.input.cursor.saturating_sub(1);
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cursor_right(state: &mut AppStateRest) {
|
||||
if state.input.cursor < state.input.buffer.len() {
|
||||
state.input.cursor += 1;
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_history_up(state: &mut AppStateRest) {
|
||||
if state.input.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
let idx = match state.input.history_idx {
|
||||
Some(i) if i > 0 => i - 1,
|
||||
None => state.input.history.len() - 1,
|
||||
_ => return,
|
||||
};
|
||||
state.input.history_idx = Some(idx);
|
||||
state.input.buffer = state.input.history[idx].clone();
|
||||
state.input.cursor = state.input.buffer.len();
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_history_down(state: &mut AppStateRest) {
|
||||
match state.input.history_idx {
|
||||
Some(i) if i + 1 < state.input.history.len() => {
|
||||
state.input.history_idx = Some(i + 1);
|
||||
state.input.buffer = state.input.history[i + 1].clone();
|
||||
state.input.cursor = state.input.buffer.len();
|
||||
state.dirty = true;
|
||||
}
|
||||
Some(_) => {
|
||||
state.input.history_idx = None;
|
||||
state.input.buffer.clear();
|
||||
state.input.cursor = 0;
|
||||
state.dirty = true;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_scroll_up(state: &mut AppStateRest) {
|
||||
state.scroll.scroll_up(1);
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_scroll_down(state: &mut AppStateRest) {
|
||||
state.scroll.scroll_down(1);
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) {
|
||||
state.misc.overlay = overlay;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_close_overlay(state: &mut AppStateRest) {
|
||||
if state.misc.overlay.is_active() {
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_system_note(state: &mut AppStateRest, message: String) {
|
||||
state.push_transcript(ChatMessageDisplay::new(RoleWrapper::System, message));
|
||||
}
|
||||
|
||||
fn handle_model_list(state: &mut AppStateRest) {
|
||||
info!("opening model selector");
|
||||
state.misc.overlay = Overlay::ModelSelector;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_abort_turn(state: &mut AppStateRest) {
|
||||
state
|
||||
.abort_flag
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
if let Ok(mut in_flight) = state.turn_in_flight.lock() {
|
||||
*in_flight = false;
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_compact(state: &mut AppStateRest) {
|
||||
tracing::info!("compacting conversation with AI summarization");
|
||||
let provider_name = &state.settings.provider;
|
||||
let provider_cfg = state.app_config.providers.get(provider_name).cloned();
|
||||
let api_key = state
|
||||
.settings
|
||||
.api_keys
|
||||
.get(provider_name)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let model = zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config);
|
||||
let api_base = provider_cfg.map(|cfg| cfg.api_base.clone());
|
||||
|
||||
let client = zesdex_infrastructure::llm::provider::LlmClient::new(api_key, model, api_base);
|
||||
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
let tokio_rt = zesdex_infrastructure::runtime::runtime();
|
||||
if let Ok(()) = tokio_rt.block_on(
|
||||
zesdex_application::agent::turn_service::compact_messages_with_ai(
|
||||
&mut rt.messages,
|
||||
&client,
|
||||
),
|
||||
) {
|
||||
let msg_count = rt.messages.len();
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
RoleWrapper::System,
|
||||
format!("Conversation compacted via AI Summarizer to {msg_count} messages."),
|
||||
));
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_open_editor(state: &mut AppStateRest, path: String) {
|
||||
tracing::info!("opening editor for: {path}");
|
||||
state.misc.editor = Some(crate::state::EditorState::new(
|
||||
std::path::PathBuf::from(&path),
|
||||
std::fs::read_to_string(&path).unwrap_or_default(),
|
||||
));
|
||||
handle_open_overlay(state, Overlay::Editor);
|
||||
}
|
||||
|
||||
fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) {
|
||||
tracing::info!("adding MCP server: {name}");
|
||||
match state.mcp_manager.register(&name, &command) {
|
||||
Ok(()) => {
|
||||
state.toast_success(format!("MCP server '{name}' added with command: {command}"));
|
||||
state.dirty = true;
|
||||
}
|
||||
Err(e) => {
|
||||
state.toast_error(format!("Failed to add MCP server '{name}': {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
|
||||
tracing::info!("starting OAuth for provider: {provider}");
|
||||
state.toast_info(format!("OAuth flow started for {provider}..."));
|
||||
// Construct provider-specific OAuth authorization URL.
|
||||
let auth_url_owned;
|
||||
let url_to_open = match provider.as_str() {
|
||||
"github" => "https://github.com/login/oauth/authorize",
|
||||
"google" => "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"anthropic" => "https://anthropic.com/api/oauth/authorize",
|
||||
other => {
|
||||
auth_url_owned = format!("https://{other}.com/auth");
|
||||
&auth_url_owned
|
||||
}
|
||||
};
|
||||
if let Err(e) = webbrowser::open(url_to_open) {
|
||||
tracing::warn!("Failed to open browser for OAuth: {e}");
|
||||
state.toast_error(format!("Failed to open browser: {e}"));
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
|
||||
tracing::info!("lesson accepted: {name}");
|
||||
state.toast_success(format!("Lesson accepted: {name}"));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
|
||||
tracing::info!("lesson rejected: {name}");
|
||||
state.toast_info(format!("Lesson rejected: {name}"));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_lesson_delete(state: &mut AppStateRest, name: String) {
|
||||
tracing::info!("lesson deleted: {name}");
|
||||
state.toast_warning(format!("Lesson deleted: {name}"));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handle_key — translate crossterm KeyEvent into Vec<Action>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle a crossterm key event and produce a list of actions.
|
||||
///
|
||||
/// Maps key codes + modifiers to Action variants. Mirrors the same
|
||||
/// dispatch logic used by the single-process TUI controller.
|
||||
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
tracing::debug!(
|
||||
code = ?key.code,
|
||||
mods = ?key.modifiers,
|
||||
overlay = ?state.misc.overlay,
|
||||
"handle_key"
|
||||
);
|
||||
|
||||
// ── Editor overlay ───────────────────────────────────────────────────
|
||||
if state.misc.overlay == Overlay::Editor {
|
||||
return match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::QuitConfirm]
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
vec![Action::CloseOverlay]
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Learning overlay ──────────────────────────────────────────────────
|
||||
if state.misc.overlay == Overlay::Learning {
|
||||
return match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::QuitConfirm]
|
||||
}
|
||||
KeyCode::Esc => vec![Action::CloseOverlay],
|
||||
KeyCode::Up => {
|
||||
state.misc.selected_index = state.misc.selected_index.saturating_sub(1);
|
||||
state.dirty = true;
|
||||
vec![]
|
||||
}
|
||||
KeyCode::Down => {
|
||||
state.misc.selected_index = state.misc.selected_index.saturating_add(1);
|
||||
state.dirty = true;
|
||||
vec![]
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Normal mode ───────────────────────────────────────────────────────
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
// Always show quit-confirm, regardless of overlay state.
|
||||
vec![Action::QuitConfirm]
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::CloseOverlay]
|
||||
}
|
||||
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
// Yank: handled separately via clipboard — produce no action
|
||||
state.dirty = true;
|
||||
vec![]
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
// Cycle autocomplete
|
||||
if !state.input.autocomplete_visible {
|
||||
state.input.open_autocomplete();
|
||||
state.dirty = true;
|
||||
} else {
|
||||
state.input.autocomplete_idx = (state.input.autocomplete_idx + 1)
|
||||
% state.input.autocomplete_candidates.len().max(1);
|
||||
state.dirty = true;
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if state.misc.overlay.is_active() {
|
||||
match state.misc.overlay {
|
||||
Overlay::QuitConfirm => vec![Action::ForceQuit],
|
||||
Overlay::ClearConfirm => vec![Action::SystemNote {
|
||||
kind: "clear".to_string(),
|
||||
message: "cleared".to_string(),
|
||||
}],
|
||||
_ => vec![Action::CloseOverlay],
|
||||
}
|
||||
} else if state.input.autocomplete_visible {
|
||||
// Select the current autocomplete candidate
|
||||
if !state.input.autocomplete_candidates.is_empty() {
|
||||
let idx = state
|
||||
.input
|
||||
.autocomplete_idx
|
||||
.min(state.input.autocomplete_candidates.len().saturating_sub(1));
|
||||
if state.input.autocomplete_kind == AutocompleteKind::Command {
|
||||
state.input.buffer = state.input.autocomplete_candidates[idx].clone();
|
||||
state.input.cursor = state.input.buffer.len();
|
||||
}
|
||||
state.input.close_autocomplete();
|
||||
state.dirty = true;
|
||||
}
|
||||
vec![]
|
||||
} else if !state.input.buffer.is_empty() {
|
||||
vec![Action::SubmitInput(state.input.buffer.clone())]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
if state.misc.overlay.is_active() {
|
||||
vec![Action::CloseOverlay]
|
||||
} else if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
state.dirty = true;
|
||||
vec![]
|
||||
} else {
|
||||
vec![Action::AbortTurn]
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if state.misc.overlay.is_active() {
|
||||
vec![Action::ScrollUp]
|
||||
} else {
|
||||
vec![Action::HistoryUp]
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if state.misc.overlay.is_active() {
|
||||
vec![Action::ScrollDown]
|
||||
} else {
|
||||
vec![Action::HistoryDown]
|
||||
}
|
||||
}
|
||||
KeyCode::PageUp => vec![Action::ScrollUp],
|
||||
KeyCode::PageDown => vec![Action::ScrollDown],
|
||||
KeyCode::Home => {
|
||||
state.scroll.offset = 0;
|
||||
state.dirty = true;
|
||||
vec![]
|
||||
}
|
||||
KeyCode::End => {
|
||||
state.scroll.offset = usize::MAX;
|
||||
state.dirty = true;
|
||||
vec![]
|
||||
}
|
||||
KeyCode::Backspace => vec![Action::DeleteChar],
|
||||
KeyCode::Delete => vec![Action::DeleteCharRight],
|
||||
KeyCode::Left => vec![Action::CursorLeft],
|
||||
KeyCode::Right => vec![Action::CursorRight],
|
||||
KeyCode::Char(c) => {
|
||||
// Regular character input
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
}
|
||||
state.input.buffer.insert(state.input.cursor, c);
|
||||
state.input.cursor += c.len_utf8();
|
||||
state.dirty = true;
|
||||
vec![]
|
||||
}
|
||||
_ => {
|
||||
// Unhandled key
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// send_daemon_update — push full state snapshot to the client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Flatten the daemon's `AppStateRest` into a `StatePayload` and send it
|
||||
/// to the attached client as a `DaemonFrame::StateUpdate`.
|
||||
///
|
||||
/// Flow: map transcript messages/toasts to their wire DTOs → derive the
|
||||
/// active overlay name (or `None` if no overlay is active) → build and
|
||||
/// send one `DaemonFrame`.
|
||||
pub fn send_daemon_update(conn: &mut Connection, state: &AppStateRest) -> Result<()> {
|
||||
tracing::debug!("sending state update to attached client");
|
||||
|
||||
let messages: Vec<MessageEntry> = state
|
||||
.transcript_cache
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| MessageEntry {
|
||||
role: format!("{:?}", m.role),
|
||||
content: m.content.clone(),
|
||||
timestamp: m.timestamp,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let toasts: Vec<ToastEntry> = state
|
||||
.misc
|
||||
.toasts
|
||||
.iter()
|
||||
.map(|t| ToastEntry {
|
||||
kind: format!("{:?}", t.kind),
|
||||
message: t.message.clone(),
|
||||
created_at: t.created_at,
|
||||
lifetime_ms: t.lifetime_ms,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let overlay = if state.misc.overlay.is_active() {
|
||||
Some(format!("{:?}", state.misc.overlay))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let frame = DaemonFrame::StateUpdate(Box::new(StatePayload {
|
||||
session_id: state.session_id.clone(),
|
||||
messages,
|
||||
edit_count: state.edit_log.len().cast_or(0u32),
|
||||
message_count: state.transcript_cache.messages.len(),
|
||||
overlay,
|
||||
toasts,
|
||||
dirty: state.dirty,
|
||||
input_buffer: state.input.buffer.clone(),
|
||||
input_cursor: state.input.cursor,
|
||||
}));
|
||||
|
||||
conn.send(&frame)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handle_daemon_client — process an attached client's IPC messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle an incoming client connection for the daemon.
|
||||
///
|
||||
/// Flow: loop reading requests, modifying state, and sending updates back.
|
||||
pub fn handle_daemon_client(mut conn: Connection, state: &mut AppStateRest) -> Result<()> {
|
||||
tracing::debug!("handling daemon client connection");
|
||||
let mut running = true;
|
||||
while running {
|
||||
match conn.receive::<ClientRequest>()? {
|
||||
Some(req) => {
|
||||
match req {
|
||||
ClientRequest::Tick => {
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::KeyPress {
|
||||
key,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
} => {
|
||||
let mut modifiers = KeyModifiers::NONE;
|
||||
if ctrl {
|
||||
modifiers |= KeyModifiers::CONTROL;
|
||||
}
|
||||
if alt {
|
||||
modifiers |= KeyModifiers::ALT;
|
||||
}
|
||||
if shift {
|
||||
modifiers |= KeyModifiers::SHIFT;
|
||||
}
|
||||
let key_event = KeyEvent::new(key_action_to_code(&key), modifiers);
|
||||
let actions = handle_key(key_event, state);
|
||||
for action in actions {
|
||||
apply_action(state, action);
|
||||
}
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Submit(text) => {
|
||||
state.input.buffer = text;
|
||||
let enter_event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||||
let actions = handle_key(enter_event, state);
|
||||
for action in actions {
|
||||
apply_action(state, action);
|
||||
}
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Paste(text) => {
|
||||
state.input.buffer.insert_str(state.input.cursor, &text);
|
||||
state.input.cursor += text.len();
|
||||
state.dirty = true;
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Resize(w, h) => {
|
||||
apply_action(state, Action::Resize(w, h));
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::ScrollUp => {
|
||||
apply_action(state, Action::ScrollUp);
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::ScrollDown => {
|
||||
apply_action(state, Action::ScrollDown);
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Close => {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
if let Some(text) = state.misc.pending_clipboard_copy.take() {
|
||||
conn.send(&DaemonFrame::ClipboardCopy(text))?;
|
||||
}
|
||||
// Only send state update for valid requests, not on EOF/disconnect.
|
||||
if running {
|
||||
send_daemon_update(&mut conn, state)?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
running = false;
|
||||
// Don't call send_daemon_update — connection is dead.
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
//! Key code <-> wire-serializable `KeyAction` conversion functions.
|
||||
//!
|
||||
//! Map `crossterm::event::KeyCode` to and from the IPC protocol's `KeyAction`
|
||||
//! enum. Both directions are total (every `KeyAction` has a `KeyCode`), but
|
||||
//! `key_code_to_action` returns `None` for key codes with no IPC equivalent
|
||||
//! (e.g. media keys), which are silently dropped by the caller.
|
||||
//!
|
||||
//! Flow: daemon receives `KeyAction` over IPC → `key_action_to_code` →
|
||||
//! reconstructs `crossterm::KeyEvent` → feeds into `controller::input::handle_key`.
|
||||
//! The inverse (`key_code_to_action`) is used by the attach-mode client to
|
||||
//! serialise a local terminal key press before sending it over the socket.
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use zesdex_infrastructure::ipc::protocol::KeyAction;
|
||||
|
||||
/// Map a `crossterm` key code to the wire-serializable `KeyAction`, for
|
||||
/// sending key input from an attached client to the daemon.
|
||||
///
|
||||
/// Return: `None` for key codes with no `KeyAction` equivalent (e.g.
|
||||
/// media keys), which are silently dropped.
|
||||
pub fn key_code_to_action(code: KeyCode) -> Option<KeyAction> {
|
||||
tracing::debug!("converting key code to action: {:?}", code);
|
||||
match code {
|
||||
KeyCode::Char(c) => Some(KeyAction::Char(c)),
|
||||
KeyCode::Enter => Some(KeyAction::Enter),
|
||||
KeyCode::Esc => Some(KeyAction::Escape),
|
||||
KeyCode::Backspace => Some(KeyAction::Backspace),
|
||||
KeyCode::Delete => Some(KeyAction::Delete),
|
||||
KeyCode::Tab => Some(KeyAction::Tab),
|
||||
KeyCode::Up => Some(KeyAction::Up),
|
||||
KeyCode::Down => Some(KeyAction::Down),
|
||||
KeyCode::Left => Some(KeyAction::Left),
|
||||
KeyCode::Right => Some(KeyAction::Right),
|
||||
KeyCode::Home => Some(KeyAction::Home),
|
||||
KeyCode::End => Some(KeyAction::End),
|
||||
KeyCode::PageUp => Some(KeyAction::PageUp),
|
||||
KeyCode::PageDown => Some(KeyAction::PageDown),
|
||||
KeyCode::F(n) => Some(KeyAction::Function(n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `key_code_to_action`: reconstruct a `crossterm::KeyCode`
|
||||
/// from a `KeyAction` received over IPC, for replaying it into the
|
||||
/// daemon's normal key-handling path.
|
||||
pub fn key_action_to_code(action: &KeyAction) -> KeyCode {
|
||||
tracing::debug!("converting key action to code: {:?}", action);
|
||||
match action {
|
||||
KeyAction::Char(c) => KeyCode::Char(*c),
|
||||
KeyAction::Enter => KeyCode::Enter,
|
||||
KeyAction::Escape => KeyCode::Esc,
|
||||
KeyAction::Backspace => KeyCode::Backspace,
|
||||
KeyAction::Delete => KeyCode::Delete,
|
||||
KeyAction::Tab => KeyCode::Tab,
|
||||
KeyAction::Up => KeyCode::Up,
|
||||
KeyAction::Down => KeyCode::Down,
|
||||
KeyAction::Left => KeyCode::Left,
|
||||
KeyAction::Right => KeyCode::Right,
|
||||
KeyAction::Home => KeyCode::Home,
|
||||
KeyAction::End => KeyCode::End,
|
||||
KeyAction::PageUp => KeyCode::PageUp,
|
||||
KeyAction::PageDown => KeyCode::PageDown,
|
||||
KeyAction::Function(n) => KeyCode::F(*n),
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
//! # Daemon Interface
|
||||
//!
|
||||
//! Background process that owns agent state and communicates with TUI
|
||||
//! clients over a Unix-socket IPC protocol.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! src/
|
||||
//! ├── lib.rs — Crate root, module declarations, re-exports
|
||||
//! ├── server.rs — Daemon server: session creation, socket bind, client loop
|
||||
//! ├── client.rs — IPC client for attaching to daemon (moved from attach mode)
|
||||
//! ├── key_code.rs — KeyCode <-> KeyAction conversions
|
||||
//! ├── state.rs — AppStateRest, DaemonState, supporting types, create_session
|
||||
//! └── handler.rs — Action, apply_action, handle_key, IPC request handler
|
||||
//! ```
|
||||
//!
|
||||
//! ## Modes
|
||||
//!
|
||||
//! - **Server** (`run_daemon`): creates a session, binds a Unix socket, accepts
|
||||
//! incoming clients, and processes their IPC `ClientRequest`s.
|
||||
//! - **Client** (`run_attach`): connects to a running daemon over its Unix socket,
|
||||
//! enters raw TUI mode, forwards keystrokes, and renders state updates.
|
||||
|
||||
pub mod client;
|
||||
pub mod handler;
|
||||
pub mod key_code;
|
||||
pub mod server;
|
||||
pub mod state;
|
||||
|
||||
// Re-export key types for convenience.
|
||||
pub use handler::{apply_action, Action};
|
||||
pub use state::{AppStateRest, DaemonState, Overlay};
|
||||
@@ -1,64 +0,0 @@
|
||||
//! Daemon server — owns the agent state, listens on a per-session Unix
|
||||
//! socket, and drives one attached client at a time.
|
||||
//!
|
||||
//! Flow: `run_daemon()` creates a session + lock → binds a Unix socket
|
||||
//! under `<store>/run/<session_id>.sock` → blocks for a single client to
|
||||
//! `accept()` → loops reading `ClientRequest`s, translating each into
|
||||
//! `Action`(s) via the same `handle_key`/`apply_action` path the
|
||||
//! single-process mode uses, then pushes a full state update back →
|
||||
//! on `Close` or client disconnect, cleans up the socket file, saves
|
||||
//! settings, and releases the lock.
|
||||
//!
|
||||
//! Why: reuses `crate::handler::handle_key` by synthesising a
|
||||
//! `crossterm::KeyEvent` from the IPC `KeyAction`, so daemon and
|
||||
//! single-process modes share identical key-handling logic.
|
||||
|
||||
use anyhow::Result;
|
||||
use zesdex_infrastructure::ipc::server::IpcServer;
|
||||
|
||||
use crate::handler::handle_daemon_client;
|
||||
use crate::state::create_session;
|
||||
|
||||
/// Run zesdex as a background daemon: owns the agent state, listens on a
|
||||
/// per-session Unix socket, and drives one attached client.
|
||||
///
|
||||
/// Flow: create session + lock it → bind a Unix socket under
|
||||
/// `<store>/run/<session_id>.sock` → block for a single client to
|
||||
/// `accept()` → loop reading `ClientRequest`s, translating each into
|
||||
/// `Action`(s) → on `Close` or client disconnect, clean up the socket file,
|
||||
/// 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 _guard = rt.enter();
|
||||
|
||||
let run_dir = store.base_dir.join("run");
|
||||
std::fs::create_dir_all(&run_dir)?;
|
||||
let socket_path = run_dir.join(format!("{}.sock", state.session_id));
|
||||
let addr = socket_path.to_string_lossy().to_string();
|
||||
|
||||
let server = IpcServer::bind_unix(&addr)?;
|
||||
tracing::info!("daemon listening on {addr}");
|
||||
|
||||
loop {
|
||||
let conn = match server.accept() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("daemon accept error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
tracing::info!("daemon client connected");
|
||||
|
||||
if let Err(e) = handle_daemon_client(conn, &mut state) {
|
||||
tracing::error!("daemon error handling client: {e}");
|
||||
}
|
||||
|
||||
tracing::info!("daemon client disconnected");
|
||||
state.save_settings();
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,800 +0,0 @@
|
||||
//! Daemon state types — `AppStateRest`, `DaemonState`, and all supporting
|
||||
//! data structures for the background daemon session.
|
||||
//!
|
||||
//! `AppStateRest` is the single source-of-truth struct mutated in-place from
|
||||
//! [`handler::apply_action`](crate::handler::apply_action) and the IPC handler.
|
||||
//! `DaemonState` wraps it with IPC socket metadata.
|
||||
//!
|
||||
//! Also contains [`create_session()`] adapted from the legacy `main.rs`.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use zesdex_domain::cms::EditLog;
|
||||
use zesdex_domain::AppConfigRepository;
|
||||
use zesdex_domain::EditLogRepository;
|
||||
use zesdex_domain::Session;
|
||||
use zesdex_domain::SessionLockRepository;
|
||||
use zesdex_domain::SessionRepository;
|
||||
use zesdex_domain::Settings;
|
||||
use zesdex_domain::SettingsRepository;
|
||||
use zesdex_infrastructure::mcp::manager::McpManager;
|
||||
use zesdex_infrastructure::persistence::FileSystemSessionLockRepository;
|
||||
use zesdex_infrastructure::persistence::JsonAppConfigRepository;
|
||||
use zesdex_infrastructure::persistence::JsonSettingsRepository;
|
||||
use zesdex_infrastructure::persistence::JsonlEditLogRepository;
|
||||
use zesdex_infrastructure::AppConfig;
|
||||
use zesdex_infrastructure::DirCache;
|
||||
use zesdex_infrastructure::MentionIndex;
|
||||
use zesdex_infrastructure::SessionRuntime;
|
||||
use zesdex_infrastructure::Toast;
|
||||
use zesdex_infrastructure::ToastKind;
|
||||
use zesdex_infrastructure::TurnEvent;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supporting types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single transcript entry rendered in the TUI chat pane.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ChatMessageDisplay {
|
||||
/// Message author: User, Assistant, System, or Tool.
|
||||
pub role: RoleWrapper,
|
||||
/// Rendered text content (plain text, no markdown).
|
||||
pub content: String,
|
||||
/// Millisecond timestamp when this display entry was created.
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
/// Simple string-backed role wrapper for transcript display (avoids a direct
|
||||
/// dependency on the domain's `Role` enum which may not round-trip all wire
|
||||
/// strings).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RoleWrapper {
|
||||
User,
|
||||
Assistant,
|
||||
System,
|
||||
Tool,
|
||||
}
|
||||
|
||||
impl ChatMessageDisplay {
|
||||
/// Build a display entry, stamping it with the current time.
|
||||
pub fn new(role: RoleWrapper, content: String) -> Self {
|
||||
tracing::debug!(
|
||||
"ChatMessageDisplay::new — role={:?}, content_len={}",
|
||||
role,
|
||||
content.len()
|
||||
);
|
||||
ChatMessageDisplay {
|
||||
role,
|
||||
content,
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which modal overlay, if any, is currently shown over the main TUI view.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Overlay {
|
||||
/// No overlay; the main chat view is shown.
|
||||
None,
|
||||
/// Key bindings help screen.
|
||||
Help,
|
||||
/// Settings/configuration panel.
|
||||
Settings,
|
||||
/// Background bash job viewer.
|
||||
Bash,
|
||||
/// "Are you sure you want to quit?" confirmation.
|
||||
QuitConfirm,
|
||||
/// Raw key-code input capture (for binding custom keys).
|
||||
KeyInput,
|
||||
/// Inline editor (opened via `/edit`).
|
||||
Editor,
|
||||
/// Reasoning effort level selector.
|
||||
Effort,
|
||||
/// MCP server management panel.
|
||||
Mcp,
|
||||
/// Task list overlay.
|
||||
Todo,
|
||||
/// Session rewind / history scrubber.
|
||||
Rewind,
|
||||
/// Learning / lesson management panel.
|
||||
Learning,
|
||||
/// Token usage statistics panel.
|
||||
Usage,
|
||||
/// Generic loading spinner overlay.
|
||||
Loading,
|
||||
/// Model selector dropdown.
|
||||
ModelSelector,
|
||||
/// "Clear conversation?" confirmation (distinct from QuitConfirm).
|
||||
ClearConfirm,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
/// Human-readable name for this overlay variant.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Overlay::None => "none",
|
||||
Overlay::Help => "help",
|
||||
Overlay::Settings => "settings",
|
||||
Overlay::Bash => "bash",
|
||||
Overlay::QuitConfirm => "quit_confirm",
|
||||
Overlay::KeyInput => "key_input",
|
||||
Overlay::Editor => "editor",
|
||||
Overlay::Effort => "effort",
|
||||
Overlay::Mcp => "mcp",
|
||||
Overlay::Todo => "todo",
|
||||
Overlay::Rewind => "rewind",
|
||||
Overlay::Learning => "learning",
|
||||
Overlay::Usage => "usage",
|
||||
Overlay::Loading => "loading",
|
||||
Overlay::ModelSelector => "model_selector",
|
||||
Overlay::ClearConfirm => "clear_confirm",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any overlay (i.e. anything other than `None`) is active.
|
||||
pub fn is_active(self) -> bool {
|
||||
!matches!(self, Overlay::None)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Overlay {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded ring of recent chat messages used to render the transcript view.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranscriptCache {
|
||||
/// Ordered display messages (newest appended, oldest evicted when full).
|
||||
pub messages: Vec<ChatMessageDisplay>,
|
||||
/// Maximum messages to retain before evicting the oldest.
|
||||
pub max_lines: usize,
|
||||
/// Whether the cache has changed since the last render sweep.
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl TranscriptCache {
|
||||
/// Create an empty transcript cache holding at most `max_lines` messages.
|
||||
pub fn new(max_lines: usize) -> Self {
|
||||
TranscriptCache {
|
||||
messages: Vec::new(),
|
||||
max_lines,
|
||||
dirty: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which source populated the autocomplete dropdown.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutocompleteKind {
|
||||
/// Builtin slash-command (e.g. `/model`, `/help`).
|
||||
Command,
|
||||
/// `@file` mention from the workspace file index.
|
||||
FileMention,
|
||||
}
|
||||
|
||||
/// The user's input buffer, cursor position, history, and autocomplete state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InputState {
|
||||
/// Raw UTF-8 input buffer content.
|
||||
pub buffer: String,
|
||||
/// Byte offset of the cursor within `buffer`.
|
||||
pub cursor: usize,
|
||||
/// Previously submitted input lines, oldest-first.
|
||||
pub history: Vec<String>,
|
||||
/// Index into `history` when browsing (None = at the current input).
|
||||
pub history_idx: Option<usize>,
|
||||
/// The prefix string used to filter candidates for autocomplete.
|
||||
pub autocomplete_prefix: String,
|
||||
/// Current autocomplete candidate list.
|
||||
pub autocomplete_candidates: Vec<String>,
|
||||
/// Focused index within `autocomplete_candidates`.
|
||||
pub autocomplete_idx: usize,
|
||||
/// Whether the autocomplete dropdown is visible.
|
||||
pub autocomplete_visible: bool,
|
||||
/// Which kind of autocomplete is active.
|
||||
pub autocomplete_kind: AutocompleteKind,
|
||||
/// Byte offset of the `@` character that triggered file mention autocomplete.
|
||||
pub mention_start: usize,
|
||||
/// Optional path to a persistent history file.
|
||||
pub history_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// Create an empty input state with no buffer, no history, and no autocomplete.
|
||||
pub fn new() -> Self {
|
||||
InputState {
|
||||
buffer: String::new(),
|
||||
cursor: 0,
|
||||
history: Vec::new(),
|
||||
history_idx: None,
|
||||
autocomplete_prefix: String::new(),
|
||||
autocomplete_candidates: Vec::new(),
|
||||
autocomplete_idx: 0,
|
||||
autocomplete_visible: false,
|
||||
autocomplete_kind: AutocompleteKind::Command,
|
||||
mention_start: 0,
|
||||
history_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the autocomplete dropdown.
|
||||
pub fn close_autocomplete(&mut self) {
|
||||
self.autocomplete_visible = false;
|
||||
self.autocomplete_candidates.clear();
|
||||
self.autocomplete_prefix.clear();
|
||||
}
|
||||
|
||||
/// Open the command-autocomplete dropdown.
|
||||
pub fn open_autocomplete(&mut self) {
|
||||
self.autocomplete_kind = AutocompleteKind::Command;
|
||||
self.autocomplete_visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InputState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Viewport scroll state: current offset and visible-line count.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollState {
|
||||
/// Current scroll offset (how many lines have been scrolled past).
|
||||
pub offset: usize,
|
||||
/// Maximum number of lines that fit in the visible viewport area.
|
||||
pub max_visible: usize,
|
||||
}
|
||||
|
||||
impl ScrollState {
|
||||
/// Create a `ScrollState` with zero offset and 30 rows visible.
|
||||
pub fn new() -> Self {
|
||||
ScrollState {
|
||||
offset: 0,
|
||||
max_visible: 30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
||||
pub fn scroll_up(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_add(amount);
|
||||
}
|
||||
|
||||
/// Scroll the viewport down by `amount` lines (decreasing the offset).
|
||||
pub fn scroll_down(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_sub(amount);
|
||||
}
|
||||
|
||||
/// Update the maximum number of visible lines in the viewport.
|
||||
pub fn set_max_visible(&mut self, max: usize) {
|
||||
self.max_visible = max;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScrollState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The "miscellaneous" slice of app state: which overlay is showing,
|
||||
/// toasts, thinking flags, editor state, and tick.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiscState {
|
||||
/// Currently active modal overlay (None = main chat view).
|
||||
pub overlay: Overlay,
|
||||
/// Active toast notifications (expired ones removed on each tick).
|
||||
pub toasts: Vec<Toast>,
|
||||
/// Whether the agent is currently "thinking".
|
||||
pub thinking: bool,
|
||||
/// Current LLM reasoning effort level (1-5).
|
||||
pub effort_level: usize,
|
||||
/// Whether the API connection is established.
|
||||
pub api_connected: bool,
|
||||
/// Currently focused index in list-type overlays.
|
||||
pub selected_index: usize,
|
||||
/// Monotonically increasing tick count, incremented each render frame.
|
||||
pub tick_count: u64,
|
||||
/// Cached content of the TODO file, shown in the overlay.
|
||||
pub todo_content: String,
|
||||
/// Whether a lesson background task is currently running.
|
||||
pub lesson_running: bool,
|
||||
/// Text waiting to be written to the system clipboard.
|
||||
pub pending_clipboard_copy: Option<String>,
|
||||
/// Inline editor state, if the editor overlay is active.
|
||||
pub editor: Option<EditorState>,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
/// Create a fresh `MiscState` with no overlay, no toasts, and default effort level 1.
|
||||
pub fn new() -> Self {
|
||||
MiscState {
|
||||
overlay: Overlay::None,
|
||||
toasts: Vec::new(),
|
||||
thinking: false,
|
||||
effort_level: 1,
|
||||
api_connected: false,
|
||||
selected_index: 0,
|
||||
tick_count: 0,
|
||||
todo_content: String::new(),
|
||||
lesson_running: false,
|
||||
pending_clipboard_copy: None,
|
||||
editor: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a toast notification to the active list.
|
||||
pub fn push_toast(&mut self, toast: Toast) {
|
||||
self.toasts.push(toast);
|
||||
}
|
||||
|
||||
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
|
||||
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<Toast> {
|
||||
let expired: Vec<_> = self
|
||||
.toasts
|
||||
.iter()
|
||||
.filter(|t| t.expired(now_ms))
|
||||
.cloned()
|
||||
.collect();
|
||||
self.toasts.retain(|t| !t.expired(now_ms));
|
||||
expired
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MiscState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// State of a single workflow agent.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AgentState {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
pub current_tool: String,
|
||||
}
|
||||
|
||||
/// Workflow engine state tracking agents in hive-mind orchestration.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WorkflowEngine {
|
||||
/// List of running workflow agent states.
|
||||
pub agents: Vec<AgentState>,
|
||||
}
|
||||
|
||||
impl WorkflowEngine {
|
||||
/// Create an empty workflow engine.
|
||||
pub fn new() -> Self {
|
||||
Self { agents: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple inline editor state for the TUI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorState {
|
||||
/// Path to the file being edited.
|
||||
pub path: PathBuf,
|
||||
/// Current buffer content.
|
||||
pub content: String,
|
||||
/// Cursor position (byte offset).
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Create a new editor state for the given path.
|
||||
pub fn new(path: PathBuf, content: String) -> Self {
|
||||
let cursor = content.len();
|
||||
EditorState {
|
||||
path,
|
||||
content,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the full buffer content.
|
||||
pub fn as_string(&self) -> String {
|
||||
self.content.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppStateRest — single source-of-truth application state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The single source-of-truth state struct for the daemon.
|
||||
///
|
||||
/// Mutated in-place from two locations: `handler::apply_action`
|
||||
/// and the IPC client handler in `handler::handle_daemon_client`.
|
||||
/// Read-only from every other module.
|
||||
#[derive(Clone)]
|
||||
pub struct AppStateRest {
|
||||
/// Persistent user settings (loaded from JSON store at startup).
|
||||
pub settings: Settings,
|
||||
/// Per-project app configuration (loaded from JSON store at startup).
|
||||
pub app_config: AppConfig,
|
||||
/// Absolute paths to each open workspace root directory.
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
/// Unique session identifier.
|
||||
pub session_id: String,
|
||||
/// Path to the session's data directory.
|
||||
pub session_dir: PathBuf,
|
||||
/// Path to the session memory directory (lessons, review history).
|
||||
pub memory_dir: PathBuf,
|
||||
/// Path to the git worktrees directory (for sandboxed agent experiments).
|
||||
pub worktrees_dir: PathBuf,
|
||||
/// Shared async cache of directory listings.
|
||||
pub dir_cache: Arc<RwLock<DirCache>>,
|
||||
/// Shared workspace file-path index for `@file` mention autocomplete.
|
||||
pub mention_index: MentionIndex,
|
||||
/// Persistent edit history log (appended on every tool write).
|
||||
pub edit_log: EditLog,
|
||||
/// Optional per-session runtime state.
|
||||
pub session_runtime: Option<SessionRuntime>,
|
||||
/// Active IAM sessions linked to this app instance.
|
||||
pub sessions: Vec<Session>,
|
||||
/// Ring buffer of recent chat messages for the TUI transcript pane.
|
||||
pub transcript_cache: TranscriptCache,
|
||||
/// Viewport scroll offset tracker.
|
||||
pub scroll: ScrollState,
|
||||
/// Chat input buffer, cursor, history, and autocomplete.
|
||||
pub input: InputState,
|
||||
/// Miscellaneous state: overlay, toasts, flags, tick.
|
||||
pub misc: MiscState,
|
||||
/// Queue of events emitted by the running agent turn.
|
||||
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
/// Whether an agent turn is currently in flight.
|
||||
pub turn_in_flight: Arc<Mutex<bool>>,
|
||||
/// Atomic flag set when the user aborts the current turn (Ctrl-C / Escape).
|
||||
pub abort_flag: Arc<AtomicBool>,
|
||||
/// Workflow engine state for multi-agent hive-mind orchestration.
|
||||
pub workflow_engine: WorkflowEngine,
|
||||
/// MCP server manager.
|
||||
pub mcp_manager: McpManager,
|
||||
/// Whether the state has been modified since the last render sweep.
|
||||
pub dirty: bool,
|
||||
/// Whether the application has been requested to quit.
|
||||
pub quit: bool,
|
||||
}
|
||||
|
||||
impl AppStateRest {
|
||||
/// Construct the initial application state for a session.
|
||||
///
|
||||
/// Flow: load settings/config → derive download/worktree dirs from
|
||||
/// `memory_dir`'s parent → derive `session_id` from the session dir's
|
||||
/// file name → build the sub-state structs.
|
||||
///
|
||||
/// Why: falls back to `memory_dir` itself (with a warning) when it has
|
||||
/// no parent, and to an empty session id when the dir name can't be
|
||||
/// read, so construction never fails.
|
||||
pub fn new(
|
||||
workspace_roots: Vec<PathBuf>,
|
||||
session_dir: &std::path::Path,
|
||||
memory_dir: PathBuf,
|
||||
) -> Self {
|
||||
let store_base_dir = zesdex_infrastructure::Store::new().base_dir;
|
||||
let settings = JsonSettingsRepository::new()
|
||||
.load(&store_base_dir)
|
||||
.unwrap_or_default();
|
||||
let app_config = JsonAppConfigRepository::new()
|
||||
.load(&store_base_dir)
|
||||
.unwrap_or_default();
|
||||
let worktrees_dir = memory_dir
|
||||
.parent()
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
"[state] memory_dir '{}' has no parent, using it for worktrees",
|
||||
memory_dir.display()
|
||||
);
|
||||
&memory_dir
|
||||
})
|
||||
.join("worktrees");
|
||||
let dir_cache = DirCache::new();
|
||||
let session_id = session_dir.file_name().map_or_else(
|
||||
|| {
|
||||
tracing::warn!(
|
||||
"[state] session_dir has no file_name component, using empty session_id"
|
||||
);
|
||||
String::new()
|
||||
},
|
||||
|n| n.to_string_lossy().to_string(),
|
||||
);
|
||||
let mut state = AppStateRest {
|
||||
settings,
|
||||
app_config,
|
||||
workspace_roots,
|
||||
session_id,
|
||||
session_dir: session_dir.to_path_buf(),
|
||||
memory_dir: memory_dir.clone(),
|
||||
worktrees_dir,
|
||||
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
||||
turn_in_flight: Arc::new(Mutex::new(false)),
|
||||
abort_flag: Arc::new(AtomicBool::new(false)),
|
||||
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
||||
mention_index: MentionIndex::new(),
|
||||
edit_log: JsonlEditLogRepository::new()
|
||||
.open(session_dir)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
"[state] failed to open edit log at '{}': {e}",
|
||||
session_dir.display()
|
||||
);
|
||||
EditLog::new()
|
||||
}),
|
||||
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
|
||||
workflow_engine: WorkflowEngine::new(),
|
||||
mcp_manager: McpManager::new(),
|
||||
sessions: Vec::new(),
|
||||
transcript_cache: TranscriptCache::new(200),
|
||||
scroll: ScrollState::new(),
|
||||
input: InputState::new(),
|
||||
misc: MiscState::new(),
|
||||
dirty: true,
|
||||
quit: false,
|
||||
};
|
||||
|
||||
// Load project-specific input-line history from a file keyed by
|
||||
// the first workspace root's SHA256 hash.
|
||||
let base_dir = state.memory_dir.parent().unwrap_or(&state.memory_dir);
|
||||
if let Some(root) = state.workspace_roots.first() {
|
||||
if let Ok(abs_root) = std::fs::canonicalize(root) {
|
||||
use sha2::Digest;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(abs_root.to_string_lossy().as_bytes());
|
||||
let hash_hex = hex::encode(hasher.finalize());
|
||||
let folder_name = abs_root
|
||||
.file_name()
|
||||
.map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
|
||||
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
|
||||
let history_dir = base_dir.join("history");
|
||||
let _ = std::fs::create_dir_all(&history_dir);
|
||||
let history_file = history_dir.join(history_filename);
|
||||
|
||||
if let Ok(content) = std::fs::read_to_string(&history_file) {
|
||||
let history: Vec<String> = content
|
||||
.lines()
|
||||
.map(std::string::ToString::to_string)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
state.input.history = history;
|
||||
}
|
||||
state.input.history_file = Some(history_file);
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
/// Spawn the background thread that walks every workspace root and
|
||||
/// populates `mention_index` for `@file` mention autocomplete.
|
||||
///
|
||||
/// Callers that DO need the index (single-process mode, the daemon)
|
||||
/// call this explicitly after construction.
|
||||
pub fn spawn_mention_index_build(&self) {
|
||||
let mention_index = self.mention_index.clone();
|
||||
let roots = self.workspace_roots.clone();
|
||||
std::thread::spawn(move || {
|
||||
const MAX_MENTION_ENTRIES: usize = 50_000;
|
||||
let mut paths = Vec::new();
|
||||
'roots: for (i, root) in roots.iter().enumerate() {
|
||||
for entry in ignore::Walk::new(root).flatten() {
|
||||
if !entry.path().is_file() {
|
||||
continue;
|
||||
}
|
||||
let rel = entry.path().strip_prefix(root).unwrap_or(entry.path());
|
||||
let rel_str = rel.display().to_string();
|
||||
let formatted = if i == 0 {
|
||||
rel_str
|
||||
} else {
|
||||
format!("[{i}]{rel_str}")
|
||||
};
|
||||
paths.push(formatted);
|
||||
if paths.len() >= MAX_MENTION_ENTRIES {
|
||||
break 'roots;
|
||||
}
|
||||
}
|
||||
}
|
||||
mention_index.set(paths);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether an agent turn is currently running.
|
||||
pub fn turn_in_flight(&self) -> bool {
|
||||
self.turn_in_flight.lock().map_or_else(
|
||||
|_| {
|
||||
tracing::warn!("[state] turn_in_flight mutex poisoned");
|
||||
false
|
||||
},
|
||||
|g| *g,
|
||||
)
|
||||
}
|
||||
|
||||
/// Append a message to the transcript, evicting the oldest entry once
|
||||
/// `max_lines` is exceeded.
|
||||
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
|
||||
self.transcript_cache.messages.push(msg);
|
||||
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
|
||||
self.transcript_cache.messages.remove(0);
|
||||
}
|
||||
self.transcript_cache.dirty = true;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Mark the app state as dirty, triggering a TUI re-render on the next frame.
|
||||
pub fn mark_dirty(&mut self) {
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Queue a toast notification for display and mark the app dirty.
|
||||
pub fn push_toast(&mut self, toast: Toast) {
|
||||
self.misc.push_toast(toast);
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
||||
/// Push an info toast with the given message.
|
||||
pub fn toast_info(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(ToastKind::Info, msg.into()));
|
||||
}
|
||||
|
||||
/// Push a success toast with the given message.
|
||||
pub fn toast_success(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(ToastKind::Success, msg.into()));
|
||||
}
|
||||
|
||||
/// Push a warning toast with the given message.
|
||||
pub fn toast_warning(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(ToastKind::Warning, msg.into()));
|
||||
}
|
||||
|
||||
/// Push an error toast with the given message.
|
||||
pub fn toast_error(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(ToastKind::Error, msg.into()));
|
||||
}
|
||||
|
||||
/// Resolve the base directory that stores this session (grandparent of
|
||||
/// `session_dir`).
|
||||
pub fn store_base_dir(&self) -> PathBuf {
|
||||
self.session_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map_or_else(
|
||||
|| {
|
||||
tracing::warn!(
|
||||
"[state] session_dir '{}' has no grandparent, using parent",
|
||||
self.session_dir.display()
|
||||
);
|
||||
self.session_dir.parent().map_or_else(
|
||||
|| {
|
||||
tracing::warn!(
|
||||
"[state] session_dir '{}' has no parent at all, using itself",
|
||||
self.session_dir.display()
|
||||
);
|
||||
self.session_dir.clone()
|
||||
},
|
||||
std::path::Path::to_path_buf,
|
||||
)
|
||||
},
|
||||
std::path::Path::to_path_buf,
|
||||
)
|
||||
}
|
||||
|
||||
/// Persist the current settings to the store and swallow any error.
|
||||
pub fn save_settings(&self) {
|
||||
let _ = JsonSettingsRepository::new().save(&self.store_base_dir(), &self.settings);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SessionLockGuard — RAII guard that releases a session lock on drop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// RAII guard that releases a session lock on drop.
|
||||
pub struct SessionLockGuard {
|
||||
lock_repo: FileSystemSessionLockRepository,
|
||||
session_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl SessionLockGuard {
|
||||
/// Create a new guard. Caller must have already acquired the lock.
|
||||
pub fn new(lock_repo: FileSystemSessionLockRepository, session_dir: PathBuf) -> Self {
|
||||
tracing::debug!("acquired session lock for {:?}", session_dir);
|
||||
Self {
|
||||
lock_repo,
|
||||
session_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionLockGuard {
|
||||
fn drop(&mut self) {
|
||||
tracing::debug!("releasing session lock for {:?}", self.session_dir);
|
||||
let _ = self.lock_repo.unlock(&self.session_dir);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DaemonState — wraps AppStateRest with IPC socket metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The daemon's overall state: owns the application state and the IPC socket
|
||||
/// metadata for client connections.
|
||||
pub struct DaemonState {
|
||||
/// The canonical application state for this daemon session.
|
||||
pub app_state: AppStateRest,
|
||||
/// The daemon session's unique identifier (same as `app_state.session_id`).
|
||||
pub session_id: String,
|
||||
/// Path to the bound Unix socket, if any.
|
||||
pub socket_path: Option<String>,
|
||||
}
|
||||
|
||||
impl DaemonState {
|
||||
/// Wrap an `AppStateRest` into a `DaemonState`.
|
||||
pub fn new(app_state: AppStateRest) -> Self {
|
||||
let session_id = app_state.session_id.clone();
|
||||
DaemonState {
|
||||
app_state,
|
||||
session_id,
|
||||
socket_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the socket path after binding.
|
||||
pub fn set_socket_path(&mut self, path: String) {
|
||||
self.socket_path = Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a new daemon session: store, session directory, exclusive lock,
|
||||
/// application state, and tokio runtime.
|
||||
///
|
||||
/// Flow: create the store → create a new session directory → attempt an
|
||||
/// exclusive lock → build `AppStateRest` → spawn mention-index builder →
|
||||
/// load session list → start a tokio runtime.
|
||||
///
|
||||
/// Return: (store, lock guard, app_state, tokio_runtime).
|
||||
pub fn create_session() -> Result<(
|
||||
zesdex_infrastructure::Store,
|
||||
SessionLockGuard,
|
||||
AppStateRest,
|
||||
tokio::runtime::Runtime,
|
||||
)> {
|
||||
tracing::info!("creating new daemon session");
|
||||
let store = zesdex_infrastructure::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let session_dir = store.base_dir.join("sessions").join(&session_id);
|
||||
std::fs::create_dir_all(&session_dir)?;
|
||||
|
||||
let lock_repo = FileSystemSessionLockRepository::new();
|
||||
if !lock_repo.try_lock(&session_dir)? {
|
||||
anyhow::bail!(
|
||||
"session already active (another zesdex process holds the lock for this session directory)"
|
||||
);
|
||||
}
|
||||
let session_lock_guard = SessionLockGuard::new(lock_repo, session_dir.clone());
|
||||
|
||||
let workspace_roots = vec![std::env::current_dir()?];
|
||||
let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
|
||||
state.spawn_mention_index_build();
|
||||
let session_repo = zesdex_infrastructure::persistence::FileSystemSessionRepository::new();
|
||||
state.sessions = session_repo
|
||||
.list_sessions(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
|
||||
Ok((store, session_lock_guard, state, rt))
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
//! gRPC interface — high-performance RPC with protobuf.
|
||||
//!
|
||||
//! Uses tonic + prost for gRPC code generation. To enable:
|
||||
//! 1. Add `tonic` and `prost` to Cargo.toml
|
||||
//! 2. Create proto/ directory with service definitions
|
||||
//! 3. Generate code via build.rs
|
||||
//! 4. Implement the generated service traits
|
||||
//!
|
||||
//! Example service:
|
||||
//! ```protobuf
|
||||
//! service Zesdex {
|
||||
//! rpc Chat(ChatRequest) returns (ChatResponse);
|
||||
//! rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse);
|
||||
//! rpc StreamChat(ChatRequest) returns (stream ChatResponse);
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! For now, this crate provides a minimal HTTP health-check endpoint
|
||||
//! so consumers can verify the gRPC server is reachable.
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
/// gRPC server state (minimal for health checks).
|
||||
pub struct GrpcState {
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Build the gRPC server router.
|
||||
pub fn build_router(state: Arc<GrpcState>) -> Router {
|
||||
Router::new()
|
||||
.route("/grpc/health", get(health_check))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Health check endpoint.
|
||||
async fn health_check(
|
||||
axum::extract::State(_state): axum::extract::State<Arc<GrpcState>>,
|
||||
) -> &'static str {
|
||||
"gRPC server is running"
|
||||
}
|
||||
|
||||
/// Run the gRPC server (currently HTTP health only; replace with tonic when ready).
|
||||
pub async fn run_server(port: u16) -> anyhow::Result<()> {
|
||||
let state = Arc::new(GrpcState {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
});
|
||||
let app = build_router(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
info!("gRPC server listening on {addr}");
|
||||
info!("Note: gRPC currently runs HTTP health endpoint. Add tonic+prost for full gRPC.");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
//! Git diff output helper for the diff overlay.
|
||||
//!
|
||||
//! Extracted to its own module so the action dispatcher stays focused on
|
||||
//! state mutation logic rather than shell-out I/O.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// Run `git diff` and return the output for display in the diff overlay.
|
||||
///
|
||||
/// Flow: runs `git diff HEAD` (staged + unstaged changes), and falls back
|
||||
/// to `git diff` if HEAD has no commits yet. Returns a summary of what
|
||||
/// changed with coloured +/- markers.
|
||||
pub fn git_diff_output(workspace_roots: &[std::path::PathBuf]) -> String {
|
||||
let root = workspace_roots
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
// Try diff against HEAD
|
||||
let head_result = Command::new("git")
|
||||
.args(["diff", "HEAD"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
|
||||
match head_result {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Changes (against HEAD) ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback: no HEAD yet (new repo)
|
||||
let diff_result = Command::new("git")
|
||||
.args(["diff"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
if let Ok(out) = diff_result {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Unstaged Changes ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get staged changes too
|
||||
let staged_result = Command::new("git")
|
||||
.args(["diff", "--cached"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
|
||||
if let Ok(out) = staged_result {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Staged Changes ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Also get status summary
|
||||
let status_result = Command::new("git")
|
||||
.args(["status", "--short"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
|
||||
if let Ok(out) = status_result {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Summary ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if output.is_empty() {
|
||||
output = "No changes detected in the working tree.".to_string();
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
//! Extracted action handlers for complex `Action` variants.
|
||||
//!
|
||||
//! The `apply_action` dispatcher delegates to these functions for variants
|
||||
//! whose logic exceeds a few lines, keeping the match statement compact
|
||||
//! and readable.
|
||||
|
||||
use crate::state::{AppStateRest, ChatMessageDisplay};
|
||||
use tracing::debug;
|
||||
use zesdex_domain::core::Role;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tick handler — drains turn events from the shared queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process the `Action::Tick` event: increment tick counter, drain all
|
||||
/// queued `TurnEvent` values from the shared queue, and apply each one
|
||||
/// to the application state.
|
||||
pub fn handle_tick(state: &mut AppStateRest) {
|
||||
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
|
||||
|
||||
// Drain turn events — collect into a local Vec first to avoid holding
|
||||
// the mutex guard across the entire dispatch loop.
|
||||
let events: Vec<zesdex_infrastructure::TurnEvent> = state
|
||||
.turn_events
|
||||
.lock()
|
||||
.map(|mut q| q.drain(..).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let had_events = !events.is_empty();
|
||||
|
||||
for event in events {
|
||||
apply_turn_event(state, event);
|
||||
}
|
||||
|
||||
// Mark dirty when there were events OR a turn is in-flight (spinner
|
||||
// animation); skip the flag when idle to avoid useless re-renders.
|
||||
if had_events || state.turn_in_flight() {
|
||||
state.mark_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TurnEvent dispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply a single `TurnEvent` to `AppStateRest`. Extracted from the
|
||||
/// `Tick` handler so each event variant's logic is self-contained.
|
||||
fn apply_turn_event(state: &mut AppStateRest, event: zesdex_infrastructure::TurnEvent) {
|
||||
match event {
|
||||
zesdex_infrastructure::TurnEvent::SystemNote { kind, message } => {
|
||||
if kind == "hive_mind_converged" {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.hive_mind_converged = true;
|
||||
}
|
||||
} else {
|
||||
state.push_transcript(ChatMessageDisplay::new(Role::System, message));
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::AssistantMessage(msg) => {
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
msg.role,
|
||||
msg.content.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::ToolResult { output, .. } => {
|
||||
state.push_transcript(ChatMessageDisplay::new(Role::Tool, output));
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Usage {
|
||||
tokens_in,
|
||||
tokens_out,
|
||||
} => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
|
||||
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
|
||||
rt.usage.last_tokens_in = tokens_in;
|
||||
rt.usage.last_tokens_out = tokens_out;
|
||||
rt.usage.api_calls = rt.usage.api_calls.saturating_add(1);
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Error(msg) => {
|
||||
state.toast_error(msg);
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamStart => {
|
||||
state.push_transcript(ChatMessageDisplay::new(Role::Assistant, String::new()));
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamToken(text) => {
|
||||
state.append_to_last_transcript(&text, false);
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamReasoning(text) => {
|
||||
state.append_to_last_transcript(&text, true);
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamDone(_msg) => {
|
||||
state.dirty = true;
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Compacted(msgs) => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.messages = msgs;
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::TodoUpdate(content) => {
|
||||
state.misc.todo_content = content;
|
||||
state.toast_success("TODO list updated.");
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::PlanUpdate(content) => {
|
||||
state.misc.plan_content = content;
|
||||
state.toast_success("Project plan updated.");
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Done => {
|
||||
state
|
||||
.turn_in_flight_flag
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
state.dirty = true;
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::WorkflowAgentUpdate {
|
||||
agent_id,
|
||||
agent_name,
|
||||
status,
|
||||
} => {
|
||||
apply_workflow_update(state, agent_id, agent_name, status);
|
||||
}
|
||||
_ => {
|
||||
debug!("unhandled turn event variant");
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow-agent update handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Update the workflow sidebar state from a `WorkflowAgentUpdate` event.
|
||||
fn apply_workflow_update(
|
||||
state: &mut AppStateRest,
|
||||
agent_id: String,
|
||||
agent_name: String,
|
||||
status: zesdex_infrastructure::AgentStatus,
|
||||
) {
|
||||
let idx = state
|
||||
.workflow_engine
|
||||
.agents
|
||||
.iter()
|
||||
.position(|a| a.name == agent_id);
|
||||
|
||||
match status {
|
||||
zesdex_infrastructure::AgentStatus::Pending => {
|
||||
if idx.is_none() {
|
||||
state
|
||||
.workflow_engine
|
||||
.agents
|
||||
.push(crate::state::SimpleAgent::with_display(
|
||||
agent_id, agent_name,
|
||||
));
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Running => {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents[i].state = crate::state::AgentState::Running;
|
||||
state.workflow_engine.agents[i].display_name = agent_name;
|
||||
state.workflow_engine.agents[i].started_at = Some(now);
|
||||
} else {
|
||||
let mut agent = crate::state::SimpleAgent::with_display(agent_id, agent_name);
|
||||
agent.state = crate::state::AgentState::Running;
|
||||
agent.started_at = Some(now);
|
||||
state.workflow_engine.agents.push(agent);
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Completed => {
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents[i].state = crate::state::AgentState::Completed;
|
||||
state.workflow_engine.agents[i].display_name = agent_name;
|
||||
state.workflow_engine.agents[i].completed_at =
|
||||
Some(chrono::Utc::now().timestamp_millis());
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Failed(msg) => {
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents[i].state = crate::state::AgentState::Failed;
|
||||
state.workflow_engine.agents[i].display_name = agent_name;
|
||||
state.workflow_engine.agents[i].error = Some(msg);
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Cancelled => {
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
//! The `Action` enum — a single well-typed event in the TUI, produced by
|
||||
//! key input and applied to `AppStateRest` by the event loop.
|
||||
//!
|
||||
//! # Flow
|
||||
//! `controller::input::handle_key` returns `Vec<Action>` \u{2192} the event loop
|
||||
//! calls `apply_action(&mut state, action)` for each one \u{2192} state is mutated
|
||||
//! in place.
|
||||
//!
|
||||
//! # Organisation
|
||||
//!
|
||||
//! ```text
|
||||
//! action/
|
||||
//! ├── mod.rs — Action enum + apply_action dispatcher
|
||||
//! ├── handlers.rs — Extracted handlers for complex action variants
|
||||
//! └── git.rs — git diff output helper (shell-out I/O)
|
||||
//! ```
|
||||
//!
|
||||
//! # Design
|
||||
//! Every state mutation funnels through this single chokepoint so the view
|
||||
//! layer never mutates state directly and the controller never needs to know
|
||||
//! *how* state is updated \u{2014} only *what* action to produce.
|
||||
|
||||
mod git;
|
||||
mod handlers;
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
use crate::state::Overlay;
|
||||
use crate::state::{AppStateRest, ChatMessageDisplay};
|
||||
use zesdex_domain::core::Role;
|
||||
|
||||
/// A single well-typed event in the TUI that mutates `AppStateRest`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Action {
|
||||
/// Hard exit — immediately terminates the process.
|
||||
ForceQuit,
|
||||
/// Submit a user message to the LLM, starting a new agent turn.
|
||||
SubmitInput(String),
|
||||
/// Delete one character before the cursor in the input buffer.
|
||||
DeleteChar,
|
||||
/// Delete one character after the cursor in the input buffer.
|
||||
DeleteCharRight,
|
||||
/// Move the cursor one position left in the input buffer.
|
||||
CursorLeft,
|
||||
/// Move the cursor one position right in the input buffer.
|
||||
CursorRight,
|
||||
/// Navigate up through command history.
|
||||
HistoryUp,
|
||||
/// Navigate down through command history.
|
||||
HistoryDown,
|
||||
/// Scroll the transcript pane up.
|
||||
ScrollUp,
|
||||
/// Scroll the transcript pane down.
|
||||
ScrollDown,
|
||||
/// Open a named overlay.
|
||||
OpenOverlay(Overlay),
|
||||
/// Close the currently active overlay.
|
||||
CloseOverlay,
|
||||
/// Insert a system-generated note into the transcript.
|
||||
SystemNote {
|
||||
/// Note category: "error", "info", "clear", "hive_mind_converged", etc.
|
||||
kind: String,
|
||||
/// The message text to display.
|
||||
message: String,
|
||||
},
|
||||
/// Show the quit-confirmation overlay.
|
||||
QuitConfirm,
|
||||
/// Terminal resize event.
|
||||
Resize(u16, u16),
|
||||
/// Periodic timer tick — drains queued `TurnEvent`s.
|
||||
Tick,
|
||||
/// Accept a lesson (learned behaviour pattern) by name.
|
||||
LessonAccept { name: String },
|
||||
/// Reject a lesson by name.
|
||||
LessonReject { name: String },
|
||||
/// Delete a previously stored lesson by name.
|
||||
LessonDelete { name: String },
|
||||
/// Start the OAuth device-code login flow for a named provider.
|
||||
StartOAuth { provider: String },
|
||||
/// Open the inline file editor for `path`.
|
||||
OpenEditor { path: String },
|
||||
/// Register a new MCP server by name and shell command.
|
||||
McpAdd { name: String, command: String },
|
||||
/// Open the model-picker overlay.
|
||||
ModelList,
|
||||
/// Set the abort flag on the currently running turn.
|
||||
AbortTurn,
|
||||
/// Request AI-summary compaction of the conversation history.
|
||||
Compact,
|
||||
/// Show the git diff preview overlay.
|
||||
ShowDiff,
|
||||
/// Scroll the diff overlay by `amount` lines (negative = up, positive = down).
|
||||
DiffScroll(i32),
|
||||
}
|
||||
|
||||
/// Apply an `Action` to `AppStateRest`.
|
||||
///
|
||||
/// Flow: pattern-match the variant \u{2192} mutate state in place.
|
||||
/// This is the single chokepoint for all state mutations.
|
||||
///
|
||||
/// Return: nothing; `state` is mutated in place.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
debug!("apply_action: {:?}", action);
|
||||
match action {
|
||||
// ── Lifecycle ─────────────────────────────────────────────────
|
||||
Action::ForceQuit => state.quit = true,
|
||||
Action::QuitConfirm => {
|
||||
state.misc.overlay = Overlay::QuitConfirm;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::Resize(w, _h) => {
|
||||
if state.last_render_width != w {
|
||||
state.transcript_cache.dirty = true;
|
||||
state.last_render_width = w;
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
// ── Timer tick (delegates to extracted handler) ────────────────
|
||||
Action::Tick => handlers::handle_tick(state),
|
||||
|
||||
// ── Input ──────────────────────────────────────────────────────
|
||||
Action::SubmitInput(text) => {
|
||||
state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
|
||||
state.input.submit();
|
||||
crate::turn::spawn_agent_turn(state, text);
|
||||
state.token_count_dirty = true;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DeleteChar => {
|
||||
state.input.delete_left();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DeleteCharRight => {
|
||||
state.input.delete_right();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::CursorLeft => {
|
||||
state.input.cursor = state.input.cursor.saturating_sub(1);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::CursorRight => {
|
||||
if state.input.cursor < state.input.buffer.len() {
|
||||
state.input.cursor += 1;
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::HistoryUp => {
|
||||
state.input.history_up();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::HistoryDown => {
|
||||
state.input.history_down();
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
// ── Scroll ─────────────────────────────────────────────────────
|
||||
Action::ScrollUp => {
|
||||
state.scroll.scroll_up(3);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::ScrollDown => {
|
||||
state.scroll.scroll_down(3);
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
// ── Overlay ────────────────────────────────────────────────────
|
||||
Action::OpenOverlay(overlay) => {
|
||||
state.misc.overlay = overlay;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::CloseOverlay => {
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::SystemNote { kind: _, message } => {
|
||||
state.push_transcript(ChatMessageDisplay::new(Role::System, message));
|
||||
}
|
||||
Action::ModelList => {
|
||||
state.misc.overlay = Overlay::ModelSelector;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::OpenEditor { path } => {
|
||||
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
state.misc.editor = Some(crate::state::EditorState::new(
|
||||
std::path::PathBuf::from(&path),
|
||||
content,
|
||||
));
|
||||
state.misc.overlay = Overlay::Editor;
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
// ── Lessons ────────────────────────────────────────────────────
|
||||
Action::LessonAccept { name } => state.toast_info(format!("Lesson accepted: {name}")),
|
||||
Action::LessonReject { name } => state.toast_info(format!("Lesson rejected: {name}")),
|
||||
Action::LessonDelete { name } => state.toast_info(format!("Lesson deleted: {name}")),
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────
|
||||
Action::StartOAuth { provider } => {
|
||||
state.toast_info(format!("OAuth login started for {provider}"))
|
||||
}
|
||||
|
||||
// ── MCP ────────────────────────────────────────────────────────
|
||||
Action::McpAdd { name, command } => {
|
||||
state.toast_info(format!("MCP server added: {name} ({command})"))
|
||||
}
|
||||
|
||||
// ── Turn control ────────────────────────────────────────────────
|
||||
Action::AbortTurn => {
|
||||
state
|
||||
.abort_flag
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
state.toast_info("Aborting current turn...".to_string());
|
||||
}
|
||||
Action::Compact => state.toast_info("Compacting conversation...".to_string()),
|
||||
|
||||
// ── Diff ───────────────────────────────────────────────────────
|
||||
Action::ShowDiff => {
|
||||
let diff_output = git::git_diff_output(&state.workspace_roots);
|
||||
state.misc.diff_content = diff_output;
|
||||
state.misc.diff_scroll = 0;
|
||||
state.misc.overlay = Overlay::Diff;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DiffScroll(amount) => {
|
||||
let max_scroll = state.misc.diff_content.lines().count().saturating_sub(1);
|
||||
let new_scroll = (state.misc.diff_scroll as i32 + amount).max(0) as usize;
|
||||
state.misc.diff_scroll = new_scroll.min(max_scroll);
|
||||
state.mark_dirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//! Base trait for all TUI components.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::state::AppStateRest;
|
||||
use crossterm::event::Event;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Base trait for modular UI components in the TUI.
|
||||
pub trait Component {
|
||||
/// Draw the component to the screen.
|
||||
fn draw(&mut self, f: &mut Frame, area: Rect, state: &AppStateRest);
|
||||
|
||||
/// Optional: Pre-render caching phase called before draw.
|
||||
fn pre_render(&mut self, _state: &mut AppStateRest) {}
|
||||
|
||||
/// Handle an input event. Return an optional Action to dispatch.
|
||||
fn handle_event(&mut self, _event: Event, _state: &mut AppStateRest) -> Option<Action> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//! Reusable UI components for the TUI.
|
||||
//!
|
||||
//! This module will grow as shared widgets (buttons, input fields, etc.)
|
||||
//! are extracted from individual overlay and view modules.
|
||||
|
||||
pub mod component;
|
||||
pub use component::Component;
|
||||
@@ -1,191 +0,0 @@
|
||||
//! Slash-command parser that maps TUI `/foo` input lines into `Command`
|
||||
//! variants for the action dispatch system.
|
||||
//!
|
||||
//! Flow: the TUI input handler in `controller::input` calls `parse_command`
|
||||
//! on every `/`-prefixed line, then maps the resulting `Command` to an
|
||||
//! `Action` for the event loop to apply to `AppStateRest`.
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
/// A parsed slash command from the TUI input buffer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
/// `/help` — show keybindings / help overlay.
|
||||
Help,
|
||||
/// `/quit` — exit the application.
|
||||
Quit,
|
||||
/// `/mcp` (no args) — open MCP configuration panel.
|
||||
McpOpen,
|
||||
/// `/clear` (with args) — clear with a specific scope.
|
||||
Clear,
|
||||
/// `/clear` (no args) — show confirmation prompt before clearing.
|
||||
ClearConfirm,
|
||||
/// `/login <provider>` — trigger OAuth login for the given provider.
|
||||
Login { provider: String },
|
||||
/// `/edit <path>` — open the given file for review/inline editing.
|
||||
Edit(String),
|
||||
/// `/mcp add <name> <command>` — add a new MCP server definition.
|
||||
McpAdd { name: String, command: String },
|
||||
/// `/model` — list available LLM models.
|
||||
ModelList,
|
||||
/// `/compact` — trigger conversation compaction.
|
||||
Compact,
|
||||
/// `/todo` — open the todo-list overlay.
|
||||
TodoOpen,
|
||||
/// `/plan` — open the project plan overlay.
|
||||
PlanOpen,
|
||||
/// `/usage` — open the usage-stats overlay.
|
||||
UsageOpen,
|
||||
/// `/diff` — show git diff preview.
|
||||
Diff,
|
||||
/// Catch-all: unrecognised or non-slash input.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
/// Parse a slash-prefixed input line into a `Command` value.
|
||||
///
|
||||
/// Flow: trim -> check for leading `/` -> split on space (max 3 parts) ->
|
||||
/// match the first token against known commands -> extract arguments from
|
||||
/// the remaining parts.
|
||||
#[tracing::instrument]
|
||||
pub fn parse_command(text: &str) -> Command {
|
||||
let text = text.trim();
|
||||
if !text.starts_with('/') {
|
||||
return Command::Unknown(text.to_string());
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = text.splitn(3, ' ').collect();
|
||||
let cmd = parts[0];
|
||||
let arg1 = parts.get(1).copied().unwrap_or("");
|
||||
let arg2 = parts.get(2).copied().unwrap_or("");
|
||||
|
||||
let result = match cmd {
|
||||
"/help" => Command::Help,
|
||||
"/quit" => Command::Quit,
|
||||
"/clear" if arg1.is_empty() => Command::ClearConfirm,
|
||||
"/clear" => Command::Clear,
|
||||
"/login" if arg1.is_empty() => Command::Login {
|
||||
provider: String::new(),
|
||||
},
|
||||
"/login" if !arg1.is_empty() => Command::Login {
|
||||
provider: arg1.to_string(),
|
||||
},
|
||||
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
|
||||
"/edit" => Command::Edit(".".to_string()),
|
||||
"/mcp" if arg1.is_empty() => Command::McpOpen,
|
||||
"/mcp" if arg1 == "add" && !arg2.is_empty() => {
|
||||
let rest = arg2.trim();
|
||||
if let Some(space) = rest.find(' ') {
|
||||
let name = rest[..space].to_string();
|
||||
let command = rest[space + 1..].trim().to_string();
|
||||
Command::McpAdd { name, command }
|
||||
} else {
|
||||
Command::McpAdd {
|
||||
name: rest.to_string(),
|
||||
command: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
"/model" => Command::ModelList,
|
||||
"/compact" => Command::Compact,
|
||||
"/todo" => Command::TodoOpen,
|
||||
"/plan" => Command::PlanOpen,
|
||||
"/usage" => Command::UsageOpen,
|
||||
"/diff" => Command::Diff,
|
||||
_ => Command::Unknown(cmd.to_string()),
|
||||
};
|
||||
|
||||
debug!(%text, command = ?result, "parse_command");
|
||||
result
|
||||
}
|
||||
|
||||
/// Map a parsed `Command` into `Action` values for the event loop.
|
||||
#[tracing::instrument]
|
||||
pub fn apply_command(cmd: Command) -> Vec<crate::action::Action> {
|
||||
match cmd {
|
||||
Command::Help => {
|
||||
vec![crate::action::Action::OpenOverlay(
|
||||
crate::state::Overlay::Help,
|
||||
)]
|
||||
}
|
||||
Command::Quit => {
|
||||
vec![crate::action::Action::QuitConfirm]
|
||||
}
|
||||
Command::McpOpen => {
|
||||
vec![crate::action::Action::OpenOverlay(
|
||||
crate::state::Overlay::Mcp,
|
||||
)]
|
||||
}
|
||||
Command::Clear => {
|
||||
vec![crate::action::Action::SystemNote {
|
||||
kind: "clear".to_string(),
|
||||
message: "Transcript cleared.".to_string(),
|
||||
}]
|
||||
}
|
||||
Command::ClearConfirm => {
|
||||
vec![crate::action::Action::OpenOverlay(
|
||||
crate::state::Overlay::ClearConfirm,
|
||||
)]
|
||||
}
|
||||
Command::Login { provider } => {
|
||||
vec![crate::action::Action::StartOAuth { provider }]
|
||||
}
|
||||
Command::Edit(path) => {
|
||||
vec![crate::action::Action::OpenEditor { path }]
|
||||
}
|
||||
Command::McpAdd { name, command } => {
|
||||
vec![crate::action::Action::McpAdd { name, command }]
|
||||
}
|
||||
Command::ModelList => {
|
||||
vec![crate::action::Action::ModelList]
|
||||
}
|
||||
Command::Compact => {
|
||||
vec![crate::action::Action::Compact]
|
||||
}
|
||||
Command::TodoOpen => {
|
||||
vec![crate::action::Action::OpenOverlay(
|
||||
crate::state::Overlay::Todo,
|
||||
)]
|
||||
}
|
||||
Command::PlanOpen => {
|
||||
vec![crate::action::Action::OpenOverlay(
|
||||
crate::state::Overlay::Plan,
|
||||
)]
|
||||
}
|
||||
Command::UsageOpen => {
|
||||
vec![crate::action::Action::OpenOverlay(
|
||||
crate::state::Overlay::Usage,
|
||||
)]
|
||||
}
|
||||
Command::Diff => {
|
||||
vec![crate::action::Action::ShowDiff]
|
||||
}
|
||||
Command::Unknown(text) => {
|
||||
if text.starts_with('/') {
|
||||
vec![crate::action::Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
message: format!("Unknown command: {text}"),
|
||||
}]
|
||||
} else {
|
||||
vec![crate::action::Action::SubmitInput(text)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Verify that `parse_command` recognises the `/todo` command.
|
||||
#[test]
|
||||
fn parses_todo_open() {
|
||||
assert_eq!(parse_command("/todo"), Command::TodoOpen);
|
||||
}
|
||||
|
||||
/// Verify that `parse_command` recognises the `/usage` command.
|
||||
#[test]
|
||||
fn parses_usage_open() {
|
||||
assert_eq!(parse_command("/usage"), Command::UsageOpen);
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
//! Key event dispatcher: maps crossterm `KeyEvent` values into `Action`
|
||||
//! variants, with special handling for overlays, auto-complete, and the
|
||||
//! inline editor.
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. `handle_key` is called on each key press.
|
||||
//! 2. Overlays with full-screen input (Editor, Learning) intercept *all* keys
|
||||
//! before the main match.
|
||||
//! 3. The main match handles navigation, auto-complete, editing, and shortcuts.
|
||||
//! 4. Multi-key actions return `Vec<Action>`.
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
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::{AppStateRest, AutocompleteKind, Overlay};
|
||||
|
||||
/// Mark state dirty and return an empty action list.
|
||||
fn mark(state: &mut AppStateRest) -> Vec<Action> {
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Translate a terminal `KeyEvent` into zero or more `Action` values
|
||||
/// based on the current application state.
|
||||
///
|
||||
/// Return: `Vec<Action>` so a single key can produce multiple queued actions.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
debug!(code = ?key.code, mods = ?key.modifiers, overlay = ?state.misc.overlay, "handle_key");
|
||||
|
||||
// ── Editor overlay ───────────────────────────────────────────────────
|
||||
if state.misc.overlay == Overlay::Editor {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return vec![Action::QuitConfirm];
|
||||
}
|
||||
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if let Some(ref ed) = state.misc.editor.clone() {
|
||||
let content = ed.as_string();
|
||||
if let Err(e) = std::fs::write(&ed.path, &content) {
|
||||
state.toast_error(format!("Save failed: {e}"));
|
||||
} else {
|
||||
state.toast_success(format!("Saved {}", ed.path.display()));
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
// Dismiss editor
|
||||
state.misc.editor = None;
|
||||
state.misc.overlay = Overlay::None;
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if let Some(ref mut ed) = state.misc.editor {
|
||||
ed.delete_left();
|
||||
state.mark_dirty();
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(ref mut ed) = state.misc.editor {
|
||||
ed.content.insert(ed.cursor, '\n');
|
||||
ed.cursor += 1;
|
||||
state.mark_dirty();
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let Some(ref mut ed) = state.misc.editor {
|
||||
ed.content.insert(ed.cursor, c);
|
||||
ed.cursor += c.len_utf8();
|
||||
state.mark_dirty();
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
_ => return vec![],
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diff overlay ──────────────────────────────────────────────────────
|
||||
if state.misc.overlay == Overlay::Diff {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return vec![Action::QuitConfirm];
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
return vec![Action::DiffScroll(-1)];
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
return vec![Action::DiffScroll(1)];
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
return vec![Action::DiffScroll(-20)];
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
return vec![Action::DiffScroll(20)];
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
return vec![Action::CloseOverlay];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Learning overlay ──────────────────────────────────────────────────
|
||||
if state.misc.overlay == Overlay::Learning {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return vec![Action::QuitConfirm];
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
return vec![Action::CloseOverlay];
|
||||
}
|
||||
KeyCode::Up => {
|
||||
let items = crate::state::get_learning_items(state);
|
||||
let n = items.len();
|
||||
state.misc.selected_index =
|
||||
crate::state::cycle_selected_index(state.misc.selected_index, n, false);
|
||||
return mark(state);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let items = crate::state::get_learning_items(state);
|
||||
let n = items.len();
|
||||
state.misc.selected_index =
|
||||
crate::state::cycle_selected_index(state.misc.selected_index, n, true);
|
||||
return mark(state);
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('a') => {
|
||||
let items = crate::state::get_learning_items(state);
|
||||
if let Some(crate::state::LearningItem::Pending { name, .. }) =
|
||||
items.get(state.misc.selected_index)
|
||||
{
|
||||
return vec![Action::LessonAccept { name: name.clone() }];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
let items = crate::state::get_learning_items(state);
|
||||
if let Some(crate::state::LearningItem::Pending { name, .. }) =
|
||||
items.get(state.misc.selected_index)
|
||||
{
|
||||
return vec![Action::LessonReject { name: name.clone() }];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete | KeyCode::Backspace => {
|
||||
let items = crate::state::get_learning_items(state);
|
||||
if let Some(item) = items.get(state.misc.selected_index) {
|
||||
match item {
|
||||
crate::state::LearningItem::Pending { name, .. } => {
|
||||
return vec![Action::LessonReject { name: name.clone() }];
|
||||
}
|
||||
crate::state::LearningItem::Stored { name, .. } => {
|
||||
return vec![Action::LessonDelete { name: name.clone() }];
|
||||
}
|
||||
}
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
_ => return vec![],
|
||||
}
|
||||
}
|
||||
|
||||
// ── Normal (non-overlay) dispatch ────────────────────────────────────
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::QuitConfirm]
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::CloseOverlay]
|
||||
}
|
||||
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
let last_assistant = state
|
||||
.transcript_cache
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == zesdex_domain::core::Role::Assistant);
|
||||
match last_assistant {
|
||||
Some(msg) => {
|
||||
state.misc.pending_clipboard_copy = Some(msg.content.clone());
|
||||
}
|
||||
None => {
|
||||
state.toast_info("No assistant message to copy yet".to_string());
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
vec![crate::action::Action::ShowDiff]
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.select_autocomplete();
|
||||
return mark(state);
|
||||
}
|
||||
if state.misc.overlay.is_active() {
|
||||
return handle_overlay_enter(state);
|
||||
}
|
||||
let text = state.input.buffer.clone();
|
||||
if text.starts_with('/') {
|
||||
return apply_command(parse_command(&text));
|
||||
}
|
||||
vec![Action::SubmitInput(text)]
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
return mark(state);
|
||||
}
|
||||
vec![Action::DeleteChar]
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
return mark(state);
|
||||
}
|
||||
vec![Action::DeleteCharRight]
|
||||
}
|
||||
KeyCode::Left => {
|
||||
vec![Action::CursorLeft]
|
||||
}
|
||||
KeyCode::Right => {
|
||||
vec![Action::CursorRight]
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.cycle_autocomplete(false);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::Effort {
|
||||
crate::state::cycle_effort(state, false);
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::Rewind {
|
||||
let n = crate::state::rewind_count(state);
|
||||
state.misc.selected_index =
|
||||
crate::state::cycle_selected_index(state.misc.selected_index, n, false);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::ModelSelector {
|
||||
let n = state.app_config.providers.len();
|
||||
state.misc.selected_index =
|
||||
crate::state::cycle_selected_index(state.misc.selected_index, n, false);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
vec![Action::ScrollUp]
|
||||
} else {
|
||||
vec![Action::HistoryUp]
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.cycle_autocomplete(true);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::Effort {
|
||||
crate::state::cycle_effort(state, true);
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::Rewind {
|
||||
let n = crate::state::rewind_count(state);
|
||||
state.misc.selected_index =
|
||||
crate::state::cycle_selected_index(state.misc.selected_index, n, true);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::ModelSelector {
|
||||
let n = state.app_config.providers.len();
|
||||
state.misc.selected_index =
|
||||
crate::state::cycle_selected_index(state.misc.selected_index, n, true);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
vec![Action::ScrollDown]
|
||||
} else {
|
||||
vec![Action::HistoryDown]
|
||||
}
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
vec![Action::ScrollUp]
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
vec![Action::ScrollDown]
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
if state.turn_in_flight() {
|
||||
vec![Action::AbortTurn]
|
||||
} else if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if state.misc.overlay.is_active() {
|
||||
vec![Action::CloseOverlay]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
if state.input.buffer.starts_with('/') {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.cycle_autocomplete(true);
|
||||
} else {
|
||||
state.input.tab_complete();
|
||||
}
|
||||
state.mark_dirty();
|
||||
} else if state.input.autocomplete_kind == AutocompleteKind::FileMention
|
||||
&& state.input.autocomplete_visible
|
||||
{
|
||||
state.input.cycle_autocomplete(true);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
state.mark_dirty();
|
||||
}
|
||||
state.input.insert(c);
|
||||
state.mark_dirty();
|
||||
if state.input.buffer.starts_with('/') {
|
||||
state.input.open_autocomplete();
|
||||
} else if state.input.mention_query_at_cursor().is_some() {
|
||||
state
|
||||
.input
|
||||
.open_mention_autocomplete(&state.mention_index.snapshot());
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal `AppStateRest` in a temp directory for test isolation.
|
||||
fn test_state() -> AppStateRest {
|
||||
let tmp = std::env::temp_dir().join(format!("zesdex-input-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"))
|
||||
}
|
||||
|
||||
/// Verify that Ctrl+Y sets `pending_clipboard_copy` to the most
|
||||
/// recent assistant message (skipping non-assistant roles like Tool).
|
||||
#[test]
|
||||
fn ctrl_y_sets_pending_clipboard_copy_to_last_assistant_message() {
|
||||
let mut state = test_state();
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::User,
|
||||
"hi".to_string(),
|
||||
));
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::Assistant,
|
||||
"first reply".to_string(),
|
||||
));
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::Tool,
|
||||
"tool output".to_string(),
|
||||
));
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::Assistant,
|
||||
"second reply".to_string(),
|
||||
));
|
||||
handle_key(
|
||||
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
|
||||
&mut state,
|
||||
);
|
||||
assert_eq!(
|
||||
state.misc.pending_clipboard_copy,
|
||||
Some("second reply".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that Ctrl+Y with no assistant messages in the transcript
|
||||
/// pushes an info toast instead of setting `pending_clipboard_copy`.
|
||||
#[test]
|
||||
fn ctrl_y_with_no_assistant_message_pushes_info_toast() {
|
||||
let mut state = test_state();
|
||||
handle_key(
|
||||
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
|
||||
&mut state,
|
||||
);
|
||||
assert!(state.misc.pending_clipboard_copy.is_none());
|
||||
assert_eq!(state.misc.toasts.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//! Keyboard input handling and command parsing for the TUI.
|
||||
//!
|
||||
//! The controller layer bridges raw terminal key events (from `crossterm`) to
|
||||
//! application actions. It contains two sub-modules:
|
||||
//!
|
||||
//! - `input` — key-event dispatch, prompt-line editing, history navigation,
|
||||
//! tab-completion, and action invocation.
|
||||
//! - `command` — the `/slash` command parser that translates user-typed
|
||||
//! commands into structured `Action` variants.
|
||||
pub mod command;
|
||||
pub mod input;
|
||||
pub mod overlay_enter;
|
||||
@@ -1,125 +0,0 @@
|
||||
//! 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()
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
//! # Zesdex TUI (Terminal User Interface)
|
||||
//!
|
||||
//! This crate provides the terminal UI interface for the Zesdex application,
|
||||
//! built on `ratatui` with `crossterm` for terminal interaction.
|
||||
//!
|
||||
//! It is one of MANY possible user interfaces — others include the HTTP API
|
||||
//! gateway, CLI batch commands, and daemon-mode background processing.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! apps/interfaces/tui/src/
|
||||
//! ├── lib.rs — Crate root: module declarations + re-exports
|
||||
//! ├── state.rs — AppStateRest + all TUI-perspective state types
|
||||
//! ├── action.rs — Action enum + apply_action dispatcher
|
||||
//! ├── view/ — TUI rendering (ratatui widgets)
|
||||
//! │ ├── mod.rs — Main draw function (layered layout)
|
||||
//! │ ├── chat.rs — Chat transcript panel
|
||||
//! │ ├── markdown.rs — Markdown-to-styled-spans renderer
|
||||
//! │ ├── sidebar.rs — Right-hand dashboard sidebar
|
||||
//! │ ├── status.rs — Bottom status bar
|
||||
//! │ ├── theme.rs — Tokyo Night colour palette
|
||||
//! │ ├── workflow.rs — Workflow agent status panel
|
||||
//! │ └── overlays/ — 15 modal overlay panels
|
||||
//! ├── controller/ — Input handling + command parsing
|
||||
//! │ ├── mod.rs
|
||||
//! │ ├── command.rs — /slash command parser
|
||||
//! │ └── input.rs — Key event → Action dispatch
|
||||
//! ├── model/ — Data persistence layer
|
||||
//! │ ├── store.rs — Store path configuration (re-export)
|
||||
//! │ ├── msglog/ — SQLite message-log (schema, insert, blobs)
|
||||
//! │ └── agent_def/ — Agent definitions (builtin/global/session)
|
||||
//! └── components/ — Reusable UI widgets (extensible)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Dependencies
|
||||
//!
|
||||
//! - `zesdex-domain` — Domain entities (Role, ChatMessage, Settings, AppConfig)
|
||||
//! - `zesdex-application` — Application port traits and use-cases
|
||||
//! - `zesdex-infrastructure` — Shared concrete infrastructure types
|
||||
//! (SessionRuntime, Toast, DirCache, TurnEvent, etc.)
|
||||
//! - `ratatui` / `crossterm` — Terminal rendering and raw-key input
|
||||
//! - `pulldown-cmark` — Markdown parsing for message rendering
|
||||
//!
|
||||
//! ## State Flow
|
||||
//!
|
||||
//! 1. `state::AppStateRest` is constructed in the application's main/entry point
|
||||
//! 2. The TUI event loop calls `controller::input::handle_key` on each key press
|
||||
//! 3. `handle_key` returns `Vec<action::Action>` which the loop applies via
|
||||
//! `action::apply_action`
|
||||
//! 4. After each action batch, `view::draw` re-renders the terminal
|
||||
//!
|
||||
//! The state types defined here (`AppStateRest`, `InputState`, `MiscState`,
|
||||
//! `Overlay`, etc.) are TUI-perspective — they represent what the interface
|
||||
//! needs to render, not the full application state.
|
||||
|
||||
// Module declarations
|
||||
pub mod action;
|
||||
pub mod components;
|
||||
pub mod controller;
|
||||
pub mod model;
|
||||
pub mod run;
|
||||
pub mod state;
|
||||
pub mod turn;
|
||||
pub mod view;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exports for convenient access by consumers (main.rs / bin entry points)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub use action::{apply_action, Action};
|
||||
pub use run::run_single_process;
|
||||
pub use state::{
|
||||
AgentState, AppStateRest, AutocompleteKind, ChatMessageDisplay, EditorState, InputState,
|
||||
MiscState, Overlay, ScrollState, SimpleAgent, SimpleWorkflowEngine, TranscriptCache,
|
||||
};
|
||||
|
||||
/// Convenience: initialise a `Store` for data directory resolution.
|
||||
pub use zesdex_domain::core::Store;
|
||||
@@ -1,127 +0,0 @@
|
||||
//! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner).
|
||||
//!
|
||||
//! These agents are always available regardless of user or session config.
|
||||
//! They provide the default set of roles shipped with the application.
|
||||
//!
|
||||
//! ## Available agents
|
||||
//! | Agent | Purpose | Key tools |
|
||||
//! |-------|---------|-----------|
|
||||
//! | coder | Write/edit code | read, write, edit, bash, git_operator |
|
||||
//! | reviewer | Review code for correctness/safety | read, grep, recall |
|
||||
//! | researcher | Search and summarise | read, grep, bash, search_web |
|
||||
//! | planner | Break down tasks into steps | read, write, edit, bash, todo_* |
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Declarative specification for instantiating a subagent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDefinition {
|
||||
/// Human-readable name (e.g. `"quick-reviewer"`).
|
||||
pub name: String,
|
||||
/// Functional role (e.g. `"reviewer"`, `"coder"`).
|
||||
pub role: String,
|
||||
/// Optional system prompt override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt: Option<String>,
|
||||
/// Optional tool allowlist. `None` means role-based defaults.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// Optional step budget. `None` means no limit.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_steps: Option<usize>,
|
||||
/// Optional temperature override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl AgentDefinition {
|
||||
/// Create an agent definition with the required name and role.
|
||||
pub fn new(name: String, role: String) -> Self {
|
||||
AgentDefinition {
|
||||
name,
|
||||
role,
|
||||
system_prompt: None,
|
||||
allowed_tools: None,
|
||||
max_steps: None,
|
||||
temperature: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder: set the system prompt.
|
||||
pub fn with_system_prompt(mut self, prompt: String) -> Self {
|
||||
self.system_prompt = Some(prompt);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set the allowed tool list.
|
||||
pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
|
||||
self.allowed_tools = Some(tools);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set the maximum step count.
|
||||
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||
self.max_steps = Some(steps);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the fixed list of built-in agent definitions shipped with zesdex.
|
||||
pub fn builtin_agents() -> Vec<AgentDefinition> {
|
||||
vec![
|
||||
AgentDefinition::new("coder".to_string(), "coder".to_string())
|
||||
.with_system_prompt(
|
||||
"You are a coding agent. Write correct, idiomatic Rust code.".to_string(),
|
||||
)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
"edit".to_string(),
|
||||
"bash".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"git_operator".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
AgentDefinition::new("reviewer".to_string(), "reviewer".to_string())
|
||||
.with_system_prompt(
|
||||
"You are a code reviewer. Focus on correctness, safety, and performance."
|
||||
.to_string(),
|
||||
)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"recall".to_string(),
|
||||
"remember".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
AgentDefinition::new("researcher".to_string(), "researcher".to_string())
|
||||
.with_system_prompt(
|
||||
"You are a research agent. Search for information and summarize findings."
|
||||
.to_string(),
|
||||
)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"bash".to_string(),
|
||||
"search_web".to_string(),
|
||||
"fetch_url".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
AgentDefinition::new("planner".to_string(), "planner".to_string())
|
||||
.with_system_prompt(
|
||||
"You are a planning agent. Break down tasks into clear steps.".to_string(),
|
||||
)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
"edit".to_string(),
|
||||
"bash".to_string(),
|
||||
"todo_write".to_string(),
|
||||
"todo_finish".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
]
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
//! Load, save, and remove user-defined agent definitions stored globally
|
||||
//! (under the store's `agents/` directory), independent of any session.
|
||||
use super::builtin::AgentDefinition;
|
||||
|
||||
/// Load all globally-registered agent definitions from disk.
|
||||
///
|
||||
/// Flow: resolve `<store>/agents/` -> read directory -> parse each `*.json`
|
||||
/// file into an `AgentDefinition`, skipping any that fail to read or parse.
|
||||
pub fn load_global_agents() -> Vec<AgentDefinition> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
tracing::debug!(dir = %agents_dir.display(), "load_global_agents");
|
||||
|
||||
if !agents_dir.exists() {
|
||||
tracing::debug!("load_global_agents — agents dir does not exist");
|
||||
return Vec::new();
|
||||
}
|
||||
let mut agents = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "json") {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(def) = serde_json::from_str::<AgentDefinition>(&content) {
|
||||
tracing::debug!(agent = %def.name, "load_global_agents — loaded");
|
||||
agents.push(def);
|
||||
} else {
|
||||
tracing::warn!(file = %path.display(), "load_global_agents — failed to parse JSON");
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(file = %path.display(), "load_global_agents — failed to read file");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(count = agents.len(), "load_global_agents — done");
|
||||
agents
|
||||
}
|
||||
|
||||
/// Persist a global agent definition as `<store>/agents/<name>.json`.
|
||||
pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
let path = agents_dir.join(format!("{}.json", def.name));
|
||||
let tmp = agents_dir.join(format!("{}.json.tmp", def.name));
|
||||
let content = serde_json::to_string_pretty(def)?;
|
||||
tracing::debug!(agent = %def.name, "save_global_agent — writing");
|
||||
std::fs::write(&tmp, content)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
if let Some(parent) = agents_dir.parent() {
|
||||
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
tracing::info!(agent = %def.name, "save_global_agent — saved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a global agent definition by name.
|
||||
pub fn remove_global_agent(name: &str) -> anyhow::Result<bool> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let path = store.base_dir.join("agents").join(format!("{name}.json"));
|
||||
tracing::debug!(%name, path = %path.display(), "remove_global_agent");
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(_) => {
|
||||
tracing::info!(%name, "remove_global_agent — removed");
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::debug!(%name, "remove_global_agent — not found");
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(%name, error = %e, "remove_global_agent — failed");
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//! Agent definition sources: built-in defaults, global (user-wide), and
|
||||
//! per-session overrides.
|
||||
//!
|
||||
//! Agent definitions control the system prompt, tool set, and configuration
|
||||
//! for each agent. The resolution order (lowest to highest priority) is:
|
||||
//!
|
||||
//! 1. `builtin` — hardcoded default agent shipped with the application.
|
||||
//! 2. `global` — user-wide overrides stored in the config directory.
|
||||
//! 3. `session` — per-session overrides stored in the session directory.
|
||||
pub mod builtin;
|
||||
pub mod global;
|
||||
pub mod session;
|
||||
@@ -1,70 +0,0 @@
|
||||
//! Load, save, add, and remove agent definitions scoped to a single
|
||||
//! session (`<session_dir>/agents.json`).
|
||||
use super::builtin::AgentDefinition;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load agent definitions saved for a specific session.
|
||||
pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
tracing::debug!(file = %agents_file.display(), "load_session_agents");
|
||||
|
||||
if !agents_file.exists() {
|
||||
tracing::debug!("load_session_agents — file does not exist");
|
||||
return Vec::new();
|
||||
}
|
||||
match std::fs::read_to_string(&agents_file) {
|
||||
Ok(content) => {
|
||||
let agents: Vec<AgentDefinition> = serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
tracing::warn!("load_session_agents — failed to parse agents.json: {}", e);
|
||||
Vec::new()
|
||||
});
|
||||
tracing::debug!(count = agents.len(), "load_session_agents — loaded");
|
||||
agents
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "load_session_agents — failed to read");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrite `<session_dir>/agents.json` with the given agent list.
|
||||
pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
let tmp = session_dir.join("agents.json.tmp");
|
||||
let content = serde_json::to_string_pretty(agents)?;
|
||||
tracing::debug!(count = agents.len(), "save_session_agents — writing");
|
||||
|
||||
std::fs::write(&tmp, content)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, agents_file)?;
|
||||
let _ = std::fs::File::open(session_dir).and_then(|d| d.sync_all());
|
||||
|
||||
tracing::info!(count = agents.len(), "save_session_agents — saved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add or replace a session agent definition by name.
|
||||
pub fn add_session_agent(session_dir: &Path, def: &AgentDefinition) -> anyhow::Result<()> {
|
||||
tracing::debug!(agent = %def.name, "add_session_agent");
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
agents.retain(|a| a.name != def.name);
|
||||
agents.push(def.clone());
|
||||
save_session_agents(session_dir, &agents)
|
||||
}
|
||||
|
||||
/// Remove a session agent definition by name.
|
||||
pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<bool> {
|
||||
tracing::debug!(%name, "remove_session_agent");
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
let before = agents.len();
|
||||
agents.retain(|a| a.name != name);
|
||||
if agents.len() == before {
|
||||
tracing::debug!(%name, "remove_session_agent — not found");
|
||||
return Ok(false);
|
||||
}
|
||||
save_session_agents(session_dir, &agents)?;
|
||||
tracing::info!(%name, "remove_session_agent — removed");
|
||||
Ok(true)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//! Data-model layer for the TUI interface.
|
||||
//!
|
||||
//! This module contains:
|
||||
//! - `store` — Store path configuration
|
||||
//! - `agent_def` — Agent definition model (built-in, global, session scopes)
|
||||
//! - `msglog` — SQLite-backed message-log persistence (schema, insert, blobs)
|
||||
//!
|
||||
//! The `Store` type is re-exported from `zesdex_domain::core::store`.
|
||||
|
||||
pub mod store {
|
||||
//! Re-export `Store` from the domain layer for path resolution.
|
||||
pub use zesdex_domain::core::Store;
|
||||
}
|
||||
|
||||
pub mod agent_def;
|
||||
pub mod msglog;
|
||||
@@ -1,67 +0,0 @@
|
||||
//! Binary blob storage in the message-log `SQLite` database (e.g. images,
|
||||
//! attachments), keyed by session id and an arbitrary blob key.
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
/// Insert or overwrite a blob for a session under `blob_key`.
|
||||
///
|
||||
/// Flow: compute current timestamp -> `INSERT OR REPLACE` into `blobs`
|
||||
/// keyed on `(session_id, blob_key)`.
|
||||
pub fn store_blob(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
blob_key: &str,
|
||||
data: &[u8],
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let created_at = chrono::Utc::now().timestamp_millis();
|
||||
tracing::debug!(%session_id, %blob_key, size = data.len(), "store_blob");
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![session_id, blob_key, data, mime_type, created_at],
|
||||
)?;
|
||||
tracing::info!(%session_id, %blob_key, "store_blob — stored");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a blob's bytes for a session by key.
|
||||
pub fn retrieve_blob(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
blob_key: &str,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
tracing::debug!(%session_id, %blob_key, "retrieve_blob");
|
||||
let result = conn.query_row(
|
||||
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
||||
params![session_id, blob_key],
|
||||
|row| row.get::<_, Vec<u8>>(0),
|
||||
);
|
||||
match result {
|
||||
Ok(data) => {
|
||||
tracing::debug!(%session_id, %blob_key, size = data.len(), "retrieve_blob — found");
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
tracing::debug!(%session_id, %blob_key, "retrieve_blob — not found");
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(%session_id, %blob_key, error = %e, "retrieve_blob — query failed");
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all blob keys stored for a session, oldest first.
|
||||
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
|
||||
tracing::debug!(%session_id, "list_blob_keys");
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC")?;
|
||||
let rows = stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?;
|
||||
let mut keys = Vec::new();
|
||||
for row in rows {
|
||||
keys.push(row?);
|
||||
}
|
||||
tracing::debug!(%session_id, count = keys.len(), "list_blob_keys — done");
|
||||
Ok(keys)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//! Insert queries against the message log's `messages` table.
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Connection};
|
||||
use zesdex_domain::core::{ChatMessage, Role};
|
||||
|
||||
/// Insert a chat message into the session's message log.
|
||||
///
|
||||
/// Flow: extract optional content/tool_call_id/tool_name -> serialize
|
||||
/// `tool_calls` to a JSON string if present -> map `Role` to its string
|
||||
/// column value -> `INSERT` the row with the current timestamp.
|
||||
///
|
||||
/// Return: the new row's `rowid` on success, or the underlying error.
|
||||
pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result<i64> {
|
||||
let content = msg.content.as_deref();
|
||||
let tool_call_id = msg.tool_call_id.as_deref();
|
||||
let tool_name = msg.name.as_deref();
|
||||
let tool_arguments = msg
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|calls| serde_json::to_string(calls).unwrap_or_default());
|
||||
let created_at = chrono::Utc::now().timestamp_millis();
|
||||
let role_str = match msg.role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
Role::Tool => "tool",
|
||||
};
|
||||
|
||||
tracing::debug!(%session_id, %role_str, content_len = content.map_or(0, str::len), "insert_message");
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, tool_arguments, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![session_id, role_str, content, tool_call_id, tool_name, tool_arguments, created_at],
|
||||
)?;
|
||||
let rowid = conn.last_insert_rowid();
|
||||
tracing::info!(%session_id, %role_str, rowid, "insert_message — inserted");
|
||||
Ok(rowid)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//! SQLite-backed message log: per-session `messages.sqlite` storing chat
|
||||
//! messages, blobs, and archive/summary metadata.
|
||||
//!
|
||||
//! ## Tables
|
||||
//! | Table | Purpose |
|
||||
//! |-------|---------|
|
||||
//! | `messages` | Individual chat messages (role, content, tool calls) |
|
||||
//! | `archives` | Session archive metadata (title, model, summary) |
|
||||
//! | `blobs` | Binary attachments keyed by `(session_id, blob_key)` |
|
||||
//!
|
||||
//! All writes use WAL mode for concurrent reads without blocking.
|
||||
pub mod blobs;
|
||||
pub mod insert;
|
||||
pub mod schema;
|
||||
|
||||
pub use blobs::store_blob;
|
||||
pub use insert::insert_message;
|
||||
|
||||
/// Open (creating if needed) a session's `messages.sqlite` and ensure its
|
||||
/// schema is initialized.
|
||||
///
|
||||
/// Flow: resolve `<session_dir>/messages.sqlite` -> create parent dirs ->
|
||||
/// open a `SQLite` connection -> run `schema::init_schema`.
|
||||
///
|
||||
/// Return: an open, schema-ready `Connection`, or an error if any step fails.
|
||||
pub fn open_or_create(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
|
||||
let path = session_dir.join("messages.sqlite");
|
||||
tracing::debug!(?path, "open_or_create");
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let conn = rusqlite::Connection::open(&path)?;
|
||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
|
||||
schema::init_schema(&conn)?;
|
||||
tracing::info!("open_or_create — database ready");
|
||||
Ok(conn)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
//! `SQLite` schema definition for the message log database.
|
||||
use anyhow::Result;
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Create the message log's tables and indexes if they don't already
|
||||
/// exist (`messages`, `archives`, `blobs`).
|
||||
pub fn init_schema(conn: &Connection) -> Result<()> {
|
||||
tracing::debug!("init_schema — creating tables if not exists");
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_name TEXT,
|
||||
tool_arguments TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
model TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
token_count INTEGER DEFAULT 0,
|
||||
summary TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
blob_key TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
mime_type TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(session_id, blob_key)
|
||||
);
|
||||
",
|
||||
)?;
|
||||
tracing::info!("init_schema — schema ready");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
//! TUI event loop — single-process mode entry point.
|
||||
//!
|
||||
//! Provides `run_single_process()` which sets up the terminal,
|
||||
//! creates a session, and enters the render/input loop.
|
||||
//!
|
||||
//! Flow: create session + lock → enable raw mode + alternate screen →
|
||||
//! run_loop (render → poll events → handle key → tick) →
|
||||
//! restore terminal → save settings → release lock.
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{
|
||||
DisableBracketedPaste, DisableMouseCapture, Event, KeyEventKind, MouseEventKind,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::action::{apply_action, Action};
|
||||
use crate::controller::input::handle_key;
|
||||
use crate::state::AppStateRest;
|
||||
use crate::view;
|
||||
|
||||
/// Run zesdex as a self-contained TUI + agent loop in one process.
|
||||
///
|
||||
/// Flow: build `AppStateRest` → enter raw mode / alternate screen →
|
||||
/// run the event loop → always restore the terminal (even on error) →
|
||||
/// 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()?;
|
||||
|
||||
// Enter raw mode and alternate screen for the TUI
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
|
||||
execute!(stdout, crossterm::event::EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
|
||||
let run_result = run_loop(&mut state, &mut terminal);
|
||||
|
||||
let mut restore_stdout = io::stdout();
|
||||
let _ = execute!(restore_stdout, DisableBracketedPaste);
|
||||
let _ = execute!(restore_stdout, DisableMouseCapture);
|
||||
let _ = execute!(restore_stdout, LeaveAlternateScreen);
|
||||
let _ = disable_raw_mode();
|
||||
|
||||
if let Err(e) = run_result {
|
||||
let _ = writeln!(restore_stdout, "error: {e}");
|
||||
let _ = restore_stdout.flush();
|
||||
}
|
||||
|
||||
// Save settings
|
||||
state.save_settings();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the event loop, guaranteeing terminal restoration on error.
|
||||
///
|
||||
/// Wraps `run_loop_inner` so that if it panics or returns an error the
|
||||
/// terminal is restored to a usable state before propagating the error.
|
||||
#[tracing::instrument(skip(state, terminal))]
|
||||
fn run_loop(
|
||||
state: &mut AppStateRest,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> Result<()> {
|
||||
debug!("entering run_loop");
|
||||
let result = run_loop_inner(state, terminal);
|
||||
if let Err(ref _e) = result {
|
||||
let _ = terminal.clear();
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(io::stdout(), DisableBracketedPaste);
|
||||
let _ = execute!(io::stdout(), DisableMouseCapture);
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The core single-process render/input loop.
|
||||
///
|
||||
/// Performance optimizations applied here:
|
||||
/// - Skip `terminal.draw()` when `state.dirty == false` (nothing changed)
|
||||
/// - Adaptive poll timeout: 50ms when a turn is in-flight (spinner needs
|
||||
/// smooth updates), 200ms when idle (no reason to busy-loop)
|
||||
/// - Toast expiry is drained once here instead of in two places
|
||||
/// - Chat display lines are cached and rebuilt only when content changes
|
||||
#[tracing::instrument(skip(state, terminal))]
|
||||
fn run_loop_inner(
|
||||
state: &mut AppStateRest,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> Result<()> {
|
||||
info!("TUI event loop started");
|
||||
loop {
|
||||
if state.quit {
|
||||
info!("TUI event loop exiting (quit=true)");
|
||||
break;
|
||||
}
|
||||
|
||||
// Drain expired toasts exactly once per loop iteration (was being
|
||||
// done here AND in Action::Tick before this fix). Skip the clock
|
||||
// syscall + Vec scan when there are no toasts at all.
|
||||
if !state.misc.toasts.is_empty() {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
state.misc.drain_expired_toasts(now_ms);
|
||||
}
|
||||
|
||||
// Skip render when nothing has changed — avoids expensive markdown
|
||||
// re-parse and layout recalculation every cycle while idle.
|
||||
if state.dirty {
|
||||
// Pre-warm display caches before entering the terminal.draw closure.
|
||||
// This lets view::draw work with &AppStateRest (no mutation inside draw).
|
||||
view::pre_render(state);
|
||||
terminal.draw(|f| {
|
||||
view::draw(f, state);
|
||||
state.dirty = false;
|
||||
})?;
|
||||
}
|
||||
|
||||
// Adaptive poll: fast when animating, slow when idle.
|
||||
let poll_timeout = if state.turn_in_flight() {
|
||||
Duration::from_millis(50) // smooth spinner @ ~20fps
|
||||
} else {
|
||||
Duration::from_millis(200) // idle: 5fps, saves CPU
|
||||
};
|
||||
|
||||
if crossterm::event::poll(poll_timeout)? {
|
||||
match crossterm::event::read()? {
|
||||
Event::Key(key) => {
|
||||
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
|
||||
let actions = handle_key(key, state);
|
||||
for action in actions {
|
||||
apply_action(state, action);
|
||||
}
|
||||
if let Some(text) = state.misc.pending_clipboard_copy.take() {
|
||||
let _ =
|
||||
zesdex_infrastructure::utils::write_osc52(&mut io::stdout(), &text);
|
||||
state.push_toast(zesdex_infrastructure::Toast::new(
|
||||
zesdex_infrastructure::ToastKind::Success,
|
||||
"Copied to clipboard".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
}
|
||||
state.input.buffer.insert_str(state.input.cursor, &text);
|
||||
state.input.cursor += text.len();
|
||||
if state.input.buffer.starts_with('/') {
|
||||
state.input.open_autocomplete();
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Event::Resize(w, h) => {
|
||||
apply_action(state, Action::Resize(w, h));
|
||||
}
|
||||
Event::Mouse(mouse_event) => {
|
||||
if mouse_event.kind == MouseEventKind::ScrollUp {
|
||||
apply_action(state, Action::ScrollUp);
|
||||
} else if mouse_event.kind == MouseEventKind::ScrollDown {
|
||||
apply_action(state, Action::ScrollDown);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Tick always fires each iteration
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
terminal.clear()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create session state with real infrastructure wired in.
|
||||
///
|
||||
/// Flow: initialise `Store` → ensure data directories → generate session ID →
|
||||
/// load `Settings` and `AppConfig` from disk → construct `AppStateRest`.
|
||||
#[tracing::instrument]
|
||||
fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest)> {
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
debug!("store created at {:?}", store.base_dir);
|
||||
store.ensure_dirs()?;
|
||||
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let session_dir = store.base_dir.join("sessions").join(&session_id);
|
||||
std::fs::create_dir_all(&session_dir)?;
|
||||
|
||||
// Load real settings from disk
|
||||
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
|
||||
let settings =
|
||||
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new()
|
||||
.load(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
|
||||
let app_config =
|
||||
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository::new()
|
||||
.load(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
|
||||
let workspace_roots = vec![std::env::current_dir()?];
|
||||
let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
|
||||
state.settings = settings;
|
||||
state.app_config = app_config;
|
||||
|
||||
Ok((store, state))
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
//! Standalone helper functions that operate on AppStateRest.
|
||||
//!
|
||||
//! These are kept separate from the AppStateRest impl block to keep that
|
||||
//! file focused on state definition and direct mutations. Helpers that
|
||||
//! aggregate, compute, or read from the filesystem live here.
|
||||
|
||||
use crate::state::AppStateRest;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effort levels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Name of each reasoning-effort tier.
|
||||
pub const EFFORT_LEVELS: &[&str] = &[
|
||||
"Auto \u{2014} let the provider decide",
|
||||
"Low \u{2014} fast, minimal reasoning",
|
||||
"Medium \u{2014} balanced speed & reasoning",
|
||||
"High \u{2014} thorough reasoning",
|
||||
"Maximum \u{2014} deep analysis",
|
||||
];
|
||||
|
||||
/// Return the current effort index from state.
|
||||
pub fn current_effort(state: &AppStateRest) -> usize {
|
||||
state
|
||||
.misc
|
||||
.effort_level
|
||||
.saturating_sub(1)
|
||||
.min(EFFORT_LEVELS.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Cycle effort level up or down.
|
||||
pub fn cycle_effort(state: &mut AppStateRest, forward: bool) {
|
||||
let n = EFFORT_LEVELS.len();
|
||||
if forward {
|
||||
state.misc.effort_level = (state.misc.effort_level % n) + 1;
|
||||
} else {
|
||||
state.misc.effort_level = if state.misc.effort_level <= 1 {
|
||||
n
|
||||
} else {
|
||||
state.misc.effort_level - 1
|
||||
};
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Learning items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A lesson entry displayed in the Learning overlay.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LearningItem {
|
||||
/// A newly-generated lesson pending user approval.
|
||||
Pending {
|
||||
name: String,
|
||||
content: String,
|
||||
scope: String,
|
||||
confidence: f64,
|
||||
},
|
||||
/// A lesson that has been accepted and stored.
|
||||
Stored {
|
||||
name: String,
|
||||
content: String,
|
||||
lifecycle: String,
|
||||
scope: String,
|
||||
description: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Read lesson markdown files from the `lessons/` subdirectory under the
|
||||
/// memory directory.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
||||
let lessons_dir = state.memory_dir.join("lessons");
|
||||
if !lessons_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&lessons_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
items.push(LearningItem::Stored {
|
||||
name: path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
content,
|
||||
lifecycle: "filesystem".to_string(),
|
||||
scope: "filesystem".to_string(),
|
||||
description: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Cycle the selected index within bounds.
|
||||
pub fn cycle_selected_index(current: usize, n: usize, forward: bool) -> usize {
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
if forward {
|
||||
(current + 1) % n
|
||||
} else if current == 0 {
|
||||
n - 1
|
||||
} else {
|
||||
current - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of rewind points available.
|
||||
pub fn rewind_count(state: &AppStateRest) -> usize {
|
||||
state.transcript_cache.messages.len()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token counting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve the window size for context window management.
|
||||
///
|
||||
/// Uses the model name from settings to determine max context window,
|
||||
/// falling back to settings-configured max or 128k default.
|
||||
#[tracing::instrument(skip(_app_config, settings))]
|
||||
pub fn resolve_context_window(
|
||||
_app_config: &zesdex_domain::cms::AppConfig,
|
||||
settings: &zesdex_domain::cms::Settings,
|
||||
) -> usize {
|
||||
if let Some(max) = settings.max_tokens {
|
||||
if max > 0 {
|
||||
return max as usize;
|
||||
}
|
||||
}
|
||||
256_000
|
||||
}
|
||||
|
||||
/// Count tokens using tiktoken, fall back to character estimation.
|
||||
///
|
||||
/// Flow: try tiktoken-rs `cl100k_base` BPE encoding \u{2192} return accurate count.
|
||||
/// On failure (~4 chars per token heuristic), fall back to character-based
|
||||
/// estimation so the UI never blocks on an unavailable tokeniser.
|
||||
#[tracing::instrument]
|
||||
pub fn count_tokens(text: &str) -> usize {
|
||||
if let Ok(bpe) = tiktoken_rs::cl100k_base() {
|
||||
return bpe.encode_with_special_tokens(text).len();
|
||||
}
|
||||
// Fallback: ~4 chars per token
|
||||
text.len().div_ceil(4)
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
//! Chat input buffer, cursor, history, and autocomplete state.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Which source populated the autocomplete dropdown.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutocompleteKind {
|
||||
/// Builtin slash-command (e.g. `/model`, `/help`).
|
||||
Command,
|
||||
/// `@file` mention from the workspace file index.
|
||||
FileMention,
|
||||
}
|
||||
|
||||
/// Builtin slash-commands recognised by the chat input autocomplete.
|
||||
const COMMANDS: &[&str] = &[
|
||||
"/help",
|
||||
"/quit",
|
||||
"/clear",
|
||||
"/login",
|
||||
"/login zen",
|
||||
"/login openai",
|
||||
"/edit",
|
||||
"/mcp add",
|
||||
"/model",
|
||||
"/model ls",
|
||||
"/model add",
|
||||
"/todo",
|
||||
"/usage",
|
||||
"/compact",
|
||||
];
|
||||
|
||||
/// The user's input buffer, cursor position, history, and autocomplete
|
||||
/// state for the chat prompt.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InputState {
|
||||
/// Raw UTF-8 input buffer content.
|
||||
pub buffer: String,
|
||||
/// Byte offset of the cursor within `buffer`.
|
||||
pub cursor: usize,
|
||||
/// Previously submitted input lines, oldest-first.
|
||||
pub history: Vec<String>,
|
||||
/// Index into `history` when browsing (None = at the current input).
|
||||
pub history_idx: Option<usize>,
|
||||
/// Current autocomplete candidate list.
|
||||
pub autocomplete_candidates: Vec<String>,
|
||||
/// Focused index within `autocomplete_candidates`.
|
||||
pub autocomplete_idx: usize,
|
||||
/// Whether the autocomplete dropdown is visible.
|
||||
pub autocomplete_visible: bool,
|
||||
/// Which kind of autocomplete is active.
|
||||
pub autocomplete_kind: AutocompleteKind,
|
||||
/// Byte offset of the `@` character that triggered file mention autocomplete.
|
||||
pub mention_start: usize,
|
||||
/// Optional path to a persistent history file.
|
||||
pub history_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// Create an empty input state.
|
||||
pub fn new() -> Self {
|
||||
InputState {
|
||||
buffer: String::new(),
|
||||
cursor: 0,
|
||||
history: Vec::new(),
|
||||
history_idx: None,
|
||||
autocomplete_candidates: Vec::new(),
|
||||
autocomplete_idx: 0,
|
||||
autocomplete_visible: false,
|
||||
autocomplete_kind: AutocompleteKind::Command,
|
||||
mention_start: 0,
|
||||
history_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the autocomplete dropdown and clear its state.
|
||||
pub fn close_autocomplete(&mut self) {
|
||||
self.autocomplete_visible = false;
|
||||
self.autocomplete_candidates.clear();
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_kind = AutocompleteKind::Command;
|
||||
self.mention_start = 0;
|
||||
}
|
||||
|
||||
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`.
|
||||
pub fn open_autocomplete(&mut self) {
|
||||
let trimmed = self.buffer.trim().to_string();
|
||||
if trimmed.is_empty() || !trimmed.starts_with('/') {
|
||||
self.close_autocomplete();
|
||||
return;
|
||||
}
|
||||
let prefix = trimmed.to_lowercase();
|
||||
self.autocomplete_candidates = COMMANDS
|
||||
.iter()
|
||||
.filter(|c| c.starts_with(&prefix))
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
self.autocomplete_kind = AutocompleteKind::Command;
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||
}
|
||||
|
||||
/// Find the `@mention` token (if any) immediately before the cursor.
|
||||
pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> {
|
||||
let before_cursor = &self.buffer[..self.cursor];
|
||||
let at_pos = before_cursor.rfind('@')?;
|
||||
let between = &before_cursor[at_pos + 1..];
|
||||
if between.chars().any(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
let boundary_ok = at_pos == 0
|
||||
|| before_cursor[..at_pos]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(char::is_whitespace);
|
||||
if !boundary_ok {
|
||||
return None;
|
||||
}
|
||||
Some((at_pos, between.to_string()))
|
||||
}
|
||||
|
||||
/// Open or refresh the `@file` mention dropdown from `files`.
|
||||
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
|
||||
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
||||
use nucleo_matcher::{Config, Matcher};
|
||||
let Some((start, query)) = self.mention_query_at_cursor() else {
|
||||
self.close_autocomplete();
|
||||
return;
|
||||
};
|
||||
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
|
||||
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
|
||||
let matched_files = pattern.match_list(files.iter(), &mut matcher);
|
||||
self.autocomplete_candidates = matched_files
|
||||
.into_iter()
|
||||
.take(10)
|
||||
.map(|(f, _)| f.clone())
|
||||
.collect();
|
||||
self.autocomplete_kind = AutocompleteKind::FileMention;
|
||||
self.mention_start = start;
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||
}
|
||||
|
||||
/// Move the autocomplete selection up (forward=false) or down (forward=true).
|
||||
pub fn cycle_autocomplete(&mut self, forward: bool) {
|
||||
let n = self.autocomplete_candidates.len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
if forward {
|
||||
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
|
||||
} else {
|
||||
self.autocomplete_idx = if self.autocomplete_idx == 0 {
|
||||
n - 1
|
||||
} else {
|
||||
self.autocomplete_idx - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept the currently selected autocomplete candidate.
|
||||
pub fn select_autocomplete(&mut self) -> bool {
|
||||
let Some(candidate) = self
|
||||
.autocomplete_candidates
|
||||
.get(self.autocomplete_idx)
|
||||
.cloned()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match self.autocomplete_kind {
|
||||
AutocompleteKind::Command => {
|
||||
self.buffer = candidate;
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
AutocompleteKind::FileMention => {
|
||||
if self.cursor < self.mention_start || self.mention_start > self.buffer.len() {
|
||||
self.close_autocomplete();
|
||||
return false;
|
||||
}
|
||||
let replacement = format!("@{candidate} ");
|
||||
self.buffer
|
||||
.replace_range(self.mention_start..self.cursor, &replacement);
|
||||
self.cursor = self.mention_start + replacement.len();
|
||||
}
|
||||
}
|
||||
self.close_autocomplete();
|
||||
true
|
||||
}
|
||||
|
||||
/// Tab-complete: open dropdown or cycle forward.
|
||||
pub fn tab_complete(&mut self) {
|
||||
if self.autocomplete_visible {
|
||||
self.cycle_autocomplete(true);
|
||||
} else {
|
||||
self.open_autocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
pub fn insert(&mut self, c: char) {
|
||||
self.buffer.insert(self.cursor, c);
|
||||
self.cursor += c.len_utf8();
|
||||
}
|
||||
|
||||
/// Delete the character to the left of the cursor (backspace).
|
||||
pub fn delete_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character at the cursor position (forward delete).
|
||||
pub fn delete_right(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit the current buffer and return the submitted text.
|
||||
pub fn submit(&mut self) -> String {
|
||||
let result = self.buffer.clone();
|
||||
if !result.is_empty() {
|
||||
if self.history.last() != Some(&result) {
|
||||
self.history.push(result.clone());
|
||||
if let Some(ref path) = self.history_file {
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(file, "{result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.history_idx = None;
|
||||
}
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
result
|
||||
}
|
||||
|
||||
/// Navigate backward through input history.
|
||||
pub fn history_up(&mut self) {
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
let idx = match self.history_idx {
|
||||
Some(i) if i > 0 => i - 1,
|
||||
None => self.history.len() - 1,
|
||||
Some(_) => return,
|
||||
};
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
|
||||
/// Navigate forward through input history.
|
||||
pub fn history_down(&mut self) {
|
||||
match self.history_idx {
|
||||
Some(i) if i < self.history.len() - 1 => {
|
||||
let idx = i + 1;
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
Some(_) => {
|
||||
self.history_idx = None;
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InputState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
//! Miscellaneous state: overlay enum, misc state bag, editor state.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use zesdex_infrastructure::Toast;
|
||||
|
||||
/// Which modal overlay, if any, is currently shown over the main TUI view.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Overlay {
|
||||
/// No overlay; the main chat view is shown.
|
||||
None,
|
||||
/// Key bindings help screen.
|
||||
Help,
|
||||
/// Settings/configuration panel.
|
||||
Settings,
|
||||
/// Background bash job viewer.
|
||||
Bash,
|
||||
/// "Are you sure you want to quit?" confirmation.
|
||||
QuitConfirm,
|
||||
/// Raw key-code input capture (for binding custom keys).
|
||||
KeyInput,
|
||||
/// Inline editor (opened via `/edit`).
|
||||
Editor,
|
||||
/// Reasoning effort level selector.
|
||||
Effort,
|
||||
/// MCP server management panel.
|
||||
Mcp,
|
||||
/// Task list overlay.
|
||||
Todo,
|
||||
/// Project plan overlay.
|
||||
Plan,
|
||||
/// Session rewind / history scrubber.
|
||||
Rewind,
|
||||
/// Learning / lesson management panel.
|
||||
Learning,
|
||||
/// Token usage statistics panel.
|
||||
Usage,
|
||||
/// Generic loading spinner overlay.
|
||||
Loading,
|
||||
/// Model selector dropdown.
|
||||
ModelSelector,
|
||||
/// "Clear conversation?" confirmation.
|
||||
ClearConfirm,
|
||||
/// Git diff preview overlay.
|
||||
Diff,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
/// Human-readable name for this overlay variant.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Overlay::None => "none",
|
||||
Overlay::Help => "help",
|
||||
Overlay::Settings => "settings",
|
||||
Overlay::Bash => "bash",
|
||||
Overlay::QuitConfirm => "quit_confirm",
|
||||
Overlay::KeyInput => "key_input",
|
||||
Overlay::Editor => "editor",
|
||||
Overlay::Effort => "effort",
|
||||
Overlay::Mcp => "mcp",
|
||||
Overlay::Todo => "todo",
|
||||
Overlay::Plan => "plan",
|
||||
Overlay::Rewind => "rewind",
|
||||
Overlay::Learning => "learning",
|
||||
Overlay::Usage => "usage",
|
||||
Overlay::Loading => "loading",
|
||||
Overlay::ModelSelector => "model_selector",
|
||||
Overlay::ClearConfirm => "clear_confirm",
|
||||
Overlay::Diff => "diff",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any overlay (i.e. anything other than `None`) is active.
|
||||
pub fn is_active(self) -> bool {
|
||||
!matches!(self, Overlay::None)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Overlay {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple inline editor state for the TUI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorState {
|
||||
/// Path to the file being edited.
|
||||
pub path: PathBuf,
|
||||
/// Current buffer content.
|
||||
pub content: String,
|
||||
/// Cursor position (byte offset).
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Create a new editor state for the given path.
|
||||
pub fn new(path: PathBuf, content: String) -> Self {
|
||||
let cursor = content.len();
|
||||
EditorState {
|
||||
path,
|
||||
content,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the full buffer content.
|
||||
pub fn as_string(&self) -> String {
|
||||
self.content.clone()
|
||||
}
|
||||
|
||||
/// Delete one character to the left of the cursor.
|
||||
pub fn delete_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
self.content.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The "miscellaneous" slice of app state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiscState {
|
||||
/// Currently active modal overlay (None = main chat view).
|
||||
pub overlay: Overlay,
|
||||
/// Active toast notifications.
|
||||
pub toasts: Vec<Toast>,
|
||||
/// Timestamp (ms) of the last staleness sweep for lesson cache.
|
||||
pub last_staleness_sweep_ms: i64,
|
||||
/// Whether the agent is currently "thinking".
|
||||
pub thinking: bool,
|
||||
/// Current LLM reasoning effort level (1-5).
|
||||
pub effort_level: usize,
|
||||
/// Currently focused index in list-type overlays.
|
||||
pub selected_index: usize,
|
||||
/// Optional inline editor state.
|
||||
pub editor: Option<EditorState>,
|
||||
/// Whether the API connection is established.
|
||||
pub api_connected: bool,
|
||||
/// Monotonically increasing tick count, incremented each render frame.
|
||||
pub tick_count: u64,
|
||||
/// Cached content of the TODO file.
|
||||
pub todo_content: String,
|
||||
/// Cached content of the PLAN file.
|
||||
pub plan_content: String,
|
||||
/// Whether a lesson background task is currently running.
|
||||
pub lesson_running: bool,
|
||||
/// Text waiting to be written to the system clipboard.
|
||||
pub pending_clipboard_copy: Option<String>,
|
||||
/// Cached git diff content for the preview overlay.
|
||||
pub diff_content: String,
|
||||
/// Scroll offset for the diff overlay.
|
||||
pub diff_scroll: usize,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
/// Create a fresh `MiscState` with no overlay, no toasts.
|
||||
pub fn new() -> Self {
|
||||
MiscState {
|
||||
overlay: Overlay::None,
|
||||
toasts: Vec::new(),
|
||||
last_staleness_sweep_ms: 0,
|
||||
thinking: false,
|
||||
effort_level: 1,
|
||||
selected_index: 0,
|
||||
editor: None,
|
||||
api_connected: false,
|
||||
tick_count: 0,
|
||||
todo_content: String::new(),
|
||||
plan_content: String::new(),
|
||||
lesson_running: false,
|
||||
pending_clipboard_copy: None,
|
||||
diff_content: String::new(),
|
||||
diff_scroll: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a toast notification to the active list.
|
||||
pub fn push_toast(&mut self, toast: Toast) {
|
||||
self.toasts.push(toast);
|
||||
}
|
||||
|
||||
/// Remove all toasts whose lifetime has expired at `now_ms`.
|
||||
pub fn drain_expired_toasts(&mut self, now_ms: i64) {
|
||||
self.toasts.retain(|t| !t.expired(now_ms));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MiscState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
//! TUI-perspective application state: `AppStateRest` and all the types it
|
||||
//! owns. This is the single source-of-truth struct for the TUI interface,
|
||||
//! mutated from `action::apply_action` and read by `view/` every render frame.
|
||||
//!
|
||||
//! # Organisation
|
||||
//!
|
||||
//! ```text
|
||||
//! state/
|
||||
//! ├── mod.rs — AppStateRest (the central struct) + re-exports
|
||||
//! ├── input.rs — InputState, AutocompleteKind
|
||||
//! ├── transcript.rs — TranscriptCache, ChatMessageDisplay
|
||||
//! ├── scroll.rs — ScrollState
|
||||
//! ├── misc.rs — MiscState, EditorState, Overlay
|
||||
//! ├── workflow.rs — SimpleAgent, AgentState, SimpleWorkflowEngine
|
||||
//! └── helpers.rs — Standalone functions operating on AppStateRest
|
||||
//! ```
|
||||
//!
|
||||
//! # Flow
|
||||
//! Construction in `run.rs::create_local_session` \u{2192} mutated by
|
||||
//! `action::apply_action` \u{2192} read-only in every `view/*::draw*` function.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
use ratatui::text::Line;
|
||||
use zesdex_domain::cms::{AppConfig, Settings};
|
||||
use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent};
|
||||
|
||||
pub mod helpers;
|
||||
pub mod input;
|
||||
pub mod misc;
|
||||
pub mod scroll;
|
||||
pub mod transcript;
|
||||
pub mod workflow;
|
||||
|
||||
// Re-export all public types from sub-modules at the `state` level so
|
||||
// consumers that previously used `crate::state::InputState` etc. still work.
|
||||
pub use input::{AutocompleteKind, InputState};
|
||||
pub use misc::{EditorState, MiscState, Overlay};
|
||||
pub use scroll::ScrollState;
|
||||
pub use transcript::{ChatMessageDisplay, TranscriptCache};
|
||||
pub use workflow::{AgentState, SimpleAgent, SimpleWorkflowEngine};
|
||||
|
||||
// Re-export the most commonly used helpers at the `state` level.
|
||||
pub use helpers::{
|
||||
count_tokens, current_effort, cycle_effort, cycle_selected_index, get_learning_items,
|
||||
resolve_context_window, rewind_count, LearningItem, EFFORT_LEVELS,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppStateRest — the single source-of-truth TUI state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The single source-of-truth state struct for the TUI interface.
|
||||
///
|
||||
/// Mutated from `controller/input.rs` and `actions/mod.rs` (via `Action`).
|
||||
/// Read-only from every `view/*` render function.
|
||||
#[derive(Clone)]
|
||||
pub struct AppStateRest {
|
||||
/// Persistent user settings.
|
||||
pub settings: Settings,
|
||||
/// Per-project app configuration.
|
||||
pub app_config: AppConfig,
|
||||
/// Absolute paths to each open workspace root directory.
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
/// Unique session identifier.
|
||||
pub session_id: String,
|
||||
/// Path to the session's data directory.
|
||||
pub session_dir: PathBuf,
|
||||
/// Path to the session memory directory.
|
||||
pub memory_dir: PathBuf,
|
||||
/// Path to the git worktrees directory.
|
||||
pub worktrees_dir: PathBuf,
|
||||
/// Shared async cache of directory listings.
|
||||
pub dir_cache: Arc<tokio::sync::RwLock<DirCache>>,
|
||||
/// Shared workspace file-path index for `@file` mention autocomplete.
|
||||
pub mention_index: MentionIndex,
|
||||
/// Optional per-session runtime state.
|
||||
pub session_runtime: Option<SessionRuntime>,
|
||||
/// Ring buffer of recent chat messages for the transcript pane.
|
||||
pub transcript_cache: TranscriptCache,
|
||||
/// Viewport scroll offset tracker.
|
||||
pub scroll: ScrollState,
|
||||
/// Chat input buffer, cursor, history, and autocomplete.
|
||||
pub input: InputState,
|
||||
/// Miscellaneous state: overlay, toasts, flags, editor, tick.
|
||||
pub misc: MiscState,
|
||||
/// Queue of events emitted by the running agent turn.
|
||||
pub turn_events: Arc<std::sync::Mutex<VecDeque<TurnEvent>>>,
|
||||
/// Whether an agent turn is currently in flight.
|
||||
/// Uses AtomicBool for lock-free check from render loop.
|
||||
pub turn_in_flight_flag: Arc<AtomicBool>,
|
||||
/// Cached display lines for the chat transcript panel.
|
||||
pub display_lines_cache: Vec<Line<'static>>,
|
||||
/// Cached token count for the current message history.
|
||||
pub cached_token_count: usize,
|
||||
/// Whether the token count cache is stale and needs recalculation.
|
||||
pub token_count_dirty: bool,
|
||||
/// Terminal width at the time of the last display_lines_cache rebuild.
|
||||
pub last_render_width: u16,
|
||||
/// Number of messages that were in the cache when it was last built.
|
||||
pub cached_msg_count: usize,
|
||||
/// Cache index where the last message's rendered lines begin. Used to
|
||||
/// splice streaming updates (only re-render the trailing message).
|
||||
pub cached_last_start: usize,
|
||||
/// Content+reasoning byte length of the last message when it was last
|
||||
/// rendered. Guards the streaming branch from re-rendering on ticks
|
||||
/// where no new token arrived (spinner-only frames).
|
||||
pub cached_last_len: usize,
|
||||
/// Terminal width at the time of the last full cache build.
|
||||
pub render_width_at_cache: u16,
|
||||
/// Atomic flag set when the user aborts the current turn.
|
||||
pub abort_flag: Arc<AtomicBool>,
|
||||
/// Simplified workflow engine state for display.
|
||||
pub workflow_engine: SimpleWorkflowEngine,
|
||||
/// Whether the state has been modified since the last render sweep.
|
||||
pub dirty: bool,
|
||||
/// Whether the application has been requested to quit.
|
||||
pub quit: bool,
|
||||
/// Cached help text content.
|
||||
pub help_text: &'static str,
|
||||
}
|
||||
|
||||
/// Default help text shown in the Help overlay.
|
||||
pub const DEFAULT_HELP_TEXT: &str = r#" Zesdex TUI \u{2014} Keyboard Shortcuts
|
||||
|
||||
\u{2500}\u{2500}\u{2500} General \u{2500}\u{2500}\u{2500}
|
||||
Ctrl+C Quit confirm
|
||||
Ctrl+D Close overlay
|
||||
Ctrl+Y Copy last assistant message
|
||||
Esc Abort turn / Close overlay
|
||||
Tab Autocomplete
|
||||
|
||||
\u{2500}\u{2500}\u{2500} Navigation \u{2500}\u{2500}\u{2500}
|
||||
\u{2191} / \u{2193} History browse / Overlay navigate
|
||||
Ctrl+\u{2191}/\u{2193} Scroll transcript
|
||||
PgUp / PgDown Scroll transcript
|
||||
Enter Submit / Select autocomplete
|
||||
|
||||
\u{2500}\u{2500}\u{2500} Overlays \u{2500}\u{2500}\u{2500}
|
||||
/help Show this help
|
||||
/settings Open settings overlay
|
||||
/todo Open tasks (todo) overlay
|
||||
/usage Open usage statistics
|
||||
/bash Open bash jobs overlay
|
||||
/mcp Open MCP server management
|
||||
/model Open model selector
|
||||
/compact Compact conversation
|
||||
/clear Clear transcript
|
||||
/rewind Rewind conversation history
|
||||
|
||||
\u{2500}\u{2500}\u{2500} Editor Mode \u{2500}\u{2500}\u{2500}
|
||||
/edit <path> Open file for inline editing
|
||||
Ctrl+S Save changes
|
||||
Esc Dismiss editor
|
||||
"#;
|
||||
|
||||
impl Default for AppStateRest {
|
||||
fn default() -> Self {
|
||||
AppStateRest {
|
||||
settings: Settings::default(),
|
||||
app_config: AppConfig::default(),
|
||||
workspace_roots: Vec::new(),
|
||||
session_id: String::new(),
|
||||
session_dir: PathBuf::new(),
|
||||
memory_dir: PathBuf::new(),
|
||||
worktrees_dir: PathBuf::new(),
|
||||
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
|
||||
mention_index: MentionIndex::new(),
|
||||
session_runtime: None,
|
||||
transcript_cache: TranscriptCache::new(200),
|
||||
scroll: ScrollState::new(),
|
||||
input: InputState::new(),
|
||||
misc: MiscState::new(),
|
||||
turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())),
|
||||
turn_in_flight_flag: Arc::new(AtomicBool::new(false)),
|
||||
abort_flag: Arc::new(AtomicBool::new(false)),
|
||||
workflow_engine: SimpleWorkflowEngine::new(),
|
||||
dirty: true,
|
||||
quit: false,
|
||||
help_text: DEFAULT_HELP_TEXT,
|
||||
display_lines_cache: Vec::new(),
|
||||
cached_token_count: 0,
|
||||
token_count_dirty: true,
|
||||
last_render_width: 0,
|
||||
cached_msg_count: 0,
|
||||
cached_last_start: 0,
|
||||
cached_last_len: 0,
|
||||
render_width_at_cache: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppStateRest {
|
||||
/// Construct initial TUI state.
|
||||
pub fn new(
|
||||
workspace_roots: Vec<PathBuf>,
|
||||
session_dir: &std::path::Path,
|
||||
memory_dir: PathBuf,
|
||||
) -> Self {
|
||||
let settings = Settings::default();
|
||||
let app_config = AppConfig::default();
|
||||
let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees");
|
||||
let session_id = session_dir.file_name().map_or_else(
|
||||
|| {
|
||||
warn!("[state] session_dir has no file_name, using empty session_id");
|
||||
String::new()
|
||||
},
|
||||
|n| n.to_string_lossy().to_string(),
|
||||
);
|
||||
|
||||
AppStateRest {
|
||||
settings,
|
||||
app_config,
|
||||
workspace_roots,
|
||||
session_id,
|
||||
session_dir: session_dir.to_path_buf(),
|
||||
memory_dir: memory_dir.clone(),
|
||||
worktrees_dir,
|
||||
turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())),
|
||||
turn_in_flight_flag: Arc::new(AtomicBool::new(false)),
|
||||
abort_flag: Arc::new(AtomicBool::new(false)),
|
||||
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
|
||||
mention_index: MentionIndex::new(),
|
||||
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
|
||||
workflow_engine: SimpleWorkflowEngine::new(),
|
||||
transcript_cache: TranscriptCache::new(200),
|
||||
scroll: ScrollState::new(),
|
||||
input: InputState::new(),
|
||||
misc: MiscState::new(),
|
||||
dirty: true,
|
||||
quit: false,
|
||||
help_text: DEFAULT_HELP_TEXT,
|
||||
display_lines_cache: Vec::new(),
|
||||
cached_token_count: 0,
|
||||
token_count_dirty: true,
|
||||
last_render_width: 0,
|
||||
cached_msg_count: 0,
|
||||
cached_last_start: 0,
|
||||
cached_last_len: 0,
|
||||
render_width_at_cache: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an agent turn is currently running.
|
||||
/// Uses lock-free AtomicBool load \u{2014} safe to call every render frame.
|
||||
pub fn turn_in_flight(&self) -> bool {
|
||||
self.turn_in_flight_flag.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Append a message to the transcript.
|
||||
/// Eviction is O(1) via VecDeque::pop_front.
|
||||
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
|
||||
self.transcript_cache.push(msg);
|
||||
self.token_count_dirty = true;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Append text to the last assistant message in the transcript, if one exists.
|
||||
pub fn append_to_last_transcript(&mut self, text: &str, is_reasoning: bool) {
|
||||
self.transcript_cache.append_to_last(text, is_reasoning);
|
||||
if self.transcript_cache.dirty {
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the app state as dirty, triggering a TUI re-render.
|
||||
pub fn mark_dirty(&mut self) {
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Queue a toast notification.
|
||||
pub fn push_toast(&mut self, toast: Toast) {
|
||||
self.misc.push_toast(toast);
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
||||
/// Push an info toast.
|
||||
pub fn toast_info(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(
|
||||
zesdex_infrastructure::ToastKind::Info,
|
||||
msg.into(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Push a success toast.
|
||||
pub fn toast_success(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(
|
||||
zesdex_infrastructure::ToastKind::Success,
|
||||
msg.into(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Push a warning toast.
|
||||
pub fn toast_warning(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(
|
||||
zesdex_infrastructure::ToastKind::Warning,
|
||||
msg.into(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Push an error toast.
|
||||
pub fn toast_error(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(
|
||||
zesdex_infrastructure::ToastKind::Error,
|
||||
msg.into(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Persist settings to disk.
|
||||
pub fn save_settings(&self) {
|
||||
if let Ok(store_dir) = std::fs::canonicalize(self.store_base_dir()) {
|
||||
let repo =
|
||||
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new(
|
||||
);
|
||||
use zesdex_domain::SettingsRepository;
|
||||
if let Err(e) = repo.save(&store_dir, &self.settings) {
|
||||
tracing::warn!("Failed to save settings: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the base directory for session stores.
|
||||
pub fn store_base_dir(&self) -> PathBuf {
|
||||
self.session_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map_or_else(
|
||||
|| {
|
||||
warn!("[state] no grandparent, using session_dir");
|
||||
self.session_dir.clone()
|
||||
},
|
||||
std::path::Path::to_path_buf,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
//! Viewport scroll state: current offset and visible-line count.
|
||||
|
||||
/// Viewport scroll state: current offset and visible-line count.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollState {
|
||||
/// Current scroll offset (how many lines have been scrolled past).
|
||||
pub offset: usize,
|
||||
/// Maximum number of lines that fit in the visible viewport area.
|
||||
pub max_visible: usize,
|
||||
}
|
||||
|
||||
impl ScrollState {
|
||||
/// Create a `ScrollState` with zero offset and 30 rows visible.
|
||||
pub fn new() -> Self {
|
||||
ScrollState {
|
||||
offset: 0,
|
||||
max_visible: 30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
||||
pub fn scroll_up(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_add(amount);
|
||||
}
|
||||
|
||||
/// Scroll the viewport down by `amount` lines (decreasing the offset).
|
||||
pub fn scroll_down(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_sub(amount);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScrollState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//! Transcript display types: bounded ring-buffer cache of rendered messages.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use zesdex_domain::core::Role;
|
||||
|
||||
/// A single transcript entry rendered in the TUI chat pane.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ChatMessageDisplay {
|
||||
/// Message author: User or Assistant.
|
||||
pub role: Role,
|
||||
/// Rendered text content (plain text, no markdown).
|
||||
pub content: String,
|
||||
/// Model reasoning (e.g. from DeepSeek-R1 <think> block).
|
||||
pub reasoning: String,
|
||||
/// Millisecond timestamp when this display entry was created.
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl ChatMessageDisplay {
|
||||
/// Build a display entry, stamping it with the current time.
|
||||
pub fn new(role: Role, content: String) -> Self {
|
||||
ChatMessageDisplay {
|
||||
role,
|
||||
content,
|
||||
reasoning: String::new(),
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded ring of recent chat messages used to render the transcript view.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranscriptCache {
|
||||
/// Ordered display messages (newest appended, oldest evicted when full).
|
||||
/// Uses VecDeque for O(1) front eviction instead of O(n) Vec::remove(0).
|
||||
pub messages: VecDeque<ChatMessageDisplay>,
|
||||
/// Maximum messages to retain before evicting the oldest.
|
||||
pub max_lines: usize,
|
||||
/// Whether the cache has changed since the last render sweep.
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl TranscriptCache {
|
||||
/// Create an empty transcript cache holding at most `max_lines` messages.
|
||||
pub fn new(max_lines: usize) -> Self {
|
||||
TranscriptCache {
|
||||
messages: VecDeque::new(),
|
||||
max_lines,
|
||||
dirty: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a message, evicting the oldest if at capacity.
|
||||
/// Eviction is O(1) via VecDeque::pop_front instead of O(n) Vec::remove(0).
|
||||
pub fn push(&mut self, msg: ChatMessageDisplay) {
|
||||
self.messages.push_back(msg);
|
||||
while self.messages.len() > self.max_lines {
|
||||
self.messages.pop_front();
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Append text to the last assistant message, if one exists.
|
||||
pub fn append_to_last(&mut self, text: &str, is_reasoning: bool) {
|
||||
if let Some(msg) = self.messages.back_mut() {
|
||||
if msg.role == Role::Assistant {
|
||||
if is_reasoning {
|
||||
msg.reasoning.push_str(text);
|
||||
} else {
|
||||
msg.content.push_str(text);
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
//! Simplified agent lifecycle display types for the workflow sidebar.
|
||||
|
||||
/// Simplified agent lifecycle state for TUI display.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AgentState {
|
||||
Idle,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A single agent entry in the workflow sidebar.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimpleAgent {
|
||||
/// Agent unique ID (e.g. "auto-review", "Node-0-1").
|
||||
pub name: String,
|
||||
/// Human-readable display label (e.g. "Auto-Review", "Backend API Agent").
|
||||
pub display_name: String,
|
||||
/// Current lifecycle state.
|
||||
pub state: AgentState,
|
||||
/// Millisecond timestamp when the agent started.
|
||||
pub started_at: Option<i64>,
|
||||
/// Millisecond timestamp when the agent completed.
|
||||
pub completed_at: Option<i64>,
|
||||
/// Optional error message if the agent failed.
|
||||
pub error: Option<String>,
|
||||
/// Optional progress text (current tool, step description).
|
||||
pub progress: Option<String>,
|
||||
}
|
||||
|
||||
impl SimpleAgent {
|
||||
/// Create a new agent with the given name (used as both ID and display name).
|
||||
pub fn new(name: String) -> Self {
|
||||
SimpleAgent {
|
||||
display_name: name.clone(),
|
||||
name,
|
||||
state: AgentState::Idle,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new agent with separate ID and display label.
|
||||
pub fn with_display(name: String, display_name: String) -> Self {
|
||||
SimpleAgent {
|
||||
name,
|
||||
display_name,
|
||||
state: AgentState::Idle,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simplified workflow engine state for TUI display.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimpleWorkflowEngine {
|
||||
/// Active agents in the workflow.
|
||||
pub agents: Vec<SimpleAgent>,
|
||||
/// Summary findings produced by completed agents.
|
||||
pub findings: Vec<String>,
|
||||
}
|
||||
|
||||
impl SimpleWorkflowEngine {
|
||||
/// Create an empty workflow engine state.
|
||||
pub fn new() -> Self {
|
||||
SimpleWorkflowEngine {
|
||||
agents: Vec::new(),
|
||||
findings: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SimpleWorkflowEngine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Loaded 50 of 250 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user