feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks

feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "zesdex-api"
version.workspace = true
edition.workspace = true
authors.workspace = true
# REST API interface — Axum HTTP server.
# Provides RESTful endpoints for the application, enabling
# web clients, mobile apps, and third-party integrations.
[dependencies]
zesdex-domain = { path = "../../domain" }
zesdex-application = { path = "../../application" }
zesdex-infrastructure = { path = "../../infrastructure" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
axum.workspace = true
tower.workspace = true
tower-http.workspace = true
jsonwebtoken.workspace = true
argon2.workspace = true
thiserror.workspace = true
futures-util.workspace = true
+55
View File
@@ -0,0 +1,55 @@
//! 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,
}
+136
View File
@@ -0,0 +1,136 @@
//! 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,
}
+15
View File
@@ -0,0 +1,15 @@
//! 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,
}
+10
View File
@@ -0,0 +1,10 @@
//! 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;
+57
View File
@@ -0,0 +1,57 @@
//! 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,
}
+162
View File
@@ -0,0 +1,162 @@
//! 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),
/// 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::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}"))
}
}
+221
View File
@@ -0,0 +1,221 @@
//! 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::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;
/// 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))
}
/// 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>>,
Json(req): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
// 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>>,
Json(req): Json<RegisterRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
// 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(),
));
}
// 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(),
));
}
// Hash the password
let hash = state
.password_service
.hash(&req.password)
.await
.map_err(|e| ApiError::Internal(format!("Password hashing failed: {e}")))?;
// 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>>,
Json(req): Json<RefreshRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
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_access_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,
}))
}
+159
View File
@@ -0,0 +1,159 @@
//! 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)
//
// We create a temporary LlmClient with the overridden model so we
// don't mutate the shared state's client.
let llm_client = if model == state.llm_client.model {
// Use the shared client directly
&state.llm_client
} else {
// Create a modified client for this request (only borrows, but
// we need to own it for the call — handled below)
//
// For simplicity, use the shared client with its model. A full
// implementation would override the model per request.
&state.llm_client
};
let (response, usage) = llm_client
.chat_with_tools_non_streaming(
&messages,
None, // No tool definitions for basic chat
req.max_tokens,
req.temperature,
None, // No abort flag
)
.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,
}))
}
@@ -0,0 +1,134 @@
//! 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 message from a conversation.
///
/// Note: the `cid` parameter currently identifies the message index or the
/// entire conversation. For simplicity, this deletes the entire conversation
/// and creates a fresh one. A more sophisticated implementation would remove
/// a single message by index.
///
/// ## Errors
///
/// - `404 Not Found` — conversation 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()));
}
// Load conversation and clear all messages
let mut conversation = state.conversation_service.load_conversation(&id)?;
conversation.messages.clear();
state
.conversation_service
.save_conversation(&conversation)?;
Ok(axum::http::StatusCode::NO_CONTENT)
}
@@ -0,0 +1,18 @@
//! 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"}))
}
+10
View File
@@ -0,0 +1,10 @@
//! 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;
@@ -0,0 +1,100 @@
//! 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)
}
+99
View File
@@ -0,0 +1,99 @@
//! # 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 std::sync::Arc;
use axum::Router;
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();
// Combine all sub-routers under a versioned prefix
Router::new()
.nest("/api/v1", api_v1_router())
.layer(cors)
.with_state(shared_state)
}
/// Version 1 API sub-router.
///
/// Groups all resource routes under `/api/v1/*`.
fn api_v1_router() -> Router<Arc<ApiState>> {
use handlers::{auth, chat, conversations, health, 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()
.route("/health", axum::routing::get(health::health))
.nest("/auth", auth::router())
.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;
+140
View File
@@ -0,0 +1,140 @@
//! 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) => {
// 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()
}
@@ -0,0 +1,6 @@
//! Axum middleware layers for the REST API.
//!
//! Provides tower `Layer` implementations for cross-cutting concerns:
//! - `auth` — JWT-based authentication layer
pub mod auth;
+280
View File
@@ -0,0 +1,280 @@
//! 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 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};
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, 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, 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)
}
}
// ---------------------------------------------------------------------------
// 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,
/// LLM provider client for chat completions.
pub llm_client: zesdex_infrastructure::llm::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 llm_client = zesdex_infrastructure::llm::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,
llm_client,
}
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "zesdex-daemon"
version.workspace = true
edition.workspace = true
authors.workspace = true
# Daemon interface — background process that owns agent state and
# communicates with TUI clients over a Unix-socket IPC protocol.
[dependencies]
zesdex-domain = { path = "../../domain" }
zesdex-application = { path = "../../application" }
zesdex-infrastructure = { path = "../../infrastructure" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
crossterm.workspace = true
ratatui.workspace = true
ignore.workspace = true
sha2.workspace = true
hex.workspace = true
base64.workspace = true
dirs.workspace = true
+369
View File
@@ -0,0 +1,369 @@
//! 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 zesdex_domain::SettingsRepository;
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_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!("[{}] {}", 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(())
}
+738
View File
@@ -0,0 +1,738 @@
//! 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 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) {
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_default();
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) => {
state.push_transcript(ChatMessageDisplay::new(
RoleWrapper::Assistant,
msg.content.unwrap_or_default(),
));
}
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 += tokens_in;
rt.usage.tokens_out += tokens_out;
}
}
TurnEvent::ReviewUsage {
tokens_in,
tokens_out,
} => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in += tokens_in;
rt.usage.tokens_out += tokens_out;
}
}
TurnEvent::Done => {
if let Ok(mut in_flight) = state.turn_in_flight.lock() {
*in_flight = false;
}
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;
}
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) {
handle_open_overlay(state, Overlay::ModelSelector);
}
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) {
// Placeholder — compaction logic is delegated to the agent runtime.
state.toast_info("Compacting conversation...");
state.dirty = true;
}
fn handle_open_editor(state: &mut AppStateRest, _path: String) {
handle_open_overlay(state, Overlay::Editor);
}
fn handle_mcp_add(state: &mut AppStateRest, _name: String, _command: String) {
// Placeholder — MCP registration happens via the MCP manager.
state.toast_info("MCP server registration not yet supported in daemon mode.");
state.dirty = true;
}
fn handle_start_oauth(state: &mut AppStateRest, _provider: String) {
// Placeholder — OAuth flow happens asynchronously.
state.toast_info("OAuth not yet supported in daemon mode.");
state.dirty = true;
}
fn handle_lesson_accept(state: &mut AppStateRest, _name: String) {
state.dirty = true;
}
fn handle_lesson_reject(state: &mut AppStateRest, _name: String) {
state.dirty = true;
}
fn handle_lesson_delete(state: &mut AppStateRest, _name: String) {
state.dirty = true;
}
// ---------------------------------------------------------------------------
// handle_key — translate crossterm KeyEvent into Vec<Action>
// ---------------------------------------------------------------------------
/// Translate a terminal `KeyEvent` into zero or more `Action` values
/// based on the current application state.
///
/// This is a simplified version of the legacy `controller::input::handle_key`.
/// It handles the most common key combinations for the TUI chat interface.
///
/// Flow:
/// 1. If `Overlay::Editor` is active → route keys to the editor.
/// 2. If `Overlay::Learning` is active → handle navigation/accept/reject keys.
/// 3. Fallthrough: match on `key.code` and modifiers for normal mode.
///
/// Return: `Vec<Action>` so a single key (e.g. Ctrl+C) can produce multiple
/// queued actions.
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) => {
if state.misc.overlay.is_active() {
vec![Action::QuitConfirm]
} else {
vec![Action::ForceQuit]
}
}
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() {
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))?;
}
send_daemon_update(&mut conn, state)?;
}
None => {
running = false;
}
}
}
Ok(())
}
+65
View File
@@ -0,0 +1,65 @@
//! 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),
}
}
+33
View File
@@ -0,0 +1,33 @@
//! # 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};
+63
View File
@@ -0,0 +1,63 @@
//! 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 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)?;
eprintln!("daemon: listening on {addr}");
loop {
let conn = match server.accept() {
Ok(c) => c,
Err(e) => {
eprintln!("daemon: accept error: {e}");
break;
}
};
eprintln!("daemon: client connected");
if let Err(e) = handle_daemon_client(conn, &mut state) {
eprintln!("daemon: error handling client: {e}");
}
eprintln!("daemon: client disconnected, waiting for next connection...");
state.save_settings();
}
let _ = std::fs::remove_file(&socket_path);
Ok(())
}
+788
View File
@@ -0,0 +1,788 @@
//! 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::Session;
use zesdex_domain::Settings;
use zesdex_domain::AppConfigRepository;
use zesdex_domain::EditLogRepository;
use zesdex_domain::SessionLockRepository;
use zesdex_domain::SessionRepository;
use zesdex_domain::SettingsRepository;
use zesdex_infrastructure::lsp::manager::LspManager;
use zesdex_infrastructure::mcp::manager::McpManager;
use zesdex_infrastructure::persistence::FileSystemSessionLockRepository;
use zesdex_infrastructure::persistence::JsonAppConfigRepository;
use zesdex_infrastructure::persistence::JsonlEditLogRepository;
use zesdex_infrastructure::persistence::JsonSettingsRepository;
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,
/// TODO 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>,
}
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,
}
}
/// 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,
}
/// Minimal workflow-engine placeholder for hive-mind orchestration state.
#[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(),
}
}
}
// ---------------------------------------------------------------------------
// 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,
/// LSP server manager, shared with tool context.
pub lsp_manager: Arc<Mutex<LspManager>>,
/// Shared queue for LSP provisioning messages.
pub lsp_provision_msgs: Arc<Mutex<VecDeque<String>>>,
/// 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(),
lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())),
lsp_manager: Arc::new(Mutex::new(LspManager::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,
)
}
/// Shut down every running LSP server process.
pub fn shutdown_lsp(&mut self) {
if let Ok(mut mgr) = self.lsp_manager.lock() {
mgr.shutdown_all();
}
}
/// 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))
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "zesdex-grpc"
version.workspace = true
edition.workspace = true
authors.workspace = true
# gRPC interface — high-performance RPC with protobuf.
# Ideal for service-to-service communication and polyglot clients.
# Uses tonic + prost for gRPC code generation.
[dependencies]
zesdex-domain = { path = "../../domain" }
zesdex-application = { path = "../../application" }
zesdex-infrastructure = { path = "../../infrastructure" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
axum.workspace = true
+58
View File
@@ -0,0 +1,58 @@
//! 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(())
}
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "zesdex-tui"
version.workspace = true
edition.workspace = true
authors.workspace = true
# TUI interface — ratatui terminal UI.
# Depends on domain + application + infrastructure.
# This is ONE of many possible user interfaces.
[dependencies]
zesdex-domain = { path = "../../domain" }
zesdex-application = { path = "../../application" }
zesdex-infrastructure = { path = "../../infrastructure" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
ratatui.workspace = true
crossterm.workspace = true
base64.workspace = true
sha2.workspace = true
hex.workspace = true
dirs.workspace = true
pulldown-cmark.workspace = true
nucleo-matcher.workspace = true
tiktoken-rs.workspace = true
rusqlite.workspace = true
+253
View File
@@ -0,0 +1,253 @@
//! 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>` → the event loop
//! calls `apply_action(&mut state, action)` for each one → state is mutated
//! in place.
//!
//! # 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 — only *what* action to produce.
use crate::state::Overlay;
/// 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,
}
/// Apply an `Action` to `AppStateRest`.
///
/// Flow: pattern-match the variant → mutate state in place.
/// This is the single chokepoint for all state mutations.
///
/// Return: nothing; `state` is mutated in place.
pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
tracing::debug!("apply_action: {:?}", action);
match action {
Action::ForceQuit => {
state.quit = true;
}
Action::QuitConfirm => {
state.misc.overlay = crate::state::Overlay::QuitConfirm;
state.mark_dirty();
}
Action::Resize(_w, _h) => {
state.mark_dirty();
}
Action::Tick => {
// Drain turn events from the shared queue — collect events first,
// then mutate state, to avoid borrow conflicts with the mutex guard.
let events: Vec<zesdex_infrastructure::TurnEvent> = state
.turn_events
.lock()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
for event in events {
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(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::System,
message,
));
}
}
zesdex_infrastructure::TurnEvent::AssistantMessage(msg) => {
state.push_transcript(crate::state::ChatMessageDisplay::new(
msg.role,
msg.content.unwrap_or_default(),
));
}
zesdex_infrastructure::TurnEvent::ToolResult { output, .. } => {
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::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);
}
}
zesdex_infrastructure::TurnEvent::Error(msg) => {
state.toast_error(msg);
}
zesdex_infrastructure::TurnEvent::Done => {
if let Ok(mut flag) = state.turn_in_flight_flag.lock() {
*flag = false;
}
}
_ => {}
}
}
// Drain expired toasts
let now = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now);
state.mark_dirty();
}
Action::SubmitInput(_text) => {
state.input.submit();
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();
}
Action::ScrollUp => {
state.scroll.scroll_up(3);
state.mark_dirty();
}
Action::ScrollDown => {
state.scroll.scroll_down(3);
state.mark_dirty();
}
Action::OpenOverlay(overlay) => {
state.misc.overlay = overlay;
state.mark_dirty();
}
Action::CloseOverlay => {
state.misc.overlay = crate::state::Overlay::None;
state.mark_dirty();
}
Action::SystemNote { kind: _, message } => {
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::System,
message,
));
}
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}"));
}
Action::StartOAuth { provider } => {
state.toast_info(format!("OAuth login started for {provider}"));
}
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 = crate::state::Overlay::Editor;
state.mark_dirty();
}
Action::McpAdd { name, command } => {
state.toast_info(format!("MCP server added: {name} ({command})"));
}
Action::ModelList => {
state.misc.overlay = crate::state::Overlay::ModelSelector;
state.mark_dirty();
}
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());
}
}
}
@@ -0,0 +1,4 @@
//! 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.
@@ -0,0 +1,161 @@
//! 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`.
/// 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,
/// `/usage` — open the usage-stats overlay.
UsageOpen,
/// 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.
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,
"/usage" => Command::UsageOpen,
_ => Command::Unknown(cmd.to_string()),
};
tracing::debug!(%text, command = ?result, "parse_command");
result
}
/// Map a parsed `Command` into `Action` values for the event loop.
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::UsageOpen => {
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Usage)]
}
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::*;
#[test]
fn parses_todo_open() {
assert_eq!(parse_command("/todo"), Command::TodoOpen);
}
#[test]
fn parses_usage_open() {
assert_eq!(parse_command("/usage"), Command::UsageOpen);
}
}
+447
View File
@@ -0,0 +1,447 @@
//! 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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::action::Action;
use crate::controller::command::{apply_command, parse_command};
use crate::state::{AutocompleteKind, Overlay, AppStateRest};
/// 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.
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 {
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![],
}
}
// ── 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::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(),
}
}
/// Handle pressing Enter while a modal overlay is active.
fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
tracing::debug!(overlay = ?state.misc.overlay, "handle_overlay_enter");
match state.misc.overlay {
Overlay::Bash => {
let command = state.input.buffer.clone();
state.toast_info(format!("Submitting bash command: {command}"));
state.input.buffer.clear();
state.input.cursor = 0;
state.mark_dirty();
Vec::new()
}
Overlay::Settings => {
state.mark_dirty();
Vec::new()
}
Overlay::Todo => {
state.mark_dirty();
Vec::new()
}
Overlay::QuitConfirm => {
state.quit = true;
state.mark_dirty();
Vec::new()
}
Overlay::KeyInput => {
let text = state.input.buffer.clone();
if !text.is_empty() {
state
.settings
.api_keys
.insert(state.settings.provider.clone(), text);
}
state.toast_success("API key saved".to_string());
state.input.buffer.clear();
state.input.cursor = 0;
state.misc.overlay = Overlay::None;
state.save_settings();
state.mark_dirty();
Vec::new()
}
Overlay::Mcp => {
state.toast_info("Connecting MCP...".to_string());
Vec::new()
}
Overlay::Rewind => {
let idx = state.misc.selected_index;
let n = state.transcript_cache.messages.len();
if idx < n {
let rewind_to = n - idx - 1;
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::System,
format!("Rewound to message {rewind_to}"),
));
}
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
Overlay::ModelSelector => {
let providers: Vec<String> = state.app_config.providers.keys().cloned().collect();
if let Some(provider) = providers.get(state.misc.selected_index) {
if let Some(cfg) = state.app_config.providers.get(provider) {
let model = cfg.default_model.clone().unwrap_or_else(|| {
"claude-opus-4-8".to_string()
});
state.settings.provider.clone_from(provider);
state.settings.model.clone_from(&model);
if let Some(ref key) = cfg.default_api_key {
state.settings.api_keys.insert(provider.clone(), key.clone());
} else if let Some(env_key) = cfg
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
{
state.settings.api_keys.insert(provider.clone(), env_key);
}
state.save_settings();
state.toast_success(format!("Switched to {provider} / {model}"));
}
}
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
Overlay::ClearConfirm => {
state.toast_info("Transcript cleared".to_string());
state.transcript_cache.messages.clear();
state.transcript_cache.dirty = true;
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
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"))
}
#[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())
);
}
#[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);
}
}
+11
View File
@@ -0,0 +1,11 @@
//! 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;
+79
View File
@@ -0,0 +1,79 @@
//! # 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 view;
// ---------------------------------------------------------------------------
// Re-exports for convenient access by consumers (main.rs / bin entry points)
// ---------------------------------------------------------------------------
pub use action::{Action, apply_action};
pub use run::run_single_process;
pub use state::{
AgentState, AppStateRest, AutocompleteKind, ChatMessageDisplay, InputState,
MiscState, Overlay, ScrollState, SimpleAgent, SimpleWorkflowEngine,
TranscriptCache, EditorState,
};
/// Convenience: initialise a `Store` for data directory resolution.
pub use zesdex_domain::core::Store;
@@ -0,0 +1,138 @@
//! 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, lsp_* |
//! | reviewer | Review code for correctness/safety | read, grep, lsp_diagnostics |
//! | 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(),
"lsp_connect".to_string(),
"lsp_diagnostics".to_string(),
"lsp_hover".to_string(),
"lsp_definition".to_string(),
"lsp_references".to_string(),
"lsp_completion".to_string(),
"lsp_disconnect".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(),
"lsp_diagnostics".to_string(),
"lsp_hover".to_string(),
"lsp_definition".to_string(),
"lsp_references".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),
]
}
@@ -0,0 +1,79 @@
//! 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())
}
}
}
@@ -0,0 +1,12 @@
//! 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;
@@ -0,0 +1,70 @@
//! 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)
}
+16
View File
@@ -0,0 +1,16 @@
//! 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;
@@ -0,0 +1,67 @@
//! 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)
}
@@ -0,0 +1,37 @@
//! 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)
}
@@ -0,0 +1,39 @@
//! 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)
}
@@ -0,0 +1,53 @@
//! `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,
FOREIGN KEY (session_id) REFERENCES archives(session_id)
);
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(())
}
+158
View File
@@ -0,0 +1,158 @@
//! 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::execute;
use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, Event, KeyEventKind, MouseEventKind};
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 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.
pub fn run_single_process() -> Result<()> {
// Create session state
let (_store, mut state, _rt) = 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.
fn run_loop(
state: &mut AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
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.
fn run_loop_inner(
state: &mut AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
loop {
if state.quit {
break;
}
let now_ms = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now_ms);
terminal.draw(|f| {
view::draw(f, state);
state.dirty = false;
})?;
// Poll terminal with 50 ms timeout
if crossterm::event::poll(Duration::from_millis(50))? {
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 for single-process mode.
fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest, tokio::runtime::Runtime)> {
let store = zesdex_domain::core::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 workspace_roots = vec![std::env::current_dir()?];
let state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
let rt = tokio::runtime::Runtime::new()?;
Ok((store, state, rt))
}
+961
View File
@@ -0,0 +1,961 @@
//! 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 `controller/input.rs` and read by `view/` every render frame.
//!
//! Infrastructure types (SessionRuntime, DirCache, Toast, etc.) are imported
//! from `zesdex_infrastructure`; domain types (Settings, AppConfig, Role)
//! come from `zesdex_domain`.
//!
//! # Flow
//! Construction in `lib.rs::create_tui_state` → mutated by key events in
//! `controller/input.rs::handle_key` → read-only in every `view/*::draw*`
//! function.
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use tracing::warn;
use zesdex_domain::cms::{AppConfig, Settings};
use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent};
// ---------------------------------------------------------------------------
// Transcript display type
// ---------------------------------------------------------------------------
/// A single transcript entry rendered in the TUI chat pane.
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessageDisplay {
/// Message author: User or Assistant.
pub role: zesdex_domain::core::Role,
/// Rendered text content (plain text, no markdown).
pub content: 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: zesdex_domain::core::Role, content: String) -> Self {
ChatMessageDisplay {
role,
content,
timestamp: chrono::Utc::now().timestamp_millis(),
}
}
}
// ---------------------------------------------------------------------------
// Bounded ring-buffer transcript cache
// ---------------------------------------------------------------------------
/// 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,
}
}
}
// ---------------------------------------------------------------------------
// Scroll state
// ---------------------------------------------------------------------------
/// 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()
}
}
// ---------------------------------------------------------------------------
// Input state (buffer, cursor, history, autocomplete)
// ---------------------------------------------------------------------------
/// 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()
}
}
// ---------------------------------------------------------------------------
// Overlay enum
// ---------------------------------------------------------------------------
/// 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,
/// TODO 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.
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())
}
}
// ---------------------------------------------------------------------------
// MiscState — overlay, toasts, flags, tick, editor
// ---------------------------------------------------------------------------
/// 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,
/// 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>,
}
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(),
lesson_running: false,
pending_clipboard_copy: 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()
}
}
// ---------------------------------------------------------------------------
// EditorState (simplified — used by the Editor overlay)
// ---------------------------------------------------------------------------
/// 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);
}
}
}
// ---------------------------------------------------------------------------
// AgentState + SimpleAgent + SimpleWorkflowEngine (workflow display)
// ---------------------------------------------------------------------------
/// 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 display name.
pub 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.
pub fn new(name: String) -> Self {
SimpleAgent {
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()
}
}
// ---------------------------------------------------------------------------
// Effort levels (for effort overlay)
// ---------------------------------------------------------------------------
/// Name of each reasoning-effort tier.
pub const EFFORT_LEVELS: &[&str] = &[
"Auto — let the provider decide",
"Low — fast, minimal reasoning",
"Medium — balanced speed & reasoning",
"High — thorough reasoning",
"Maximum — 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) {
// Simplified: cycle through levels
let n = EFFORT_LEVELS.len();
state.misc.effort_level = (state.misc.effort_level % n) + 1;
state.mark_dirty();
}
// ---------------------------------------------------------------------------
// Learning item types (for learning overlay)
// ---------------------------------------------------------------------------
/// 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,
},
}
/// Return learning items from state (simplified — uses session_runtime data).
pub fn get_learning_items(_state: &AppStateRest) -> Vec<LearningItem> {
Vec::new()
}
/// 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 }
}
}
// ---------------------------------------------------------------------------
// Rewind helpers
// ---------------------------------------------------------------------------
/// Return the number of rewind points available.
pub fn rewind_count(state: &AppStateRest) -> usize {
state.transcript_cache.messages.len()
}
// ---------------------------------------------------------------------------
// Context window helpers (stubs for status bar)
// ---------------------------------------------------------------------------
/// Resolve the window size for context window management.
pub fn resolve_context_window(
_app_config: &zesdex_domain::cms::AppConfig,
_settings: &zesdex_domain::cms::Settings,
) -> usize {
// Default to 128k for most modern models
128_000
}
/// Count tokens using tiktoken, fall back to character estimation.
pub fn count_tokens(text: &str) -> usize {
// Try tiktoken for accurate counting
if let Ok(bpe) = tiktoken_rs::cl100k_base() {
return bpe.encode_with_special_tokens(text).len();
}
// Fallback: ~4 chars per token
(text.len() + 3) / 4
}
// ---------------------------------------------------------------------------
// 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<Mutex<VecDeque<TurnEvent>>>,
/// Whether an agent turn is currently in flight.
pub turn_in_flight_flag: Arc<Mutex<bool>>,
/// 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 — Keyboard Shortcuts
General
Ctrl+C Quit confirm
Ctrl+D Close overlay
Ctrl+Y Copy last assistant message
Esc Abort turn / Close overlay
Tab Autocomplete
Navigation
/ History browse / Overlay navigate
Ctrl+/ Scroll transcript
PgUp / PgDown Scroll transcript
Enter Submit / Select autocomplete
Overlays
/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
Editor Mode
/edit <path> Open file for inline editing
Ctrl+S Save changes
Esc Dismiss editor
"#;
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(Mutex::new(VecDeque::new())),
turn_in_flight_flag: Arc::new(Mutex::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: None,
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,
}
}
/// Whether an agent turn is currently running.
pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight_flag.lock().map_or_else(
|_| {
warn!("[state] turn_in_flight mutex poisoned");
false
},
|g| *g,
)
}
/// Append a message to the transcript.
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.
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,
)
}
}
+221
View File
@@ -0,0 +1,221 @@
//! Chat transcript panel rendering — tight inline log style.
//!
//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a dense,
//! log-like transcript: each non-tool message gets a one-line
//! `{role} {time} {content}` header with wrapped continuation lines
//! aligned under the content column; `Role::Tool` messages render as a
//! dim `↳`-prefixed sub-line attached to whatever came before.
use super::theme::Theme;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::Frame;
use zesdex_domain::core::Role;
const PREFIX_WIDTH: usize = 15;
fn role_accent_color(role: &Role) -> Color {
match role {
Role::User => Theme::ROLE_USER,
Role::Assistant => Theme::ROLE_ASSISTANT,
Role::System => Theme::ROLE_SYSTEM,
Role::Tool => Theme::ROLE_TOOL,
}
}
fn format_role_label(role: &Role) -> &'static str {
match role {
Role::User => "👤 you ",
Role::Assistant => "🤖 ai ",
Role::System => "💻 sys ",
Role::Tool => "🔧 tool",
}
}
fn format_timestamp(ts: i64) -> String {
if ts <= 0 {
return String::new();
}
let secs = ts / 1000;
let mins = (secs / 60) % 60;
let hrs = (secs / 3600) % 24;
format!("{hrs:02}:{mins:02}")
}
/// Render the scrollable chat transcript panel in tight inline-log style.
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
let messages = &state.transcript_cache.messages;
let scroll_offset = state.scroll.offset;
let max_visible = (area.height as usize).saturating_sub(3);
let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2);
let mut display_lines: Vec<Line> = Vec::new();
for msg in messages {
if msg.role == Role::Tool {
let content = if msg.content.trim().is_empty() {
"(tool execution)".to_string()
} else {
msg.content.clone()
};
let dim = Style::default().fg(Theme::TEXT_DIM);
let content_spans = super::markdown::render_markdown(&content, content_width, true);
let content_lines = split_spans_into_lines(content_spans);
let mut lines_iter = content_lines.into_iter();
let first_spans = lines_iter.next().map_or_else(Vec::new, |line| line.spans);
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH)), Span::styled("", dim)];
spans.extend(first_spans);
display_lines.push(Line::from(spans));
for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
display_lines.push(Line::from(spans));
}
continue;
}
let accent = role_accent_color(&msg.role);
let label = format_role_label(&msg.role);
let ts_str = format_timestamp(msg.timestamp);
let header_prefix = vec![
Span::styled(
format!("{label} "),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{ts_str:<5} "),
Style::default().fg(Theme::TEXT_DIM),
),
];
let content_str = if msg.content.trim().is_empty() {
"(tool execution)".to_string()
} else {
msg.content.clone()
};
let content_spans = super::markdown::render_markdown(&content_str, content_width, false);
let content_lines = split_spans_into_lines(content_spans);
let mut lines_iter = content_lines.into_iter();
if let Some(first) = lines_iter.next() {
let mut spans = header_prefix;
spans.extend(first.spans);
display_lines.push(Line::from(spans));
} else {
display_lines.push(Line::from(header_prefix));
}
for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
display_lines.push(Line::from(spans));
}
}
// Streaming indicator
if state.turn_in_flight() {
let spinner_frames = ["", "", "", "", "", "", "", "", "", ""];
let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len();
let spinner = spinner_frames[frame_idx];
display_lines.push(Line::from(vec![
Span::styled(
format!("{} ", format_role_label(&Role::Assistant)),
Style::default()
.fg(Theme::ROLE_ASSISTANT)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)),
Span::styled(
"generating...",
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::ITALIC),
),
]));
}
// Scrolling
let title = if messages.is_empty() {
String::from(" 💬 Chat ")
} else {
format!(" 💬 Chat [{} msgs] ", messages.len())
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled(
title,
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::BOLD),
));
let total = display_lines.len();
let max_offset = total.saturating_sub(max_visible);
let offset = scroll_offset.min(max_offset);
let end_idx = total.saturating_sub(offset);
let start_idx = end_idx.saturating_sub(max_visible);
let visible: Vec<Line> = if start_idx < end_idx && start_idx < total {
display_lines[start_idx..end_idx].to_vec()
} else {
display_lines[total.saturating_sub(max_visible)..total].to_vec()
};
let scroll_pct = if total > max_visible {
((offset as f64 / max_offset as f64) * 100.0) as u8
} else {
0
};
let block = if scroll_pct > 0 {
let scroll_title = format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct);
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled(
scroll_title,
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::BOLD),
))
} else {
block
};
let paragraph = Paragraph::new(visible)
.block(block)
.style(Style::default().bg(Theme::BG));
frame.render_widget(paragraph, area);
}
fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
let mut lines = Vec::new();
let mut current_spans = Vec::new();
for span in spans {
let text = span.content.as_ref();
let mut parts = text.split('\n').peekable();
while let Some(part) = parts.next() {
if !part.is_empty() {
current_spans.push(Span::styled(part.to_string(), span.style));
}
if parts.peek().is_some() {
lines.push(Line::from(std::mem::take(&mut current_spans)));
}
}
}
if !current_spans.is_empty() {
lines.push(Line::from(current_spans));
}
if lines.is_empty() {
lines.push(Line::from(vec![]));
}
lines
}
+455
View File
@@ -0,0 +1,455 @@
//! Markdown-to-styled-spans rendering for the chat transcript.
//!
//! Flow: `render_markdown` walks a `pulldown_cmark` event stream and
//! translates each markdown construct into styled `ratatui::text::Span`s,
//! then re-wraps the flat span list to a target column width.
use super::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
/// Apply the "tool output" dim/italic style, or pass `style` through
/// unchanged, depending on `dim`.
fn apply_dim(style: Style, dim: bool) -> Style {
if dim {
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC)
} else {
style
}
}
/// Classify a single line inside a ` ```diff ` fenced block by its unified-diff
/// prefix, returning the color it should always render with.
fn diff_line_style(line: &str) -> Option<Style> {
if line.starts_with("@@") {
Some(Style::default().fg(Theme::INFO).bg(Theme::CODE_BG))
} else if line.starts_with('+') && !line.starts_with("+++") {
Some(Style::default().fg(Theme::SUCCESS).bg(Theme::CODE_BG))
} else if line.starts_with('-') && !line.starts_with("---") {
Some(Style::default().fg(Theme::ERROR).bg(Theme::CODE_BG))
} else {
None
}
}
/// Render a markdown string into styled terminal spans, word-wrapped to `width`.
///
/// Flow: `pulldown_cmark` parses `text` into an event stream → each
/// Start/End/Text/Code/Break event is translated into styled `Span`s →
/// if `width > 0`, a second pass wraps long lines.
///
/// `dim`: when `true`, every span falls back to `Theme::TEXT_DIM` + italic
/// (the "tool output" look) *except* lines inside a ` ```diff ` fenced
/// block, which always keep their +/-/@@ diff color regardless of `dim`.
pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
let parser = pulldown_cmark::Parser::new_ext(text, options);
let mut in_code_block = false;
let mut in_diff_block = false;
let mut in_heading = false;
let mut heading_level = 0;
let mut in_table_cell = false;
let mut table_rows: Vec<Vec<Vec<Span<'static>>>> = Vec::new();
let mut current_row: Vec<Vec<Span<'static>>> = Vec::new();
let mut current_cell: Vec<Span<'static>> = Vec::new();
for event in parser {
match event {
pulldown_cmark::Event::Start(tag) => {
match tag {
pulldown_cmark::Tag::CodeBlock(kind) => {
in_code_block = true;
in_diff_block = matches!(
&kind,
pulldown_cmark::CodeBlockKind::Fenced(lang) if lang.as_ref() == "diff"
);
spans.push(Span::styled("\n", Style::default()));
spans.push(Span::styled(
" ┌─ code ",
apply_dim(
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
dim,
),
));
spans.push(Span::styled("\n", Style::default()));
}
pulldown_cmark::Tag::Heading { level, .. } => {
in_heading = true;
heading_level = match level {
pulldown_cmark::HeadingLevel::H1 => 1,
pulldown_cmark::HeadingLevel::H2 => 2,
pulldown_cmark::HeadingLevel::H3 => 3,
_ => 4,
};
}
pulldown_cmark::Tag::Item => {
spans.push(Span::styled(
"",
apply_dim(Style::default().fg(Theme::PRIMARY), dim),
));
}
pulldown_cmark::Tag::Link { dest_url, .. } => {
spans.push(Span::styled(
"[",
apply_dim(Style::default().fg(Theme::INFO), dim),
));
spans.push(Span::styled(
format!("]({dest_url})"),
apply_dim(
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::ITALIC),
dim,
),
));
}
pulldown_cmark::Tag::BlockQuote(_) => {
spans.push(Span::styled(
"",
apply_dim(Style::default().fg(Theme::BLOCKQUOTE_BAR), dim),
));
}
pulldown_cmark::Tag::Table(_) => {
table_rows.clear();
}
pulldown_cmark::Tag::TableHead | pulldown_cmark::Tag::TableRow => {
current_row.clear();
}
pulldown_cmark::Tag::TableCell => {
in_table_cell = true;
current_cell.clear();
}
_ => {}
}
}
pulldown_cmark::Event::End(tag) => {
match tag {
pulldown_cmark::TagEnd::CodeBlock => {
in_code_block = false;
in_diff_block = false;
spans.push(Span::styled(
"\n └─\n",
apply_dim(
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
dim,
),
));
}
pulldown_cmark::TagEnd::Heading(_) => {
in_heading = false;
heading_level = 0;
spans.push(Span::raw("\n"));
}
pulldown_cmark::TagEnd::Paragraph => {
spans.push(Span::raw("\n\n"));
}
pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => {
spans.push(Span::raw("\n"));
}
pulldown_cmark::TagEnd::TableCell => {
in_table_cell = false;
current_row.push(std::mem::take(&mut current_cell));
}
pulldown_cmark::TagEnd::TableHead | pulldown_cmark::TagEnd::TableRow => {
table_rows.push(std::mem::take(&mut current_row));
}
pulldown_cmark::TagEnd::Table => {
let cols_count = table_rows.first().map_or(0, std::vec::Vec::len);
if cols_count == 0 {
continue;
}
let mut col_widths = vec![0; cols_count];
for row in &table_rows {
for (i, cell) in row.iter().enumerate() {
if i < cols_count {
let cell_width: usize =
cell.iter().map(|s| s.content.chars().count()).sum();
if cell_width > col_widths[i] {
col_widths[i] = cell_width;
}
}
}
}
let effective_width = if width > 0 {
(width as usize).saturating_sub(2)
} else {
0
};
let border_overhead = cols_count * 3 + 4;
let available_width = effective_width.saturating_sub(border_overhead);
let mut total_width: usize = col_widths.iter().sum();
if width > 0 && total_width > available_width && available_width > 0 {
while total_width > available_width {
let max_idx = col_widths
.iter()
.enumerate()
.max_by_key(|&(_, &w)| w)
.map(|(i, _)| i)
.unwrap();
if col_widths[max_idx] <= 3 {
break;
}
col_widths[max_idx] -= 1;
total_width -= 1;
}
}
spans.push(Span::raw("\n"));
for (r, row) in table_rows.iter().enumerate() {
let mut cell_lines = Vec::new();
for (i, cell) in row.iter().enumerate() {
if i < cols_count {
cell_lines.push(wrap_spans_to_lines(cell, col_widths[i]));
}
}
let max_height =
cell_lines.iter().map(std::vec::Vec::len).max().unwrap_or(1);
for y in 0..max_height {
spans.push(Span::styled(
" | ",
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
for (i, cl) in cell_lines.iter().enumerate() {
let line_spans =
if y < cl.len() { &cl[y] } else { [].as_slice() };
let mut line_width = 0;
for span in line_spans {
line_width += span.content.chars().count();
spans.push(span.clone());
}
let pad = col_widths[i].saturating_sub(line_width);
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(
" | ",
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
}
spans.push(Span::raw("\n"));
}
if r == 0 {
spans.push(Span::styled(
" |",
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
for w in &col_widths {
spans.push(Span::styled(
format!("{}-|", "-".repeat(*w + 2)),
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
}
spans.push(Span::raw("\n"));
}
}
spans.push(Span::raw("\n"));
}
_ => {}
}
}
pulldown_cmark::Event::Text(text) => {
let s = text.to_string();
if in_code_block {
if in_diff_block {
for (i, line) in s.split('\n').enumerate() {
if i > 0 {
spans.push(Span::raw("\n"));
}
if line.is_empty() {
continue;
}
let style = diff_line_style(line).unwrap_or_else(|| {
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG)
});
spans.push(Span::styled(format!(" {line}"), style));
}
} else {
let indented = format!(" {}", s.replace('\n', "\n "));
spans.push(Span::styled(
indented,
apply_dim(
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
dim,
),
));
}
} else if in_heading {
let color = match heading_level {
1 => Theme::PRIMARY,
2 => Theme::INFO,
3 => Theme::ACCENT_PURPLE,
_ => Theme::TEXT,
};
spans.push(Span::styled(
s,
apply_dim(Style::default().fg(color).add_modifier(Modifier::BOLD), dim),
));
} else if in_table_cell {
current_cell.push(Span::styled(s, apply_dim(Style::default(), dim)));
} else {
spans.push(Span::styled(s, apply_dim(Style::default(), dim)));
}
}
pulldown_cmark::Event::Code(text) => {
let span = Span::styled(
format!(" {text} "),
apply_dim(
Style::default()
.fg(Theme::ACCENT_TEAL)
.bg(Theme::CODE_BAR)
.add_modifier(Modifier::BOLD),
dim,
),
);
if in_table_cell {
current_cell.push(span);
} else {
spans.push(span);
}
}
pulldown_cmark::Event::SoftBreak => {
spans.push(Span::raw(" "));
}
pulldown_cmark::Event::HardBreak => {
spans.push(Span::raw("\n"));
}
_ => {}
}
}
if width > 0 {
let mut spans_out = Vec::new();
let mut line_len = 0;
let effective_width = (width as usize).saturating_sub(2);
for span in spans {
let style = span.style;
let text = span.content.as_ref();
let mut current = String::new();
let mut tokens = Vec::new();
for c in text.chars() {
if c == ' ' {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
tokens.push(" ".to_string());
} else if c == '\n' {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
tokens.push("\n".to_string());
} else {
current.push(c);
}
}
if !current.is_empty() {
tokens.push(current);
}
for token in tokens {
if token == "\n" {
spans_out.push(Span::styled("\n", style));
line_len = 0;
} else if token == " " {
if line_len > 0 && line_len < effective_width {
spans_out.push(Span::styled(" ", style));
line_len += 1;
}
} else {
let token_len = token.chars().count();
if line_len + token_len > effective_width && line_len > 0 {
spans_out.push(Span::raw("\n"));
line_len = 0;
}
if token_len > effective_width {
for c in token.chars() {
if line_len >= effective_width {
spans_out.push(Span::raw("\n"));
line_len = 0;
}
spans_out.push(Span::styled(c.to_string(), style));
line_len += 1;
}
} else {
spans_out.push(Span::styled(token, style));
line_len += token_len;
}
}
}
}
spans = spans_out;
}
spans
}
fn wrap_spans_to_lines(spans: &[Span<'static>], target_width: usize) -> Vec<Vec<Span<'static>>> {
let mut lines = Vec::new();
let mut current_line = Vec::new();
let mut line_len = 0;
for span in spans {
let style = span.style;
let text = span.content.as_ref();
let mut current_word = String::new();
let mut tokens = Vec::new();
for c in text.chars() {
if c == ' ' {
if !current_word.is_empty() {
tokens.push(current_word.clone());
current_word.clear();
}
tokens.push(" ".to_string());
} else {
current_word.push(c);
}
}
if !current_word.is_empty() {
tokens.push(current_word);
}
for token in tokens {
if token == " " {
if line_len > 0 && line_len < target_width {
current_line.push(Span::styled(" ", style));
line_len += 1;
}
} else {
let token_len = token.chars().count();
if line_len + token_len > target_width && line_len > 0 {
lines.push(std::mem::take(&mut current_line));
line_len = 0;
}
if token_len > target_width {
for c in token.chars() {
if target_width > 0 && line_len >= target_width {
lines.push(std::mem::take(&mut current_line));
line_len = 0;
}
current_line.push(Span::styled(c.to_string(), style));
line_len += 1;
}
} else {
current_line.push(Span::styled(token, style));
line_len += token_len;
}
}
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(vec![]);
}
lines
}
+241
View File
@@ -0,0 +1,241 @@
//! Top-level TUI render pipeline: layouts the terminal into chat / input
//! / status regions, dispatches overlay rendering with glassmorphism-style
//! centered panels, and floats toast notifications over the top-right corner.
pub mod chat;
pub mod markdown;
pub mod sidebar;
pub mod status;
pub mod theme;
pub mod workflow;
pub mod overlays;
use crate::state::AppStateRest;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use ratatui::Frame;
use theme::Theme;
use zesdex_infrastructure::ToastKind;
const SIDEBAR_MIN_WIDTH: u16 = 90;
/// Top-level render entry point called once per TUI frame.
pub fn draw(frame: &mut Frame, state: &AppStateRest) {
let area = frame.area();
let show_sidebar = area.width > SIDEBAR_MIN_WIDTH;
let (main_area, sidebar_area) = if show_sidebar {
let has_workflow = !state.workflow_engine.agents.is_empty();
let sidebar_width = if has_workflow { 48 } else { 30 };
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(40), Constraint::Length(sidebar_width)])
.split(area);
(h_chunks[0], Some(h_chunks[1]))
} else {
(area, None)
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.split(main_area);
let chat_area = chunks[0];
let input_area = chunks[1];
let status_area = chunks[2];
if state.misc.overlay.is_active() {
let overlay = state.misc.overlay;
overlays::render_overlay(frame, chat_area, overlay, state);
} else {
render_main_panel(frame, chat_area, state);
}
render_input_bar(frame, input_area, state);
status::draw_status_bar(frame, status_area, state);
if let Some(sidebar_rect) = sidebar_area {
sidebar::draw_sidebar(frame, sidebar_rect, state);
}
render_toasts(frame, state);
}
fn render_main_panel(frame: &mut Frame, area: Rect, state: &AppStateRest) {
chat::draw_chat(frame, area, state);
}
fn render_input_bar(frame: &mut Frame, area: Rect, state: &AppStateRest) {
if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() {
let n = state.input.autocomplete_candidates.len().min(10) as u16;
let dropdown_height = n + 2;
let dropdown_area = Rect {
x: area.x,
y: area.y.saturating_sub(dropdown_height),
width: area.width.min(45),
height: dropdown_height,
};
let dropdown_title = match state.input.autocomplete_kind {
crate::state::AutocompleteKind::Command => " ⌘ Commands ",
crate::state::AutocompleteKind::FileMention => " 📁 Files ",
};
let dropdown_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled(
dropdown_title,
Style::default().fg(Theme::PRIMARY),
))
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
let mut lines: Vec<Line> = Vec::new();
let selected = state.input.autocomplete_idx;
for (i, candidate) in state
.input
.autocomplete_candidates
.iter()
.enumerate()
.take(10)
{
let prefix = if i == selected { "" } else { " " };
let style = if i == selected {
Style::default()
.fg(Theme::TEXT)
.bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::TEXT)
};
let label = format!("{prefix}{candidate}");
lines.push(Line::from(Span::styled(label, style)));
}
let dropdown = Paragraph::new(lines).block(dropdown_block);
frame.render_widget(dropdown, dropdown_area);
}
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::SURFACE));
let input_text = &state.input.buffer;
let cursor_pos = state.input.cursor;
let prompt = Span::styled(
" ",
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
);
let mut spans = vec![prompt];
if input_text.is_empty() {
spans.push(Span::styled(
"Type a message or /command...",
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
));
} else {
let (before, after) = input_text.split_at(cursor_pos);
spans.push(Span::raw(before.to_string()));
let cursor_char = if after.is_empty() { " " } else { &after[..1] };
spans.push(Span::styled(
cursor_char,
Style::default()
.bg(Theme::HIGHLIGHT)
.fg(Theme::BG)
.add_modifier(Modifier::BOLD),
));
if after.len() > 1 {
spans.push(Span::raw(after[1..].to_string()));
}
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line).block(block);
frame.render_widget(paragraph, area);
}
fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
let now_ms = chrono::Utc::now().timestamp_millis();
let active: Vec<&zesdex_infrastructure::Toast> = state
.misc
.toasts
.iter()
.filter(|t| !t.expired(now_ms))
.collect();
if active.is_empty() {
return;
}
let area = frame.area();
let toast_w: u16 = 48;
let x = area.width.saturating_sub(toast_w).saturating_sub(2);
let mut y: u16 = 1;
for toast in active.iter().rev().take(4) {
let line_count = toast.message.lines().count().max(1) as u16;
let h = line_count + 2;
let toast_area = Rect {
x,
y,
width: toast_w,
height: h,
};
if toast_area.bottom() > area.height {
break;
}
frame.render_widget(Clear, toast_area);
let (border_color, icon) = match toast.kind {
ToastKind::Success => (Theme::SUCCESS, ""),
ToastKind::Warning => (Theme::WARNING, ""),
ToastKind::Error => (Theme::ERROR, ""),
ToastKind::Info => (Theme::INFO, " "),
ToastKind::Lesson => (Theme::ACCENT_PURPLE, " 📘 "),
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(Span::styled(icon, Style::default().fg(border_color)))
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
let paragraph = Paragraph::new(toast.message.as_str())
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, toast_area);
y = y.saturating_add(h).saturating_add(1);
}
}
/// Split `items` into the slice that fits within `max_visible` entries and
/// the count of items hidden beyond that limit.
pub(crate) fn split_for_display<T>(items: &[T], max_visible: usize) -> (&[T], usize) {
if items.len() <= max_visible {
(items, 0)
} else {
(&items[..max_visible], items.len() - max_visible)
}
}
/// Build the dim trailing hint line a sidebar widget shows when its
/// content is truncated.
pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static> {
Line::from(Span::styled(
format!(" +{hidden} more — {command}"),
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
))
}
@@ -0,0 +1,46 @@
//! Overlay: list of active / completed bash background jobs.
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Bash Jobs overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = super::overlay_block(block, "Bash Jobs", Theme::ACCENT_ORANGE);
let lines: Vec<Line> = state
.session_runtime
.as_ref()
.map(|r| {
r.bash_jobs
.iter()
.map(|job| {
Line::from(Span::styled(
format!(
" [{}] {} — {}",
job.id,
job.command,
if job.running { "running" } else { "done" },
),
Style::default().fg(Theme::TEXT),
))
})
.collect()
})
.unwrap_or_default();
let paragraph = if lines.is_empty() {
Paragraph::new(Line::from(Span::styled(
" No active bash jobs.",
Style::default().fg(Theme::TEXT_DIM),
)))
.block(block)
} else {
Paragraph::new(lines).block(block)
};
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,36 @@
//! Overlay: confirm-before-clear dialog for the chat transcript.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Clear Transcript confirmation dialog.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
_state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Clear Transcript ",
Style::default()
.fg(Theme::WARNING)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::WARNING));
let lines = vec![
Line::from(Span::styled(
" Clear all messages from the transcript?",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Enter to confirm · Esc to cancel",
Style::default().fg(Theme::TEXT_DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,52 @@
//! Overlay: inline editor mode — shows the current input buffer with cursor
//! position and save/dismiss key hints.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Editor overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Editor ",
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::PRIMARY));
let lines = vec![
Line::from(Span::styled(
" Editor Mode — Ctrl+S save, Esc dismiss",
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::ITALIC),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Buffer:",
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
format!(" {}", state.input.buffer),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
format!(
" Cursor: pos {} / {}",
state.input.cursor,
state.input.buffer.len()
),
Style::default().fg(Theme::TEXT_DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,52 @@
//! Overlay: effort-level selector — lets the user pick a reasoning/quality tier.
use crate::state::{current_effort, EFFORT_LEVELS};
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
/// Render the Effort Level overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Effort Level ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let levels = EFFORT_LEVELS;
let current_idx = current_effort(state);
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
" Use ↑↓ to change effort level",
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::raw("")),
];
for (i, l) in levels.iter().enumerate() {
let selected = i == current_idx;
lines.push(Line::from(Span::styled(
if selected {
format!("{l} (active)")
} else {
format!(" {l}")
},
if selected {
Style::default()
.fg(Theme::HIGHLIGHT)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::TEXT)
},
)));
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,21 @@
//! Overlay: keyboard shortcut reference.
use ratatui::style::Style;
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Help overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = super::overlay_block(block, "Help", Theme::INFO);
let content = state.help_text;
let paragraph = Paragraph::new(content)
.block(block)
.style(Style::default().bg(Theme::BG))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,58 @@
//! Overlay: API key input dialog — prompts the user for a provider API key
//! with masked display (shows first 4 chars only).
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the API Key input overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" API Key ",
Style::default()
.fg(Theme::WARNING)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::WARNING));
let input_text = &state.input.buffer;
let display = if input_text.is_empty() {
" Type your API key..."
} else {
if input_text.len() > 8 {
&input_text[..4]
} else {
input_text.as_str()
}
};
let masked = if input_text.is_empty() {
display.to_string()
} else {
let suffix = if input_text.len() > 8 { "****" } else { "" };
format!("{display}{suffix}")
};
let lines = vec![
Line::from(Span::styled(
" Enter API key for authentication:",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::raw("")),
Line::from(vec![
Span::styled(" Key: ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(
masked,
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
),
]),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,203 @@
//! Overlay: Learning / lesson management — two-panel view with a scrollable
//! lesson list (left) and detail pane (right).
use crate::state::{get_learning_items, LearningItem};
use crate::view::theme::Theme;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
/// Render the Learning overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
drop(block);
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
let left_block = Block::default()
.title(Span::styled(
" Lessons ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::BG));
let right_block = Block::default()
.title(Span::styled(
" Details ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::BG));
let items = get_learning_items(state);
let mut left_lines = Vec::new();
if items.is_empty() {
left_lines.push(Line::from(Span::styled(
" No lessons found.",
Style::default().fg(Theme::TEXT_DIM),
)));
} else {
for (i, item) in items.iter().enumerate() {
let is_selected = i == state.misc.selected_index;
let prefix = if is_selected { "" } else { " " };
let (label, style) = match item {
LearningItem::Pending { name, .. } => (
format!("{prefix}[Pending] {name}"),
if is_selected {
Style::default()
.fg(Theme::WARNING)
.bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::WARNING)
},
),
LearningItem::Stored { name, lifecycle, .. } => {
let status = if lifecycle == "stale" { "Stale" } else { "Active" };
(
format!("{prefix}[{status}] {name}"),
if is_selected {
Style::default()
.fg(Theme::TEXT)
.bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::TEXT)
},
)
}
};
left_lines.push(Line::from(Span::styled(label, style)));
}
}
let max_lines = h_chunks[0].height.saturating_sub(2) as usize;
let selected = state.misc.selected_index;
let start_idx = if selected >= max_lines {
selected - max_lines + 1
} else {
0
};
let end_idx = (start_idx + max_lines).min(left_lines.len());
let visible_lines = if left_lines.is_empty() {
Vec::new()
} else {
left_lines[start_idx..end_idx].to_vec()
};
let left_paragraph = Paragraph::new(visible_lines).block(left_block);
frame.render_widget(left_paragraph, h_chunks[0]);
let mut right_lines = Vec::new();
if let Some(item) = items.get(selected) {
match item {
LearningItem::Pending { name, content, scope, confidence } => {
right_lines.push(Line::from(Span::styled(
" Name:",
Style::default().fg(Theme::TEXT_DIM),
)));
right_lines.push(Line::from(Span::styled(
format!(" {name}"),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)));
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" Status: Pending Approval",
Style::default().fg(Theme::WARNING),
)));
right_lines.push(Line::from(Span::styled(
format!(" Scope: {scope}"),
Style::default().fg(Theme::TEXT),
)));
right_lines.push(Line::from(Span::styled(
format!(" Confidence: {confidence}"),
Style::default().fg(Theme::TEXT),
)));
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" Content:",
Style::default().fg(Theme::TEXT_DIM),
)));
for line in content.lines() {
right_lines.push(Line::from(Span::styled(
format!(" {line}"),
Style::default().fg(Theme::TEXT),
)));
}
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" [Enter]/[a] Accept · [r]/[Del] Reject",
Style::default().fg(Theme::TEXT_DIM),
)));
}
LearningItem::Stored { name, content, lifecycle, scope, description } => {
right_lines.push(Line::from(Span::styled(
" Name:",
Style::default().fg(Theme::TEXT_DIM),
)));
right_lines.push(Line::from(Span::styled(
format!(" {name}"),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)));
right_lines.push(Line::from(Span::raw("")));
let status_color = if lifecycle == "stale" { Theme::WARNING } else { Theme::SUCCESS };
right_lines.push(Line::from(Span::styled(
format!(" Status: {lifecycle}"),
Style::default().fg(status_color),
)));
right_lines.push(Line::from(Span::styled(
format!(" Scope: {scope}"),
Style::default().fg(Theme::TEXT),
)));
right_lines.push(Line::from(Span::styled(
format!(" Description: {description}"),
Style::default().fg(Theme::TEXT),
)));
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" Content:",
Style::default().fg(Theme::TEXT_DIM),
)));
for line in content.lines() {
right_lines.push(Line::from(Span::styled(
format!(" {line}"),
Style::default().fg(Theme::TEXT),
)));
}
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" [d]/[Del] Delete Lesson",
Style::default().fg(Theme::TEXT_DIM),
)));
}
}
} else {
right_lines.push(Line::from(Span::styled(
" Select a lesson on the left.",
Style::default().fg(Theme::TEXT_DIM),
)));
}
let right_paragraph = Paragraph::new(right_lines)
.block(right_block)
.wrap(Wrap { trim: false });
frame.render_widget(right_paragraph, h_chunks[1]);
}
@@ -0,0 +1,28 @@
//! Overlay: loading / processing spinner — shown during blocking operations.
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Loading overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Loading ",
Style::default()
.fg(Theme::WARNING)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::WARNING));
let spinner = ["", "", "", "", "", "", "", "", "", ""];
let frame_idx = (state.misc.tick_count as usize) % spinner.len();
let content = format!(" {} Processing, please wait...", spinner[frame_idx]);
let paragraph = Paragraph::new(content).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,47 @@
//! Overlay: MCP (Model Context Protocol) server management.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the MCP Servers overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" MCP Servers ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::INFO));
let lines = vec![
Line::from(Span::styled(
" MCP Server Management",
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
format!(" Session dir: {}", state.session_dir.display()),
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
" No MCP servers configured.",
Style::default().fg(Theme::TEXT_MUTED),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Press Ctrl+P to configure provider settings.",
Style::default().fg(Theme::TEXT_DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,117 @@
//! Overlay rendering: each overlay variant gets its own module with a
//! `pub fn render(frame, area, block, state)` entry point, dispatched by
//! the top-level `render_overlay` function in this module.
pub mod bash;
pub mod clear_confirm;
pub mod editor;
pub mod effort;
pub mod help;
pub mod key_input;
pub mod learning;
pub mod loading;
pub mod mcp;
pub mod model_selector;
pub mod quit_confirm;
pub mod rewind;
pub mod settings;
pub mod todo;
pub mod usage;
use crate::state::{AppStateRest, Overlay};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Clear};
use ratatui::Frame;
use super::theme::Theme;
/// Decorate an overlay block with a styled title and matching border color.
pub fn overlay_block(block: Block<'static>, title: &str, color: ratatui::style::Color) -> Block<'static> {
block
.title(Span::styled(
format!(" {title} "),
Style::default()
.fg(color)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(color))
}
/// Compute a centered rectangle within `area` at the given percentage width and height.
pub fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2;
let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2;
Rect {
x: area.x.saturating_add(x_pad),
y: area.y.saturating_add(y_pad),
width: area.width.saturating_sub(x_pad * 2).max(40),
height: area.height.saturating_sub(y_pad * 2).max(10),
}
}
/// Render the active modal overlay as a centered panel.
pub fn render_overlay(
frame: &mut Frame,
area: Rect,
overlay: Overlay,
state: &AppStateRest,
) {
let overlay_area = centered_rect(area, 75, 70);
frame.render_widget(Clear, overlay_area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::BG));
match overlay {
Overlay::None => {}
Overlay::Help => {
help::render(frame, overlay_area, block, state);
}
Overlay::Settings => {
settings::render(frame, overlay_area, block, state);
}
Overlay::Bash => {
bash::render(frame, overlay_area, block, state);
}
Overlay::QuitConfirm => {
quit_confirm::render(frame, overlay_area, block, state);
}
Overlay::KeyInput => {
key_input::render(frame, overlay_area, block, state);
}
Overlay::Editor => {
editor::render(frame, overlay_area, block, state);
}
Overlay::Effort => {
effort::render(frame, overlay_area, block, state);
}
Overlay::Mcp => {
mcp::render(frame, overlay_area, block, state);
}
Overlay::Todo => {
todo::render(frame, overlay_area, block, state);
}
Overlay::Rewind => {
rewind::render(frame, overlay_area, block, state);
}
Overlay::Learning => {
learning::render(frame, overlay_area, block, state);
}
Overlay::Usage => {
usage::render(frame, overlay_area, block, state);
}
Overlay::Loading => {
loading::render(frame, overlay_area, block, state);
}
Overlay::ModelSelector => {
model_selector::render(frame, overlay_area, block, state);
}
Overlay::ClearConfirm => {
clear_confirm::render(frame, overlay_area, block, state);
}
}
}
@@ -0,0 +1,66 @@
//! Overlay: model/provider selector — lists available providers from config
//! and lets the user pick one with ↑/↓/Enter.
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
/// Render the Model Selector overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Model Selector ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
format!(
" Current: {} / {}",
state.settings.provider, state.settings.model
),
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Providers:",
Style::default().fg(Theme::TEXT_DIM),
)),
];
let providers: Vec<(&String, &zesdex_domain::cms::ProviderConfig)> =
state.app_config.providers.iter().collect();
for (i, (name, cfg)) in providers.iter().enumerate() {
let is_current = *name == &state.settings.provider;
let is_selected = i == state.misc.selected_index;
let prefix = if is_selected { "" } else { " " };
let model_str = cfg.default_model.as_deref().unwrap_or("(any)");
let label = format!("{prefix}{name} ({model_str})");
let style = if is_current {
Style::default()
.fg(Theme::HIGHLIGHT)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT)
} else {
Style::default().fg(Theme::TEXT)
};
lines.push(Line::from(Span::styled(label, style)));
}
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
" ↑↓ navigate · Enter select · Esc close",
Style::default().fg(Theme::TEXT_DIM),
)));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,38 @@
//! Overlay: quit confirmation dialog.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Quit confirmation overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
_state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Quit ",
Style::default()
.fg(Theme::ERROR)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ERROR));
let lines = vec![
Line::from(Span::styled(
" Are you sure you want to quit?",
Style::default()
.fg(Theme::ERROR)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Press Enter to confirm, Esc to cancel.",
Style::default().fg(Theme::TEXT_DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,62 @@
//! Overlay: Rewind / session history — shows recent messages and lets the user
//! pick a point to rewind the transcript back to.
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use zesdex_domain::core::Role;
/// Render the Rewind overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Rewind ",
Style::default()
.fg(Theme::ACCENT_ORANGE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_ORANGE));
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
" Use ↑↓ to navigate, Enter to rewind to that point",
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::raw("")),
];
let messages = &state.transcript_cache.messages;
if messages.is_empty() {
lines.push(Line::from(Span::styled(
" No messages in current session.",
Style::default().fg(Theme::TEXT_DIM),
)));
} else {
let start = if messages.len() > 8 { messages.len() - 8 } else { 0 };
for msg in &messages[start..] {
let role_str = match msg.role {
Role::User => "User",
Role::Assistant => "Asst",
Role::System => "Sys",
Role::Tool => "Tool",
};
let preview: String = msg.content.chars().take(70).collect();
lines.push(Line::from(Span::styled(
format!(" [{role_str}] {preview}"),
Style::default().fg(if msg.role == Role::User { Theme::INFO } else { Theme::TEXT }),
)));
}
if messages.len() > 8 {
lines.push(Line::from(Span::styled(
format!(" ... and {} more messages", messages.len() - 8),
Style::default().fg(Theme::TEXT_DIM),
)));
}
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,51 @@
//! Overlay: settings overview — displays the current provider, model,
//! max tokens, temperature, internet mode, and review toggle.
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Settings overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = super::overlay_block(block, "Settings", Theme::PRIMARY);
let lines = vec![
Line::from(Span::styled(
format!(" Provider: {}", state.settings.provider),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Model: {}", state.settings.model),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(
" Max tokens: {}",
state.settings.max_tokens.map_or_else(|| "auto".to_string(), |v| v.to_string())
),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(
" Temperature: {}",
state.settings.temperature.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))
),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Internet: {:?}", state.settings.internet_mode),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Review: {}", state.settings.flags.review_enabled),
Style::default().fg(Theme::TEXT),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,32 @@
//! Overlay: Tasks (todo) view — shows the full todo list content.
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Tasks / Todo overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Tasks ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let content = if state.misc.todo_content.is_empty() {
" No tasks yet."
} else {
&state.misc.todo_content
};
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,98 @@
//! Overlay: usage statistics — detailed token usage, API call count, edit/
//! review/lesson activity counters, and session elapsed time.
use crate::view::sidebar::compute_usage_summary;
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
/// Render the Usage overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Usage ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::INFO));
let runtime = state.session_runtime.as_ref();
let now_ms = chrono::Utc::now().timestamp_millis();
let summary = runtime.map(|r| compute_usage_summary(&r.usage, r.session_start, now_ms));
let (edit_count, lesson_count, review_count, consec_empty) =
runtime.map_or((0, 0, 0, 0), |r| {
(r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
});
let mut lines = vec![
Line::from(Span::styled(
" Token Usage",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
];
if let Some(s) = &summary {
lines.push(Line::from(Span::styled(
format!(" Main agent: {} tokens", s.main_tokens),
Style::default().fg(Theme::TEXT),
)));
lines.push(Line::from(Span::styled(
format!(" Self-learning: {} tokens", s.self_learning_tokens),
Style::default().fg(Theme::TEXT_MUTED),
)));
lines.push(Line::from(Span::styled(
format!(" Total: {} tokens", s.total_tokens),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" API calls: {}", s.api_calls),
Style::default().fg(Theme::TEXT),
)));
} else {
lines.push(Line::from(Span::styled(
" No active session.",
Style::default().fg(Theme::TEXT_DIM),
)));
}
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
" Activity",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" Edits: {edit_count}"),
Style::default().fg(Theme::TEXT),
)));
lines.push(Line::from(Span::styled(
format!(" Reviews: {review_count}"),
Style::default().fg(Theme::TEXT),
)));
lines.push(Line::from(Span::styled(
format!(" Lessons: {lesson_count}"),
Style::default().fg(Theme::TEXT_MUTED),
)));
lines.push(Line::from(Span::styled(
format!(" Empty reviews: {consec_empty}"),
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::TEXT_DIM }),
)));
if let Some(s) = &summary {
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
format!(" Session: {}h {}m {}s", s.elapsed_hours, s.elapsed_minutes, s.elapsed_seconds),
Style::default().fg(Theme::TEXT_DIM),
)));
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
+168
View File
@@ -0,0 +1,168 @@
//! Persistent right-hand dashboard sidebar: Workflow, Tasks, and Usage
//! widgets stacked in three vertical thirds.
use super::theme::Theme;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
/// Render the persistent right-hand dashboard: Workflow, Tasks, and Usage.
pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
let has_workflow = !state.workflow_engine.agents.is_empty();
let constraints = if has_workflow {
vec![
Constraint::Ratio(1, 2),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
]
} else {
vec![
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
super::workflow::draw_workflow_panel(frame, chunks[0], state);
draw_tasks_widget(frame, chunks[1], state);
draw_usage_widget(frame, chunks[2], state);
}
fn draw_tasks_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
let block = Block::default()
.title(Span::styled(
" Tasks ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER));
let budget = (block.inner(area).height as usize).max(1);
let content = &state.misc.todo_content;
let task_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
let lines: Vec<Line> = if task_lines.is_empty() {
vec![Line::from(Span::styled(
" No tasks yet.",
Style::default().fg(Theme::TEXT_DIM),
))]
} else {
let show_hint = task_lines.len() > budget;
let item_budget = if show_hint {
budget.saturating_sub(1).max(1)
} else {
budget
};
let (visible, hidden) = super::split_for_display(&task_lines, item_budget);
let mut lines: Vec<Line> = visible
.iter()
.map(|l| {
Line::from(Span::styled(
format!(" {l}"),
Style::default().fg(Theme::TEXT),
))
})
.collect();
if show_hint {
lines.push(super::overflow_hint_line(hidden, "/todo"));
}
lines
};
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
let block = Block::default()
.title(Span::styled(
" Usage ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER));
let lines: Vec<Line> = if let Some(ref rt) = state.session_runtime {
let now_ms = chrono::Utc::now().timestamp_millis();
let summary = compute_usage_summary(&rt.usage, rt.session_start, now_ms);
vec![
Line::from(Span::styled(
format!(" {:>6}: {} tok", "total", summary.total_tokens),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" {:>6}: {} tok", "main", summary.main_tokens),
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
format!(" {:>6}: {} tok", "learn", summary.self_learning_tokens),
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
format!(" {:>6}: {}", "calls", summary.api_calls),
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
format!(
" {:>6}: {}h {:02}m {:02}s",
"time", summary.elapsed_hours, summary.elapsed_minutes, summary.elapsed_seconds
),
Style::default().fg(Theme::TEXT_DIM),
)),
]
} else {
vec![Line::from(Span::styled(
" No active session.",
Style::default().fg(Theme::TEXT_DIM),
))]
};
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
pub(crate) struct UsageSummary {
pub main_tokens: u64,
pub self_learning_tokens: u64,
pub total_tokens: u64,
pub api_calls: u64,
pub elapsed_hours: i64,
pub elapsed_minutes: i64,
pub elapsed_seconds: i64,
}
pub(crate) fn compute_usage_summary(
usage: &zesdex_domain::core::UsageStats,
session_start: i64,
now_ms: i64,
) -> UsageSummary {
let total_tokens = usage.tokens_in.saturating_add(usage.tokens_out);
let self_learning_tokens = usage.review_tokens;
let main_tokens = total_tokens.saturating_sub(self_learning_tokens);
let elapsed_ms = now_ms.saturating_sub(session_start);
let elapsed_hours = elapsed_ms / 3_600_000;
let elapsed_minutes = (elapsed_ms % 3_600_000) / 60_000;
let elapsed_seconds = (elapsed_ms % 60_000) / 1000;
UsageSummary {
main_tokens,
self_learning_tokens,
total_tokens,
api_calls: usage.api_calls,
elapsed_hours,
elapsed_minutes,
elapsed_seconds,
}
}
+115
View File
@@ -0,0 +1,115 @@
//! Status bar rendering for the TUI — modern segmented bar design.
//!
//! Flow: `draw_status_bar` reads live connection/turn state off
//! `AppStateRest` every frame and paints a single-line bar at the
//! bottom of the screen with three visual segments.
use super::theme::Theme;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Block;
use ratatui::Frame;
/// Render the single-line status bar.
pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
use ratatui::layout::{Alignment, Constraint, Direction, Layout};
let spinner_frames = ["", "", "", "", "", "", "", "", "", ""];
let (status_text, badge_bg, status_fg) = if state.turn_in_flight() {
let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()];
(format!(" {f} PROG "), Theme::MODE_YOLO, Theme::BG)
} else if state.misc.api_connected {
(" READY ".to_string(), Theme::MODE_AUTO, Theme::BG)
} else {
(" NOAPI ".to_string(), Theme::TEXT_DIM, Theme::BG)
};
let status_badge = Span::styled(
status_text,
Style::default()
.fg(status_fg)
.bg(badge_bg)
.add_modifier(Modifier::BOLD),
);
let left_spans = vec![
Span::styled(
" ⚡zesdex ",
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
),
status_badge,
];
let max_tokens = crate::state::resolve_context_window(&state.app_config, &state.settings);
let right_str = if let Some(ref rt) = state.session_runtime {
let current_tokens: usize = rt
.messages
.iter()
.filter_map(|m| m.content.as_deref())
.map(crate::state::count_tokens)
.sum();
let mut parts = Vec::new();
if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 {
parts.push(format!(
"↑{} ↓{}",
rt.usage.last_tokens_in, rt.usage.last_tokens_out
));
}
parts.push(format!("{current_tokens}/{max_tokens}"));
parts.push(state.settings.provider.clone());
parts.push(state.settings.model.clone());
format!(" {} ", parts.join(" · "))
} else {
format!(
" 0/{max_tokens} · {} · {} ",
state.settings.provider, state.settings.model
)
};
let left_line = Line::from(left_spans);
let right_line = Line::from(Span::styled(
right_str,
Style::default().fg(Theme::TEXT_MUTED),
));
let center_line = if state.misc.lesson_running {
Line::from(vec![Span::styled(
" 📘 Generating Lesson... ",
Style::default()
.fg(Theme::MODE_YOLO)
.add_modifier(Modifier::BOLD),
)])
} else {
Line::from("")
};
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(25),
Constraint::Min(10),
Constraint::Length(60),
])
.split(area);
let block = Block::default().style(Style::default().bg(Theme::STATUS_BAR_BG).fg(Theme::TEXT));
let left_para = ratatui::widgets::Paragraph::new(left_line).block(block.clone());
frame.render_widget(left_para, chunks[0]);
let center_para = ratatui::widgets::Paragraph::new(center_line)
.block(block.clone())
.alignment(Alignment::Center);
frame.render_widget(center_para, chunks[1]);
let right_para = ratatui::widgets::Paragraph::new(right_line)
.block(block)
.alignment(Alignment::Right);
frame.render_widget(right_para, chunks[2]);
}
+56
View File
@@ -0,0 +1,56 @@
//! Central color theme for the TUI — Tokyo Night palette.
//!
//! Design: muted blue-purple dark background with desaturated blue/cyan/
//! purple accents (not neon) — the popular Tokyo Night editor/terminal
//! theme. Chosen for a calmer "professional dev tool" read.
use ratatui::style::Color;
/// Central palette of terminal colors used across all TUI render functions.
pub struct Theme;
impl Theme {
// ── Base surface colors ──────────────────────────────────────────────
pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26);
pub const SURFACE: Color = Color::Rgb(0x1f, 0x23, 0x35);
pub const SURFACE_ELEVATED: Color = Color::Rgb(0x29, 0x2e, 0x42);
// ── Text colors ──────────────────────────────────────────────────────
pub const TEXT: Color = Color::Rgb(0xc0, 0xca, 0xf5);
pub const TEXT_MUTED: Color = Color::Rgb(0xa9, 0xb1, 0xd6);
pub const TEXT_DIM: Color = Color::Rgb(0x56, 0x5f, 0x89);
// ── Accent colors ────────────────────────────────────────────────────
pub const PRIMARY: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
pub const SUCCESS: Color = Color::Rgb(0x9e, 0xce, 0x6a);
pub const WARNING: Color = Color::Rgb(0xe0, 0xaf, 0x68);
pub const ERROR: Color = Color::Rgb(0xf7, 0x76, 0x8e);
pub const INFO: Color = Color::Rgb(0x7d, 0xcf, 0xff);
// ── Extended accent palette ──────────────────────────────────────────
pub const ACCENT_PURPLE: Color = Color::Rgb(0xbb, 0x9a, 0xf7);
pub const ACCENT_ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64);
pub const ACCENT_TEAL: Color = Color::Rgb(0x73, 0xda, 0xca);
// ── Border colors ────────────────────────────────────────────────────
pub const BORDER: Color = Color::Rgb(0x3b, 0x42, 0x61);
// ── Role badge colors ────────────────────────────────────────────────
pub const ROLE_USER: Color = Color::Rgb(0x9e, 0xce, 0x6a);
pub const ROLE_ASSISTANT: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
pub const ROLE_SYSTEM: Color = Color::Rgb(0x7d, 0xcf, 0xff);
pub const ROLE_TOOL: Color = Color::Rgb(0xe0, 0xaf, 0x68);
// ── Status colors ────────────────────────────────────────────────────
pub const STATUS_BAR_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
pub const MODE_AUTO: Color = Color::Rgb(0x9e, 0xce, 0x6a);
pub const MODE_YOLO: Color = Color::Rgb(0xf7, 0x76, 0x8e);
// ── Code / markdown ──────────────────────────────────────────────────
pub const CODE_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
pub const CODE_BAR: Color = Color::Rgb(0x29, 0x2e, 0x42);
pub const BLOCKQUOTE_BAR: Color = Color::Rgb(0x7d, 0xcf, 0xff);
// ── Misc ─────────────────────────────────────────────────────────────
pub const HIGHLIGHT: Color = Color::Rgb(0x3d, 0x59, 0xa1);
pub const HIGHLIGHT_DIM: Color = Color::Rgb(0x29, 0x2e, 0x42);
}
+221
View File
@@ -0,0 +1,221 @@
//! Workflow status panel rendering — agent cards with state badges.
use super::theme::Theme;
use crate::state::AgentState;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
fn state_icon(state: AgentState) -> &'static str {
match state {
AgentState::Idle => "",
AgentState::Running => "",
AgentState::Completed => "",
AgentState::Failed => "",
}
}
fn state_label(state: AgentState) -> &'static str {
match state {
AgentState::Idle => "Idle",
AgentState::Running => "Running",
AgentState::Completed => "Done",
AgentState::Failed => "Failed",
}
}
fn state_color(state: AgentState) -> Color {
match state {
AgentState::Idle => Theme::TEXT_DIM,
AgentState::Running => Theme::WARNING,
AgentState::Completed => Theme::SUCCESS,
AgentState::Failed => Theme::ERROR,
}
}
/// Render the workflow status panel.
pub fn draw_workflow_panel(
frame: &mut Frame,
area: Rect,
state: &crate::state::AppStateRest,
) {
use ratatui::layout::{Constraint, Direction, Layout};
let title = Span::styled(
" Workflow ",
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(4)])
.split(inner);
// Header area
let mut header_lines: Vec<Line> = Vec::new();
header_lines.push(Line::from(vec![
Span::styled(
"/workflow run ",
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
),
Span::styled("<prompt>", Style::default().fg(Theme::TEXT_DIM)),
]));
header_lines.push(Line::from(vec![
Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)),
if state.turn_in_flight() {
Span::styled(
"● Running",
Style::default()
.fg(Theme::WARNING)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled("● Idle", Style::default().fg(Theme::SUCCESS))
},
Span::raw(" "),
Span::styled(
format!(
"Agents: {} | Findings: {}",
state.workflow_engine.agents.len(),
state.workflow_engine.findings.len(),
),
Style::default().fg(Theme::TEXT_DIM),
),
]));
let header = Paragraph::new(header_lines);
frame.render_widget(header, chunks[0]);
// Body: agent cards
if state.workflow_engine.agents.is_empty() {
let session_lines = build_session_lines(state);
let placeholder = Paragraph::new(session_lines).wrap(Wrap { trim: false });
frame.render_widget(placeholder, chunks[1]);
} else {
let mut card_lines: Vec<Line> = Vec::new();
for agent in &state.workflow_engine.agents {
let color = state_color(agent.state);
let icon = state_icon(agent.state);
let label = state_label(agent.state);
let duration_str = match (agent.started_at, agent.completed_at) {
(Some(s), Some(e)) => format!(" {}ms", e.saturating_sub(s)),
(Some(_), None) => " (running)".to_string(),
_ => String::new(),
};
card_lines.push(Line::from(vec![
Span::styled(
format!(" {icon} "),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", agent.name),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!(" [{label}]"), Style::default().fg(color)),
Span::styled(duration_str, Style::default().fg(Theme::TEXT_DIM)),
]));
if let Some(ref err) = agent.error {
card_lines.push(Line::from(vec![
Span::styled("", Style::default().fg(Theme::ERROR)),
Span::styled(err.clone(), Style::default().fg(Theme::ERROR)),
]));
} else if let Some(ref prog) = agent.progress {
for line in prog.lines().take(2) {
card_lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
line.to_string(),
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
),
]));
}
}
}
let list = Paragraph::new(card_lines);
frame.render_widget(list, chunks[1]);
}
}
fn build_session_lines(state: &crate::state::AppStateRest) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
" No workflow running.",
Style::default().fg(Theme::TEXT_DIM),
)));
lines.push(Line::from(Span::raw("")));
if let Some(ref rt) = state.session_runtime {
let tool_count = rt.tool_call_results.len();
let pending = rt.pending_tool_queue.len();
let bash_count = rt.bash_jobs.len();
let msg_count = rt.messages.len();
lines.push(Line::from(vec![
Span::styled(" Messages ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(
msg_count.to_string(),
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled(" Tool calls", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(
format!(" {tool_count}"),
Style::default().fg(Theme::SUCCESS),
),
]));
if pending > 0 {
lines.push(Line::from(vec![
Span::styled(" Pending ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(format!(" {pending}"), Style::default().fg(Theme::WARNING)),
]));
}
if bash_count > 0 {
lines.push(Line::from(vec![
Span::styled(" Bash jobs ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(
format!(" {bash_count}"),
Style::default().fg(Theme::WARNING),
),
]));
}
} else {
lines.push(Line::from(Span::styled(
" (no active session)",
Style::default().fg(Theme::TEXT_DIM),
)));
}
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
" The Hive is dormant. Complex tasks will stir it.",
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
)));
lines
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "zesdex-web"
version.workspace = true
edition.workspace = true
authors.workspace = true
# Web frontend interface — serves a browser-based UI.
# The frontend assets (JS/HTML/CSS) are compiled separately and
# served by the Axum-based API server or a dedicated HTTP server.
[dependencies]
zesdex-domain = { path = "../../domain" }
zesdex-application = { path = "../../application" }
zesdex-infrastructure = { path = "../../infrastructure" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
axum.workspace = true
tower.workspace = true
include_dir.workspace = true
mime_guess = "2"
+111
View File
@@ -0,0 +1,111 @@
//! Web frontend interface — serves the browser-based UI.
//!
//! The frontend assets (JS/HTML/CSS) are expected to be built into
//! a `dist/` directory at compile time via `include_dir!`, or served
//! from a path at runtime.
//!
//! ## Development
//!
//! During development, point `--web-dir` to the frontend dev server
//! or build directory.
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs;
use tracing::{info, warn};
/// Web server state.
pub struct WebState {
/// Directory from which to serve static files.
pub static_dir: PathBuf,
}
/// Build the web frontend router.
pub fn build_router(state: Arc<WebState>) -> Router {
Router::new()
.route("/", get(index_handler))
.route("/{*path}", get(static_handler))
.with_state(state)
}
/// Serve the index.html for root requests.
async fn index_handler(
axum::extract::State(state): axum::extract::State<Arc<WebState>>,
) -> Result<Html<String>, StatusCode> {
let index_path = state.static_dir.join("index.html");
match fs::read_to_string(&index_path).await {
Ok(html) => Ok(Html(html)),
Err(_) => {
// Return a minimal HTML page when no frontend is built
Ok(Html(
r#"<!DOCTYPE html>
<html><head><title>Zesdex Web</title>
<meta charset="utf-8">
<style>body{font-family:sans-serif;padding:2em;background:#1a1b26;color:#c0caf5}
h1{color:#7aa2f7}a{color:#bb9af7}</style></head>
<body>
<h1>Zesdex Web</h1>
<p>Web interface is ready.</p>
<p>To connect the frontend:</p>
<ol>
<li>Build the frontend: <code>cd apps/interfaces/web && npm install && npm run build</code></li>
<li>Restart with <code>--web-dir apps/interfaces/web/dist</code></li>
</ol>
</body></html>"#.to_string(),
))
}
}
}
/// Serve static files from the configured directory.
async fn static_handler(
axum::extract::State(state): axum::extract::State<Arc<WebState>>,
path: axum::extract::Path<String>,
) -> Response {
let file_path = state.static_dir.join(path.0);
// Security: prevent directory traversal
let canonical = match file_path.canonicalize() {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(),
};
if !canonical.starts_with(&state.static_dir) {
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
}
match fs::read(&canonical).await {
Ok(data) => {
let mime = mime_guess::from_path(&canonical).first_or_octet_stream();
Response::builder()
.status(200)
.header("Content-Type", mime.to_string())
.body(axum::body::Body::from(data))
.unwrap()
.into_response()
}
Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(),
}
}
/// Run the web frontend server.
pub async fn run_server(port: u16, static_dir: Option<PathBuf>) -> anyhow::Result<()> {
let dir = static_dir.unwrap_or_else(|| {
let p = PathBuf::from("apps/interfaces/web/dist");
if p.exists() {
p
} else {
warn!("No static dir found at {:?}, using current dir", p);
PathBuf::from(".")
}
});
let state = Arc::new(WebState { static_dir: dir });
let app = build_router(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
info!("Web frontend server listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "zesdex-ws"
version.workspace = true
edition.workspace = true
authors.workspace = true
# WebSocket interface — real-time bidirectional communication.
# Enables web clients and other WS-capable consumers to connect
# and participate in sessions.
[dependencies]
zesdex-domain = { path = "../../domain" }
zesdex-application = { path = "../../application" }
zesdex-infrastructure = { path = "../../infrastructure" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
axum = { workspace = true, features = ["ws"] }
futures-util.workspace = true
+102
View File
@@ -0,0 +1,102 @@
//! WebSocket interface — real-time bidirectional communication.
//!
//! Enables web clients and other WS-capable consumers to connect
//! and participate in sessions. Built on Axum's WebSocket support.
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use futures_util::stream::StreamExt;
use futures_util::SinkExt;
use std::sync::Arc;
use tracing::info;
/// Shared application state for the WS server.
pub struct WsState {
pub store_base_dir: std::path::PathBuf,
pub session_id: Option<String>,
}
/// Build the WebSocket router.
pub fn build_router(state: Arc<WsState>) -> Router {
Router::new()
.route("/ws", get(ws_handler))
.with_state(state)
}
/// WebSocket upgrade handler.
async fn ws_handler(
ws: WebSocketUpgrade,
axum::extract::State(state): axum::extract::State<Arc<WsState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
/// Handle an established WebSocket connection.
async fn handle_socket(mut socket: WebSocket, state: Arc<WsState>) {
// Channel for sending text messages to the WebSocket send task.
// The receiver side runs in a spawned task that forwards each
// string as a `Message::Text` to the client.
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
info!("WebSocket client connected");
// Send a welcome message
let welcome = serde_json::json!({
"type": "connected",
"session": state.session_id,
"message": "Connected to Zesdex WebSocket server"
});
// axum 0.8 Message::Text wraps Utf8Bytes; convert via .into()
let _ = socket.send(Message::Text(welcome.to_string().into())).await;
// Split the socket into sender and receiver halves
let (mut sender, mut receiver) = socket.split();
// Spawn task to forward messages from channel to WebSocket sender
let send_task = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if sender.send(Message::Text(msg.into())).await.is_err() {
break;
}
}
});
// Receive messages from the client
// receiver is SplitStream<WebSocket> — use StreamExt::next()
while let Some(Ok(msg)) = receiver.next().await {
match msg {
Message::Text(text) => {
// Convert Utf8Bytes -> String for JSON serialisation
let text_str = text.to_string();
info!("Received WS message: {text_str}");
// Echo back for now
let response = serde_json::json!({
"type": "echo",
"data": text_str
});
let _ = tx.send(response.to_string());
}
Message::Close(_) => break,
_ => {}
}
}
send_task.abort();
info!("WebSocket client disconnected");
}
/// Run the WebSocket server standalone.
pub async fn run_server(port: u16) -> anyhow::Result<()> {
let state = Arc::new(WsState {
store_base_dir: std::path::PathBuf::from("."),
session_id: None,
});
let app = build_router(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
info!("WebSocket server listening on ws://{addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}