chore: hapus semua source Rust + Cargo.toml/lock + .cargo (247 file .rs, migrasi ke TS/Bun)

This commit is contained in:
asepharyana committed 2026-09-02 22:51:10 +07:00
1 parent 319198d2f0
commit c165dff114
250 files changed
-33715

No files matched your search

-88
View File
@@ -1,88 +0,0 @@
//! Git diff output helper for the diff overlay.
//!
//! Extracted to its own module so the action dispatcher stays focused on
//! state mutation logic rather than shell-out I/O.
use std::process::Command;
/// Run `git diff` and return the output for display in the diff overlay.
///
/// Flow: runs `git diff HEAD` (staged + unstaged changes), and falls back
/// to `git diff` if HEAD has no commits yet. Returns a summary of what
/// changed with coloured +/- markers.
pub fn git_diff_output(workspace_roots: &[std::path::PathBuf]) -> String {
let root = workspace_roots
.first()
.cloned()
.unwrap_or_else(|| std::path::PathBuf::from("."));
let mut output = String::new();
// Try diff against HEAD
let head_result = Command::new("git")
.args(["diff", "HEAD"])
.current_dir(&root)
.output();
match head_result {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Changes (against HEAD) ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
Err(_) => {
// Fallback: no HEAD yet (new repo)
let diff_result = Command::new("git")
.args(["diff"])
.current_dir(&root)
.output();
if let Ok(out) = diff_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Unstaged Changes ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
}
}
// Get staged changes too
let staged_result = Command::new("git")
.args(["diff", "--cached"])
.current_dir(&root)
.output();
if let Ok(out) = staged_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Staged Changes ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
// Also get status summary
let status_result = Command::new("git")
.args(["status", "--short"])
.current_dir(&root)
.output();
if let Ok(out) = status_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Summary ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
if output.is_empty() {
output = "No changes detected in the working tree.".to_string();
}
output
}
-191
View File
@@ -1,191 +0,0 @@
//! Extracted action handlers for complex `Action` variants.
//!
//! The `apply_action` dispatcher delegates to these functions for variants
//! whose logic exceeds a few lines, keeping the match statement compact
//! and readable.
use crate::state::{AppStateRest, ChatMessageDisplay};
use tracing::debug;
use zesdex_domain::core::Role;
// ---------------------------------------------------------------------------
// Tick handler — drains turn events from the shared queue
// ---------------------------------------------------------------------------
/// Process the `Action::Tick` event: increment tick counter, drain all
/// queued `TurnEvent` values from the shared queue, and apply each one
/// to the application state.
pub fn handle_tick(state: &mut AppStateRest) {
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
// Drain turn events — collect into a local Vec first to avoid holding
// the mutex guard across the entire dispatch loop.
let events: Vec<zesdex_infrastructure::TurnEvent> = state
.turn_events
.lock()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
let had_events = !events.is_empty();
for event in events {
apply_turn_event(state, event);
}
// Mark dirty when there were events OR a turn is in-flight (spinner
// animation); skip the flag when idle to avoid useless re-renders.
if had_events || state.turn_in_flight() {
state.mark_dirty();
}
}
// ---------------------------------------------------------------------------
// TurnEvent dispatcher
// ---------------------------------------------------------------------------
/// Apply a single `TurnEvent` to `AppStateRest`. Extracted from the
/// `Tick` handler so each event variant's logic is self-contained.
fn apply_turn_event(state: &mut AppStateRest, event: zesdex_infrastructure::TurnEvent) {
match event {
zesdex_infrastructure::TurnEvent::SystemNote { kind, message } => {
if kind == "hive_mind_converged" {
if let Some(ref mut rt) = state.session_runtime {
rt.hive_mind_converged = true;
}
} else {
state.push_transcript(ChatMessageDisplay::new(Role::System, message));
}
}
zesdex_infrastructure::TurnEvent::AssistantMessage(msg) => {
state.push_transcript(ChatMessageDisplay::new(
msg.role,
msg.content.unwrap_or_default(),
));
}
zesdex_infrastructure::TurnEvent::ToolResult { output, .. } => {
state.push_transcript(ChatMessageDisplay::new(Role::Tool, output));
}
zesdex_infrastructure::TurnEvent::Usage {
tokens_in,
tokens_out,
} => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
rt.usage.last_tokens_in = tokens_in;
rt.usage.last_tokens_out = tokens_out;
rt.usage.api_calls = rt.usage.api_calls.saturating_add(1);
}
}
zesdex_infrastructure::TurnEvent::Error(msg) => {
state.toast_error(msg);
}
zesdex_infrastructure::TurnEvent::StreamStart => {
state.push_transcript(ChatMessageDisplay::new(Role::Assistant, String::new()));
}
zesdex_infrastructure::TurnEvent::StreamToken(text) => {
state.append_to_last_transcript(&text, false);
}
zesdex_infrastructure::TurnEvent::StreamReasoning(text) => {
state.append_to_last_transcript(&text, true);
}
zesdex_infrastructure::TurnEvent::StreamDone(_msg) => {
state.dirty = true;
}
zesdex_infrastructure::TurnEvent::Compacted(msgs) => {
if let Some(ref mut rt) = state.session_runtime {
rt.messages = msgs;
}
}
zesdex_infrastructure::TurnEvent::TodoUpdate(content) => {
state.misc.todo_content = content;
state.toast_success("TODO list updated.");
}
zesdex_infrastructure::TurnEvent::PlanUpdate(content) => {
state.misc.plan_content = content;
state.toast_success("Project plan updated.");
}
zesdex_infrastructure::TurnEvent::Done => {
state
.turn_in_flight_flag
.store(false, std::sync::atomic::Ordering::SeqCst);
state.dirty = true;
}
zesdex_infrastructure::TurnEvent::WorkflowAgentUpdate {
agent_id,
agent_name,
status,
} => {
apply_workflow_update(state, agent_id, agent_name, status);
}
_ => {
debug!("unhandled turn event variant");
state.dirty = true;
}
}
}
// ---------------------------------------------------------------------------
// Workflow-agent update handler
// ---------------------------------------------------------------------------
/// Update the workflow sidebar state from a `WorkflowAgentUpdate` event.
fn apply_workflow_update(
state: &mut AppStateRest,
agent_id: String,
agent_name: String,
status: zesdex_infrastructure::AgentStatus,
) {
let idx = state
.workflow_engine
.agents
.iter()
.position(|a| a.name == agent_id);
match status {
zesdex_infrastructure::AgentStatus::Pending => {
if idx.is_none() {
state
.workflow_engine
.agents
.push(crate::state::SimpleAgent::with_display(
agent_id, agent_name,
));
}
}
zesdex_infrastructure::AgentStatus::Running => {
let now = chrono::Utc::now().timestamp_millis();
if let Some(i) = idx {
state.workflow_engine.agents[i].state = crate::state::AgentState::Running;
state.workflow_engine.agents[i].display_name = agent_name;
state.workflow_engine.agents[i].started_at = Some(now);
} else {
let mut agent = crate::state::SimpleAgent::with_display(agent_id, agent_name);
agent.state = crate::state::AgentState::Running;
agent.started_at = Some(now);
state.workflow_engine.agents.push(agent);
}
}
zesdex_infrastructure::AgentStatus::Completed => {
if let Some(i) = idx {
state.workflow_engine.agents[i].state = crate::state::AgentState::Completed;
state.workflow_engine.agents[i].display_name = agent_name;
state.workflow_engine.agents[i].completed_at =
Some(chrono::Utc::now().timestamp_millis());
}
}
zesdex_infrastructure::AgentStatus::Failed(msg) => {
if let Some(i) = idx {
state.workflow_engine.agents[i].state = crate::state::AgentState::Failed;
state.workflow_engine.agents[i].display_name = agent_name;
state.workflow_engine.agents[i].error = Some(msg);
}
}
zesdex_infrastructure::AgentStatus::Cancelled => {
if let Some(i) = idx {
state.workflow_engine.agents.remove(i);
}
}
}
state.dirty = true;
}
-233
View File
@@ -1,233 +0,0 @@
//! The `Action` enum — a single well-typed event in the TUI, produced by
//! key input and applied to `AppStateRest` by the event loop.
//!
//! # Flow
//! `controller::input::handle_key` returns `Vec<Action>` \u{2192} the event loop
//! calls `apply_action(&mut state, action)` for each one \u{2192} state is mutated
//! in place.
//!
//! # Organisation
//!
//! ```text
//! action/
//! ├── mod.rs — Action enum + apply_action dispatcher
//! ├── handlers.rs — Extracted handlers for complex action variants
//! └── git.rs — git diff output helper (shell-out I/O)
//! ```
//!
//! # Design
//! Every state mutation funnels through this single chokepoint so the view
//! layer never mutates state directly and the controller never needs to know
//! *how* state is updated \u{2014} only *what* action to produce.
mod git;
mod handlers;
use tracing::debug;
use crate::state::Overlay;
use crate::state::{AppStateRest, ChatMessageDisplay};
use zesdex_domain::core::Role;
/// A single well-typed event in the TUI that mutates `AppStateRest`.
#[derive(Debug, Clone)]
pub enum Action {
/// Hard exit — immediately terminates the process.
ForceQuit,
/// Submit a user message to the LLM, starting a new agent turn.
SubmitInput(String),
/// Delete one character before the cursor in the input buffer.
DeleteChar,
/// Delete one character after the cursor in the input buffer.
DeleteCharRight,
/// Move the cursor one position left in the input buffer.
CursorLeft,
/// Move the cursor one position right in the input buffer.
CursorRight,
/// Navigate up through command history.
HistoryUp,
/// Navigate down through command history.
HistoryDown,
/// Scroll the transcript pane up.
ScrollUp,
/// Scroll the transcript pane down.
ScrollDown,
/// Open a named overlay.
OpenOverlay(Overlay),
/// Close the currently active overlay.
CloseOverlay,
/// Insert a system-generated note into the transcript.
SystemNote {
/// Note category: "error", "info", "clear", "hive_mind_converged", etc.
kind: String,
/// The message text to display.
message: String,
},
/// Show the quit-confirmation overlay.
QuitConfirm,
/// Terminal resize event.
Resize(u16, u16),
/// Periodic timer tick — drains queued `TurnEvent`s.
Tick,
/// Accept a lesson (learned behaviour pattern) by name.
LessonAccept { name: String },
/// Reject a lesson by name.
LessonReject { name: String },
/// Delete a previously stored lesson by name.
LessonDelete { name: String },
/// Start the OAuth device-code login flow for a named provider.
StartOAuth { provider: String },
/// Open the inline file editor for `path`.
OpenEditor { path: String },
/// Register a new MCP server by name and shell command.
McpAdd { name: String, command: String },
/// Open the model-picker overlay.
ModelList,
/// Set the abort flag on the currently running turn.
AbortTurn,
/// Request AI-summary compaction of the conversation history.
Compact,
/// Show the git diff preview overlay.
ShowDiff,
/// Scroll the diff overlay by `amount` lines (negative = up, positive = down).
DiffScroll(i32),
}
/// Apply an `Action` to `AppStateRest`.
///
/// Flow: pattern-match the variant \u{2192} mutate state in place.
/// This is the single chokepoint for all state mutations.
///
/// Return: nothing; `state` is mutated in place.
#[tracing::instrument(skip(state))]
pub fn apply_action(state: &mut AppStateRest, action: Action) {
debug!("apply_action: {:?}", action);
match action {
// ── Lifecycle ─────────────────────────────────────────────────
Action::ForceQuit => state.quit = true,
Action::QuitConfirm => {
state.misc.overlay = Overlay::QuitConfirm;
state.mark_dirty();
}
Action::Resize(w, _h) => {
if state.last_render_width != w {
state.transcript_cache.dirty = true;
state.last_render_width = w;
}
state.mark_dirty();
}
// ── Timer tick (delegates to extracted handler) ────────────────
Action::Tick => handlers::handle_tick(state),
// ── Input ──────────────────────────────────────────────────────
Action::SubmitInput(text) => {
state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
state.input.submit();
crate::turn::spawn_agent_turn(state, text);
state.token_count_dirty = true;
state.mark_dirty();
}
Action::DeleteChar => {
state.input.delete_left();
state.mark_dirty();
}
Action::DeleteCharRight => {
state.input.delete_right();
state.mark_dirty();
}
Action::CursorLeft => {
state.input.cursor = state.input.cursor.saturating_sub(1);
state.mark_dirty();
}
Action::CursorRight => {
if state.input.cursor < state.input.buffer.len() {
state.input.cursor += 1;
}
state.mark_dirty();
}
Action::HistoryUp => {
state.input.history_up();
state.mark_dirty();
}
Action::HistoryDown => {
state.input.history_down();
state.mark_dirty();
}
// ── Scroll ─────────────────────────────────────────────────────
Action::ScrollUp => {
state.scroll.scroll_up(3);
state.mark_dirty();
}
Action::ScrollDown => {
state.scroll.scroll_down(3);
state.mark_dirty();
}
// ── Overlay ────────────────────────────────────────────────────
Action::OpenOverlay(overlay) => {
state.misc.overlay = overlay;
state.mark_dirty();
}
Action::CloseOverlay => {
state.misc.overlay = Overlay::None;
state.mark_dirty();
}
Action::SystemNote { kind: _, message } => {
state.push_transcript(ChatMessageDisplay::new(Role::System, message));
}
Action::ModelList => {
state.misc.overlay = Overlay::ModelSelector;
state.mark_dirty();
}
Action::OpenEditor { path } => {
let content = std::fs::read_to_string(&path).unwrap_or_default();
state.misc.editor = Some(crate::state::EditorState::new(
std::path::PathBuf::from(&path),
content,
));
state.misc.overlay = Overlay::Editor;
state.mark_dirty();
}
// ── Lessons ────────────────────────────────────────────────────
Action::LessonAccept { name } => state.toast_info(format!("Lesson accepted: {name}")),
Action::LessonReject { name } => state.toast_info(format!("Lesson rejected: {name}")),
Action::LessonDelete { name } => state.toast_info(format!("Lesson deleted: {name}")),
// ── Auth ───────────────────────────────────────────────────────
Action::StartOAuth { provider } => {
state.toast_info(format!("OAuth login started for {provider}"))
}
// ── MCP ────────────────────────────────────────────────────────
Action::McpAdd { name, command } => {
state.toast_info(format!("MCP server added: {name} ({command})"))
}
// ── Turn control ────────────────────────────────────────────────
Action::AbortTurn => {
state
.abort_flag
.store(true, std::sync::atomic::Ordering::SeqCst);
state.toast_info("Aborting current turn...".to_string());
}
Action::Compact => state.toast_info("Compacting conversation...".to_string()),
// ── Diff ───────────────────────────────────────────────────────
Action::ShowDiff => {
let diff_output = git::git_diff_output(&state.workspace_roots);
state.misc.diff_content = diff_output;
state.misc.diff_scroll = 0;
state.misc.overlay = Overlay::Diff;
state.mark_dirty();
}
Action::DiffScroll(amount) => {
let max_scroll = state.misc.diff_content.lines().count().saturating_sub(1);
let new_scroll = (state.misc.diff_scroll as i32 + amount).max(0) as usize;
state.misc.diff_scroll = new_scroll.min(max_scroll);
state.mark_dirty();
}
}
}
@@ -1,21 +0,0 @@
//! Base trait for all TUI components.
use crate::action::Action;
use crate::state::AppStateRest;
use crossterm::event::Event;
use ratatui::layout::Rect;
use ratatui::Frame;
/// Base trait for modular UI components in the TUI.
pub trait Component {
/// Draw the component to the screen.
fn draw(&mut self, f: &mut Frame, area: Rect, state: &AppStateRest);
/// Optional: Pre-render caching phase called before draw.
fn pre_render(&mut self, _state: &mut AppStateRest) {}
/// Handle an input event. Return an optional Action to dispatch.
fn handle_event(&mut self, _event: Event, _state: &mut AppStateRest) -> Option<Action> {
None
}
}
@@ -1,7 +0,0 @@
//! Reusable UI components for the TUI.
//!
//! This module will grow as shared widgets (buttons, input fields, etc.)
//! are extracted from individual overlay and view modules.
pub mod component;
pub use component::Component;
@@ -1,191 +0,0 @@
//! Slash-command parser that maps TUI `/foo` input lines into `Command`
//! variants for the action dispatch system.
//!
//! Flow: the TUI input handler in `controller::input` calls `parse_command`
//! on every `/`-prefixed line, then maps the resulting `Command` to an
//! `Action` for the event loop to apply to `AppStateRest`.
use tracing::debug;
/// A parsed slash command from the TUI input buffer.
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
/// `/help` — show keybindings / help overlay.
Help,
/// `/quit` — exit the application.
Quit,
/// `/mcp` (no args) — open MCP configuration panel.
McpOpen,
/// `/clear` (with args) — clear with a specific scope.
Clear,
/// `/clear` (no args) — show confirmation prompt before clearing.
ClearConfirm,
/// `/login <provider>` — trigger OAuth login for the given provider.
Login { provider: String },
/// `/edit <path>` — open the given file for review/inline editing.
Edit(String),
/// `/mcp add <name> <command>` — add a new MCP server definition.
McpAdd { name: String, command: String },
/// `/model` — list available LLM models.
ModelList,
/// `/compact` — trigger conversation compaction.
Compact,
/// `/todo` — open the todo-list overlay.
TodoOpen,
/// `/plan` — open the project plan overlay.
PlanOpen,
/// `/usage` — open the usage-stats overlay.
UsageOpen,
/// `/diff` — show git diff preview.
Diff,
/// Catch-all: unrecognised or non-slash input.
Unknown(String),
}
/// Parse a slash-prefixed input line into a `Command` value.
///
/// Flow: trim -> check for leading `/` -> split on space (max 3 parts) ->
/// match the first token against known commands -> extract arguments from
/// the remaining parts.
#[tracing::instrument]
pub fn parse_command(text: &str) -> Command {
let text = text.trim();
if !text.starts_with('/') {
return Command::Unknown(text.to_string());
}
let parts: Vec<&str> = text.splitn(3, ' ').collect();
let cmd = parts[0];
let arg1 = parts.get(1).copied().unwrap_or("");
let arg2 = parts.get(2).copied().unwrap_or("");
let result = match cmd {
"/help" => Command::Help,
"/quit" => Command::Quit,
"/clear" if arg1.is_empty() => Command::ClearConfirm,
"/clear" => Command::Clear,
"/login" if arg1.is_empty() => Command::Login {
provider: String::new(),
},
"/login" if !arg1.is_empty() => Command::Login {
provider: arg1.to_string(),
},
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
"/edit" => Command::Edit(".".to_string()),
"/mcp" if arg1.is_empty() => Command::McpOpen,
"/mcp" if arg1 == "add" && !arg2.is_empty() => {
let rest = arg2.trim();
if let Some(space) = rest.find(' ') {
let name = rest[..space].to_string();
let command = rest[space + 1..].trim().to_string();
Command::McpAdd { name, command }
} else {
Command::McpAdd {
name: rest.to_string(),
command: String::new(),
}
}
}
"/model" => Command::ModelList,
"/compact" => Command::Compact,
"/todo" => Command::TodoOpen,
"/plan" => Command::PlanOpen,
"/usage" => Command::UsageOpen,
"/diff" => Command::Diff,
_ => Command::Unknown(cmd.to_string()),
};
debug!(%text, command = ?result, "parse_command");
result
}
/// Map a parsed `Command` into `Action` values for the event loop.
#[tracing::instrument]
pub fn apply_command(cmd: Command) -> Vec<crate::action::Action> {
match cmd {
Command::Help => {
vec![crate::action::Action::OpenOverlay(
crate::state::Overlay::Help,
)]
}
Command::Quit => {
vec![crate::action::Action::QuitConfirm]
}
Command::McpOpen => {
vec![crate::action::Action::OpenOverlay(
crate::state::Overlay::Mcp,
)]
}
Command::Clear => {
vec![crate::action::Action::SystemNote {
kind: "clear".to_string(),
message: "Transcript cleared.".to_string(),
}]
}
Command::ClearConfirm => {
vec![crate::action::Action::OpenOverlay(
crate::state::Overlay::ClearConfirm,
)]
}
Command::Login { provider } => {
vec![crate::action::Action::StartOAuth { provider }]
}
Command::Edit(path) => {
vec![crate::action::Action::OpenEditor { path }]
}
Command::McpAdd { name, command } => {
vec![crate::action::Action::McpAdd { name, command }]
}
Command::ModelList => {
vec![crate::action::Action::ModelList]
}
Command::Compact => {
vec![crate::action::Action::Compact]
}
Command::TodoOpen => {
vec![crate::action::Action::OpenOverlay(
crate::state::Overlay::Todo,
)]
}
Command::PlanOpen => {
vec![crate::action::Action::OpenOverlay(
crate::state::Overlay::Plan,
)]
}
Command::UsageOpen => {
vec![crate::action::Action::OpenOverlay(
crate::state::Overlay::Usage,
)]
}
Command::Diff => {
vec![crate::action::Action::ShowDiff]
}
Command::Unknown(text) => {
if text.starts_with('/') {
vec![crate::action::Action::SystemNote {
kind: "error".to_string(),
message: format!("Unknown command: {text}"),
}]
} else {
vec![crate::action::Action::SubmitInput(text)]
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Verify that `parse_command` recognises the `/todo` command.
#[test]
fn parses_todo_open() {
assert_eq!(parse_command("/todo"), Command::TodoOpen);
}
/// Verify that `parse_command` recognises the `/usage` command.
#[test]
fn parses_usage_open() {
assert_eq!(parse_command("/usage"), Command::UsageOpen);
}
}
-393
View File
@@ -1,393 +0,0 @@
//! Key event dispatcher: maps crossterm `KeyEvent` values into `Action`
//! variants, with special handling for overlays, auto-complete, and the
//! inline editor.
//!
//! Flow:
//! 1. `handle_key` is called on each key press.
//! 2. Overlays with full-screen input (Editor, Learning) intercept *all* keys
//! before the main match.
//! 3. The main match handles navigation, auto-complete, editing, and shortcuts.
//! 4. Multi-key actions return `Vec<Action>`.
use tracing::debug;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::action::Action;
use crate::controller::command::{apply_command, parse_command};
use crate::controller::overlay_enter::handle_overlay_enter;
use crate::state::{AppStateRest, AutocompleteKind, Overlay};
/// Mark state dirty and return an empty action list.
fn mark(state: &mut AppStateRest) -> Vec<Action> {
state.mark_dirty();
Vec::new()
}
/// Translate a terminal `KeyEvent` into zero or more `Action` values
/// based on the current application state.
///
/// Return: `Vec<Action>` so a single key can produce multiple queued actions.
#[tracing::instrument(skip(state))]
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
debug!(code = ?key.code, mods = ?key.modifiers, overlay = ?state.misc.overlay, "handle_key");
// ── Editor overlay ───────────────────────────────────────────────────
if state.misc.overlay == Overlay::Editor {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return vec![Action::QuitConfirm];
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(ref ed) = state.misc.editor.clone() {
let content = ed.as_string();
if let Err(e) = std::fs::write(&ed.path, &content) {
state.toast_error(format!("Save failed: {e}"));
} else {
state.toast_success(format!("Saved {}", ed.path.display()));
}
state.mark_dirty();
}
return vec![];
}
KeyCode::Esc => {
// Dismiss editor
state.misc.editor = None;
state.misc.overlay = Overlay::None;
return vec![];
}
KeyCode::Backspace => {
if let Some(ref mut ed) = state.misc.editor {
ed.delete_left();
state.mark_dirty();
}
return vec![];
}
KeyCode::Enter => {
if let Some(ref mut ed) = state.misc.editor {
ed.content.insert(ed.cursor, '\n');
ed.cursor += 1;
state.mark_dirty();
}
return vec![];
}
KeyCode::Char(c) => {
if let Some(ref mut ed) = state.misc.editor {
ed.content.insert(ed.cursor, c);
ed.cursor += c.len_utf8();
state.mark_dirty();
}
return vec![];
}
_ => return vec![],
}
}
// ── Diff overlay ──────────────────────────────────────────────────────
if state.misc.overlay == Overlay::Diff {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return vec![Action::QuitConfirm];
}
KeyCode::Up | KeyCode::Char('k') => {
return vec![Action::DiffScroll(-1)];
}
KeyCode::Down | KeyCode::Char('j') => {
return vec![Action::DiffScroll(1)];
}
KeyCode::PageUp => {
return vec![Action::DiffScroll(-20)];
}
KeyCode::PageDown => {
return vec![Action::DiffScroll(20)];
}
KeyCode::Esc => {
return vec![Action::CloseOverlay];
}
_ => {}
}
}
// ── Learning overlay ──────────────────────────────────────────────────
if state.misc.overlay == Overlay::Learning {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return vec![Action::QuitConfirm];
}
KeyCode::Esc => {
return vec![Action::CloseOverlay];
}
KeyCode::Up => {
let items = crate::state::get_learning_items(state);
let n = items.len();
state.misc.selected_index =
crate::state::cycle_selected_index(state.misc.selected_index, n, false);
return mark(state);
}
KeyCode::Down => {
let items = crate::state::get_learning_items(state);
let n = items.len();
state.misc.selected_index =
crate::state::cycle_selected_index(state.misc.selected_index, n, true);
return mark(state);
}
KeyCode::Enter | KeyCode::Char('a') => {
let items = crate::state::get_learning_items(state);
if let Some(crate::state::LearningItem::Pending { name, .. }) =
items.get(state.misc.selected_index)
{
return vec![Action::LessonAccept { name: name.clone() }];
}
return vec![];
}
KeyCode::Char('r') => {
let items = crate::state::get_learning_items(state);
if let Some(crate::state::LearningItem::Pending { name, .. }) =
items.get(state.misc.selected_index)
{
return vec![Action::LessonReject { name: name.clone() }];
}
return vec![];
}
KeyCode::Char('d') | KeyCode::Delete | KeyCode::Backspace => {
let items = crate::state::get_learning_items(state);
if let Some(item) = items.get(state.misc.selected_index) {
match item {
crate::state::LearningItem::Pending { name, .. } => {
return vec![Action::LessonReject { name: name.clone() }];
}
crate::state::LearningItem::Stored { name, .. } => {
return vec![Action::LessonDelete { name: name.clone() }];
}
}
}
return vec![];
}
_ => return vec![],
}
}
// ── Normal (non-overlay) dispatch ────────────────────────────────────
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::QuitConfirm]
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::CloseOverlay]
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let last_assistant = state
.transcript_cache
.messages
.iter()
.rev()
.find(|m| m.role == zesdex_domain::core::Role::Assistant);
match last_assistant {
Some(msg) => {
state.misc.pending_clipboard_copy = Some(msg.content.clone());
}
None => {
state.toast_info("No assistant message to copy yet".to_string());
}
}
Vec::new()
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
vec![crate::action::Action::ShowDiff]
}
KeyCode::Enter => {
if state.input.autocomplete_visible {
state.input.select_autocomplete();
return mark(state);
}
if state.misc.overlay.is_active() {
return handle_overlay_enter(state);
}
let text = state.input.buffer.clone();
if text.starts_with('/') {
return apply_command(parse_command(&text));
}
vec![Action::SubmitInput(text)]
}
KeyCode::Backspace => {
if state.input.autocomplete_visible {
state.input.close_autocomplete();
return mark(state);
}
vec![Action::DeleteChar]
}
KeyCode::Delete => {
if state.input.autocomplete_visible {
state.input.close_autocomplete();
return mark(state);
}
vec![Action::DeleteCharRight]
}
KeyCode::Left => {
vec![Action::CursorLeft]
}
KeyCode::Right => {
vec![Action::CursorRight]
}
KeyCode::Up => {
if state.input.autocomplete_visible {
state.input.cycle_autocomplete(false);
state.mark_dirty();
Vec::new()
} else if state.misc.overlay == Overlay::Effort {
crate::state::cycle_effort(state, false);
Vec::new()
} else if state.misc.overlay == Overlay::Rewind {
let n = crate::state::rewind_count(state);
state.misc.selected_index =
crate::state::cycle_selected_index(state.misc.selected_index, n, false);
state.mark_dirty();
Vec::new()
} else if state.misc.overlay == Overlay::ModelSelector {
let n = state.app_config.providers.len();
state.misc.selected_index =
crate::state::cycle_selected_index(state.misc.selected_index, n, false);
state.mark_dirty();
Vec::new()
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
vec![Action::ScrollUp]
} else {
vec![Action::HistoryUp]
}
}
KeyCode::Down => {
if state.input.autocomplete_visible {
state.input.cycle_autocomplete(true);
state.mark_dirty();
Vec::new()
} else if state.misc.overlay == Overlay::Effort {
crate::state::cycle_effort(state, true);
Vec::new()
} else if state.misc.overlay == Overlay::Rewind {
let n = crate::state::rewind_count(state);
state.misc.selected_index =
crate::state::cycle_selected_index(state.misc.selected_index, n, true);
state.mark_dirty();
Vec::new()
} else if state.misc.overlay == Overlay::ModelSelector {
let n = state.app_config.providers.len();
state.misc.selected_index =
crate::state::cycle_selected_index(state.misc.selected_index, n, true);
state.mark_dirty();
Vec::new()
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
vec![Action::ScrollDown]
} else {
vec![Action::HistoryDown]
}
}
KeyCode::PageUp => {
vec![Action::ScrollUp]
}
KeyCode::PageDown => {
vec![Action::ScrollDown]
}
KeyCode::Esc => {
if state.turn_in_flight() {
vec![Action::AbortTurn]
} else if state.input.autocomplete_visible {
state.input.close_autocomplete();
state.mark_dirty();
Vec::new()
} else if state.misc.overlay.is_active() {
vec![Action::CloseOverlay]
} else {
Vec::new()
}
}
KeyCode::Tab => {
if state.input.buffer.starts_with('/') {
if state.input.autocomplete_visible {
state.input.cycle_autocomplete(true);
} else {
state.input.tab_complete();
}
state.mark_dirty();
} else if state.input.autocomplete_kind == AutocompleteKind::FileMention
&& state.input.autocomplete_visible
{
state.input.cycle_autocomplete(true);
state.mark_dirty();
}
Vec::new()
}
KeyCode::Char(c) => {
if state.input.autocomplete_visible {
state.input.close_autocomplete();
state.mark_dirty();
}
state.input.insert(c);
state.mark_dirty();
if state.input.buffer.starts_with('/') {
state.input.open_autocomplete();
} else if state.input.mention_query_at_cursor().is_some() {
state
.input
.open_mention_autocomplete(&state.mention_index.snapshot());
}
Vec::new()
}
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a minimal `AppStateRest` in a temp directory for test isolation.
fn test_state() -> AppStateRest {
let tmp = std::env::temp_dir().join(format!("zesdex-input-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"))
}
/// Verify that Ctrl+Y sets `pending_clipboard_copy` to the most
/// recent assistant message (skipping non-assistant roles like Tool).
#[test]
fn ctrl_y_sets_pending_clipboard_copy_to_last_assistant_message() {
let mut state = test_state();
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::User,
"hi".to_string(),
));
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::Assistant,
"first reply".to_string(),
));
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::Tool,
"tool output".to_string(),
));
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::Assistant,
"second reply".to_string(),
));
handle_key(
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
&mut state,
);
assert_eq!(
state.misc.pending_clipboard_copy,
Some("second reply".to_string())
);
}
/// Verify that Ctrl+Y with no assistant messages in the transcript
/// pushes an info toast instead of setting `pending_clipboard_copy`.
#[test]
fn ctrl_y_with_no_assistant_message_pushes_info_toast() {
let mut state = test_state();
handle_key(
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
&mut state,
);
assert!(state.misc.pending_clipboard_copy.is_none());
assert_eq!(state.misc.toasts.len(), 1);
}
}
-12
View File
@@ -1,12 +0,0 @@
//! Keyboard input handling and command parsing for the TUI.
//!
//! The controller layer bridges raw terminal key events (from `crossterm`) to
//! application actions. It contains two sub-modules:
//!
//! - `input` — key-event dispatch, prompt-line editing, history navigation,
//! tab-completion, and action invocation.
//! - `command` — the `/slash` command parser that translates user-typed
//! commands into structured `Action` variants.
pub mod command;
pub mod input;
pub mod overlay_enter;
@@ -1,125 +0,0 @@
//! Overlay-specific Enter-key handlers.
//!
//! Each overlay variant has its own Enter semantics. Extracted from the
//! monolithic `input.rs` so each handler is self-contained.
use tracing::debug;
use crate::action::Action;
use crate::state::{AppStateRest, Overlay};
/// Handle pressing Enter while a modal overlay is active.
///
/// Each overlay variant has its own Enter semantics:
/// - `QuitConfirm` → set `quit = true`
/// - `KeyInput` → save API key from buffer
/// - `ModelSelector` → switch provider/model from selected index
/// - `ClearConfirm` → clear transcript cache
/// - `Rewind` → rewind to selected message index
/// - `Bash` / `Settings` / `Todo` / `Mcp` → no-ops (placeholder)
#[tracing::instrument(skip(state))]
pub fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
debug!(overlay = ?state.misc.overlay, "handle_overlay_enter");
match state.misc.overlay {
Overlay::Bash => handle_bash_enter(state),
Overlay::Settings | Overlay::Todo => {
state.mark_dirty();
Vec::new()
}
Overlay::QuitConfirm => {
state.quit = true;
state.mark_dirty();
Vec::new()
}
Overlay::KeyInput => handle_keyinput_enter(state),
Overlay::Mcp => {
state.toast_info("Connecting MCP...".to_string());
Vec::new()
}
Overlay::Rewind => handle_rewind_enter(state),
Overlay::ModelSelector => handle_model_selector_enter(state),
Overlay::ClearConfirm => handle_clear_confirm(state),
_ => Vec::new(),
}
}
fn handle_bash_enter(state: &mut AppStateRest) -> Vec<Action> {
let command = state.input.buffer.clone();
state.toast_info(format!("Submitting bash command: {command}"));
state.input.buffer.clear();
state.input.cursor = 0;
state.mark_dirty();
Vec::new()
}
fn handle_keyinput_enter(state: &mut AppStateRest) -> Vec<Action> {
let text = state.input.buffer.clone();
if !text.is_empty() {
state
.settings
.api_keys
.insert(state.settings.provider.clone(), text);
}
state.toast_success("API key saved".to_string());
state.input.buffer.clear();
state.input.cursor = 0;
state.misc.overlay = Overlay::None;
state.save_settings();
state.mark_dirty();
Vec::new()
}
fn handle_rewind_enter(state: &mut AppStateRest) -> Vec<Action> {
let idx = state.misc.selected_index;
let n = state.transcript_cache.messages.len();
if idx < n {
let rewind_to = n - idx - 1;
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::System,
format!("Rewound to message {rewind_to}"),
));
}
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
fn handle_model_selector_enter(state: &mut AppStateRest) -> Vec<Action> {
let providers: Vec<String> = state.app_config.providers.keys().cloned().collect();
if let Some(provider) = providers.get(state.misc.selected_index) {
if let Some(cfg) = state.app_config.providers.get(provider) {
let model = cfg
.default_model
.clone()
.unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string());
state.settings.provider.clone_from(provider);
state.settings.model.clone_from(&model);
if let Some(ref key) = cfg.default_api_key {
state
.settings
.api_keys
.insert(provider.clone(), key.clone());
} else if let Some(env_key) = cfg
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
{
state.settings.api_keys.insert(provider.clone(), env_key);
}
state.save_settings();
state.toast_success(format!("Switched to {provider} / {model}"));
}
}
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
fn handle_clear_confirm(state: &mut AppStateRest) -> Vec<Action> {
state.toast_info("Transcript cleared".to_string());
state.transcript_cache.messages.clear();
state.transcript_cache.dirty = true;
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
-79
View File
@@ -1,79 +0,0 @@
//! # Zesdex TUI (Terminal User Interface)
//!
//! This crate provides the terminal UI interface for the Zesdex application,
//! built on `ratatui` with `crossterm` for terminal interaction.
//!
//! It is one of MANY possible user interfaces — others include the HTTP API
//! gateway, CLI batch commands, and daemon-mode background processing.
//!
//! ## Architecture
//!
//! ```text
//! apps/interfaces/tui/src/
//! ├── lib.rs — Crate root: module declarations + re-exports
//! ├── state.rs — AppStateRest + all TUI-perspective state types
//! ├── action.rs — Action enum + apply_action dispatcher
//! ├── view/ — TUI rendering (ratatui widgets)
//! │ ├── mod.rs — Main draw function (layered layout)
//! │ ├── chat.rs — Chat transcript panel
//! │ ├── markdown.rs — Markdown-to-styled-spans renderer
//! │ ├── sidebar.rs — Right-hand dashboard sidebar
//! │ ├── status.rs — Bottom status bar
//! │ ├── theme.rs — Tokyo Night colour palette
//! │ ├── workflow.rs — Workflow agent status panel
//! │ └── overlays/ — 15 modal overlay panels
//! ├── controller/ — Input handling + command parsing
//! │ ├── mod.rs
//! │ ├── command.rs — /slash command parser
//! │ └── input.rs — Key event → Action dispatch
//! ├── model/ — Data persistence layer
//! │ ├── store.rs — Store path configuration (re-export)
//! │ ├── msglog/ — SQLite message-log (schema, insert, blobs)
//! │ └── agent_def/ — Agent definitions (builtin/global/session)
//! └── components/ — Reusable UI widgets (extensible)
//! ```
//!
//! ## Dependencies
//!
//! - `zesdex-domain` — Domain entities (Role, ChatMessage, Settings, AppConfig)
//! - `zesdex-application` — Application port traits and use-cases
//! - `zesdex-infrastructure` — Shared concrete infrastructure types
//! (SessionRuntime, Toast, DirCache, TurnEvent, etc.)
//! - `ratatui` / `crossterm` — Terminal rendering and raw-key input
//! - `pulldown-cmark` — Markdown parsing for message rendering
//!
//! ## State Flow
//!
//! 1. `state::AppStateRest` is constructed in the application's main/entry point
//! 2. The TUI event loop calls `controller::input::handle_key` on each key press
//! 3. `handle_key` returns `Vec<action::Action>` which the loop applies via
//! `action::apply_action`
//! 4. After each action batch, `view::draw` re-renders the terminal
//!
//! The state types defined here (`AppStateRest`, `InputState`, `MiscState`,
//! `Overlay`, etc.) are TUI-perspective — they represent what the interface
//! needs to render, not the full application state.
// Module declarations
pub mod action;
pub mod components;
pub mod controller;
pub mod model;
pub mod run;
pub mod state;
pub mod turn;
pub mod view;
// ---------------------------------------------------------------------------
// Re-exports for convenient access by consumers (main.rs / bin entry points)
// ---------------------------------------------------------------------------
pub use action::{apply_action, Action};
pub use run::run_single_process;
pub use state::{
AgentState, AppStateRest, AutocompleteKind, ChatMessageDisplay, EditorState, InputState,
MiscState, Overlay, ScrollState, SimpleAgent, SimpleWorkflowEngine, TranscriptCache,
};
/// Convenience: initialise a `Store` for data directory resolution.
pub use zesdex_domain::core::Store;
@@ -1,127 +0,0 @@
//! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner).
//!
//! These agents are always available regardless of user or session config.
//! They provide the default set of roles shipped with the application.
//!
//! ## Available agents
//! | Agent | Purpose | Key tools |
//! |-------|---------|-----------|
//! | coder | Write/edit code | read, write, edit, bash, git_operator |
//! | reviewer | Review code for correctness/safety | read, grep, recall |
//! | researcher | Search and summarise | read, grep, bash, search_web |
//! | planner | Break down tasks into steps | read, write, edit, bash, todo_* |
use serde::{Deserialize, Serialize};
/// Declarative specification for instantiating a subagent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDefinition {
/// Human-readable name (e.g. `"quick-reviewer"`).
pub name: String,
/// Functional role (e.g. `"reviewer"`, `"coder"`).
pub role: String,
/// Optional system prompt override.
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
/// Optional tool allowlist. `None` means role-based defaults.
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_tools: Option<Vec<String>>,
/// Optional step budget. `None` means no limit.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_steps: Option<usize>,
/// Optional temperature override.
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
}
impl AgentDefinition {
/// Create an agent definition with the required name and role.
pub fn new(name: String, role: String) -> Self {
AgentDefinition {
name,
role,
system_prompt: None,
allowed_tools: None,
max_steps: None,
temperature: None,
}
}
/// Builder: set the system prompt.
pub fn with_system_prompt(mut self, prompt: String) -> Self {
self.system_prompt = Some(prompt);
self
}
/// Builder: set the allowed tool list.
pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
self.allowed_tools = Some(tools);
self
}
/// Builder: set the maximum step count.
pub fn with_max_steps(mut self, steps: usize) -> Self {
self.max_steps = Some(steps);
self
}
}
/// Build the fixed list of built-in agent definitions shipped with zesdex.
pub fn builtin_agents() -> Vec<AgentDefinition> {
vec![
AgentDefinition::new("coder".to_string(), "coder".to_string())
.with_system_prompt(
"You are a coding agent. Write correct, idiomatic Rust code.".to_string(),
)
.with_allowed_tools(vec![
"read".to_string(),
"write".to_string(),
"edit".to_string(),
"bash".to_string(),
"grep".to_string(),
"glob".to_string(),
"git_operator".to_string(),
])
.with_max_steps(usize::MAX),
AgentDefinition::new("reviewer".to_string(), "reviewer".to_string())
.with_system_prompt(
"You are a code reviewer. Focus on correctness, safety, and performance."
.to_string(),
)
.with_allowed_tools(vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"recall".to_string(),
"remember".to_string(),
])
.with_max_steps(usize::MAX),
AgentDefinition::new("researcher".to_string(), "researcher".to_string())
.with_system_prompt(
"You are a research agent. Search for information and summarize findings."
.to_string(),
)
.with_allowed_tools(vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"bash".to_string(),
"search_web".to_string(),
"fetch_url".to_string(),
])
.with_max_steps(usize::MAX),
AgentDefinition::new("planner".to_string(), "planner".to_string())
.with_system_prompt(
"You are a planning agent. Break down tasks into clear steps.".to_string(),
)
.with_allowed_tools(vec![
"read".to_string(),
"write".to_string(),
"edit".to_string(),
"bash".to_string(),
"todo_write".to_string(),
"todo_finish".to_string(),
])
.with_max_steps(usize::MAX),
]
}
@@ -1,79 +0,0 @@
//! Load, save, and remove user-defined agent definitions stored globally
//! (under the store's `agents/` directory), independent of any session.
use super::builtin::AgentDefinition;
/// Load all globally-registered agent definitions from disk.
///
/// Flow: resolve `<store>/agents/` -> read directory -> parse each `*.json`
/// file into an `AgentDefinition`, skipping any that fail to read or parse.
pub fn load_global_agents() -> Vec<AgentDefinition> {
let store = crate::model::store::Store::new();
let agents_dir = store.base_dir.join("agents");
tracing::debug!(dir = %agents_dir.display(), "load_global_agents");
if !agents_dir.exists() {
tracing::debug!("load_global_agents — agents dir does not exist");
return Vec::new();
}
let mut agents = Vec::new();
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(def) = serde_json::from_str::<AgentDefinition>(&content) {
tracing::debug!(agent = %def.name, "load_global_agents — loaded");
agents.push(def);
} else {
tracing::warn!(file = %path.display(), "load_global_agents — failed to parse JSON");
}
} else {
tracing::warn!(file = %path.display(), "load_global_agents — failed to read file");
}
}
}
}
tracing::info!(count = agents.len(), "load_global_agents — done");
agents
}
/// Persist a global agent definition as `<store>/agents/<name>.json`.
pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
let store = crate::model::store::Store::new();
let agents_dir = store.base_dir.join("agents");
std::fs::create_dir_all(&agents_dir)?;
let path = agents_dir.join(format!("{}.json", def.name));
let tmp = agents_dir.join(format!("{}.json.tmp", def.name));
let content = serde_json::to_string_pretty(def)?;
tracing::debug!(agent = %def.name, "save_global_agent — writing");
std::fs::write(&tmp, content)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
if let Some(parent) = agents_dir.parent() {
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
}
tracing::info!(agent = %def.name, "save_global_agent — saved");
Ok(())
}
/// Remove a global agent definition by name.
pub fn remove_global_agent(name: &str) -> anyhow::Result<bool> {
let store = crate::model::store::Store::new();
let path = store.base_dir.join("agents").join(format!("{name}.json"));
tracing::debug!(%name, path = %path.display(), "remove_global_agent");
match std::fs::remove_file(&path) {
Ok(_) => {
tracing::info!(%name, "remove_global_agent — removed");
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!(%name, "remove_global_agent — not found");
Ok(false)
}
Err(e) => {
tracing::error!(%name, error = %e, "remove_global_agent — failed");
Err(e.into())
}
}
}
@@ -1,12 +0,0 @@
//! Agent definition sources: built-in defaults, global (user-wide), and
//! per-session overrides.
//!
//! Agent definitions control the system prompt, tool set, and configuration
//! for each agent. The resolution order (lowest to highest priority) is:
//!
//! 1. `builtin` — hardcoded default agent shipped with the application.
//! 2. `global` — user-wide overrides stored in the config directory.
//! 3. `session` — per-session overrides stored in the session directory.
pub mod builtin;
pub mod global;
pub mod session;
@@ -1,70 +0,0 @@
//! Load, save, add, and remove agent definitions scoped to a single
//! session (`<session_dir>/agents.json`).
use super::builtin::AgentDefinition;
use std::path::Path;
/// Load agent definitions saved for a specific session.
pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
let agents_file = session_dir.join("agents.json");
tracing::debug!(file = %agents_file.display(), "load_session_agents");
if !agents_file.exists() {
tracing::debug!("load_session_agents — file does not exist");
return Vec::new();
}
match std::fs::read_to_string(&agents_file) {
Ok(content) => {
let agents: Vec<AgentDefinition> = serde_json::from_str(&content).unwrap_or_else(|e| {
tracing::warn!("load_session_agents — failed to parse agents.json: {}", e);
Vec::new()
});
tracing::debug!(count = agents.len(), "load_session_agents — loaded");
agents
}
Err(e) => {
tracing::warn!(error = %e, "load_session_agents — failed to read");
Vec::new()
}
}
}
/// Overwrite `<session_dir>/agents.json` with the given agent list.
pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> {
let agents_file = session_dir.join("agents.json");
let tmp = session_dir.join("agents.json.tmp");
let content = serde_json::to_string_pretty(agents)?;
tracing::debug!(count = agents.len(), "save_session_agents — writing");
std::fs::write(&tmp, content)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, agents_file)?;
let _ = std::fs::File::open(session_dir).and_then(|d| d.sync_all());
tracing::info!(count = agents.len(), "save_session_agents — saved");
Ok(())
}
/// Add or replace a session agent definition by name.
pub fn add_session_agent(session_dir: &Path, def: &AgentDefinition) -> anyhow::Result<()> {
tracing::debug!(agent = %def.name, "add_session_agent");
let mut agents = load_session_agents(session_dir);
agents.retain(|a| a.name != def.name);
agents.push(def.clone());
save_session_agents(session_dir, &agents)
}
/// Remove a session agent definition by name.
pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<bool> {
tracing::debug!(%name, "remove_session_agent");
let mut agents = load_session_agents(session_dir);
let before = agents.len();
agents.retain(|a| a.name != name);
if agents.len() == before {
tracing::debug!(%name, "remove_session_agent — not found");
return Ok(false);
}
save_session_agents(session_dir, &agents)?;
tracing::info!(%name, "remove_session_agent — removed");
Ok(true)
}
-16
View File
@@ -1,16 +0,0 @@
//! Data-model layer for the TUI interface.
//!
//! This module contains:
//! - `store` — Store path configuration
//! - `agent_def` — Agent definition model (built-in, global, session scopes)
//! - `msglog` — SQLite-backed message-log persistence (schema, insert, blobs)
//!
//! The `Store` type is re-exported from `zesdex_domain::core::store`.
pub mod store {
//! Re-export `Store` from the domain layer for path resolution.
pub use zesdex_domain::core::Store;
}
pub mod agent_def;
pub mod msglog;
@@ -1,67 +0,0 @@
//! Binary blob storage in the message-log `SQLite` database (e.g. images,
//! attachments), keyed by session id and an arbitrary blob key.
use anyhow::Result;
use rusqlite::{params, Connection};
/// Insert or overwrite a blob for a session under `blob_key`.
///
/// Flow: compute current timestamp -> `INSERT OR REPLACE` into `blobs`
/// keyed on `(session_id, blob_key)`.
pub fn store_blob(
conn: &Connection,
session_id: &str,
blob_key: &str,
data: &[u8],
mime_type: Option<&str>,
) -> Result<()> {
let created_at = chrono::Utc::now().timestamp_millis();
tracing::debug!(%session_id, %blob_key, size = data.len(), "store_blob");
conn.execute(
"INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![session_id, blob_key, data, mime_type, created_at],
)?;
tracing::info!(%session_id, %blob_key, "store_blob — stored");
Ok(())
}
/// Fetch a blob's bytes for a session by key.
pub fn retrieve_blob(
conn: &Connection,
session_id: &str,
blob_key: &str,
) -> Result<Option<Vec<u8>>> {
tracing::debug!(%session_id, %blob_key, "retrieve_blob");
let result = conn.query_row(
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
params![session_id, blob_key],
|row| row.get::<_, Vec<u8>>(0),
);
match result {
Ok(data) => {
tracing::debug!(%session_id, %blob_key, size = data.len(), "retrieve_blob — found");
Ok(Some(data))
}
Err(rusqlite::Error::QueryReturnedNoRows) => {
tracing::debug!(%session_id, %blob_key, "retrieve_blob — not found");
Ok(None)
}
Err(e) => {
tracing::error!(%session_id, %blob_key, error = %e, "retrieve_blob — query failed");
Err(e.into())
}
}
}
/// List all blob keys stored for a session, oldest first.
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
tracing::debug!(%session_id, "list_blob_keys");
let mut stmt =
conn.prepare("SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC")?;
let rows = stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?;
let mut keys = Vec::new();
for row in rows {
keys.push(row?);
}
tracing::debug!(%session_id, count = keys.len(), "list_blob_keys — done");
Ok(keys)
}
@@ -1,37 +0,0 @@
//! Insert queries against the message log's `messages` table.
use anyhow::Result;
use rusqlite::{params, Connection};
use zesdex_domain::core::{ChatMessage, Role};
/// Insert a chat message into the session's message log.
///
/// Flow: extract optional content/tool_call_id/tool_name -> serialize
/// `tool_calls` to a JSON string if present -> map `Role` to its string
/// column value -> `INSERT` the row with the current timestamp.
///
/// Return: the new row's `rowid` on success, or the underlying error.
pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result<i64> {
let content = msg.content.as_deref();
let tool_call_id = msg.tool_call_id.as_deref();
let tool_name = msg.name.as_deref();
let tool_arguments = msg
.tool_calls
.as_ref()
.map(|calls| serde_json::to_string(calls).unwrap_or_default());
let created_at = chrono::Utc::now().timestamp_millis();
let role_str = match msg.role {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => "system",
Role::Tool => "tool",
};
tracing::debug!(%session_id, %role_str, content_len = content.map_or(0, str::len), "insert_message");
conn.execute(
"INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, tool_arguments, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![session_id, role_str, content, tool_call_id, tool_name, tool_arguments, created_at],
)?;
let rowid = conn.last_insert_rowid();
tracing::info!(%session_id, %role_str, rowid, "insert_message — inserted");
Ok(rowid)
}
@@ -1,39 +0,0 @@
//! SQLite-backed message log: per-session `messages.sqlite` storing chat
//! messages, blobs, and archive/summary metadata.
//!
//! ## Tables
//! | Table | Purpose |
//! |-------|---------|
//! | `messages` | Individual chat messages (role, content, tool calls) |
//! | `archives` | Session archive metadata (title, model, summary) |
//! | `blobs` | Binary attachments keyed by `(session_id, blob_key)` |
//!
//! All writes use WAL mode for concurrent reads without blocking.
pub mod blobs;
pub mod insert;
pub mod schema;
pub use blobs::store_blob;
pub use insert::insert_message;
/// Open (creating if needed) a session's `messages.sqlite` and ensure its
/// schema is initialized.
///
/// Flow: resolve `<session_dir>/messages.sqlite` -> create parent dirs ->
/// open a `SQLite` connection -> run `schema::init_schema`.
///
/// Return: an open, schema-ready `Connection`, or an error if any step fails.
pub fn open_or_create(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
let path = session_dir.join("messages.sqlite");
tracing::debug!(?path, "open_or_create");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = rusqlite::Connection::open(&path)?;
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
schema::init_schema(&conn)?;
tracing::info!("open_or_create — database ready");
Ok(conn)
}
@@ -1,52 +0,0 @@
//! `SQLite` schema definition for the message log database.
use anyhow::Result;
use rusqlite::Connection;
/// Create the message log's tables and indexes if they don't already
/// exist (`messages`, `archives`, `blobs`).
pub fn init_schema(conn: &Connection) -> Result<()> {
tracing::debug!("init_schema — creating tables if not exists");
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_name TEXT,
tool_arguments TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
title TEXT,
model TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
message_count INTEGER DEFAULT 0,
token_count INTEGER DEFAULT 0,
summary TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at);
CREATE TABLE IF NOT EXISTS blobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
blob_key TEXT NOT NULL,
data BLOB NOT NULL,
mime_type TEXT,
created_at INTEGER NOT NULL,
UNIQUE(session_id, blob_key)
);
",
)?;
tracing::info!("init_schema — schema ready");
Ok(())
}
-222
View File
@@ -1,222 +0,0 @@
//! TUI event loop — single-process mode entry point.
//!
//! Provides `run_single_process()` which sets up the terminal,
//! creates a session, and enters the render/input loop.
//!
//! Flow: create session + lock → enable raw mode + alternate screen →
//! run_loop (render → poll events → handle key → tick) →
//! restore terminal → save settings → release lock.
use anyhow::Result;
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, Event, KeyEventKind, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::io::{self, Write};
use std::time::Duration;
use tracing::{debug, info};
use crate::action::{apply_action, Action};
use crate::controller::input::handle_key;
use crate::state::AppStateRest;
use crate::view;
/// Run zesdex as a self-contained TUI + agent loop in one process.
///
/// Flow: build `AppStateRest` → enter raw mode / alternate screen →
/// run the event loop → always restore the terminal (even on error) →
/// save settings.
#[tracing::instrument]
pub fn run_single_process() -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
let _guard = rt.enter();
// Create session state
info!("starting single-process TUI");
let (_store, mut state) = create_local_session()?;
// Enter raw mode and alternate screen for the TUI
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
execute!(stdout, crossterm::event::EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let run_result = run_loop(&mut state, &mut terminal);
let mut restore_stdout = io::stdout();
let _ = execute!(restore_stdout, DisableBracketedPaste);
let _ = execute!(restore_stdout, DisableMouseCapture);
let _ = execute!(restore_stdout, LeaveAlternateScreen);
let _ = disable_raw_mode();
if let Err(e) = run_result {
let _ = writeln!(restore_stdout, "error: {e}");
let _ = restore_stdout.flush();
}
// Save settings
state.save_settings();
Ok(())
}
/// Run the event loop, guaranteeing terminal restoration on error.
///
/// Wraps `run_loop_inner` so that if it panics or returns an error the
/// terminal is restored to a usable state before propagating the error.
#[tracing::instrument(skip(state, terminal))]
fn run_loop(
state: &mut AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
debug!("entering run_loop");
let result = run_loop_inner(state, terminal);
if let Err(ref _e) = result {
let _ = terminal.clear();
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), DisableBracketedPaste);
let _ = execute!(io::stdout(), DisableMouseCapture);
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
result
}
/// The core single-process render/input loop.
///
/// Performance optimizations applied here:
/// - Skip `terminal.draw()` when `state.dirty == false` (nothing changed)
/// - Adaptive poll timeout: 50ms when a turn is in-flight (spinner needs
/// smooth updates), 200ms when idle (no reason to busy-loop)
/// - Toast expiry is drained once here instead of in two places
/// - Chat display lines are cached and rebuilt only when content changes
#[tracing::instrument(skip(state, terminal))]
fn run_loop_inner(
state: &mut AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
info!("TUI event loop started");
loop {
if state.quit {
info!("TUI event loop exiting (quit=true)");
break;
}
// Drain expired toasts exactly once per loop iteration (was being
// done here AND in Action::Tick before this fix). Skip the clock
// syscall + Vec scan when there are no toasts at all.
if !state.misc.toasts.is_empty() {
let now_ms = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now_ms);
}
// Skip render when nothing has changed — avoids expensive markdown
// re-parse and layout recalculation every cycle while idle.
if state.dirty {
// Pre-warm display caches before entering the terminal.draw closure.
// This lets view::draw work with &AppStateRest (no mutation inside draw).
view::pre_render(state);
terminal.draw(|f| {
view::draw(f, state);
state.dirty = false;
})?;
}
// Adaptive poll: fast when animating, slow when idle.
let poll_timeout = if state.turn_in_flight() {
Duration::from_millis(50) // smooth spinner @ ~20fps
} else {
Duration::from_millis(200) // idle: 5fps, saves CPU
};
if crossterm::event::poll(poll_timeout)? {
match crossterm::event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
let actions = handle_key(key, state);
for action in actions {
apply_action(state, action);
}
if let Some(text) = state.misc.pending_clipboard_copy.take() {
let _ =
zesdex_infrastructure::utils::write_osc52(&mut io::stdout(), &text);
state.push_toast(zesdex_infrastructure::Toast::new(
zesdex_infrastructure::ToastKind::Success,
"Copied to clipboard".to_string(),
));
}
}
}
Event::Paste(text) => {
if state.input.autocomplete_visible {
state.input.close_autocomplete();
}
state.input.buffer.insert_str(state.input.cursor, &text);
state.input.cursor += text.len();
if state.input.buffer.starts_with('/') {
state.input.open_autocomplete();
}
state.dirty = true;
}
Event::Resize(w, h) => {
apply_action(state, Action::Resize(w, h));
}
Event::Mouse(mouse_event) => {
if mouse_event.kind == MouseEventKind::ScrollUp {
apply_action(state, Action::ScrollUp);
} else if mouse_event.kind == MouseEventKind::ScrollDown {
apply_action(state, Action::ScrollDown);
}
}
_ => {}
}
}
// Tick always fires each iteration
apply_action(state, Action::Tick);
}
terminal.clear()?;
Ok(())
}
/// Create session state with real infrastructure wired in.
///
/// Flow: initialise `Store` → ensure data directories → generate session ID →
/// load `Settings` and `AppConfig` from disk → construct `AppStateRest`.
#[tracing::instrument]
fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest)> {
let store = zesdex_domain::core::Store::new();
debug!("store created at {:?}", store.base_dir);
store.ensure_dirs()?;
let session_id = uuid::Uuid::new_v4().to_string();
let session_dir = store.base_dir.join("sessions").join(&session_id);
std::fs::create_dir_all(&session_dir)?;
// Load real settings from disk
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
let settings =
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new()
.load(&store.base_dir)
.unwrap_or_default();
let app_config =
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository::new()
.load(&store.base_dir)
.unwrap_or_default();
let workspace_roots = vec![std::env::current_dir()?];
let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
state.settings = settings;
state.app_config = app_config;
Ok((store, state))
}
-158
View File
@@ -1,158 +0,0 @@
//! Standalone helper functions that operate on AppStateRest.
//!
//! These are kept separate from the AppStateRest impl block to keep that
//! file focused on state definition and direct mutations. Helpers that
//! aggregate, compute, or read from the filesystem live here.
use crate::state::AppStateRest;
// ---------------------------------------------------------------------------
// Effort levels
// ---------------------------------------------------------------------------
/// Name of each reasoning-effort tier.
pub const EFFORT_LEVELS: &[&str] = &[
"Auto \u{2014} let the provider decide",
"Low \u{2014} fast, minimal reasoning",
"Medium \u{2014} balanced speed & reasoning",
"High \u{2014} thorough reasoning",
"Maximum \u{2014} deep analysis",
];
/// Return the current effort index from state.
pub fn current_effort(state: &AppStateRest) -> usize {
state
.misc
.effort_level
.saturating_sub(1)
.min(EFFORT_LEVELS.len().saturating_sub(1))
}
/// Cycle effort level up or down.
pub fn cycle_effort(state: &mut AppStateRest, forward: bool) {
let n = EFFORT_LEVELS.len();
if forward {
state.misc.effort_level = (state.misc.effort_level % n) + 1;
} else {
state.misc.effort_level = if state.misc.effort_level <= 1 {
n
} else {
state.misc.effort_level - 1
};
}
state.mark_dirty();
}
// ---------------------------------------------------------------------------
// Learning items
// ---------------------------------------------------------------------------
/// A lesson entry displayed in the Learning overlay.
#[derive(Debug, Clone)]
pub enum LearningItem {
/// A newly-generated lesson pending user approval.
Pending {
name: String,
content: String,
scope: String,
confidence: f64,
},
/// A lesson that has been accepted and stored.
Stored {
name: String,
content: String,
lifecycle: String,
scope: String,
description: String,
},
}
/// Read lesson markdown files from the `lessons/` subdirectory under the
/// memory directory.
#[tracing::instrument(skip(state))]
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
let lessons_dir = state.memory_dir.join("lessons");
if !lessons_dir.exists() {
return Vec::new();
}
let mut items = Vec::new();
if let Ok(entries) = std::fs::read_dir(&lessons_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
if let Ok(content) = std::fs::read_to_string(&path) {
items.push(LearningItem::Stored {
name: path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
content,
lifecycle: "filesystem".to_string(),
scope: "filesystem".to_string(),
description: String::new(),
});
}
}
}
}
items
}
// ---------------------------------------------------------------------------
// Generic helpers
// ---------------------------------------------------------------------------
/// Cycle the selected index within bounds.
pub fn cycle_selected_index(current: usize, n: usize, forward: bool) -> usize {
if n == 0 {
return 0;
}
if forward {
(current + 1) % n
} else if current == 0 {
n - 1
} else {
current - 1
}
}
/// Return the number of rewind points available.
pub fn rewind_count(state: &AppStateRest) -> usize {
state.transcript_cache.messages.len()
}
// ---------------------------------------------------------------------------
// Token counting
// ---------------------------------------------------------------------------
/// Resolve the window size for context window management.
///
/// Uses the model name from settings to determine max context window,
/// falling back to settings-configured max or 128k default.
#[tracing::instrument(skip(_app_config, settings))]
pub fn resolve_context_window(
_app_config: &zesdex_domain::cms::AppConfig,
settings: &zesdex_domain::cms::Settings,
) -> usize {
if let Some(max) = settings.max_tokens {
if max > 0 {
return max as usize;
}
}
256_000
}
/// Count tokens using tiktoken, fall back to character estimation.
///
/// Flow: try tiktoken-rs `cl100k_base` BPE encoding \u{2192} return accurate count.
/// On failure (~4 chars per token heuristic), fall back to character-based
/// estimation so the UI never blocks on an unavailable tokeniser.
#[tracing::instrument]
pub fn count_tokens(text: &str) -> usize {
if let Ok(bpe) = tiktoken_rs::cl100k_base() {
return bpe.encode_with_special_tokens(text).len();
}
// Fallback: ~4 chars per token
text.len().div_ceil(4)
}
-281
View File
@@ -1,281 +0,0 @@
//! Chat input buffer, cursor, history, and autocomplete state.
use std::path::PathBuf;
/// Which source populated the autocomplete dropdown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutocompleteKind {
/// Builtin slash-command (e.g. `/model`, `/help`).
Command,
/// `@file` mention from the workspace file index.
FileMention,
}
/// Builtin slash-commands recognised by the chat input autocomplete.
const COMMANDS: &[&str] = &[
"/help",
"/quit",
"/clear",
"/login",
"/login zen",
"/login openai",
"/edit",
"/mcp add",
"/model",
"/model ls",
"/model add",
"/todo",
"/usage",
"/compact",
];
/// The user's input buffer, cursor position, history, and autocomplete
/// state for the chat prompt.
#[derive(Debug, Clone)]
pub struct InputState {
/// Raw UTF-8 input buffer content.
pub buffer: String,
/// Byte offset of the cursor within `buffer`.
pub cursor: usize,
/// Previously submitted input lines, oldest-first.
pub history: Vec<String>,
/// Index into `history` when browsing (None = at the current input).
pub history_idx: Option<usize>,
/// Current autocomplete candidate list.
pub autocomplete_candidates: Vec<String>,
/// Focused index within `autocomplete_candidates`.
pub autocomplete_idx: usize,
/// Whether the autocomplete dropdown is visible.
pub autocomplete_visible: bool,
/// Which kind of autocomplete is active.
pub autocomplete_kind: AutocompleteKind,
/// Byte offset of the `@` character that triggered file mention autocomplete.
pub mention_start: usize,
/// Optional path to a persistent history file.
pub history_file: Option<PathBuf>,
}
impl InputState {
/// Create an empty input state.
pub fn new() -> Self {
InputState {
buffer: String::new(),
cursor: 0,
history: Vec::new(),
history_idx: None,
autocomplete_candidates: Vec::new(),
autocomplete_idx: 0,
autocomplete_visible: false,
autocomplete_kind: AutocompleteKind::Command,
mention_start: 0,
history_file: None,
}
}
/// Hide the autocomplete dropdown and clear its state.
pub fn close_autocomplete(&mut self) {
self.autocomplete_visible = false;
self.autocomplete_candidates.clear();
self.autocomplete_idx = 0;
self.autocomplete_kind = AutocompleteKind::Command;
self.mention_start = 0;
}
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`.
pub fn open_autocomplete(&mut self) {
let trimmed = self.buffer.trim().to_string();
if trimmed.is_empty() || !trimmed.starts_with('/') {
self.close_autocomplete();
return;
}
let prefix = trimmed.to_lowercase();
self.autocomplete_candidates = COMMANDS
.iter()
.filter(|c| c.starts_with(&prefix))
.map(std::string::ToString::to_string)
.collect();
self.autocomplete_kind = AutocompleteKind::Command;
self.autocomplete_idx = 0;
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
}
/// Find the `@mention` token (if any) immediately before the cursor.
pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> {
let before_cursor = &self.buffer[..self.cursor];
let at_pos = before_cursor.rfind('@')?;
let between = &before_cursor[at_pos + 1..];
if between.chars().any(char::is_whitespace) {
return None;
}
let boundary_ok = at_pos == 0
|| before_cursor[..at_pos]
.chars()
.next_back()
.is_some_and(char::is_whitespace);
if !boundary_ok {
return None;
}
Some((at_pos, between.to_string()))
}
/// Open or refresh the `@file` mention dropdown from `files`.
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};
let Some((start, query)) = self.mention_query_at_cursor() else {
self.close_autocomplete();
return;
};
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
let matched_files = pattern.match_list(files.iter(), &mut matcher);
self.autocomplete_candidates = matched_files
.into_iter()
.take(10)
.map(|(f, _)| f.clone())
.collect();
self.autocomplete_kind = AutocompleteKind::FileMention;
self.mention_start = start;
self.autocomplete_idx = 0;
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
}
/// Move the autocomplete selection up (forward=false) or down (forward=true).
pub fn cycle_autocomplete(&mut self, forward: bool) {
let n = self.autocomplete_candidates.len();
if n == 0 {
return;
}
if forward {
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
} else {
self.autocomplete_idx = if self.autocomplete_idx == 0 {
n - 1
} else {
self.autocomplete_idx - 1
};
}
}
/// Accept the currently selected autocomplete candidate.
pub fn select_autocomplete(&mut self) -> bool {
let Some(candidate) = self
.autocomplete_candidates
.get(self.autocomplete_idx)
.cloned()
else {
return false;
};
match self.autocomplete_kind {
AutocompleteKind::Command => {
self.buffer = candidate;
self.cursor = self.buffer.len();
}
AutocompleteKind::FileMention => {
if self.cursor < self.mention_start || self.mention_start > self.buffer.len() {
self.close_autocomplete();
return false;
}
let replacement = format!("@{candidate} ");
self.buffer
.replace_range(self.mention_start..self.cursor, &replacement);
self.cursor = self.mention_start + replacement.len();
}
}
self.close_autocomplete();
true
}
/// Tab-complete: open dropdown or cycle forward.
pub fn tab_complete(&mut self) {
if self.autocomplete_visible {
self.cycle_autocomplete(true);
} else {
self.open_autocomplete();
}
}
/// Insert a character at the cursor position.
pub fn insert(&mut self, c: char) {
self.buffer.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
/// Delete the character to the left of the cursor (backspace).
pub fn delete_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.buffer.remove(self.cursor);
}
}
/// Delete the character at the cursor position (forward delete).
pub fn delete_right(&mut self) {
if self.cursor < self.buffer.len() {
self.buffer.remove(self.cursor);
}
}
/// Submit the current buffer and return the submitted text.
pub fn submit(&mut self) -> String {
let result = self.buffer.clone();
if !result.is_empty() {
if self.history.last() != Some(&result) {
self.history.push(result.clone());
if let Some(ref path) = self.history_file {
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
use std::io::Write;
let _ = writeln!(file, "{result}");
}
}
}
self.history_idx = None;
}
self.buffer.clear();
self.cursor = 0;
result
}
/// Navigate backward through input history.
pub fn history_up(&mut self) {
if self.history.is_empty() {
return;
}
let idx = match self.history_idx {
Some(i) if i > 0 => i - 1,
None => self.history.len() - 1,
Some(_) => return,
};
self.history_idx = Some(idx);
self.buffer = self.history[idx].clone();
self.cursor = self.buffer.len();
}
/// Navigate forward through input history.
pub fn history_down(&mut self) {
match self.history_idx {
Some(i) if i < self.history.len() - 1 => {
let idx = i + 1;
self.history_idx = Some(idx);
self.buffer = self.history[idx].clone();
self.cursor = self.buffer.len();
}
Some(_) => {
self.history_idx = None;
self.buffer.clear();
self.cursor = 0;
}
None => {}
}
}
}
impl Default for InputState {
fn default() -> Self {
Self::new()
}
}
-192
View File
@@ -1,192 +0,0 @@
//! Miscellaneous state: overlay enum, misc state bag, editor state.
use std::path::PathBuf;
use zesdex_infrastructure::Toast;
/// Which modal overlay, if any, is currently shown over the main TUI view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overlay {
/// No overlay; the main chat view is shown.
None,
/// Key bindings help screen.
Help,
/// Settings/configuration panel.
Settings,
/// Background bash job viewer.
Bash,
/// "Are you sure you want to quit?" confirmation.
QuitConfirm,
/// Raw key-code input capture (for binding custom keys).
KeyInput,
/// Inline editor (opened via `/edit`).
Editor,
/// Reasoning effort level selector.
Effort,
/// MCP server management panel.
Mcp,
/// Task list overlay.
Todo,
/// Project plan overlay.
Plan,
/// Session rewind / history scrubber.
Rewind,
/// Learning / lesson management panel.
Learning,
/// Token usage statistics panel.
Usage,
/// Generic loading spinner overlay.
Loading,
/// Model selector dropdown.
ModelSelector,
/// "Clear conversation?" confirmation.
ClearConfirm,
/// Git diff preview overlay.
Diff,
}
impl Overlay {
/// Human-readable name for this overlay variant.
pub fn as_str(&self) -> &'static str {
match self {
Overlay::None => "none",
Overlay::Help => "help",
Overlay::Settings => "settings",
Overlay::Bash => "bash",
Overlay::QuitConfirm => "quit_confirm",
Overlay::KeyInput => "key_input",
Overlay::Editor => "editor",
Overlay::Effort => "effort",
Overlay::Mcp => "mcp",
Overlay::Todo => "todo",
Overlay::Plan => "plan",
Overlay::Rewind => "rewind",
Overlay::Learning => "learning",
Overlay::Usage => "usage",
Overlay::Loading => "loading",
Overlay::ModelSelector => "model_selector",
Overlay::ClearConfirm => "clear_confirm",
Overlay::Diff => "diff",
}
}
/// Whether any overlay (i.e. anything other than `None`) is active.
pub fn is_active(self) -> bool {
!matches!(self, Overlay::None)
}
}
impl std::fmt::Display for Overlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Simple inline editor state for the TUI.
#[derive(Debug, Clone)]
pub struct EditorState {
/// Path to the file being edited.
pub path: PathBuf,
/// Current buffer content.
pub content: String,
/// Cursor position (byte offset).
pub cursor: usize,
}
impl EditorState {
/// Create a new editor state for the given path.
pub fn new(path: PathBuf, content: String) -> Self {
let cursor = content.len();
EditorState {
path,
content,
cursor,
}
}
/// Return the full buffer content.
pub fn as_string(&self) -> String {
self.content.clone()
}
/// Delete one character to the left of the cursor.
pub fn delete_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.content.remove(self.cursor);
}
}
}
/// The "miscellaneous" slice of app state.
#[derive(Debug, Clone)]
pub struct MiscState {
/// Currently active modal overlay (None = main chat view).
pub overlay: Overlay,
/// Active toast notifications.
pub toasts: Vec<Toast>,
/// Timestamp (ms) of the last staleness sweep for lesson cache.
pub last_staleness_sweep_ms: i64,
/// Whether the agent is currently "thinking".
pub thinking: bool,
/// Current LLM reasoning effort level (1-5).
pub effort_level: usize,
/// Currently focused index in list-type overlays.
pub selected_index: usize,
/// Optional inline editor state.
pub editor: Option<EditorState>,
/// Whether the API connection is established.
pub api_connected: bool,
/// Monotonically increasing tick count, incremented each render frame.
pub tick_count: u64,
/// Cached content of the TODO file.
pub todo_content: String,
/// Cached content of the PLAN file.
pub plan_content: String,
/// Whether a lesson background task is currently running.
pub lesson_running: bool,
/// Text waiting to be written to the system clipboard.
pub pending_clipboard_copy: Option<String>,
/// Cached git diff content for the preview overlay.
pub diff_content: String,
/// Scroll offset for the diff overlay.
pub diff_scroll: usize,
}
impl MiscState {
/// Create a fresh `MiscState` with no overlay, no toasts.
pub fn new() -> Self {
MiscState {
overlay: Overlay::None,
toasts: Vec::new(),
last_staleness_sweep_ms: 0,
thinking: false,
effort_level: 1,
selected_index: 0,
editor: None,
api_connected: false,
tick_count: 0,
todo_content: String::new(),
plan_content: String::new(),
lesson_running: false,
pending_clipboard_copy: None,
diff_content: String::new(),
diff_scroll: 0,
}
}
/// Append a toast notification to the active list.
pub fn push_toast(&mut self, toast: Toast) {
self.toasts.push(toast);
}
/// Remove all toasts whose lifetime has expired at `now_ms`.
pub fn drain_expired_toasts(&mut self, now_ms: i64) {
self.toasts.retain(|t| !t.expired(now_ms));
}
}
impl Default for MiscState {
fn default() -> Self {
Self::new()
}
}
-339
View File
@@ -1,339 +0,0 @@
//! TUI-perspective application state: `AppStateRest` and all the types it
//! owns. This is the single source-of-truth struct for the TUI interface,
//! mutated from `action::apply_action` and read by `view/` every render frame.
//!
//! # Organisation
//!
//! ```text
//! state/
//! ├── mod.rs — AppStateRest (the central struct) + re-exports
//! ├── input.rs — InputState, AutocompleteKind
//! ├── transcript.rs — TranscriptCache, ChatMessageDisplay
//! ├── scroll.rs — ScrollState
//! ├── misc.rs — MiscState, EditorState, Overlay
//! ├── workflow.rs — SimpleAgent, AgentState, SimpleWorkflowEngine
//! └── helpers.rs — Standalone functions operating on AppStateRest
//! ```
//!
//! # Flow
//! Construction in `run.rs::create_local_session` \u{2192} mutated by
//! `action::apply_action` \u{2192} read-only in every `view/*::draw*` function.
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tracing::warn;
use ratatui::text::Line;
use zesdex_domain::cms::{AppConfig, Settings};
use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent};
pub mod helpers;
pub mod input;
pub mod misc;
pub mod scroll;
pub mod transcript;
pub mod workflow;
// Re-export all public types from sub-modules at the `state` level so
// consumers that previously used `crate::state::InputState` etc. still work.
pub use input::{AutocompleteKind, InputState};
pub use misc::{EditorState, MiscState, Overlay};
pub use scroll::ScrollState;
pub use transcript::{ChatMessageDisplay, TranscriptCache};
pub use workflow::{AgentState, SimpleAgent, SimpleWorkflowEngine};
// Re-export the most commonly used helpers at the `state` level.
pub use helpers::{
count_tokens, current_effort, cycle_effort, cycle_selected_index, get_learning_items,
resolve_context_window, rewind_count, LearningItem, EFFORT_LEVELS,
};
// ---------------------------------------------------------------------------
// AppStateRest — the single source-of-truth TUI state
// ---------------------------------------------------------------------------
/// The single source-of-truth state struct for the TUI interface.
///
/// Mutated from `controller/input.rs` and `actions/mod.rs` (via `Action`).
/// Read-only from every `view/*` render function.
#[derive(Clone)]
pub struct AppStateRest {
/// Persistent user settings.
pub settings: Settings,
/// Per-project app configuration.
pub app_config: AppConfig,
/// Absolute paths to each open workspace root directory.
pub workspace_roots: Vec<PathBuf>,
/// Unique session identifier.
pub session_id: String,
/// Path to the session's data directory.
pub session_dir: PathBuf,
/// Path to the session memory directory.
pub memory_dir: PathBuf,
/// Path to the git worktrees directory.
pub worktrees_dir: PathBuf,
/// Shared async cache of directory listings.
pub dir_cache: Arc<tokio::sync::RwLock<DirCache>>,
/// Shared workspace file-path index for `@file` mention autocomplete.
pub mention_index: MentionIndex,
/// Optional per-session runtime state.
pub session_runtime: Option<SessionRuntime>,
/// Ring buffer of recent chat messages for the transcript pane.
pub transcript_cache: TranscriptCache,
/// Viewport scroll offset tracker.
pub scroll: ScrollState,
/// Chat input buffer, cursor, history, and autocomplete.
pub input: InputState,
/// Miscellaneous state: overlay, toasts, flags, editor, tick.
pub misc: MiscState,
/// Queue of events emitted by the running agent turn.
pub turn_events: Arc<std::sync::Mutex<VecDeque<TurnEvent>>>,
/// Whether an agent turn is currently in flight.
/// Uses AtomicBool for lock-free check from render loop.
pub turn_in_flight_flag: Arc<AtomicBool>,
/// Cached display lines for the chat transcript panel.
pub display_lines_cache: Vec<Line<'static>>,
/// Cached token count for the current message history.
pub cached_token_count: usize,
/// Whether the token count cache is stale and needs recalculation.
pub token_count_dirty: bool,
/// Terminal width at the time of the last display_lines_cache rebuild.
pub last_render_width: u16,
/// Number of messages that were in the cache when it was last built.
pub cached_msg_count: usize,
/// Cache index where the last message's rendered lines begin. Used to
/// splice streaming updates (only re-render the trailing message).
pub cached_last_start: usize,
/// Content+reasoning byte length of the last message when it was last
/// rendered. Guards the streaming branch from re-rendering on ticks
/// where no new token arrived (spinner-only frames).
pub cached_last_len: usize,
/// Terminal width at the time of the last full cache build.
pub render_width_at_cache: u16,
/// Atomic flag set when the user aborts the current turn.
pub abort_flag: Arc<AtomicBool>,
/// Simplified workflow engine state for display.
pub workflow_engine: SimpleWorkflowEngine,
/// Whether the state has been modified since the last render sweep.
pub dirty: bool,
/// Whether the application has been requested to quit.
pub quit: bool,
/// Cached help text content.
pub help_text: &'static str,
}
/// Default help text shown in the Help overlay.
pub const DEFAULT_HELP_TEXT: &str = r#" Zesdex TUI \u{2014} Keyboard Shortcuts
\u{2500}\u{2500}\u{2500} General \u{2500}\u{2500}\u{2500}
Ctrl+C Quit confirm
Ctrl+D Close overlay
Ctrl+Y Copy last assistant message
Esc Abort turn / Close overlay
Tab Autocomplete
\u{2500}\u{2500}\u{2500} Navigation \u{2500}\u{2500}\u{2500}
\u{2191} / \u{2193} History browse / Overlay navigate
Ctrl+\u{2191}/\u{2193} Scroll transcript
PgUp / PgDown Scroll transcript
Enter Submit / Select autocomplete
\u{2500}\u{2500}\u{2500} Overlays \u{2500}\u{2500}\u{2500}
/help Show this help
/settings Open settings overlay
/todo Open tasks (todo) overlay
/usage Open usage statistics
/bash Open bash jobs overlay
/mcp Open MCP server management
/model Open model selector
/compact Compact conversation
/clear Clear transcript
/rewind Rewind conversation history
\u{2500}\u{2500}\u{2500} Editor Mode \u{2500}\u{2500}\u{2500}
/edit <path> Open file for inline editing
Ctrl+S Save changes
Esc Dismiss editor
"#;
impl Default for AppStateRest {
fn default() -> Self {
AppStateRest {
settings: Settings::default(),
app_config: AppConfig::default(),
workspace_roots: Vec::new(),
session_id: String::new(),
session_dir: PathBuf::new(),
memory_dir: PathBuf::new(),
worktrees_dir: PathBuf::new(),
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
mention_index: MentionIndex::new(),
session_runtime: None,
transcript_cache: TranscriptCache::new(200),
scroll: ScrollState::new(),
input: InputState::new(),
misc: MiscState::new(),
turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())),
turn_in_flight_flag: Arc::new(AtomicBool::new(false)),
abort_flag: Arc::new(AtomicBool::new(false)),
workflow_engine: SimpleWorkflowEngine::new(),
dirty: true,
quit: false,
help_text: DEFAULT_HELP_TEXT,
display_lines_cache: Vec::new(),
cached_token_count: 0,
token_count_dirty: true,
last_render_width: 0,
cached_msg_count: 0,
cached_last_start: 0,
cached_last_len: 0,
render_width_at_cache: 0,
}
}
}
impl AppStateRest {
/// Construct initial TUI state.
pub fn new(
workspace_roots: Vec<PathBuf>,
session_dir: &std::path::Path,
memory_dir: PathBuf,
) -> Self {
let settings = Settings::default();
let app_config = AppConfig::default();
let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees");
let session_id = session_dir.file_name().map_or_else(
|| {
warn!("[state] session_dir has no file_name, using empty session_id");
String::new()
},
|n| n.to_string_lossy().to_string(),
);
AppStateRest {
settings,
app_config,
workspace_roots,
session_id,
session_dir: session_dir.to_path_buf(),
memory_dir: memory_dir.clone(),
worktrees_dir,
turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())),
turn_in_flight_flag: Arc::new(AtomicBool::new(false)),
abort_flag: Arc::new(AtomicBool::new(false)),
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
mention_index: MentionIndex::new(),
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
workflow_engine: SimpleWorkflowEngine::new(),
transcript_cache: TranscriptCache::new(200),
scroll: ScrollState::new(),
input: InputState::new(),
misc: MiscState::new(),
dirty: true,
quit: false,
help_text: DEFAULT_HELP_TEXT,
display_lines_cache: Vec::new(),
cached_token_count: 0,
token_count_dirty: true,
last_render_width: 0,
cached_msg_count: 0,
cached_last_start: 0,
cached_last_len: 0,
render_width_at_cache: 0,
}
}
/// Whether an agent turn is currently running.
/// Uses lock-free AtomicBool load \u{2014} safe to call every render frame.
pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight_flag.load(Ordering::Relaxed)
}
/// Append a message to the transcript.
/// Eviction is O(1) via VecDeque::pop_front.
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
self.transcript_cache.push(msg);
self.token_count_dirty = true;
self.dirty = true;
}
/// Append text to the last assistant message in the transcript, if one exists.
pub fn append_to_last_transcript(&mut self, text: &str, is_reasoning: bool) {
self.transcript_cache.append_to_last(text, is_reasoning);
if self.transcript_cache.dirty {
self.dirty = true;
}
}
/// Mark the app state as dirty, triggering a TUI re-render.
pub fn mark_dirty(&mut self) {
self.dirty = true;
}
/// Queue a toast notification.
pub fn push_toast(&mut self, toast: Toast) {
self.misc.push_toast(toast);
self.mark_dirty();
}
/// Push an info toast.
pub fn toast_info(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(
zesdex_infrastructure::ToastKind::Info,
msg.into(),
));
}
/// Push a success toast.
pub fn toast_success(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(
zesdex_infrastructure::ToastKind::Success,
msg.into(),
));
}
/// Push a warning toast.
pub fn toast_warning(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(
zesdex_infrastructure::ToastKind::Warning,
msg.into(),
));
}
/// Push an error toast.
pub fn toast_error(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(
zesdex_infrastructure::ToastKind::Error,
msg.into(),
));
}
/// Persist settings to disk.
pub fn save_settings(&self) {
if let Ok(store_dir) = std::fs::canonicalize(self.store_base_dir()) {
let repo =
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new(
);
use zesdex_domain::SettingsRepository;
if let Err(e) = repo.save(&store_dir, &self.settings) {
tracing::warn!("Failed to save settings: {e}");
}
}
}
/// Resolve the base directory for session stores.
pub fn store_base_dir(&self) -> PathBuf {
self.session_dir
.parent()
.and_then(|p| p.parent())
.map_or_else(
|| {
warn!("[state] no grandparent, using session_dir");
self.session_dir.clone()
},
std::path::Path::to_path_buf,
)
}
}
-36
View File
@@ -1,36 +0,0 @@
//! Viewport scroll state: current offset and visible-line count.
/// Viewport scroll state: current offset and visible-line count.
#[derive(Debug, Clone)]
pub struct ScrollState {
/// Current scroll offset (how many lines have been scrolled past).
pub offset: usize,
/// Maximum number of lines that fit in the visible viewport area.
pub max_visible: usize,
}
impl ScrollState {
/// Create a `ScrollState` with zero offset and 30 rows visible.
pub fn new() -> Self {
ScrollState {
offset: 0,
max_visible: 30,
}
}
/// Scroll the viewport up by `amount` lines (increasing the offset).
pub fn scroll_up(&mut self, amount: usize) {
self.offset = self.offset.saturating_add(amount);
}
/// Scroll the viewport down by `amount` lines (decreasing the offset).
pub fn scroll_down(&mut self, amount: usize) {
self.offset = self.offset.saturating_sub(amount);
}
}
impl Default for ScrollState {
fn default() -> Self {
Self::new()
}
}
@@ -1,76 +0,0 @@
//! Transcript display types: bounded ring-buffer cache of rendered messages.
use std::collections::VecDeque;
use zesdex_domain::core::Role;
/// A single transcript entry rendered in the TUI chat pane.
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessageDisplay {
/// Message author: User or Assistant.
pub role: Role,
/// Rendered text content (plain text, no markdown).
pub content: String,
/// Model reasoning (e.g. from DeepSeek-R1 <think> block).
pub reasoning: String,
/// Millisecond timestamp when this display entry was created.
pub timestamp: i64,
}
impl ChatMessageDisplay {
/// Build a display entry, stamping it with the current time.
pub fn new(role: Role, content: String) -> Self {
ChatMessageDisplay {
role,
content,
reasoning: String::new(),
timestamp: chrono::Utc::now().timestamp_millis(),
}
}
}
/// Bounded ring of recent chat messages used to render the transcript view.
#[derive(Debug, Clone)]
pub struct TranscriptCache {
/// Ordered display messages (newest appended, oldest evicted when full).
/// Uses VecDeque for O(1) front eviction instead of O(n) Vec::remove(0).
pub messages: VecDeque<ChatMessageDisplay>,
/// Maximum messages to retain before evicting the oldest.
pub max_lines: usize,
/// Whether the cache has changed since the last render sweep.
pub dirty: bool,
}
impl TranscriptCache {
/// Create an empty transcript cache holding at most `max_lines` messages.
pub fn new(max_lines: usize) -> Self {
TranscriptCache {
messages: VecDeque::new(),
max_lines,
dirty: true,
}
}
/// Append a message, evicting the oldest if at capacity.
/// Eviction is O(1) via VecDeque::pop_front instead of O(n) Vec::remove(0).
pub fn push(&mut self, msg: ChatMessageDisplay) {
self.messages.push_back(msg);
while self.messages.len() > self.max_lines {
self.messages.pop_front();
}
self.dirty = true;
}
/// Append text to the last assistant message, if one exists.
pub fn append_to_last(&mut self, text: &str, is_reasoning: bool) {
if let Some(msg) = self.messages.back_mut() {
if msg.role == Role::Assistant {
if is_reasoning {
msg.reasoning.push_str(text);
} else {
msg.content.push_str(text);
}
self.dirty = true;
}
}
}
}
-82
View File
@@ -1,82 +0,0 @@
//! Simplified agent lifecycle display types for the workflow sidebar.
/// Simplified agent lifecycle state for TUI display.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentState {
Idle,
Running,
Completed,
Failed,
}
/// A single agent entry in the workflow sidebar.
#[derive(Debug, Clone)]
pub struct SimpleAgent {
/// Agent unique ID (e.g. "auto-review", "Node-0-1").
pub name: String,
/// Human-readable display label (e.g. "Auto-Review", "Backend API Agent").
pub display_name: String,
/// Current lifecycle state.
pub state: AgentState,
/// Millisecond timestamp when the agent started.
pub started_at: Option<i64>,
/// Millisecond timestamp when the agent completed.
pub completed_at: Option<i64>,
/// Optional error message if the agent failed.
pub error: Option<String>,
/// Optional progress text (current tool, step description).
pub progress: Option<String>,
}
impl SimpleAgent {
/// Create a new agent with the given name (used as both ID and display name).
pub fn new(name: String) -> Self {
SimpleAgent {
display_name: name.clone(),
name,
state: AgentState::Idle,
started_at: None,
completed_at: None,
error: None,
progress: None,
}
}
/// Create a new agent with separate ID and display label.
pub fn with_display(name: String, display_name: String) -> Self {
SimpleAgent {
name,
display_name,
state: AgentState::Idle,
started_at: None,
completed_at: None,
error: None,
progress: None,
}
}
}
/// Simplified workflow engine state for TUI display.
#[derive(Debug, Clone)]
pub struct SimpleWorkflowEngine {
/// Active agents in the workflow.
pub agents: Vec<SimpleAgent>,
/// Summary findings produced by completed agents.
pub findings: Vec<String>,
}
impl SimpleWorkflowEngine {
/// Create an empty workflow engine state.
pub fn new() -> Self {
SimpleWorkflowEngine {
agents: Vec::new(),
findings: Vec::new(),
}
}
}
impl Default for SimpleWorkflowEngine {
fn default() -> Self {
Self::new()
}
}
-175
View File
@@ -1,175 +0,0 @@
//! TUI agent turn interface adapter — resolves LLM provider configuration,
//! builds the tool context, and spawns the agent turn on a background task.
use std::sync::atomic::Ordering;
use tracing::info;
use zesdex_application::agent::turn_service::AgentTurnServiceImpl;
use zesdex_application::agent::AgentTurnService;
use zesdex_domain::agent::AgentTurnParams;
use zesdex_domain::core::ChatMessage;
use zesdex_infrastructure::llm::provider::LlmClient;
use zesdex_infrastructure::tools::executor::InfrastructureToolExecutor;
use zesdex_infrastructure::tools::{all_tools, tool_defs, ToolCtx};
use crate::state::AppStateRest;
// ---------------------------------------------------------------------------
// Provider resolution
// ---------------------------------------------------------------------------
/// Resolve the API key from settings or environment for the given provider.
fn resolve_api_key(state: &AppStateRest, provider_name: &str) -> String {
if let Some(key) = state.settings.api_keys.get(provider_name) {
return key.clone();
}
if let Some(cfg) = state.app_config.providers.get(provider_name) {
if let Some(ref default_key) = cfg.default_api_key {
if !default_key.is_empty() {
return default_key.clone();
}
}
if let Some(ref env_name) = cfg.api_key_env {
if let Ok(val) = std::env::var(env_name) {
return val;
}
}
}
String::new()
}
/// Resolve the API base URL from the provider config.
fn resolve_api_base(state: &AppStateRest, provider_name: &str) -> Option<String> {
state
.app_config
.providers
.get(provider_name)
.map(|cfg| cfg.api_base.clone())
}
// ---------------------------------------------------------------------------
// Turn spawning
// ---------------------------------------------------------------------------
/// Spawn an agent turn on a background Tokio task.
///
/// Flow:
/// 1. Compare-exchange the in-flight flag (no-op if already running).
/// 2. Resolve provider (API key, model, base URL) from settings.
/// 3. Build messages including the user's input text.
/// 4. Construct `AgentTurnParams` with the turn-event queue and abort flag.
/// 5. Create `LlmClient`, `ToolCtx`, and `InfrastructureToolExecutor`.
/// 6. Assemble `AgentTurnServiceImpl` and spawn it via `tokio::spawn`.
#[tracing::instrument(skip(state))]
pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
// Only one turn at a time — compare_exchange is lock-free
if state
.turn_in_flight_flag
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_err()
{
return;
}
let turn_events = state.turn_events.clone();
let in_flight = state.turn_in_flight_flag.clone();
let abort = state.abort_flag.clone();
let session_dir = state.session_dir.clone();
let workspace_roots = state.workspace_roots.clone();
// ── Resolve provider configuration ─────────────────────────────────
let provider_name = &state.settings.provider;
let api_key = resolve_api_key(state, provider_name);
let model = zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config);
let api_base = resolve_api_base(state, provider_name);
// ── Build message list ─────────────────────────────────────────────
let mut messages: Vec<ChatMessage> = state
.session_runtime
.as_ref()
.map(|rt| rt.messages.clone())
.unwrap_or_default();
messages.push(ChatMessage::user(text));
if let Some(ref mut rt) = state.session_runtime {
rt.messages = messages.clone();
}
info!("Spawning agent turn (model: {model})");
// ── Assemble dependencies (composition root) ──────────────────────
let params = AgentTurnParams {
messages,
session_dir: session_dir.clone(),
workspace_roots: workspace_roots.clone(),
turn_events: turn_events.clone(),
in_flight: in_flight.clone(),
abort: abort.clone(),
api_key: api_key.clone(),
model: model.clone(),
api_base: api_base.clone(),
};
// Capture for the optional background auto-review before `api_key` /
// `model` / `api_base` move into the client below. The reviewer is
// fire-and-forget and skips itself when the workspace has no diff.
let review_enabled = state.settings.flags.review_enabled;
let review_key = api_key.clone();
let review_model = model.clone();
let review_base = api_base.clone();
let review_ws = workspace_roots.clone();
let review_events = turn_events.clone();
let client = std::sync::Arc::new(LlmClient::new(api_key, model, api_base));
let tool_ctx = ToolCtx::builder()
.session_dir(session_dir)
.workspaces(workspace_roots)
.turn_events(turn_events)
.build();
let tool_executor = std::sync::Arc::new(InfrastructureToolExecutor::new(tool_ctx));
let tools = all_tools();
let defs = tool_defs(&tools);
let turn_service = AgentTurnServiceImpl::new(client, tool_executor, defs);
tokio::spawn(async move {
let _ = turn_service.run_turn(params).await;
if review_enabled {
zesdex_infrastructure::subagent::auto::engine::spawn_background_review(
review_ws,
review_events,
review_key,
review_model,
review_base,
);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spawn_agent_turn_with_tokio_runtime() {
let rt = tokio::runtime::Runtime::new().unwrap();
let _guard = rt.enter();
let temp_dir = std::env::temp_dir().join(format!("zesdex_test_{}", uuid::Uuid::new_v4()));
let session_dir = temp_dir.join("session");
let memory_dir = temp_dir.join("memory");
std::fs::create_dir_all(&session_dir).unwrap();
std::fs::create_dir_all(&memory_dir).unwrap();
let workspace_roots = vec![temp_dir.clone()];
let mut state = AppStateRest::new(workspace_roots, &session_dir, memory_dir);
spawn_agent_turn(&mut state, "hello".to_string());
assert!(state.turn_in_flight());
let _ = std::fs::remove_dir_all(&temp_dir);
}
}
-363
View File
@@ -1,363 +0,0 @@
//! Chat transcript panel rendering — tight inline log style.
//!
//! Performance design:
//! - `display_lines_cache` menyimpan Vec<Line> per pesan (indexed by message position)
//! - Saat pesan baru masuk, hanya pesan BARU yang di-render, bukan rebuild seluruh history
//! - `draw_chat` tidak pernah clone seluruh cache — hanya slice window visible yang di-ambil
//! - `mark_dirty()` di Tick hanya dipanggil jika ada event aktif atau spinner berjalan
use super::theme::Theme;
use crate::components::Component;
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;
#[derive(Default)]
pub struct ChatComponent;
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 satu pesan menjadi Vec<Line<'static>>.
/// Dipanggil incremental — hanya untuk pesan baru, bukan seluruh history.
fn render_one_message(
msg: &crate::state::ChatMessageDisplay,
content_width: u16,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
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, |l| l.spans);
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH)), Span::styled("↳ ", dim)];
spans.extend(first_spans);
lines.push(Line::from(spans));
for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
return lines;
}
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 mut first_line_handled = false;
if !msg.reasoning.trim().is_empty() {
let reasoning_str = format!("[Thinking...]\n{}", msg.reasoning.trim());
let reasoning_spans =
super::markdown::render_markdown(&reasoning_str, content_width, false);
// Dim the reasoning text
let dimmed_spans: Vec<Span> = reasoning_spans
.into_iter()
.map(|mut s| {
s.style = s.style.fg(Theme::TEXT_DIM);
s
})
.collect();
let reasoning_lines = split_spans_into_lines(dimmed_spans);
let mut lines_iter = reasoning_lines.into_iter();
if let Some(first) = lines_iter.next() {
let mut spans = header_prefix.clone();
spans.extend(first.spans);
lines.push(Line::from(spans));
first_line_handled = true;
}
for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
}
let content_str = if msg.content.trim().is_empty() {
if msg.reasoning.trim().is_empty() {
"(tool execution)".to_string()
} else {
String::new()
}
} else {
msg.content.clone()
};
if !content_str.is_empty() {
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 !first_line_handled {
if let Some(first) = lines_iter.next() {
let mut spans = header_prefix.clone();
spans.extend(first.spans);
lines.push(Line::from(spans));
first_line_handled = true;
}
} else {
// Header already printed by reasoning, just indent
if let Some(first) = lines_iter.next() {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(first.spans);
lines.push(Line::from(spans));
}
}
for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
}
if !first_line_handled {
lines.push(Line::from(header_prefix));
}
lines
}
impl Component for ChatComponent {
fn pre_render(&mut self, state: &mut crate::state::AppStateRest) {
let content_width = state
.last_render_width
.saturating_sub(PREFIX_WIDTH as u16 + 2);
let msg_count = state.transcript_cache.messages.len();
let cached_count = state.cached_msg_count;
let needs_full_rebuild = state.transcript_cache.dirty
&& (state.last_render_width == 0
|| msg_count < cached_count // pesan di-evict dari depan
|| state.render_width_at_cache != state.last_render_width); // resize
if needs_full_rebuild {
// Full rebuild: parse semua pesan dari nol
let mut all_lines: Vec<Line<'static>> = Vec::new();
for msg in &state.transcript_cache.messages {
let msg_lines = render_one_message(msg, content_width);
all_lines.extend(msg_lines);
}
state.display_lines_cache = all_lines;
state.cached_msg_count = msg_count;
state.cached_last_start = state.display_lines_cache.len();
state.cached_last_len = last_msg_len(state);
state.render_width_at_cache = state.last_render_width;
state.transcript_cache.dirty = false;
} else if state.transcript_cache.dirty && msg_count > cached_count {
// Incremental: hanya render pesan baru yang belum ada di cache
let new_msgs: Vec<_> = state
.transcript_cache
.messages
.iter()
.skip(cached_count)
.collect();
for msg in new_msgs {
let msg_lines = render_one_message(msg, content_width);
state.display_lines_cache.extend(msg_lines);
}
state.cached_msg_count = msg_count;
state.cached_last_start = state.display_lines_cache.len();
state.cached_last_len = last_msg_len(state);
state.transcript_cache.dirty = false;
} else if state.transcript_cache.dirty {
// Streaming update: message count unchanged, but the last
// assistant message's content grew (StreamToken). Re-render only
// that message and splice its lines back into the cache — this
// keeps updates O(new content), not O(whole history) per token.
// Without this branch, streamed tokens would never appear (the
// cache is only rebuilt on new-message or resize paths).
let cur_len = last_msg_len(state);
if cur_len != state.cached_last_len {
if let Some(last_msg) = state.transcript_cache.messages.back() {
if state.cached_last_start <= state.display_lines_cache.len() {
let new_lines = render_one_message(last_msg, content_width);
state.display_lines_cache.truncate(state.cached_last_start);
state.display_lines_cache.extend(new_lines);
}
}
state.cached_last_len = cur_len;
}
state.transcript_cache.dirty = false;
}
// Lazily recompute token count — hanya saat ada pesan baru
if state.token_count_dirty {
if let Some(ref rt) = state.session_runtime {
state.cached_token_count = rt
.messages
.iter()
.filter_map(|m| m.content.as_deref())
.map(crate::state::count_tokens)
.sum();
} else {
state.cached_token_count = 0;
}
state.token_count_dirty = false;
}
}
fn draw(&mut self, 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 cache = &state.display_lines_cache;
// Hitung window tanpa clone
let total = cache.len();
let spinner_extra = usize::from(state.turn_in_flight());
let total_with_spinner = total + spinner_extra;
let max_offset = total_with_spinner.saturating_sub(max_visible);
let offset = scroll_offset.min(max_offset);
let end_idx = total_with_spinner.saturating_sub(offset);
let start_idx = end_idx.saturating_sub(max_visible);
// Kumpulkan hanya baris yang visible — tidak clone semua
let mut visible: Vec<Line> = Vec::with_capacity(max_visible);
let cache_end = end_idx.min(total);
let cache_start = start_idx.min(cache_end);
if cache_start < cache_end {
visible.extend_from_slice(&cache[cache_start..cache_end]);
}
// Spinner hanya ditambahkan jika visible window mencakup posisi terakhir
if state.turn_in_flight() && end_idx > total {
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len();
let spinner = spinner_frames[frame_idx];
visible.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),
),
]));
}
let scroll_pct = if total_with_spinner > max_visible && max_offset > 0 {
((offset as f64 / max_offset as f64) * 100.0) as u8
} else {
0
};
let title = if scroll_pct > 0 {
format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct)
} else 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 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
}
/// Byte length of the last message's content + reasoning. Used as a cheap
/// change detector for the streaming render branch — avoids re-rendering on
/// spinner-only ticks where no new token arrived.
fn last_msg_len(state: &crate::state::AppStateRest) -> usize {
state
.transcript_cache
.messages
.back()
.map(|m| m.content.len() + m.reasoning.len())
.unwrap_or(0)
}
-127
View File
@@ -1,127 +0,0 @@
//! Chat input box and autocomplete dropdown rendering.
//! Implements the Component trait.
use super::theme::Theme;
use crate::components::Component;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::Frame;
#[derive(Default)]
pub struct InputComponent;
impl Component for InputComponent {
fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::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_type(BorderType::Rounded)
.border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled(
dropdown_title,
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
))
.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(ratatui::widgets::Clear, dropdown_area);
frame.render_widget(dropdown, dropdown_area);
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Theme::BORDER_FOCUSED))
.style(Style::default().bg(Theme::BG));
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, @file, 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, after_char) = if after.is_empty() {
(" ".to_string(), "")
} else {
let c = after.chars().next().unwrap();
let char_len = c.len_utf8();
(c.to_string(), &after[char_len..])
};
// Blinking cursor logic based on tick count
let cursor_style = if state.misc.tick_count % 10 < 5 {
Style::default()
.bg(Theme::PRIMARY)
.fg(Theme::BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.bg(Theme::TEXT_DIM)
.fg(Theme::BG)
.add_modifier(Modifier::BOLD)
};
spans.push(Span::styled(cursor_char, cursor_style));
if !after_char.is_empty() {
spans.push(Span::raw(after_char.to_string()));
}
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line).block(block);
frame.render_widget(paragraph, area);
}
}
-452
View File
@@ -1,452 +0,0 @@
//! 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 Some(max_idx) = col_widths
.iter()
.enumerate()
.max_by_key(|&(_, &w)| w)
.map(|(i, _)| i)
else {
break;
};
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 + 1)),
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
}
-167
View File
@@ -1,167 +0,0 @@
//! 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 input;
pub mod markdown;
pub mod overlays;
pub mod sidebar;
pub mod status;
pub mod theme;
pub mod workflow;
use crate::components::Component;
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;
/// Pre-render hook: update mutable caches (display lines, token count)
/// before the immutable `draw` pass. Called once per frame when dirty.
///
/// This separates cache mutation from rendering so `draw` can take
/// `&AppStateRest` (required by `terminal.draw` closure constraints).
pub fn pre_render(state: &mut AppStateRest) {
let mut chat_comp = chat::ChatComponent;
chat_comp.pre_render(state);
}
/// 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 {
let mut chat_comp = chat::ChatComponent;
chat_comp.draw(frame, chat_area, state);
}
let mut input_comp = input::InputComponent;
input_comp.draw(frame, input_area, state);
let mut status_comp = status::StatusBarComponent;
status_comp.draw(frame, status_area, state);
if let Some(sidebar_rect) = sidebar_area {
let mut sidebar_comp = sidebar::SidebarComponent;
sidebar_comp.draw(frame, sidebar_rect, state);
}
render_toasts(frame, state);
}
fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
// Fast path: nothing to render.
if state.misc.toasts.is_empty() {
return;
}
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;
}
// Shadow/Dimming effect background
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_type(ratatui::widgets::BorderType::Rounded)
.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),
))
}
@@ -1,49 +0,0 @@
//! Overlay: list of active / completed bash background jobs.
use crate::view::theme::Theme;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use tracing::debug;
/// Render the Bash Jobs overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
debug!("Rendering Bash Jobs overlay");
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);
}
@@ -1,39 +0,0 @@
//! Overlay: confirm-before-clear dialog for the chat transcript.
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use tracing::debug;
/// Render the Clear Transcript confirmation dialog.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
_state: &crate::state::AppStateRest,
) {
debug!("Rendering Clear Transcript confirmation overlay");
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);
}
@@ -1,136 +0,0 @@
//! Diff preview overlay — renders git diff output with color-coded
//! additions (green) and deletions (red) in a scrollable panel.
//!
//! Flow: read `state.misc.diff_content` → parse diff lines → apply syntax
//! coloring (green `+`, red `-`, blue header, dim context) → render in a
//! scrollable paragraph widget.
use super::super::theme::Theme;
use super::overlay_block;
use crate::state::AppStateRest;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::Frame;
/// Render the diff preview overlay with color-coded +/- lines and scrolling.
///
/// The overlay fills most of the screen and shows:
/// - Green lines for additions (`+`)
/// - Red lines for deletions (`-`)
/// - Blue/dim headers (diff --git, @@ hunk headers)
/// - Dim context lines
/// - Scroll hint at the bottom
pub fn render(frame: &mut Frame, area: Rect, block: Block<'static>, state: &AppStateRest) {
let block = overlay_block(block, " Git Diff Preview ", Theme::PRIMARY);
let inner = block.inner(area);
// Render the block background
frame.render_widget(block.clone(), area);
let diff_content = &state.misc.diff_content;
let diff_scroll = state.misc.diff_scroll;
// Parse lines with ANSI-style coloring
let lines: Vec<Line> = diff_content
.lines()
.skip(diff_scroll)
.map(|line| {
let trimmed = line.trim_end();
if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
// Addition — green
Line::from(Span::styled(
format!("{trimmed}\n"),
Style::default().fg(Theme::SUCCESS),
))
} else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
// Deletion — red
Line::from(Span::styled(
format!("{trimmed}\n"),
Style::default().fg(Theme::ERROR),
))
} else if trimmed.starts_with("@@") {
// Hunk header — cyan
Line::from(Span::styled(
format!("{trimmed}\n"),
Style::default().fg(Theme::INFO).add_modifier(Modifier::DIM),
))
} else if trimmed.starts_with("diff --git")
|| trimmed.starts_with("index ")
|| trimmed.starts_with("--- ")
|| trimmed.starts_with("+++ ")
|| trimmed.starts_with("===")
{
// File header — blue/bold
Line::from(Span::styled(
format!("{trimmed}\n"),
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
))
} else {
// Context — dim
Line::from(Span::styled(
format!("{trimmed}\n"),
Style::default().fg(Theme::TEXT_DIM),
))
}
})
.collect();
let total_lines = diff_content.lines().count();
let visible_lines = inner.height as usize;
let has_more = diff_scroll + visible_lines < total_lines;
// Create the content paragraph
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
frame.render_widget(paragraph, inner);
// Render scroll indicator at the bottom
if has_more {
let scroll_pct = if total_lines > 0 {
((diff_scroll as f64 / total_lines as f64) * 100.0) as u32
} else {
0
};
let scroll_text = format!(
" Lines {}-{} of {} ({}%) ",
diff_scroll + 1,
(diff_scroll + visible_lines).min(total_lines),
total_lines,
scroll_pct
);
let status_line = Line::from(Span::styled(
scroll_text,
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
));
let status_area = Rect {
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(status_line), status_area);
}
// Render keybinding hint at the top
let hint = Line::from(Span::styled(
" ↑/↓: scroll Esc: close /diff: refresh",
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
));
let hint_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(hint), hint_area);
}
@@ -1,58 +0,0 @@
//! Overlay: inline editor mode — shows the current input buffer with cursor
//! position and save/dismiss key hints.
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use tracing::debug;
/// Render the Editor overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
debug!(
"Rendering Editor overlay, buffer length: {}",
state.input.buffer.len()
);
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);
}
@@ -1,56 +0,0 @@
//! 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;
use tracing::debug;
/// Render the Effort Level overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let current_idx = current_effort(state);
debug!("Rendering Effort Level overlay, current idx: {current_idx}");
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);
}
@@ -1,27 +0,0 @@
//! Overlay: keyboard shortcut reference.
use crate::view::theme::Theme;
use ratatui::style::Style;
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::Frame;
use tracing::debug;
/// Render the Help overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
debug!(
"Rendering Help overlay, content length: {}",
state.help_text.len()
);
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);
}
@@ -1,61 +0,0 @@
//! Overlay: API key input dialog — prompts the user for a provider API key
//! with masked display (shows first 4 chars only).
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use tracing::debug;
/// Render the API Key input overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let char_count = state.input.buffer.chars().count();
debug!("Rendering API Key overlay, char count: {char_count}");
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 char_count = input_text.chars().count();
let display: String = if input_text.is_empty() {
" Type your API key...".to_string()
} else if char_count > 8 {
input_text.chars().take(4).collect()
} else {
input_text.to_string()
};
let masked = if input_text.is_empty() {
display
} else {
let suffix = if char_count > 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);
}
@@ -1,228 +0,0 @@
//! 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;
use tracing::debug;
/// Render the Learning overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let items = get_learning_items(state);
debug!("Rendering Learning overlay, {} items", items.len());
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.saturating_sub(max_lines).saturating_add(1)
} else {
0
};
let end_idx = (start_idx + max_lines).min(left_lines.len());
let visible_lines = if left_lines.is_empty() || start_idx >= left_lines.len() {
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]);
}
@@ -1,31 +0,0 @@
//! Overlay: loading / processing spinner — shown during blocking operations.
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use tracing::debug;
/// Render the Loading overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
debug!("Rendering Loading overlay, tick: {}", state.misc.tick_count);
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);
}
@@ -1,53 +0,0 @@
//! Overlay: MCP (Model Context Protocol) server management.
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use tracing::debug;
/// Render the MCP Servers overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
debug!(
"Rendering MCP Servers overlay, session dir: {}",
state.session_dir.display()
);
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);
}
@@ -1,129 +0,0 @@
//! 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 diff;
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 plan;
pub mod quit_confirm;
pub mod rewind;
pub mod settings;
pub mod todo;
pub mod usage;
use super::theme::Theme;
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 tracing::debug;
/// 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.
///
/// Dispatches to the appropriate overlay module's `render` function based on the
/// `Overlay` variant. No-ops for `Overlay::None`.
#[tracing::instrument(skip(frame, state))]
pub fn render_overlay(frame: &mut Frame, area: Rect, overlay: Overlay, state: &AppStateRest) {
debug!("Rendering overlay: {overlay:?}");
let overlay_area = centered_rect(area, 75, 70);
frame.render_widget(Clear, overlay_area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(Theme::BORDER_FOCUSED))
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
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::Plan => {
plan::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);
}
Overlay::Diff => {
diff::render(frame, overlay_area, block, state);
}
}
}
@@ -1,72 +0,0 @@
//! 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;
use tracing::debug;
/// Render the Model Selector overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
debug!(
"Rendering Model Selector overlay, current provider: {}, model: {}",
state.settings.provider, state.settings.model
);
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);
}
@@ -1,36 +0,0 @@
//! Overlay: Project Plan view — shows the full project plan content.
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::Frame;
use tracing::debug;
/// Render the Project Plan overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let has_content = !state.misc.plan_content.is_empty();
debug!("Rendering Project Plan overlay, has content: {has_content}");
let block = block
.title(Span::styled(
" Project Plan ",
Style::default()
.fg(Theme::ACCENT_TEAL)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_TEAL));
let content = if state.misc.plan_content.is_empty() {
" No active project plan."
} else {
&state.misc.plan_content
};
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
@@ -1,41 +0,0 @@
//! Overlay: quit confirmation dialog.
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use tracing::debug;
/// Render the Quit confirmation overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
_state: &crate::state::AppStateRest,
) {
debug!("Rendering Quit confirmation overlay");
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);
}
@@ -1,74 +0,0 @@
//! 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 tracing::debug;
use zesdex_domain::core::Role;
/// Render the Rewind overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let msg_count = state.transcript_cache.messages.len();
debug!("Rendering Rewind overlay, {} messages", msg_count);
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.iter().skip(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);
}
@@ -1,63 +0,0 @@
//! Overlay: settings overview — displays the current provider, model,
//! max tokens, temperature, internet mode, and review toggle.
use crate::view::theme::Theme;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use tracing::debug;
/// Render the Settings overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
debug!(
"Rendering Settings overlay, provider: {}, model: {}",
state.settings.provider, state.settings.model
);
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);
}
@@ -1,36 +0,0 @@
//! Overlay: Tasks (todo) view — shows the full todo list content.
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::Frame;
use tracing::debug;
/// Render the Tasks / Todo overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let has_content = !state.misc.todo_content.is_empty();
debug!("Rendering Tasks overlay, has content: {has_content}");
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);
}
@@ -1,114 +0,0 @@
//! 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;
use tracing::debug;
/// Render the Usage overlay.
#[tracing::instrument(skip_all)]
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let has_runtime = state.session_runtime.is_some();
debug!("Rendering Usage overlay, has runtime: {has_runtime}");
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.lessons.total,
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);
}
Loaded 50 of 250 files, more files were not shown because too many files have changed in this diff. Show more