feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
//! The `Action` enum — a single well-typed event in the TUI, produced by
|
||||
//! key input and applied to `AppStateRest` by the event loop.
|
||||
//!
|
||||
//! # Flow
|
||||
//! `controller::input::handle_key` returns `Vec<Action>` → the event loop
|
||||
//! calls `apply_action(&mut state, action)` for each one → state is mutated
|
||||
//! in place.
|
||||
//!
|
||||
//! # Design
|
||||
//! Every state mutation funnels through this single chokepoint so the view
|
||||
//! layer never mutates state directly and the controller never needs to know
|
||||
//! *how* state is updated — only *what* action to produce.
|
||||
|
||||
use crate::state::Overlay;
|
||||
|
||||
/// A single well-typed event in the TUI that mutates `AppStateRest`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Action {
|
||||
/// Hard exit — immediately terminates the process.
|
||||
ForceQuit,
|
||||
/// Submit a user message to the LLM, starting a new agent turn.
|
||||
SubmitInput(String),
|
||||
/// Delete one character before the cursor in the input buffer.
|
||||
DeleteChar,
|
||||
/// Delete one character after the cursor in the input buffer.
|
||||
DeleteCharRight,
|
||||
/// Move the cursor one position left in the input buffer.
|
||||
CursorLeft,
|
||||
/// Move the cursor one position right in the input buffer.
|
||||
CursorRight,
|
||||
/// Navigate up through command history.
|
||||
HistoryUp,
|
||||
/// Navigate down through command history.
|
||||
HistoryDown,
|
||||
/// Scroll the transcript pane up.
|
||||
ScrollUp,
|
||||
/// Scroll the transcript pane down.
|
||||
ScrollDown,
|
||||
/// Open a named overlay.
|
||||
OpenOverlay(Overlay),
|
||||
/// Close the currently active overlay.
|
||||
CloseOverlay,
|
||||
/// Insert a system-generated note into the transcript.
|
||||
SystemNote {
|
||||
/// Note category: "error", "info", "clear", "hive_mind_converged", etc.
|
||||
kind: String,
|
||||
/// The message text to display.
|
||||
message: String,
|
||||
},
|
||||
/// Show the quit-confirmation overlay.
|
||||
QuitConfirm,
|
||||
/// Terminal resize event.
|
||||
Resize(u16, u16),
|
||||
/// Periodic timer tick — drains queued `TurnEvent`s.
|
||||
Tick,
|
||||
/// Accept a lesson (learned behaviour pattern) by name.
|
||||
LessonAccept {
|
||||
name: String,
|
||||
},
|
||||
/// Reject a lesson by name.
|
||||
LessonReject {
|
||||
name: String,
|
||||
},
|
||||
/// Delete a previously stored lesson by name.
|
||||
LessonDelete {
|
||||
name: String,
|
||||
},
|
||||
/// Start the OAuth device-code login flow for a named provider.
|
||||
StartOAuth {
|
||||
provider: String,
|
||||
},
|
||||
/// Open the inline file editor for `path`.
|
||||
OpenEditor {
|
||||
path: String,
|
||||
},
|
||||
/// Register a new MCP server by name and shell command.
|
||||
McpAdd {
|
||||
name: String,
|
||||
command: String,
|
||||
},
|
||||
/// Open the model-picker overlay.
|
||||
ModelList,
|
||||
/// Set the abort flag on the currently running turn.
|
||||
AbortTurn,
|
||||
/// Request AI-summary compaction of the conversation history.
|
||||
Compact,
|
||||
}
|
||||
|
||||
/// Apply an `Action` to `AppStateRest`.
|
||||
///
|
||||
/// Flow: pattern-match the variant → mutate state in place.
|
||||
/// This is the single chokepoint for all state mutations.
|
||||
///
|
||||
/// Return: nothing; `state` is mutated in place.
|
||||
pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
|
||||
tracing::debug!("apply_action: {:?}", action);
|
||||
match action {
|
||||
Action::ForceQuit => {
|
||||
state.quit = true;
|
||||
}
|
||||
Action::QuitConfirm => {
|
||||
state.misc.overlay = crate::state::Overlay::QuitConfirm;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::Resize(_w, _h) => {
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::Tick => {
|
||||
// Drain turn events from the shared queue — collect events first,
|
||||
// then mutate state, to avoid borrow conflicts with the mutex guard.
|
||||
let events: Vec<zesdex_infrastructure::TurnEvent> = state
|
||||
.turn_events
|
||||
.lock()
|
||||
.map(|mut q| q.drain(..).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
zesdex_infrastructure::TurnEvent::SystemNote { kind, message } => {
|
||||
if kind == "hive_mind_converged" {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.hive_mind_converged = true;
|
||||
}
|
||||
} else {
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::System,
|
||||
message,
|
||||
));
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::AssistantMessage(msg) => {
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
msg.role,
|
||||
msg.content.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::ToolResult { output, .. } => {
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::Tool,
|
||||
output,
|
||||
));
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Usage { tokens_in, tokens_out } => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
|
||||
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Error(msg) => {
|
||||
state.toast_error(msg);
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Done => {
|
||||
if let Ok(mut flag) = state.turn_in_flight_flag.lock() {
|
||||
*flag = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Drain expired toasts
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
state.misc.drain_expired_toasts(now);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::SubmitInput(_text) => {
|
||||
state.input.submit();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DeleteChar => {
|
||||
state.input.delete_left();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DeleteCharRight => {
|
||||
state.input.delete_right();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::CursorLeft => {
|
||||
state.input.cursor = state.input.cursor.saturating_sub(1);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::CursorRight => {
|
||||
if state.input.cursor < state.input.buffer.len() {
|
||||
state.input.cursor += 1;
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::HistoryUp => {
|
||||
state.input.history_up();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::HistoryDown => {
|
||||
state.input.history_down();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::ScrollUp => {
|
||||
state.scroll.scroll_up(3);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::ScrollDown => {
|
||||
state.scroll.scroll_down(3);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::OpenOverlay(overlay) => {
|
||||
state.misc.overlay = overlay;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::CloseOverlay => {
|
||||
state.misc.overlay = crate::state::Overlay::None;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::SystemNote { kind: _, message } => {
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::System,
|
||||
message,
|
||||
));
|
||||
}
|
||||
Action::LessonAccept { name } => {
|
||||
state.toast_info(format!("Lesson accepted: {name}"));
|
||||
}
|
||||
Action::LessonReject { name } => {
|
||||
state.toast_info(format!("Lesson rejected: {name}"));
|
||||
}
|
||||
Action::LessonDelete { name } => {
|
||||
state.toast_info(format!("Lesson deleted: {name}"));
|
||||
}
|
||||
Action::StartOAuth { provider } => {
|
||||
state.toast_info(format!("OAuth login started for {provider}"));
|
||||
}
|
||||
Action::OpenEditor { path } => {
|
||||
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
state.misc.editor = Some(crate::state::EditorState::new(
|
||||
std::path::PathBuf::from(&path),
|
||||
content,
|
||||
));
|
||||
state.misc.overlay = crate::state::Overlay::Editor;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::McpAdd { name, command } => {
|
||||
state.toast_info(format!("MCP server added: {name} ({command})"));
|
||||
}
|
||||
Action::ModelList => {
|
||||
state.misc.overlay = crate::state::Overlay::ModelSelector;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::AbortTurn => {
|
||||
state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
state.toast_info("Aborting current turn...".to_string());
|
||||
}
|
||||
Action::Compact => {
|
||||
state.toast_info("Compacting conversation...".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Reusable UI components for the TUI.
|
||||
//!
|
||||
//! This module will grow as shared widgets (buttons, input fields, etc.)
|
||||
//! are extracted from individual overlay and view modules.
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Slash-command parser that maps TUI `/foo` input lines into `Command`
|
||||
//! variants for the action dispatch system.
|
||||
//!
|
||||
//! Flow: the TUI input handler in `controller::input` calls `parse_command`
|
||||
//! on every `/`-prefixed line, then maps the resulting `Command` to an
|
||||
//! `Action` for the event loop to apply to `AppStateRest`.
|
||||
|
||||
/// A parsed slash command from the TUI input buffer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
/// `/help` — show keybindings / help overlay.
|
||||
Help,
|
||||
/// `/quit` — exit the application.
|
||||
Quit,
|
||||
/// `/mcp` (no args) — open MCP configuration panel.
|
||||
McpOpen,
|
||||
/// `/clear` (with args) — clear with a specific scope.
|
||||
Clear,
|
||||
/// `/clear` (no args) — show confirmation prompt before clearing.
|
||||
ClearConfirm,
|
||||
/// `/login <provider>` — trigger OAuth login for the given provider.
|
||||
Login { provider: String },
|
||||
/// `/edit <path>` — open the given file for review/inline editing.
|
||||
Edit(String),
|
||||
/// `/mcp add <name> <command>` — add a new MCP server definition.
|
||||
McpAdd { name: String, command: String },
|
||||
/// `/model` — list available LLM models.
|
||||
ModelList,
|
||||
/// `/compact` — trigger conversation compaction.
|
||||
Compact,
|
||||
/// `/todo` — open the todo-list overlay.
|
||||
TodoOpen,
|
||||
/// `/usage` — open the usage-stats overlay.
|
||||
UsageOpen,
|
||||
/// Catch-all: unrecognised or non-slash input.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
/// Parse a slash-prefixed input line into a `Command` value.
|
||||
///
|
||||
/// Flow: trim -> check for leading `/` -> split on space (max 3 parts) ->
|
||||
/// match the first token against known commands -> extract arguments from
|
||||
/// the remaining parts.
|
||||
pub fn parse_command(text: &str) -> Command {
|
||||
let text = text.trim();
|
||||
if !text.starts_with('/') {
|
||||
return Command::Unknown(text.to_string());
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = text.splitn(3, ' ').collect();
|
||||
let cmd = parts[0];
|
||||
let arg1 = parts.get(1).copied().unwrap_or("");
|
||||
let arg2 = parts.get(2).copied().unwrap_or("");
|
||||
|
||||
let result = match cmd {
|
||||
"/help" => Command::Help,
|
||||
"/quit" => Command::Quit,
|
||||
"/clear" if arg1.is_empty() => Command::ClearConfirm,
|
||||
"/clear" => Command::Clear,
|
||||
"/login" if arg1.is_empty() => Command::Login {
|
||||
provider: String::new(),
|
||||
},
|
||||
"/login" if !arg1.is_empty() => Command::Login {
|
||||
provider: arg1.to_string(),
|
||||
},
|
||||
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
|
||||
"/edit" => Command::Edit(".".to_string()),
|
||||
"/mcp" if arg1.is_empty() => Command::McpOpen,
|
||||
"/mcp" if arg1 == "add" && !arg2.is_empty() => {
|
||||
let rest = arg2.trim();
|
||||
if let Some(space) = rest.find(' ') {
|
||||
let name = rest[..space].to_string();
|
||||
let command = rest[space + 1..].trim().to_string();
|
||||
Command::McpAdd { name, command }
|
||||
} else {
|
||||
Command::McpAdd {
|
||||
name: rest.to_string(),
|
||||
command: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
"/model" => Command::ModelList,
|
||||
"/compact" => Command::Compact,
|
||||
"/todo" => Command::TodoOpen,
|
||||
"/usage" => Command::UsageOpen,
|
||||
_ => Command::Unknown(cmd.to_string()),
|
||||
};
|
||||
|
||||
tracing::debug!(%text, command = ?result, "parse_command");
|
||||
result
|
||||
}
|
||||
|
||||
/// Map a parsed `Command` into `Action` values for the event loop.
|
||||
pub fn apply_command(cmd: Command) -> Vec<crate::action::Action> {
|
||||
match cmd {
|
||||
Command::Help => {
|
||||
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Help)]
|
||||
}
|
||||
Command::Quit => {
|
||||
vec![crate::action::Action::QuitConfirm]
|
||||
}
|
||||
Command::McpOpen => {
|
||||
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Mcp)]
|
||||
}
|
||||
Command::Clear => {
|
||||
vec![crate::action::Action::SystemNote {
|
||||
kind: "clear".to_string(),
|
||||
message: "Transcript cleared.".to_string(),
|
||||
}]
|
||||
}
|
||||
Command::ClearConfirm => {
|
||||
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::ClearConfirm)]
|
||||
}
|
||||
Command::Login { provider } => {
|
||||
vec![crate::action::Action::StartOAuth { provider }]
|
||||
}
|
||||
Command::Edit(path) => {
|
||||
vec![crate::action::Action::OpenEditor { path }]
|
||||
}
|
||||
Command::McpAdd { name, command } => {
|
||||
vec![crate::action::Action::McpAdd { name, command }]
|
||||
}
|
||||
Command::ModelList => {
|
||||
vec![crate::action::Action::ModelList]
|
||||
}
|
||||
Command::Compact => {
|
||||
vec![crate::action::Action::Compact]
|
||||
}
|
||||
Command::TodoOpen => {
|
||||
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Todo)]
|
||||
}
|
||||
Command::UsageOpen => {
|
||||
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Usage)]
|
||||
}
|
||||
Command::Unknown(text) => {
|
||||
if text.starts_with('/') {
|
||||
vec![crate::action::Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
message: format!("Unknown command: {text}"),
|
||||
}]
|
||||
} else {
|
||||
vec![crate::action::Action::SubmitInput(text)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_todo_open() {
|
||||
assert_eq!(parse_command("/todo"), Command::TodoOpen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_usage_open() {
|
||||
assert_eq!(parse_command("/usage"), Command::UsageOpen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Key event dispatcher: maps crossterm `KeyEvent` values into `Action`
|
||||
//! variants, with special handling for overlays, auto-complete, and the
|
||||
//! inline editor.
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. `handle_key` is called on each key press.
|
||||
//! 2. Overlays with full-screen input (Editor, Learning) intercept *all* keys
|
||||
//! before the main match.
|
||||
//! 3. The main match handles navigation, auto-complete, editing, and shortcuts.
|
||||
//! 4. Multi-key actions return `Vec<Action>`.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::controller::command::{apply_command, parse_command};
|
||||
use crate::state::{AutocompleteKind, Overlay, AppStateRest};
|
||||
|
||||
/// Mark state dirty and return an empty action list.
|
||||
fn mark(state: &mut AppStateRest) -> Vec<Action> {
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Translate a terminal `KeyEvent` into zero or more `Action` values
|
||||
/// based on the current application state.
|
||||
///
|
||||
/// Return: `Vec<Action>` so a single key can produce multiple queued actions.
|
||||
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
tracing::debug!(code = ?key.code, mods = ?key.modifiers, overlay = ?state.misc.overlay, "handle_key");
|
||||
|
||||
// ── Editor overlay ───────────────────────────────────────────────────
|
||||
if state.misc.overlay == Overlay::Editor {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return vec![Action::QuitConfirm];
|
||||
}
|
||||
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if let Some(ref ed) = state.misc.editor.clone() {
|
||||
let content = ed.as_string();
|
||||
if let Err(e) = std::fs::write(&ed.path, &content) {
|
||||
state.toast_error(format!("Save failed: {e}"));
|
||||
} else {
|
||||
state.toast_success(format!("Saved {}", ed.path.display()));
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
// Dismiss editor
|
||||
state.misc.editor = None;
|
||||
state.misc.overlay = Overlay::None;
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if let Some(ref mut ed) = state.misc.editor {
|
||||
ed.delete_left();
|
||||
state.mark_dirty();
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(ref mut ed) = state.misc.editor {
|
||||
ed.content.insert(ed.cursor, '\n');
|
||||
ed.cursor += 1;
|
||||
state.mark_dirty();
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let Some(ref mut ed) = state.misc.editor {
|
||||
ed.content.insert(ed.cursor, c);
|
||||
ed.cursor += c.len_utf8();
|
||||
state.mark_dirty();
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
_ => return vec![],
|
||||
}
|
||||
}
|
||||
|
||||
// ── Learning overlay ──────────────────────────────────────────────────
|
||||
if state.misc.overlay == Overlay::Learning {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return vec![Action::QuitConfirm];
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
return vec![Action::CloseOverlay];
|
||||
}
|
||||
KeyCode::Up => {
|
||||
let items = crate::state::get_learning_items(state);
|
||||
let n = items.len();
|
||||
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, false);
|
||||
return mark(state);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let items = crate::state::get_learning_items(state);
|
||||
let n = items.len();
|
||||
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, true);
|
||||
return mark(state);
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('a') => {
|
||||
let items = crate::state::get_learning_items(state);
|
||||
if let Some(crate::state::LearningItem::Pending { name, .. }) =
|
||||
items.get(state.misc.selected_index)
|
||||
{
|
||||
return vec![Action::LessonAccept { name: name.clone() }];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
let items = crate::state::get_learning_items(state);
|
||||
if let Some(crate::state::LearningItem::Pending { name, .. }) =
|
||||
items.get(state.misc.selected_index)
|
||||
{
|
||||
return vec![Action::LessonReject { name: name.clone() }];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete | KeyCode::Backspace => {
|
||||
let items = crate::state::get_learning_items(state);
|
||||
if let Some(item) = items.get(state.misc.selected_index) {
|
||||
match item {
|
||||
crate::state::LearningItem::Pending { name, .. } => {
|
||||
return vec![Action::LessonReject { name: name.clone() }];
|
||||
}
|
||||
crate::state::LearningItem::Stored { name, .. } => {
|
||||
return vec![Action::LessonDelete { name: name.clone() }];
|
||||
}
|
||||
}
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
_ => return vec![],
|
||||
}
|
||||
}
|
||||
|
||||
// ── Normal (non-overlay) dispatch ────────────────────────────────────
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::QuitConfirm]
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::CloseOverlay]
|
||||
}
|
||||
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
let last_assistant = state
|
||||
.transcript_cache
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == zesdex_domain::core::Role::Assistant);
|
||||
match last_assistant {
|
||||
Some(msg) => {
|
||||
state.misc.pending_clipboard_copy = Some(msg.content.clone());
|
||||
}
|
||||
None => {
|
||||
state.toast_info("No assistant message to copy yet".to_string());
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.select_autocomplete();
|
||||
return mark(state);
|
||||
}
|
||||
if state.misc.overlay.is_active() {
|
||||
return handle_overlay_enter(state);
|
||||
}
|
||||
let text = state.input.buffer.clone();
|
||||
if text.starts_with('/') {
|
||||
return apply_command(parse_command(&text));
|
||||
}
|
||||
vec![Action::SubmitInput(text)]
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
return mark(state);
|
||||
}
|
||||
vec![Action::DeleteChar]
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
return mark(state);
|
||||
}
|
||||
vec![Action::DeleteCharRight]
|
||||
}
|
||||
KeyCode::Left => {
|
||||
vec![Action::CursorLeft]
|
||||
}
|
||||
KeyCode::Right => {
|
||||
vec![Action::CursorRight]
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.cycle_autocomplete(false);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::Effort {
|
||||
crate::state::cycle_effort(state, false);
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::Rewind {
|
||||
let n = crate::state::rewind_count(state);
|
||||
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, false);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::ModelSelector {
|
||||
let n = state.app_config.providers.len();
|
||||
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, false);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
vec![Action::ScrollUp]
|
||||
} else {
|
||||
vec![Action::HistoryUp]
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.cycle_autocomplete(true);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::Effort {
|
||||
crate::state::cycle_effort(state, true);
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::Rewind {
|
||||
let n = crate::state::rewind_count(state);
|
||||
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, true);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if state.misc.overlay == Overlay::ModelSelector {
|
||||
let n = state.app_config.providers.len();
|
||||
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, true);
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
vec![Action::ScrollDown]
|
||||
} else {
|
||||
vec![Action::HistoryDown]
|
||||
}
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
vec![Action::ScrollUp]
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
vec![Action::ScrollDown]
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
if state.turn_in_flight() {
|
||||
vec![Action::AbortTurn]
|
||||
} else if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
} else if state.misc.overlay.is_active() {
|
||||
vec![Action::CloseOverlay]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
if state.input.buffer.starts_with('/') {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.cycle_autocomplete(true);
|
||||
} else {
|
||||
state.input.tab_complete();
|
||||
}
|
||||
state.mark_dirty();
|
||||
} else if state.input.autocomplete_kind == AutocompleteKind::FileMention
|
||||
&& state.input.autocomplete_visible
|
||||
{
|
||||
state.input.cycle_autocomplete(true);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
state.mark_dirty();
|
||||
}
|
||||
state.input.insert(c);
|
||||
state.mark_dirty();
|
||||
if state.input.buffer.starts_with('/') {
|
||||
state.input.open_autocomplete();
|
||||
} else if state.input.mention_query_at_cursor().is_some() {
|
||||
state
|
||||
.input
|
||||
.open_mention_autocomplete(&state.mention_index.snapshot());
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle pressing Enter while a modal overlay is active.
|
||||
fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
||||
tracing::debug!(overlay = ?state.misc.overlay, "handle_overlay_enter");
|
||||
match state.misc.overlay {
|
||||
Overlay::Bash => {
|
||||
let command = state.input.buffer.clone();
|
||||
state.toast_info(format!("Submitting bash command: {command}"));
|
||||
state.input.buffer.clear();
|
||||
state.input.cursor = 0;
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::Settings => {
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::Todo => {
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::QuitConfirm => {
|
||||
state.quit = true;
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::KeyInput => {
|
||||
let text = state.input.buffer.clone();
|
||||
if !text.is_empty() {
|
||||
state
|
||||
.settings
|
||||
.api_keys
|
||||
.insert(state.settings.provider.clone(), text);
|
||||
}
|
||||
state.toast_success("API key saved".to_string());
|
||||
state.input.buffer.clear();
|
||||
state.input.cursor = 0;
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.save_settings();
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::Mcp => {
|
||||
state.toast_info("Connecting MCP...".to_string());
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::Rewind => {
|
||||
let idx = state.misc.selected_index;
|
||||
let n = state.transcript_cache.messages.len();
|
||||
if idx < n {
|
||||
let rewind_to = n - idx - 1;
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::System,
|
||||
format!("Rewound to message {rewind_to}"),
|
||||
));
|
||||
}
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::ModelSelector => {
|
||||
let providers: Vec<String> = state.app_config.providers.keys().cloned().collect();
|
||||
if let Some(provider) = providers.get(state.misc.selected_index) {
|
||||
if let Some(cfg) = state.app_config.providers.get(provider) {
|
||||
let model = cfg.default_model.clone().unwrap_or_else(|| {
|
||||
"claude-opus-4-8".to_string()
|
||||
});
|
||||
state.settings.provider.clone_from(provider);
|
||||
state.settings.model.clone_from(&model);
|
||||
if let Some(ref key) = cfg.default_api_key {
|
||||
state.settings.api_keys.insert(provider.clone(), key.clone());
|
||||
} else if let Some(env_key) = cfg
|
||||
.api_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
{
|
||||
state.settings.api_keys.insert(provider.clone(), env_key);
|
||||
}
|
||||
state.save_settings();
|
||||
state.toast_success(format!("Switched to {provider} / {model}"));
|
||||
}
|
||||
}
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::ClearConfirm => {
|
||||
state.toast_info("Transcript cleared".to_string());
|
||||
state.transcript_cache.messages.clear();
|
||||
state.transcript_cache.dirty = true;
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_state() -> AppStateRest {
|
||||
let tmp = std::env::temp_dir().join(format!("zesdex-input-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_y_sets_pending_clipboard_copy_to_last_assistant_message() {
|
||||
let mut state = test_state();
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::User,
|
||||
"hi".to_string(),
|
||||
));
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::Assistant,
|
||||
"first reply".to_string(),
|
||||
));
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::Tool,
|
||||
"tool output".to_string(),
|
||||
));
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::Assistant,
|
||||
"second reply".to_string(),
|
||||
));
|
||||
handle_key(
|
||||
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
|
||||
&mut state,
|
||||
);
|
||||
assert_eq!(
|
||||
state.misc.pending_clipboard_copy,
|
||||
Some("second reply".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_y_with_no_assistant_message_pushes_info_toast() {
|
||||
let mut state = test_state();
|
||||
handle_key(
|
||||
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
|
||||
&mut state,
|
||||
);
|
||||
assert!(state.misc.pending_clipboard_copy.is_none());
|
||||
assert_eq!(state.misc.toasts.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Keyboard input handling and command parsing for the TUI.
|
||||
//!
|
||||
//! The controller layer bridges raw terminal key events (from `crossterm`) to
|
||||
//! application actions. It contains two sub-modules:
|
||||
//!
|
||||
//! - `input` — key-event dispatch, prompt-line editing, history navigation,
|
||||
//! tab-completion, and action invocation.
|
||||
//! - `command` — the `/slash` command parser that translates user-typed
|
||||
//! commands into structured `Action` variants.
|
||||
pub mod command;
|
||||
pub mod input;
|
||||
@@ -0,0 +1,79 @@
|
||||
//! # Zesdex TUI (Terminal User Interface)
|
||||
//!
|
||||
//! This crate provides the terminal UI interface for the Zesdex application,
|
||||
//! built on `ratatui` with `crossterm` for terminal interaction.
|
||||
//!
|
||||
//! It is one of MANY possible user interfaces — others include the HTTP API
|
||||
//! gateway, CLI batch commands, and daemon-mode background processing.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! apps/interfaces/tui/src/
|
||||
//! ├── lib.rs — Crate root: module declarations + re-exports
|
||||
//! ├── state.rs — AppStateRest + all TUI-perspective state types
|
||||
//! ├── action.rs — Action enum + apply_action dispatcher
|
||||
//! ├── view/ — TUI rendering (ratatui widgets)
|
||||
//! │ ├── mod.rs — Main draw function (layered layout)
|
||||
//! │ ├── chat.rs — Chat transcript panel
|
||||
//! │ ├── markdown.rs — Markdown-to-styled-spans renderer
|
||||
//! │ ├── sidebar.rs — Right-hand dashboard sidebar
|
||||
//! │ ├── status.rs — Bottom status bar
|
||||
//! │ ├── theme.rs — Tokyo Night colour palette
|
||||
//! │ ├── workflow.rs — Workflow agent status panel
|
||||
//! │ └── overlays/ — 15 modal overlay panels
|
||||
//! ├── controller/ — Input handling + command parsing
|
||||
//! │ ├── mod.rs
|
||||
//! │ ├── command.rs — /slash command parser
|
||||
//! │ └── input.rs — Key event → Action dispatch
|
||||
//! ├── model/ — Data persistence layer
|
||||
//! │ ├── store.rs — Store path configuration (re-export)
|
||||
//! │ ├── msglog/ — SQLite message-log (schema, insert, blobs)
|
||||
//! │ └── agent_def/ — Agent definitions (builtin/global/session)
|
||||
//! └── components/ — Reusable UI widgets (extensible)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Dependencies
|
||||
//!
|
||||
//! - `zesdex-domain` — Domain entities (Role, ChatMessage, Settings, AppConfig)
|
||||
//! - `zesdex-application` — Application port traits and use-cases
|
||||
//! - `zesdex-infrastructure` — Shared concrete infrastructure types
|
||||
//! (SessionRuntime, Toast, DirCache, TurnEvent, etc.)
|
||||
//! - `ratatui` / `crossterm` — Terminal rendering and raw-key input
|
||||
//! - `pulldown-cmark` — Markdown parsing for message rendering
|
||||
//!
|
||||
//! ## State Flow
|
||||
//!
|
||||
//! 1. `state::AppStateRest` is constructed in the application's main/entry point
|
||||
//! 2. The TUI event loop calls `controller::input::handle_key` on each key press
|
||||
//! 3. `handle_key` returns `Vec<action::Action>` which the loop applies via
|
||||
//! `action::apply_action`
|
||||
//! 4. After each action batch, `view::draw` re-renders the terminal
|
||||
//!
|
||||
//! The state types defined here (`AppStateRest`, `InputState`, `MiscState`,
|
||||
//! `Overlay`, etc.) are TUI-perspective — they represent what the interface
|
||||
//! needs to render, not the full application state.
|
||||
|
||||
// Module declarations
|
||||
pub mod action;
|
||||
pub mod components;
|
||||
pub mod controller;
|
||||
pub mod model;
|
||||
pub mod run;
|
||||
pub mod state;
|
||||
pub mod view;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exports for convenient access by consumers (main.rs / bin entry points)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub use action::{Action, apply_action};
|
||||
pub use run::run_single_process;
|
||||
pub use state::{
|
||||
AgentState, AppStateRest, AutocompleteKind, ChatMessageDisplay, InputState,
|
||||
MiscState, Overlay, ScrollState, SimpleAgent, SimpleWorkflowEngine,
|
||||
TranscriptCache, EditorState,
|
||||
};
|
||||
|
||||
/// Convenience: initialise a `Store` for data directory resolution.
|
||||
pub use zesdex_domain::core::Store;
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner).
|
||||
//!
|
||||
//! These agents are always available regardless of user or session config.
|
||||
//! They provide the default set of roles shipped with the application.
|
||||
//!
|
||||
//! ## Available agents
|
||||
//! | Agent | Purpose | Key tools |
|
||||
//! |-------|---------|-----------|
|
||||
//! | coder | Write/edit code | read, write, edit, bash, lsp_* |
|
||||
//! | reviewer | Review code for correctness/safety | read, grep, lsp_diagnostics |
|
||||
//! | researcher | Search and summarise | read, grep, bash, search_web |
|
||||
//! | planner | Break down tasks into steps | read, write, edit, bash, todo_* |
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Declarative specification for instantiating a subagent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDefinition {
|
||||
/// Human-readable name (e.g. `"quick-reviewer"`).
|
||||
pub name: String,
|
||||
/// Functional role (e.g. `"reviewer"`, `"coder"`).
|
||||
pub role: String,
|
||||
/// Optional system prompt override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt: Option<String>,
|
||||
/// Optional tool allowlist. `None` means role-based defaults.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// Optional step budget. `None` means no limit.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_steps: Option<usize>,
|
||||
/// Optional temperature override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl AgentDefinition {
|
||||
/// Create an agent definition with the required name and role.
|
||||
pub fn new(name: String, role: String) -> Self {
|
||||
AgentDefinition {
|
||||
name,
|
||||
role,
|
||||
system_prompt: None,
|
||||
allowed_tools: None,
|
||||
max_steps: None,
|
||||
temperature: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder: set the system prompt.
|
||||
pub fn with_system_prompt(mut self, prompt: String) -> Self {
|
||||
self.system_prompt = Some(prompt);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set the allowed tool list.
|
||||
pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
|
||||
self.allowed_tools = Some(tools);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set the maximum step count.
|
||||
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||
self.max_steps = Some(steps);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the fixed list of built-in agent definitions shipped with zesdex.
|
||||
pub fn builtin_agents() -> Vec<AgentDefinition> {
|
||||
vec![
|
||||
AgentDefinition::new("coder".to_string(), "coder".to_string())
|
||||
.with_system_prompt(
|
||||
"You are a coding agent. Write correct, idiomatic Rust code.".to_string(),
|
||||
)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
"edit".to_string(),
|
||||
"bash".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"git_operator".to_string(),
|
||||
"lsp_connect".to_string(),
|
||||
"lsp_diagnostics".to_string(),
|
||||
"lsp_hover".to_string(),
|
||||
"lsp_definition".to_string(),
|
||||
"lsp_references".to_string(),
|
||||
"lsp_completion".to_string(),
|
||||
"lsp_disconnect".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
AgentDefinition::new("reviewer".to_string(), "reviewer".to_string())
|
||||
.with_system_prompt(
|
||||
"You are a code reviewer. Focus on correctness, safety, and performance."
|
||||
.to_string(),
|
||||
)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"recall".to_string(),
|
||||
"remember".to_string(),
|
||||
"lsp_diagnostics".to_string(),
|
||||
"lsp_hover".to_string(),
|
||||
"lsp_definition".to_string(),
|
||||
"lsp_references".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
AgentDefinition::new("researcher".to_string(), "researcher".to_string())
|
||||
.with_system_prompt(
|
||||
"You are a research agent. Search for information and summarize findings."
|
||||
.to_string(),
|
||||
)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"bash".to_string(),
|
||||
"search_web".to_string(),
|
||||
"fetch_url".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
AgentDefinition::new("planner".to_string(), "planner".to_string())
|
||||
.with_system_prompt(
|
||||
"You are a planning agent. Break down tasks into clear steps.".to_string(),
|
||||
)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
"edit".to_string(),
|
||||
"bash".to_string(),
|
||||
"todo_write".to_string(),
|
||||
"todo_finish".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Load, save, and remove user-defined agent definitions stored globally
|
||||
//! (under the store's `agents/` directory), independent of any session.
|
||||
use super::builtin::AgentDefinition;
|
||||
|
||||
/// Load all globally-registered agent definitions from disk.
|
||||
///
|
||||
/// Flow: resolve `<store>/agents/` -> read directory -> parse each `*.json`
|
||||
/// file into an `AgentDefinition`, skipping any that fail to read or parse.
|
||||
pub fn load_global_agents() -> Vec<AgentDefinition> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
tracing::debug!(dir = %agents_dir.display(), "load_global_agents");
|
||||
|
||||
if !agents_dir.exists() {
|
||||
tracing::debug!("load_global_agents — agents dir does not exist");
|
||||
return Vec::new();
|
||||
}
|
||||
let mut agents = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "json") {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(def) = serde_json::from_str::<AgentDefinition>(&content) {
|
||||
tracing::debug!(agent = %def.name, "load_global_agents — loaded");
|
||||
agents.push(def);
|
||||
} else {
|
||||
tracing::warn!(file = %path.display(), "load_global_agents — failed to parse JSON");
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(file = %path.display(), "load_global_agents — failed to read file");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(count = agents.len(), "load_global_agents — done");
|
||||
agents
|
||||
}
|
||||
|
||||
/// Persist a global agent definition as `<store>/agents/<name>.json`.
|
||||
pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
let path = agents_dir.join(format!("{}.json", def.name));
|
||||
let tmp = agents_dir.join(format!("{}.json.tmp", def.name));
|
||||
let content = serde_json::to_string_pretty(def)?;
|
||||
tracing::debug!(agent = %def.name, "save_global_agent — writing");
|
||||
std::fs::write(&tmp, content)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
if let Some(parent) = agents_dir.parent() {
|
||||
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
tracing::info!(agent = %def.name, "save_global_agent — saved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a global agent definition by name.
|
||||
pub fn remove_global_agent(name: &str) -> anyhow::Result<bool> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let path = store.base_dir.join("agents").join(format!("{name}.json"));
|
||||
tracing::debug!(%name, path = %path.display(), "remove_global_agent");
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(_) => {
|
||||
tracing::info!(%name, "remove_global_agent — removed");
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::debug!(%name, "remove_global_agent — not found");
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(%name, error = %e, "remove_global_agent — failed");
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Agent definition sources: built-in defaults, global (user-wide), and
|
||||
//! per-session overrides.
|
||||
//!
|
||||
//! Agent definitions control the system prompt, tool set, and configuration
|
||||
//! for each agent. The resolution order (lowest to highest priority) is:
|
||||
//!
|
||||
//! 1. `builtin` — hardcoded default agent shipped with the application.
|
||||
//! 2. `global` — user-wide overrides stored in the config directory.
|
||||
//! 3. `session` — per-session overrides stored in the session directory.
|
||||
pub mod builtin;
|
||||
pub mod global;
|
||||
pub mod session;
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Load, save, add, and remove agent definitions scoped to a single
|
||||
//! session (`<session_dir>/agents.json`).
|
||||
use super::builtin::AgentDefinition;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load agent definitions saved for a specific session.
|
||||
pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
tracing::debug!(file = %agents_file.display(), "load_session_agents");
|
||||
|
||||
if !agents_file.exists() {
|
||||
tracing::debug!("load_session_agents — file does not exist");
|
||||
return Vec::new();
|
||||
}
|
||||
match std::fs::read_to_string(&agents_file) {
|
||||
Ok(content) => {
|
||||
let agents: Vec<AgentDefinition> = serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
tracing::warn!("load_session_agents — failed to parse agents.json: {}", e);
|
||||
Vec::new()
|
||||
});
|
||||
tracing::debug!(count = agents.len(), "load_session_agents — loaded");
|
||||
agents
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "load_session_agents — failed to read");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrite `<session_dir>/agents.json` with the given agent list.
|
||||
pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
let tmp = session_dir.join("agents.json.tmp");
|
||||
let content = serde_json::to_string_pretty(agents)?;
|
||||
tracing::debug!(count = agents.len(), "save_session_agents — writing");
|
||||
|
||||
std::fs::write(&tmp, content)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, agents_file)?;
|
||||
let _ = std::fs::File::open(session_dir).and_then(|d| d.sync_all());
|
||||
|
||||
tracing::info!(count = agents.len(), "save_session_agents — saved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add or replace a session agent definition by name.
|
||||
pub fn add_session_agent(session_dir: &Path, def: &AgentDefinition) -> anyhow::Result<()> {
|
||||
tracing::debug!(agent = %def.name, "add_session_agent");
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
agents.retain(|a| a.name != def.name);
|
||||
agents.push(def.clone());
|
||||
save_session_agents(session_dir, &agents)
|
||||
}
|
||||
|
||||
/// Remove a session agent definition by name.
|
||||
pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<bool> {
|
||||
tracing::debug!(%name, "remove_session_agent");
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
let before = agents.len();
|
||||
agents.retain(|a| a.name != name);
|
||||
if agents.len() == before {
|
||||
tracing::debug!(%name, "remove_session_agent — not found");
|
||||
return Ok(false);
|
||||
}
|
||||
save_session_agents(session_dir, &agents)?;
|
||||
tracing::info!(%name, "remove_session_agent — removed");
|
||||
Ok(true)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Data-model layer for the TUI interface.
|
||||
//!
|
||||
//! This module contains:
|
||||
//! - `store` — Store path configuration
|
||||
//! - `agent_def` — Agent definition model (built-in, global, session scopes)
|
||||
//! - `msglog` — SQLite-backed message-log persistence (schema, insert, blobs)
|
||||
//!
|
||||
//! The `Store` type is re-exported from `zesdex_domain::core::store`.
|
||||
|
||||
pub mod store {
|
||||
//! Re-export `Store` from the domain layer for path resolution.
|
||||
pub use zesdex_domain::core::Store;
|
||||
}
|
||||
|
||||
pub mod agent_def;
|
||||
pub mod msglog;
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Binary blob storage in the message-log `SQLite` database (e.g. images,
|
||||
//! attachments), keyed by session id and an arbitrary blob key.
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
/// Insert or overwrite a blob for a session under `blob_key`.
|
||||
///
|
||||
/// Flow: compute current timestamp -> `INSERT OR REPLACE` into `blobs`
|
||||
/// keyed on `(session_id, blob_key)`.
|
||||
pub fn store_blob(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
blob_key: &str,
|
||||
data: &[u8],
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let created_at = chrono::Utc::now().timestamp_millis();
|
||||
tracing::debug!(%session_id, %blob_key, size = data.len(), "store_blob");
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![session_id, blob_key, data, mime_type, created_at],
|
||||
)?;
|
||||
tracing::info!(%session_id, %blob_key, "store_blob — stored");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a blob's bytes for a session by key.
|
||||
pub fn retrieve_blob(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
blob_key: &str,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
tracing::debug!(%session_id, %blob_key, "retrieve_blob");
|
||||
let result = conn.query_row(
|
||||
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
||||
params![session_id, blob_key],
|
||||
|row| row.get::<_, Vec<u8>>(0),
|
||||
);
|
||||
match result {
|
||||
Ok(data) => {
|
||||
tracing::debug!(%session_id, %blob_key, size = data.len(), "retrieve_blob — found");
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
tracing::debug!(%session_id, %blob_key, "retrieve_blob — not found");
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(%session_id, %blob_key, error = %e, "retrieve_blob — query failed");
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all blob keys stored for a session, oldest first.
|
||||
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
|
||||
tracing::debug!(%session_id, "list_blob_keys");
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC")?;
|
||||
let rows = stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?;
|
||||
let mut keys = Vec::new();
|
||||
for row in rows {
|
||||
keys.push(row?);
|
||||
}
|
||||
tracing::debug!(%session_id, count = keys.len(), "list_blob_keys — done");
|
||||
Ok(keys)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Insert queries against the message log's `messages` table.
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Connection};
|
||||
use zesdex_domain::core::{ChatMessage, Role};
|
||||
|
||||
/// Insert a chat message into the session's message log.
|
||||
///
|
||||
/// Flow: extract optional content/tool_call_id/tool_name -> serialize
|
||||
/// `tool_calls` to a JSON string if present -> map `Role` to its string
|
||||
/// column value -> `INSERT` the row with the current timestamp.
|
||||
///
|
||||
/// Return: the new row's `rowid` on success, or the underlying error.
|
||||
pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result<i64> {
|
||||
let content = msg.content.as_deref();
|
||||
let tool_call_id = msg.tool_call_id.as_deref();
|
||||
let tool_name = msg.name.as_deref();
|
||||
let tool_arguments = msg
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|calls| serde_json::to_string(calls).unwrap_or_default());
|
||||
let created_at = chrono::Utc::now().timestamp_millis();
|
||||
let role_str = match msg.role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
Role::Tool => "tool",
|
||||
};
|
||||
|
||||
tracing::debug!(%session_id, %role_str, content_len = content.map_or(0, str::len), "insert_message");
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, tool_arguments, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![session_id, role_str, content, tool_call_id, tool_name, tool_arguments, created_at],
|
||||
)?;
|
||||
let rowid = conn.last_insert_rowid();
|
||||
tracing::info!(%session_id, %role_str, rowid, "insert_message — inserted");
|
||||
Ok(rowid)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! SQLite-backed message log: per-session `messages.sqlite` storing chat
|
||||
//! messages, blobs, and archive/summary metadata.
|
||||
//!
|
||||
//! ## Tables
|
||||
//! | Table | Purpose |
|
||||
//! |-------|---------|
|
||||
//! | `messages` | Individual chat messages (role, content, tool calls) |
|
||||
//! | `archives` | Session archive metadata (title, model, summary) |
|
||||
//! | `blobs` | Binary attachments keyed by `(session_id, blob_key)` |
|
||||
//!
|
||||
//! All writes use WAL mode for concurrent reads without blocking.
|
||||
pub mod blobs;
|
||||
pub mod insert;
|
||||
pub mod schema;
|
||||
|
||||
pub use blobs::store_blob;
|
||||
pub use insert::insert_message;
|
||||
|
||||
/// Open (creating if needed) a session's `messages.sqlite` and ensure its
|
||||
/// schema is initialized.
|
||||
///
|
||||
/// Flow: resolve `<session_dir>/messages.sqlite` -> create parent dirs ->
|
||||
/// open a `SQLite` connection -> run `schema::init_schema`.
|
||||
///
|
||||
/// Return: an open, schema-ready `Connection`, or an error if any step fails.
|
||||
pub fn open_or_create(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
|
||||
let path = session_dir.join("messages.sqlite");
|
||||
tracing::debug!(?path, "open_or_create");
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let conn = rusqlite::Connection::open(&path)?;
|
||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
|
||||
schema::init_schema(&conn)?;
|
||||
tracing::info!("open_or_create — database ready");
|
||||
Ok(conn)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `SQLite` schema definition for the message log database.
|
||||
use anyhow::Result;
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Create the message log's tables and indexes if they don't already
|
||||
/// exist (`messages`, `archives`, `blobs`).
|
||||
pub fn init_schema(conn: &Connection) -> Result<()> {
|
||||
tracing::debug!("init_schema — creating tables if not exists");
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_name TEXT,
|
||||
tool_arguments TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES archives(session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
model TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
token_count INTEGER DEFAULT 0,
|
||||
summary TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
blob_key TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
mime_type TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(session_id, blob_key)
|
||||
);
|
||||
",
|
||||
)?;
|
||||
tracing::info!("init_schema — schema ready");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! TUI event loop — single-process mode entry point.
|
||||
//!
|
||||
//! Provides `run_single_process()` which sets up the terminal,
|
||||
//! creates a session, and enters the render/input loop.
|
||||
//!
|
||||
//! Flow: create session + lock → enable raw mode + alternate screen →
|
||||
//! run_loop (render → poll events → handle key → tick) →
|
||||
//! restore terminal → save settings → release lock.
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::execute;
|
||||
use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, Event, KeyEventKind, MouseEventKind};
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::action::{apply_action, Action};
|
||||
use crate::controller::input::handle_key;
|
||||
use crate::state::AppStateRest;
|
||||
use crate::view;
|
||||
|
||||
/// Run zesdex as a self-contained TUI + agent loop in one process.
|
||||
///
|
||||
/// Flow: build `AppStateRest` → enter raw mode / alternate screen →
|
||||
/// run the event loop → always restore the terminal (even on error) →
|
||||
/// save settings.
|
||||
pub fn run_single_process() -> Result<()> {
|
||||
// Create session state
|
||||
let (_store, mut state, _rt) = create_local_session()?;
|
||||
|
||||
// Enter raw mode and alternate screen for the TUI
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
|
||||
execute!(stdout, crossterm::event::EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
|
||||
let run_result = run_loop(&mut state, &mut terminal);
|
||||
|
||||
let mut restore_stdout = io::stdout();
|
||||
let _ = execute!(restore_stdout, DisableBracketedPaste);
|
||||
let _ = execute!(restore_stdout, DisableMouseCapture);
|
||||
let _ = execute!(restore_stdout, LeaveAlternateScreen);
|
||||
let _ = disable_raw_mode();
|
||||
|
||||
if let Err(e) = run_result {
|
||||
let _ = writeln!(restore_stdout, "error: {e}");
|
||||
let _ = restore_stdout.flush();
|
||||
}
|
||||
|
||||
// Save settings
|
||||
state.save_settings();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the event loop, guaranteeing terminal restoration on error.
|
||||
fn run_loop(
|
||||
state: &mut AppStateRest,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> Result<()> {
|
||||
let result = run_loop_inner(state, terminal);
|
||||
if let Err(ref _e) = result {
|
||||
let _ = terminal.clear();
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(io::stdout(), DisableBracketedPaste);
|
||||
let _ = execute!(io::stdout(), DisableMouseCapture);
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The core single-process render/input loop.
|
||||
fn run_loop_inner(
|
||||
state: &mut AppStateRest,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
if state.quit {
|
||||
break;
|
||||
}
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
state.misc.drain_expired_toasts(now_ms);
|
||||
terminal.draw(|f| {
|
||||
view::draw(f, state);
|
||||
state.dirty = false;
|
||||
})?;
|
||||
|
||||
// Poll terminal with 50 ms timeout
|
||||
if crossterm::event::poll(Duration::from_millis(50))? {
|
||||
match crossterm::event::read()? {
|
||||
Event::Key(key) => {
|
||||
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
|
||||
let actions = handle_key(key, state);
|
||||
for action in actions {
|
||||
apply_action(state, action);
|
||||
}
|
||||
if let Some(text) = state.misc.pending_clipboard_copy.take() {
|
||||
let _ = zesdex_infrastructure::utils::write_osc52(&mut io::stdout(), &text);
|
||||
state.push_toast(zesdex_infrastructure::Toast::new(
|
||||
zesdex_infrastructure::ToastKind::Success,
|
||||
"Copied to clipboard".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => {
|
||||
if state.input.autocomplete_visible {
|
||||
state.input.close_autocomplete();
|
||||
}
|
||||
state.input.buffer.insert_str(state.input.cursor, &text);
|
||||
state.input.cursor += text.len();
|
||||
if state.input.buffer.starts_with('/') {
|
||||
state.input.open_autocomplete();
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Event::Resize(w, h) => {
|
||||
apply_action(state, Action::Resize(w, h));
|
||||
}
|
||||
Event::Mouse(mouse_event) => {
|
||||
if mouse_event.kind == MouseEventKind::ScrollUp {
|
||||
apply_action(state, Action::ScrollUp);
|
||||
} else if mouse_event.kind == MouseEventKind::ScrollDown {
|
||||
apply_action(state, Action::ScrollDown);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Tick always fires each iteration
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
terminal.clear()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create session state for single-process mode.
|
||||
fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest, tokio::runtime::Runtime)> {
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let session_dir = store.base_dir.join("sessions").join(&session_id);
|
||||
std::fs::create_dir_all(&session_dir)?;
|
||||
|
||||
let workspace_roots = vec![std::env::current_dir()?];
|
||||
let state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
|
||||
Ok((store, state, rt))
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
//! TUI-perspective application state: `AppStateRest` and all the types it
|
||||
//! owns. This is the single source-of-truth struct for the TUI interface,
|
||||
//! mutated from `controller/input.rs` and read by `view/` every render frame.
|
||||
//!
|
||||
//! Infrastructure types (SessionRuntime, DirCache, Toast, etc.) are imported
|
||||
//! from `zesdex_infrastructure`; domain types (Settings, AppConfig, Role)
|
||||
//! come from `zesdex_domain`.
|
||||
//!
|
||||
//! # Flow
|
||||
//! Construction in `lib.rs::create_tui_state` → mutated by key events in
|
||||
//! `controller/input.rs::handle_key` → read-only in every `view/*::draw*`
|
||||
//! function.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::warn;
|
||||
|
||||
use zesdex_domain::cms::{AppConfig, Settings};
|
||||
use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transcript display type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single transcript entry rendered in the TUI chat pane.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ChatMessageDisplay {
|
||||
/// Message author: User or Assistant.
|
||||
pub role: zesdex_domain::core::Role,
|
||||
/// Rendered text content (plain text, no markdown).
|
||||
pub content: String,
|
||||
/// Millisecond timestamp when this display entry was created.
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl ChatMessageDisplay {
|
||||
/// Build a display entry, stamping it with the current time.
|
||||
pub fn new(role: zesdex_domain::core::Role, content: String) -> Self {
|
||||
ChatMessageDisplay {
|
||||
role,
|
||||
content,
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bounded ring-buffer transcript cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bounded ring of recent chat messages used to render the transcript view.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranscriptCache {
|
||||
/// Ordered display messages (newest appended, oldest evicted when full).
|
||||
pub messages: Vec<ChatMessageDisplay>,
|
||||
/// Maximum messages to retain before evicting the oldest.
|
||||
pub max_lines: usize,
|
||||
/// Whether the cache has changed since the last render sweep.
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl TranscriptCache {
|
||||
/// Create an empty transcript cache holding at most `max_lines` messages.
|
||||
pub fn new(max_lines: usize) -> Self {
|
||||
TranscriptCache {
|
||||
messages: Vec::new(),
|
||||
max_lines,
|
||||
dirty: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Viewport scroll state: current offset and visible-line count.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollState {
|
||||
/// Current scroll offset (how many lines have been scrolled past).
|
||||
pub offset: usize,
|
||||
/// Maximum number of lines that fit in the visible viewport area.
|
||||
pub max_visible: usize,
|
||||
}
|
||||
|
||||
impl ScrollState {
|
||||
/// Create a `ScrollState` with zero offset and 30 rows visible.
|
||||
pub fn new() -> Self {
|
||||
ScrollState {
|
||||
offset: 0,
|
||||
max_visible: 30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
||||
pub fn scroll_up(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_add(amount);
|
||||
}
|
||||
|
||||
/// Scroll the viewport down by `amount` lines (decreasing the offset).
|
||||
pub fn scroll_down(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_sub(amount);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScrollState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input state (buffer, cursor, history, autocomplete)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which source populated the autocomplete dropdown.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutocompleteKind {
|
||||
/// Builtin slash-command (e.g. `/model`, `/help`).
|
||||
Command,
|
||||
/// `@file` mention from the workspace file index.
|
||||
FileMention,
|
||||
}
|
||||
|
||||
/// Builtin slash-commands recognised by the chat input autocomplete.
|
||||
const COMMANDS: &[&str] = &[
|
||||
"/help",
|
||||
"/quit",
|
||||
"/clear",
|
||||
"/login",
|
||||
"/login zen",
|
||||
"/login openai",
|
||||
"/edit",
|
||||
"/mcp add",
|
||||
"/model",
|
||||
"/model ls",
|
||||
"/model add",
|
||||
"/todo",
|
||||
"/usage",
|
||||
"/compact",
|
||||
];
|
||||
|
||||
/// The user's input buffer, cursor position, history, and autocomplete
|
||||
/// state for the chat prompt.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InputState {
|
||||
/// Raw UTF-8 input buffer content.
|
||||
pub buffer: String,
|
||||
/// Byte offset of the cursor within `buffer`.
|
||||
pub cursor: usize,
|
||||
/// Previously submitted input lines, oldest-first.
|
||||
pub history: Vec<String>,
|
||||
/// Index into `history` when browsing (None = at the current input).
|
||||
pub history_idx: Option<usize>,
|
||||
/// Current autocomplete candidate list.
|
||||
pub autocomplete_candidates: Vec<String>,
|
||||
/// Focused index within `autocomplete_candidates`.
|
||||
pub autocomplete_idx: usize,
|
||||
/// Whether the autocomplete dropdown is visible.
|
||||
pub autocomplete_visible: bool,
|
||||
/// Which kind of autocomplete is active.
|
||||
pub autocomplete_kind: AutocompleteKind,
|
||||
/// Byte offset of the `@` character that triggered file mention autocomplete.
|
||||
pub mention_start: usize,
|
||||
/// Optional path to a persistent history file.
|
||||
pub history_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// Create an empty input state.
|
||||
pub fn new() -> Self {
|
||||
InputState {
|
||||
buffer: String::new(),
|
||||
cursor: 0,
|
||||
history: Vec::new(),
|
||||
history_idx: None,
|
||||
autocomplete_candidates: Vec::new(),
|
||||
autocomplete_idx: 0,
|
||||
autocomplete_visible: false,
|
||||
autocomplete_kind: AutocompleteKind::Command,
|
||||
mention_start: 0,
|
||||
history_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the autocomplete dropdown and clear its state.
|
||||
pub fn close_autocomplete(&mut self) {
|
||||
self.autocomplete_visible = false;
|
||||
self.autocomplete_candidates.clear();
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_kind = AutocompleteKind::Command;
|
||||
self.mention_start = 0;
|
||||
}
|
||||
|
||||
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`.
|
||||
pub fn open_autocomplete(&mut self) {
|
||||
let trimmed = self.buffer.trim().to_string();
|
||||
if trimmed.is_empty() || !trimmed.starts_with('/') {
|
||||
self.close_autocomplete();
|
||||
return;
|
||||
}
|
||||
let prefix = trimmed.to_lowercase();
|
||||
self.autocomplete_candidates = COMMANDS
|
||||
.iter()
|
||||
.filter(|c| c.starts_with(&prefix))
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
self.autocomplete_kind = AutocompleteKind::Command;
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||
}
|
||||
|
||||
/// Find the `@mention` token (if any) immediately before the cursor.
|
||||
pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> {
|
||||
let before_cursor = &self.buffer[..self.cursor];
|
||||
let at_pos = before_cursor.rfind('@')?;
|
||||
let between = &before_cursor[at_pos + 1..];
|
||||
if between.chars().any(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
let boundary_ok = at_pos == 0
|
||||
|| before_cursor[..at_pos]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(char::is_whitespace);
|
||||
if !boundary_ok {
|
||||
return None;
|
||||
}
|
||||
Some((at_pos, between.to_string()))
|
||||
}
|
||||
|
||||
/// Open or refresh the `@file` mention dropdown from `files`.
|
||||
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
|
||||
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
||||
use nucleo_matcher::{Config, Matcher};
|
||||
let Some((start, query)) = self.mention_query_at_cursor() else {
|
||||
self.close_autocomplete();
|
||||
return;
|
||||
};
|
||||
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
|
||||
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
|
||||
let matched_files = pattern.match_list(files.iter(), &mut matcher);
|
||||
self.autocomplete_candidates = matched_files
|
||||
.into_iter()
|
||||
.take(10)
|
||||
.map(|(f, _)| f.clone())
|
||||
.collect();
|
||||
self.autocomplete_kind = AutocompleteKind::FileMention;
|
||||
self.mention_start = start;
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||
}
|
||||
|
||||
/// Move the autocomplete selection up (forward=false) or down (forward=true).
|
||||
pub fn cycle_autocomplete(&mut self, forward: bool) {
|
||||
let n = self.autocomplete_candidates.len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
if forward {
|
||||
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
|
||||
} else {
|
||||
self.autocomplete_idx = if self.autocomplete_idx == 0 {
|
||||
n - 1
|
||||
} else {
|
||||
self.autocomplete_idx - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept the currently selected autocomplete candidate.
|
||||
pub fn select_autocomplete(&mut self) -> bool {
|
||||
let Some(candidate) = self
|
||||
.autocomplete_candidates
|
||||
.get(self.autocomplete_idx)
|
||||
.cloned()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match self.autocomplete_kind {
|
||||
AutocompleteKind::Command => {
|
||||
self.buffer = candidate;
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
AutocompleteKind::FileMention => {
|
||||
if self.cursor < self.mention_start || self.mention_start > self.buffer.len() {
|
||||
self.close_autocomplete();
|
||||
return false;
|
||||
}
|
||||
let replacement = format!("@{candidate} ");
|
||||
self.buffer
|
||||
.replace_range(self.mention_start..self.cursor, &replacement);
|
||||
self.cursor = self.mention_start + replacement.len();
|
||||
}
|
||||
}
|
||||
self.close_autocomplete();
|
||||
true
|
||||
}
|
||||
|
||||
/// Tab-complete: open dropdown or cycle forward.
|
||||
pub fn tab_complete(&mut self) {
|
||||
if self.autocomplete_visible {
|
||||
self.cycle_autocomplete(true);
|
||||
} else {
|
||||
self.open_autocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
pub fn insert(&mut self, c: char) {
|
||||
self.buffer.insert(self.cursor, c);
|
||||
self.cursor += c.len_utf8();
|
||||
}
|
||||
|
||||
/// Delete the character to the left of the cursor (backspace).
|
||||
pub fn delete_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character at the cursor position (forward delete).
|
||||
pub fn delete_right(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit the current buffer and return the submitted text.
|
||||
pub fn submit(&mut self) -> String {
|
||||
let result = self.buffer.clone();
|
||||
if !result.is_empty() {
|
||||
if self.history.last() != Some(&result) {
|
||||
self.history.push(result.clone());
|
||||
if let Some(ref path) = self.history_file {
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(file, "{result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.history_idx = None;
|
||||
}
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
result
|
||||
}
|
||||
|
||||
/// Navigate backward through input history.
|
||||
pub fn history_up(&mut self) {
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
let idx = match self.history_idx {
|
||||
Some(i) if i > 0 => i - 1,
|
||||
None => self.history.len() - 1,
|
||||
Some(_) => return,
|
||||
};
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
|
||||
/// Navigate forward through input history.
|
||||
pub fn history_down(&mut self) {
|
||||
match self.history_idx {
|
||||
Some(i) if i < self.history.len() - 1 => {
|
||||
let idx = i + 1;
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
Some(_) => {
|
||||
self.history_idx = None;
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InputState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overlay enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which modal overlay, if any, is currently shown over the main TUI view.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Overlay {
|
||||
/// No overlay; the main chat view is shown.
|
||||
None,
|
||||
/// Key bindings help screen.
|
||||
Help,
|
||||
/// Settings/configuration panel.
|
||||
Settings,
|
||||
/// Background bash job viewer.
|
||||
Bash,
|
||||
/// "Are you sure you want to quit?" confirmation.
|
||||
QuitConfirm,
|
||||
/// Raw key-code input capture (for binding custom keys).
|
||||
KeyInput,
|
||||
/// Inline editor (opened via `/edit`).
|
||||
Editor,
|
||||
/// Reasoning effort level selector.
|
||||
Effort,
|
||||
/// MCP server management panel.
|
||||
Mcp,
|
||||
/// TODO list overlay.
|
||||
Todo,
|
||||
/// Session rewind / history scrubber.
|
||||
Rewind,
|
||||
/// Learning / lesson management panel.
|
||||
Learning,
|
||||
/// Token usage statistics panel.
|
||||
Usage,
|
||||
/// Generic loading spinner overlay.
|
||||
Loading,
|
||||
/// Model selector dropdown.
|
||||
ModelSelector,
|
||||
/// "Clear conversation?" confirmation.
|
||||
ClearConfirm,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
/// Human-readable name for this overlay variant.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Overlay::None => "none",
|
||||
Overlay::Help => "help",
|
||||
Overlay::Settings => "settings",
|
||||
Overlay::Bash => "bash",
|
||||
Overlay::QuitConfirm => "quit_confirm",
|
||||
Overlay::KeyInput => "key_input",
|
||||
Overlay::Editor => "editor",
|
||||
Overlay::Effort => "effort",
|
||||
Overlay::Mcp => "mcp",
|
||||
Overlay::Todo => "todo",
|
||||
Overlay::Rewind => "rewind",
|
||||
Overlay::Learning => "learning",
|
||||
Overlay::Usage => "usage",
|
||||
Overlay::Loading => "loading",
|
||||
Overlay::ModelSelector => "model_selector",
|
||||
Overlay::ClearConfirm => "clear_confirm",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any overlay (i.e. anything other than `None`) is active.
|
||||
pub fn is_active(self) -> bool {
|
||||
!matches!(self, Overlay::None)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Overlay {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MiscState — overlay, toasts, flags, tick, editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The "miscellaneous" slice of app state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiscState {
|
||||
/// Currently active modal overlay (None = main chat view).
|
||||
pub overlay: Overlay,
|
||||
/// Active toast notifications.
|
||||
pub toasts: Vec<Toast>,
|
||||
/// Timestamp (ms) of the last staleness sweep for lesson cache.
|
||||
pub last_staleness_sweep_ms: i64,
|
||||
/// Whether the agent is currently "thinking".
|
||||
pub thinking: bool,
|
||||
/// Current LLM reasoning effort level (1-5).
|
||||
pub effort_level: usize,
|
||||
/// Currently focused index in list-type overlays.
|
||||
pub selected_index: usize,
|
||||
/// Optional inline editor state.
|
||||
pub editor: Option<EditorState>,
|
||||
/// Whether the API connection is established.
|
||||
pub api_connected: bool,
|
||||
/// Monotonically increasing tick count, incremented each render frame.
|
||||
pub tick_count: u64,
|
||||
/// Cached content of the TODO file.
|
||||
pub todo_content: String,
|
||||
/// Whether a lesson background task is currently running.
|
||||
pub lesson_running: bool,
|
||||
/// Text waiting to be written to the system clipboard.
|
||||
pub pending_clipboard_copy: Option<String>,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
/// Create a fresh `MiscState` with no overlay, no toasts.
|
||||
pub fn new() -> Self {
|
||||
MiscState {
|
||||
overlay: Overlay::None,
|
||||
toasts: Vec::new(),
|
||||
last_staleness_sweep_ms: 0,
|
||||
thinking: false,
|
||||
effort_level: 1,
|
||||
selected_index: 0,
|
||||
editor: None,
|
||||
api_connected: false,
|
||||
tick_count: 0,
|
||||
todo_content: String::new(),
|
||||
lesson_running: false,
|
||||
pending_clipboard_copy: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a toast notification to the active list.
|
||||
pub fn push_toast(&mut self, toast: Toast) {
|
||||
self.toasts.push(toast);
|
||||
}
|
||||
|
||||
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
|
||||
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<Toast> {
|
||||
let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect();
|
||||
self.toasts.retain(|t| !t.expired(now_ms));
|
||||
expired
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MiscState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EditorState (simplified — used by the Editor overlay)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Simple inline editor state for the TUI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorState {
|
||||
/// Path to the file being edited.
|
||||
pub path: PathBuf,
|
||||
/// Current buffer content.
|
||||
pub content: String,
|
||||
/// Cursor position (byte offset).
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Create a new editor state for the given path.
|
||||
pub fn new(path: PathBuf, content: String) -> Self {
|
||||
let cursor = content.len();
|
||||
EditorState {
|
||||
path,
|
||||
content,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the full buffer content.
|
||||
pub fn as_string(&self) -> String {
|
||||
self.content.clone()
|
||||
}
|
||||
|
||||
/// Delete one character to the left of the cursor.
|
||||
pub fn delete_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
self.content.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentState + SimpleAgent + SimpleWorkflowEngine (workflow display)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Simplified agent lifecycle state for TUI display.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AgentState {
|
||||
Idle,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A single agent entry in the workflow sidebar.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimpleAgent {
|
||||
/// Agent display name.
|
||||
pub name: String,
|
||||
/// Current lifecycle state.
|
||||
pub state: AgentState,
|
||||
/// Millisecond timestamp when the agent started.
|
||||
pub started_at: Option<i64>,
|
||||
/// Millisecond timestamp when the agent completed.
|
||||
pub completed_at: Option<i64>,
|
||||
/// Optional error message if the agent failed.
|
||||
pub error: Option<String>,
|
||||
/// Optional progress text (current tool, step description).
|
||||
pub progress: Option<String>,
|
||||
}
|
||||
|
||||
impl SimpleAgent {
|
||||
/// Create a new agent with the given name.
|
||||
pub fn new(name: String) -> Self {
|
||||
SimpleAgent {
|
||||
name,
|
||||
state: AgentState::Idle,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simplified workflow engine state for TUI display.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimpleWorkflowEngine {
|
||||
/// Active agents in the workflow.
|
||||
pub agents: Vec<SimpleAgent>,
|
||||
/// Summary findings produced by completed agents.
|
||||
pub findings: Vec<String>,
|
||||
}
|
||||
|
||||
impl SimpleWorkflowEngine {
|
||||
/// Create an empty workflow engine state.
|
||||
pub fn new() -> Self {
|
||||
SimpleWorkflowEngine {
|
||||
agents: Vec::new(),
|
||||
findings: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SimpleWorkflowEngine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effort levels (for effort overlay)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Name of each reasoning-effort tier.
|
||||
pub const EFFORT_LEVELS: &[&str] = &[
|
||||
"Auto — let the provider decide",
|
||||
"Low — fast, minimal reasoning",
|
||||
"Medium — balanced speed & reasoning",
|
||||
"High — thorough reasoning",
|
||||
"Maximum — deep analysis",
|
||||
];
|
||||
|
||||
/// Return the current effort index from state.
|
||||
pub fn current_effort(state: &AppStateRest) -> usize {
|
||||
state.misc.effort_level.saturating_sub(1).min(EFFORT_LEVELS.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Cycle effort level up or down.
|
||||
pub fn cycle_effort(state: &mut AppStateRest, _forward: bool) {
|
||||
// Simplified: cycle through levels
|
||||
let n = EFFORT_LEVELS.len();
|
||||
state.misc.effort_level = (state.misc.effort_level % n) + 1;
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Learning item types (for learning overlay)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A lesson entry displayed in the Learning overlay.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LearningItem {
|
||||
/// A newly-generated lesson pending user approval.
|
||||
Pending {
|
||||
name: String,
|
||||
content: String,
|
||||
scope: String,
|
||||
confidence: f64,
|
||||
},
|
||||
/// A lesson that has been accepted and stored.
|
||||
Stored {
|
||||
name: String,
|
||||
content: String,
|
||||
lifecycle: String,
|
||||
scope: String,
|
||||
description: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Return learning items from state (simplified — uses session_runtime data).
|
||||
pub fn get_learning_items(_state: &AppStateRest) -> Vec<LearningItem> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Cycle the selected index within bounds.
|
||||
pub fn cycle_selected_index(current: usize, n: usize, forward: bool) -> usize {
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
if forward {
|
||||
(current + 1) % n
|
||||
} else {
|
||||
if current == 0 { n - 1 } else { current - 1 }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rewind helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Return the number of rewind points available.
|
||||
pub fn rewind_count(state: &AppStateRest) -> usize {
|
||||
state.transcript_cache.messages.len()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context window helpers (stubs for status bar)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve the window size for context window management.
|
||||
pub fn resolve_context_window(
|
||||
_app_config: &zesdex_domain::cms::AppConfig,
|
||||
_settings: &zesdex_domain::cms::Settings,
|
||||
) -> usize {
|
||||
// Default to 128k for most modern models
|
||||
128_000
|
||||
}
|
||||
|
||||
/// Count tokens using tiktoken, fall back to character estimation.
|
||||
pub fn count_tokens(text: &str) -> usize {
|
||||
// Try tiktoken for accurate counting
|
||||
if let Ok(bpe) = tiktoken_rs::cl100k_base() {
|
||||
return bpe.encode_with_special_tokens(text).len();
|
||||
}
|
||||
// Fallback: ~4 chars per token
|
||||
(text.len() + 3) / 4
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppStateRest — the single source-of-truth TUI state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The single source-of-truth state struct for the TUI interface.
|
||||
///
|
||||
/// Mutated from `controller/input.rs` and `actions/mod.rs` (via `Action`).
|
||||
/// Read-only from every `view/*` render function.
|
||||
#[derive(Clone)]
|
||||
pub struct AppStateRest {
|
||||
/// Persistent user settings.
|
||||
pub settings: Settings,
|
||||
/// Per-project app configuration.
|
||||
pub app_config: AppConfig,
|
||||
/// Absolute paths to each open workspace root directory.
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
/// Unique session identifier.
|
||||
pub session_id: String,
|
||||
/// Path to the session's data directory.
|
||||
pub session_dir: PathBuf,
|
||||
/// Path to the session memory directory.
|
||||
pub memory_dir: PathBuf,
|
||||
/// Path to the git worktrees directory.
|
||||
pub worktrees_dir: PathBuf,
|
||||
/// Shared async cache of directory listings.
|
||||
pub dir_cache: Arc<tokio::sync::RwLock<DirCache>>,
|
||||
/// Shared workspace file-path index for `@file` mention autocomplete.
|
||||
pub mention_index: MentionIndex,
|
||||
/// Optional per-session runtime state.
|
||||
pub session_runtime: Option<SessionRuntime>,
|
||||
/// Ring buffer of recent chat messages for the transcript pane.
|
||||
pub transcript_cache: TranscriptCache,
|
||||
/// Viewport scroll offset tracker.
|
||||
pub scroll: ScrollState,
|
||||
/// Chat input buffer, cursor, history, and autocomplete.
|
||||
pub input: InputState,
|
||||
/// Miscellaneous state: overlay, toasts, flags, editor, tick.
|
||||
pub misc: MiscState,
|
||||
/// Queue of events emitted by the running agent turn.
|
||||
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
/// Whether an agent turn is currently in flight.
|
||||
pub turn_in_flight_flag: Arc<Mutex<bool>>,
|
||||
/// Atomic flag set when the user aborts the current turn.
|
||||
pub abort_flag: Arc<AtomicBool>,
|
||||
/// Simplified workflow engine state for display.
|
||||
pub workflow_engine: SimpleWorkflowEngine,
|
||||
/// Whether the state has been modified since the last render sweep.
|
||||
pub dirty: bool,
|
||||
/// Whether the application has been requested to quit.
|
||||
pub quit: bool,
|
||||
/// Cached help text content.
|
||||
pub help_text: &'static str,
|
||||
}
|
||||
|
||||
/// Default help text shown in the Help overlay.
|
||||
pub const DEFAULT_HELP_TEXT: &str = r#" Zesdex TUI — Keyboard Shortcuts
|
||||
|
||||
─── General ───
|
||||
Ctrl+C Quit confirm
|
||||
Ctrl+D Close overlay
|
||||
Ctrl+Y Copy last assistant message
|
||||
Esc Abort turn / Close overlay
|
||||
Tab Autocomplete
|
||||
|
||||
─── Navigation ───
|
||||
↑ / ↓ History browse / Overlay navigate
|
||||
Ctrl+↑/↓ Scroll transcript
|
||||
PgUp / PgDown Scroll transcript
|
||||
Enter Submit / Select autocomplete
|
||||
|
||||
─── Overlays ───
|
||||
/help Show this help
|
||||
/settings Open settings overlay
|
||||
/todo Open tasks (todo) overlay
|
||||
/usage Open usage statistics
|
||||
/bash Open bash jobs overlay
|
||||
/mcp Open MCP server management
|
||||
/model Open model selector
|
||||
/compact Compact conversation
|
||||
/clear Clear transcript
|
||||
/rewind Rewind conversation history
|
||||
|
||||
─── Editor Mode ───
|
||||
/edit <path> Open file for inline editing
|
||||
Ctrl+S Save changes
|
||||
Esc Dismiss editor
|
||||
"#;
|
||||
|
||||
impl AppStateRest {
|
||||
/// Construct initial TUI state.
|
||||
pub fn new(
|
||||
workspace_roots: Vec<PathBuf>,
|
||||
session_dir: &std::path::Path,
|
||||
memory_dir: PathBuf,
|
||||
) -> Self {
|
||||
let settings = Settings::default();
|
||||
let app_config = AppConfig::default();
|
||||
let worktrees_dir = memory_dir
|
||||
.parent()
|
||||
.unwrap_or(&memory_dir)
|
||||
.join("worktrees");
|
||||
let session_id = session_dir.file_name().map_or_else(
|
||||
|| {
|
||||
warn!("[state] session_dir has no file_name, using empty session_id");
|
||||
String::new()
|
||||
},
|
||||
|n| n.to_string_lossy().to_string(),
|
||||
);
|
||||
|
||||
AppStateRest {
|
||||
settings,
|
||||
app_config,
|
||||
workspace_roots,
|
||||
session_id,
|
||||
session_dir: session_dir.to_path_buf(),
|
||||
memory_dir: memory_dir.clone(),
|
||||
worktrees_dir,
|
||||
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
||||
turn_in_flight_flag: Arc::new(Mutex::new(false)),
|
||||
abort_flag: Arc::new(AtomicBool::new(false)),
|
||||
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
|
||||
mention_index: MentionIndex::new(),
|
||||
session_runtime: None,
|
||||
workflow_engine: SimpleWorkflowEngine::new(),
|
||||
transcript_cache: TranscriptCache::new(200),
|
||||
scroll: ScrollState::new(),
|
||||
input: InputState::new(),
|
||||
misc: MiscState::new(),
|
||||
dirty: true,
|
||||
quit: false,
|
||||
help_text: DEFAULT_HELP_TEXT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an agent turn is currently running.
|
||||
pub fn turn_in_flight(&self) -> bool {
|
||||
self.turn_in_flight_flag.lock().map_or_else(
|
||||
|_| {
|
||||
warn!("[state] turn_in_flight mutex poisoned");
|
||||
false
|
||||
},
|
||||
|g| *g,
|
||||
)
|
||||
}
|
||||
|
||||
/// Append a message to the transcript.
|
||||
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
|
||||
self.transcript_cache.messages.push(msg);
|
||||
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
|
||||
self.transcript_cache.messages.remove(0);
|
||||
}
|
||||
self.transcript_cache.dirty = true;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Mark the app state as dirty, triggering a TUI re-render.
|
||||
pub fn mark_dirty(&mut self) {
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Queue a toast notification.
|
||||
pub fn push_toast(&mut self, toast: Toast) {
|
||||
self.misc.push_toast(toast);
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
||||
/// Push an info toast.
|
||||
pub fn toast_info(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Info, msg.into()));
|
||||
}
|
||||
|
||||
/// Push a success toast.
|
||||
pub fn toast_success(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Success, msg.into()));
|
||||
}
|
||||
|
||||
/// Push a warning toast.
|
||||
pub fn toast_warning(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Warning, msg.into()));
|
||||
}
|
||||
|
||||
/// Push an error toast.
|
||||
pub fn toast_error(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Error, msg.into()));
|
||||
}
|
||||
|
||||
/// Persist settings to disk.
|
||||
pub fn save_settings(&self) {
|
||||
if let Ok(store_dir) = std::fs::canonicalize(self.store_base_dir()) {
|
||||
let repo = zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new();
|
||||
use zesdex_domain::SettingsRepository;
|
||||
if let Err(e) = repo.save(&store_dir, &self.settings) {
|
||||
tracing::warn!("Failed to save settings: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the base directory for session stores.
|
||||
pub fn store_base_dir(&self) -> PathBuf {
|
||||
self.session_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map_or_else(
|
||||
|| {
|
||||
warn!("[state] no grandparent, using session_dir");
|
||||
self.session_dir.clone()
|
||||
},
|
||||
std::path::Path::to_path_buf,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Chat transcript panel rendering — tight inline log style.
|
||||
//!
|
||||
//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a dense,
|
||||
//! log-like transcript: each non-tool message gets a one-line
|
||||
//! `{role} {time} {content}` header with wrapped continuation lines
|
||||
//! aligned under the content column; `Role::Tool` messages render as a
|
||||
//! dim `↳`-prefixed sub-line attached to whatever came before.
|
||||
|
||||
use super::theme::Theme;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use zesdex_domain::core::Role;
|
||||
|
||||
const PREFIX_WIDTH: usize = 15;
|
||||
|
||||
fn role_accent_color(role: &Role) -> Color {
|
||||
match role {
|
||||
Role::User => Theme::ROLE_USER,
|
||||
Role::Assistant => Theme::ROLE_ASSISTANT,
|
||||
Role::System => Theme::ROLE_SYSTEM,
|
||||
Role::Tool => Theme::ROLE_TOOL,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_role_label(role: &Role) -> &'static str {
|
||||
match role {
|
||||
Role::User => "👤 you ",
|
||||
Role::Assistant => "🤖 ai ",
|
||||
Role::System => "💻 sys ",
|
||||
Role::Tool => "🔧 tool",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timestamp(ts: i64) -> String {
|
||||
if ts <= 0 {
|
||||
return String::new();
|
||||
}
|
||||
let secs = ts / 1000;
|
||||
let mins = (secs / 60) % 60;
|
||||
let hrs = (secs / 3600) % 24;
|
||||
format!("{hrs:02}:{mins:02}")
|
||||
}
|
||||
|
||||
/// Render the scrollable chat transcript panel in tight inline-log style.
|
||||
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
||||
let messages = &state.transcript_cache.messages;
|
||||
let scroll_offset = state.scroll.offset;
|
||||
let max_visible = (area.height as usize).saturating_sub(3);
|
||||
let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2);
|
||||
|
||||
let mut display_lines: Vec<Line> = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
if msg.role == Role::Tool {
|
||||
let content = if msg.content.trim().is_empty() {
|
||||
"(tool execution)".to_string()
|
||||
} else {
|
||||
msg.content.clone()
|
||||
};
|
||||
let dim = Style::default().fg(Theme::TEXT_DIM);
|
||||
let content_spans = super::markdown::render_markdown(&content, content_width, true);
|
||||
let content_lines = split_spans_into_lines(content_spans);
|
||||
let mut lines_iter = content_lines.into_iter();
|
||||
let first_spans = lines_iter.next().map_or_else(Vec::new, |line| line.spans);
|
||||
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH)), Span::styled("↳ ", dim)];
|
||||
spans.extend(first_spans);
|
||||
display_lines.push(Line::from(spans));
|
||||
for line in lines_iter {
|
||||
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
|
||||
spans.extend(line.spans);
|
||||
display_lines.push(Line::from(spans));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let accent = role_accent_color(&msg.role);
|
||||
let label = format_role_label(&msg.role);
|
||||
let ts_str = format_timestamp(msg.timestamp);
|
||||
let header_prefix = vec![
|
||||
Span::styled(
|
||||
format!("{label} "),
|
||||
Style::default().fg(accent).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{ts_str:<5} "),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
),
|
||||
];
|
||||
|
||||
let content_str = if msg.content.trim().is_empty() {
|
||||
"(tool execution)".to_string()
|
||||
} else {
|
||||
msg.content.clone()
|
||||
};
|
||||
|
||||
let content_spans = super::markdown::render_markdown(&content_str, content_width, false);
|
||||
let content_lines = split_spans_into_lines(content_spans);
|
||||
let mut lines_iter = content_lines.into_iter();
|
||||
|
||||
if let Some(first) = lines_iter.next() {
|
||||
let mut spans = header_prefix;
|
||||
spans.extend(first.spans);
|
||||
display_lines.push(Line::from(spans));
|
||||
} else {
|
||||
display_lines.push(Line::from(header_prefix));
|
||||
}
|
||||
|
||||
for line in lines_iter {
|
||||
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
|
||||
spans.extend(line.spans);
|
||||
display_lines.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming indicator
|
||||
if state.turn_in_flight() {
|
||||
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len();
|
||||
let spinner = spinner_frames[frame_idx];
|
||||
display_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{} ", format_role_label(&Role::Assistant)),
|
||||
Style::default()
|
||||
.fg(Theme::ROLE_ASSISTANT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)),
|
||||
Span::styled(
|
||||
"generating...",
|
||||
Style::default()
|
||||
.fg(Theme::TEXT_MUTED)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Scrolling
|
||||
let title = if messages.is_empty() {
|
||||
String::from(" 💬 Chat ")
|
||||
} else {
|
||||
format!(" 💬 Chat [{} msgs] ", messages.len())
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default()
|
||||
.fg(Theme::TEXT_MUTED)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
|
||||
let total = display_lines.len();
|
||||
let max_offset = total.saturating_sub(max_visible);
|
||||
let offset = scroll_offset.min(max_offset);
|
||||
|
||||
let end_idx = total.saturating_sub(offset);
|
||||
let start_idx = end_idx.saturating_sub(max_visible);
|
||||
let visible: Vec<Line> = if start_idx < end_idx && start_idx < total {
|
||||
display_lines[start_idx..end_idx].to_vec()
|
||||
} else {
|
||||
display_lines[total.saturating_sub(max_visible)..total].to_vec()
|
||||
};
|
||||
|
||||
let scroll_pct = if total > max_visible {
|
||||
((offset as f64 / max_offset as f64) * 100.0) as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let block = if scroll_pct > 0 {
|
||||
let scroll_title = format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct);
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.title(Span::styled(
|
||||
scroll_title,
|
||||
Style::default()
|
||||
.fg(Theme::TEXT_MUTED)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
} else {
|
||||
block
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(visible)
|
||||
.block(block)
|
||||
.style(Style::default().bg(Theme::BG));
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
|
||||
let mut lines = Vec::new();
|
||||
let mut current_spans = Vec::new();
|
||||
for span in spans {
|
||||
let text = span.content.as_ref();
|
||||
let mut parts = text.split('\n').peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
if !part.is_empty() {
|
||||
current_spans.push(Span::styled(part.to_string(), span.style));
|
||||
}
|
||||
if parts.peek().is_some() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_spans)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current_spans.is_empty() {
|
||||
lines.push(Line::from(current_spans));
|
||||
}
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(vec![]));
|
||||
}
|
||||
lines
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
//! Markdown-to-styled-spans rendering for the chat transcript.
|
||||
//!
|
||||
//! Flow: `render_markdown` walks a `pulldown_cmark` event stream and
|
||||
//! translates each markdown construct into styled `ratatui::text::Span`s,
|
||||
//! then re-wraps the flat span list to a target column width.
|
||||
|
||||
use super::theme::Theme;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
|
||||
/// Apply the "tool output" dim/italic style, or pass `style` through
|
||||
/// unchanged, depending on `dim`.
|
||||
fn apply_dim(style: Style, dim: bool) -> Style {
|
||||
if dim {
|
||||
Style::default()
|
||||
.fg(Theme::TEXT_DIM)
|
||||
.add_modifier(Modifier::ITALIC)
|
||||
} else {
|
||||
style
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a single line inside a ` ```diff ` fenced block by its unified-diff
|
||||
/// prefix, returning the color it should always render with.
|
||||
fn diff_line_style(line: &str) -> Option<Style> {
|
||||
if line.starts_with("@@") {
|
||||
Some(Style::default().fg(Theme::INFO).bg(Theme::CODE_BG))
|
||||
} else if line.starts_with('+') && !line.starts_with("+++") {
|
||||
Some(Style::default().fg(Theme::SUCCESS).bg(Theme::CODE_BG))
|
||||
} else if line.starts_with('-') && !line.starts_with("---") {
|
||||
Some(Style::default().fg(Theme::ERROR).bg(Theme::CODE_BG))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a markdown string into styled terminal spans, word-wrapped to `width`.
|
||||
///
|
||||
/// Flow: `pulldown_cmark` parses `text` into an event stream → each
|
||||
/// Start/End/Text/Code/Break event is translated into styled `Span`s →
|
||||
/// if `width > 0`, a second pass wraps long lines.
|
||||
///
|
||||
/// `dim`: when `true`, every span falls back to `Theme::TEXT_DIM` + italic
|
||||
/// (the "tool output" look) *except* lines inside a ` ```diff ` fenced
|
||||
/// block, which always keep their +/-/@@ diff color regardless of `dim`.
|
||||
pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
let mut options = pulldown_cmark::Options::empty();
|
||||
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
|
||||
let parser = pulldown_cmark::Parser::new_ext(text, options);
|
||||
let mut in_code_block = false;
|
||||
let mut in_diff_block = false;
|
||||
let mut in_heading = false;
|
||||
let mut heading_level = 0;
|
||||
|
||||
let mut in_table_cell = false;
|
||||
let mut table_rows: Vec<Vec<Vec<Span<'static>>>> = Vec::new();
|
||||
let mut current_row: Vec<Vec<Span<'static>>> = Vec::new();
|
||||
let mut current_cell: Vec<Span<'static>> = Vec::new();
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
pulldown_cmark::Event::Start(tag) => {
|
||||
match tag {
|
||||
pulldown_cmark::Tag::CodeBlock(kind) => {
|
||||
in_code_block = true;
|
||||
in_diff_block = matches!(
|
||||
&kind,
|
||||
pulldown_cmark::CodeBlockKind::Fenced(lang) if lang.as_ref() == "diff"
|
||||
);
|
||||
spans.push(Span::styled("\n", Style::default()));
|
||||
spans.push(Span::styled(
|
||||
" ┌─ code ",
|
||||
apply_dim(
|
||||
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
|
||||
dim,
|
||||
),
|
||||
));
|
||||
spans.push(Span::styled("\n", Style::default()));
|
||||
}
|
||||
pulldown_cmark::Tag::Heading { level, .. } => {
|
||||
in_heading = true;
|
||||
heading_level = match level {
|
||||
pulldown_cmark::HeadingLevel::H1 => 1,
|
||||
pulldown_cmark::HeadingLevel::H2 => 2,
|
||||
pulldown_cmark::HeadingLevel::H3 => 3,
|
||||
_ => 4,
|
||||
};
|
||||
}
|
||||
pulldown_cmark::Tag::Item => {
|
||||
spans.push(Span::styled(
|
||||
"• ",
|
||||
apply_dim(Style::default().fg(Theme::PRIMARY), dim),
|
||||
));
|
||||
}
|
||||
pulldown_cmark::Tag::Link { dest_url, .. } => {
|
||||
spans.push(Span::styled(
|
||||
"[",
|
||||
apply_dim(Style::default().fg(Theme::INFO), dim),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
format!("]({dest_url})"),
|
||||
apply_dim(
|
||||
Style::default()
|
||||
.fg(Theme::TEXT_MUTED)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
dim,
|
||||
),
|
||||
));
|
||||
}
|
||||
pulldown_cmark::Tag::BlockQuote(_) => {
|
||||
spans.push(Span::styled(
|
||||
"▎",
|
||||
apply_dim(Style::default().fg(Theme::BLOCKQUOTE_BAR), dim),
|
||||
));
|
||||
}
|
||||
pulldown_cmark::Tag::Table(_) => {
|
||||
table_rows.clear();
|
||||
}
|
||||
pulldown_cmark::Tag::TableHead | pulldown_cmark::Tag::TableRow => {
|
||||
current_row.clear();
|
||||
}
|
||||
pulldown_cmark::Tag::TableCell => {
|
||||
in_table_cell = true;
|
||||
current_cell.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pulldown_cmark::Event::End(tag) => {
|
||||
match tag {
|
||||
pulldown_cmark::TagEnd::CodeBlock => {
|
||||
in_code_block = false;
|
||||
in_diff_block = false;
|
||||
spans.push(Span::styled(
|
||||
"\n └─\n",
|
||||
apply_dim(
|
||||
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
|
||||
dim,
|
||||
),
|
||||
));
|
||||
}
|
||||
pulldown_cmark::TagEnd::Heading(_) => {
|
||||
in_heading = false;
|
||||
heading_level = 0;
|
||||
spans.push(Span::raw("\n"));
|
||||
}
|
||||
pulldown_cmark::TagEnd::Paragraph => {
|
||||
spans.push(Span::raw("\n\n"));
|
||||
}
|
||||
pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => {
|
||||
spans.push(Span::raw("\n"));
|
||||
}
|
||||
pulldown_cmark::TagEnd::TableCell => {
|
||||
in_table_cell = false;
|
||||
current_row.push(std::mem::take(&mut current_cell));
|
||||
}
|
||||
pulldown_cmark::TagEnd::TableHead | pulldown_cmark::TagEnd::TableRow => {
|
||||
table_rows.push(std::mem::take(&mut current_row));
|
||||
}
|
||||
pulldown_cmark::TagEnd::Table => {
|
||||
let cols_count = table_rows.first().map_or(0, std::vec::Vec::len);
|
||||
if cols_count == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut col_widths = vec![0; cols_count];
|
||||
for row in &table_rows {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i < cols_count {
|
||||
let cell_width: usize =
|
||||
cell.iter().map(|s| s.content.chars().count()).sum();
|
||||
if cell_width > col_widths[i] {
|
||||
col_widths[i] = cell_width;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let effective_width = if width > 0 {
|
||||
(width as usize).saturating_sub(2)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let border_overhead = cols_count * 3 + 4;
|
||||
let available_width = effective_width.saturating_sub(border_overhead);
|
||||
let mut total_width: usize = col_widths.iter().sum();
|
||||
|
||||
if width > 0 && total_width > available_width && available_width > 0 {
|
||||
while total_width > available_width {
|
||||
let max_idx = col_widths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|&(_, &w)| w)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap();
|
||||
if col_widths[max_idx] <= 3 {
|
||||
break;
|
||||
}
|
||||
col_widths[max_idx] -= 1;
|
||||
total_width -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
spans.push(Span::raw("\n"));
|
||||
for (r, row) in table_rows.iter().enumerate() {
|
||||
let mut cell_lines = Vec::new();
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i < cols_count {
|
||||
cell_lines.push(wrap_spans_to_lines(cell, col_widths[i]));
|
||||
}
|
||||
}
|
||||
let max_height =
|
||||
cell_lines.iter().map(std::vec::Vec::len).max().unwrap_or(1);
|
||||
|
||||
for y in 0..max_height {
|
||||
spans.push(Span::styled(
|
||||
" | ",
|
||||
apply_dim(Style::default().fg(Theme::BORDER), dim),
|
||||
));
|
||||
for (i, cl) in cell_lines.iter().enumerate() {
|
||||
let line_spans =
|
||||
if y < cl.len() { &cl[y] } else { [].as_slice() };
|
||||
let mut line_width = 0;
|
||||
for span in line_spans {
|
||||
line_width += span.content.chars().count();
|
||||
spans.push(span.clone());
|
||||
}
|
||||
let pad = col_widths[i].saturating_sub(line_width);
|
||||
spans.push(Span::raw(" ".repeat(pad)));
|
||||
spans.push(Span::styled(
|
||||
" | ",
|
||||
apply_dim(Style::default().fg(Theme::BORDER), dim),
|
||||
));
|
||||
}
|
||||
spans.push(Span::raw("\n"));
|
||||
}
|
||||
if r == 0 {
|
||||
spans.push(Span::styled(
|
||||
" |",
|
||||
apply_dim(Style::default().fg(Theme::BORDER), dim),
|
||||
));
|
||||
for w in &col_widths {
|
||||
spans.push(Span::styled(
|
||||
format!("{}-|", "-".repeat(*w + 2)),
|
||||
apply_dim(Style::default().fg(Theme::BORDER), dim),
|
||||
));
|
||||
}
|
||||
spans.push(Span::raw("\n"));
|
||||
}
|
||||
}
|
||||
spans.push(Span::raw("\n"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pulldown_cmark::Event::Text(text) => {
|
||||
let s = text.to_string();
|
||||
if in_code_block {
|
||||
if in_diff_block {
|
||||
for (i, line) in s.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::raw("\n"));
|
||||
}
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let style = diff_line_style(line).unwrap_or_else(|| {
|
||||
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG)
|
||||
});
|
||||
spans.push(Span::styled(format!(" {line}"), style));
|
||||
}
|
||||
} else {
|
||||
let indented = format!(" {}", s.replace('\n', "\n "));
|
||||
spans.push(Span::styled(
|
||||
indented,
|
||||
apply_dim(
|
||||
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
|
||||
dim,
|
||||
),
|
||||
));
|
||||
}
|
||||
} else if in_heading {
|
||||
let color = match heading_level {
|
||||
1 => Theme::PRIMARY,
|
||||
2 => Theme::INFO,
|
||||
3 => Theme::ACCENT_PURPLE,
|
||||
_ => Theme::TEXT,
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
s,
|
||||
apply_dim(Style::default().fg(color).add_modifier(Modifier::BOLD), dim),
|
||||
));
|
||||
} else if in_table_cell {
|
||||
current_cell.push(Span::styled(s, apply_dim(Style::default(), dim)));
|
||||
} else {
|
||||
spans.push(Span::styled(s, apply_dim(Style::default(), dim)));
|
||||
}
|
||||
}
|
||||
pulldown_cmark::Event::Code(text) => {
|
||||
let span = Span::styled(
|
||||
format!(" {text} "),
|
||||
apply_dim(
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_TEAL)
|
||||
.bg(Theme::CODE_BAR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
dim,
|
||||
),
|
||||
);
|
||||
if in_table_cell {
|
||||
current_cell.push(span);
|
||||
} else {
|
||||
spans.push(span);
|
||||
}
|
||||
}
|
||||
pulldown_cmark::Event::SoftBreak => {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
pulldown_cmark::Event::HardBreak => {
|
||||
spans.push(Span::raw("\n"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if width > 0 {
|
||||
let mut spans_out = Vec::new();
|
||||
let mut line_len = 0;
|
||||
let effective_width = (width as usize).saturating_sub(2);
|
||||
|
||||
for span in spans {
|
||||
let style = span.style;
|
||||
let text = span.content.as_ref();
|
||||
|
||||
let mut current = String::new();
|
||||
let mut tokens = Vec::new();
|
||||
for c in text.chars() {
|
||||
if c == ' ' {
|
||||
if !current.is_empty() {
|
||||
tokens.push(current.clone());
|
||||
current.clear();
|
||||
}
|
||||
tokens.push(" ".to_string());
|
||||
} else if c == '\n' {
|
||||
if !current.is_empty() {
|
||||
tokens.push(current.clone());
|
||||
current.clear();
|
||||
}
|
||||
tokens.push("\n".to_string());
|
||||
} else {
|
||||
current.push(c);
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
for token in tokens {
|
||||
if token == "\n" {
|
||||
spans_out.push(Span::styled("\n", style));
|
||||
line_len = 0;
|
||||
} else if token == " " {
|
||||
if line_len > 0 && line_len < effective_width {
|
||||
spans_out.push(Span::styled(" ", style));
|
||||
line_len += 1;
|
||||
}
|
||||
} else {
|
||||
let token_len = token.chars().count();
|
||||
if line_len + token_len > effective_width && line_len > 0 {
|
||||
spans_out.push(Span::raw("\n"));
|
||||
line_len = 0;
|
||||
}
|
||||
if token_len > effective_width {
|
||||
for c in token.chars() {
|
||||
if line_len >= effective_width {
|
||||
spans_out.push(Span::raw("\n"));
|
||||
line_len = 0;
|
||||
}
|
||||
spans_out.push(Span::styled(c.to_string(), style));
|
||||
line_len += 1;
|
||||
}
|
||||
} else {
|
||||
spans_out.push(Span::styled(token, style));
|
||||
line_len += token_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
spans = spans_out;
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
fn wrap_spans_to_lines(spans: &[Span<'static>], target_width: usize) -> Vec<Vec<Span<'static>>> {
|
||||
let mut lines = Vec::new();
|
||||
let mut current_line = Vec::new();
|
||||
let mut line_len = 0;
|
||||
|
||||
for span in spans {
|
||||
let style = span.style;
|
||||
let text = span.content.as_ref();
|
||||
let mut current_word = String::new();
|
||||
let mut tokens = Vec::new();
|
||||
|
||||
for c in text.chars() {
|
||||
if c == ' ' {
|
||||
if !current_word.is_empty() {
|
||||
tokens.push(current_word.clone());
|
||||
current_word.clear();
|
||||
}
|
||||
tokens.push(" ".to_string());
|
||||
} else {
|
||||
current_word.push(c);
|
||||
}
|
||||
}
|
||||
if !current_word.is_empty() {
|
||||
tokens.push(current_word);
|
||||
}
|
||||
|
||||
for token in tokens {
|
||||
if token == " " {
|
||||
if line_len > 0 && line_len < target_width {
|
||||
current_line.push(Span::styled(" ", style));
|
||||
line_len += 1;
|
||||
}
|
||||
} else {
|
||||
let token_len = token.chars().count();
|
||||
if line_len + token_len > target_width && line_len > 0 {
|
||||
lines.push(std::mem::take(&mut current_line));
|
||||
line_len = 0;
|
||||
}
|
||||
if token_len > target_width {
|
||||
for c in token.chars() {
|
||||
if target_width > 0 && line_len >= target_width {
|
||||
lines.push(std::mem::take(&mut current_line));
|
||||
line_len = 0;
|
||||
}
|
||||
current_line.push(Span::styled(c.to_string(), style));
|
||||
line_len += 1;
|
||||
}
|
||||
} else {
|
||||
current_line.push(Span::styled(token, style));
|
||||
line_len += token_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current_line.is_empty() {
|
||||
lines.push(current_line);
|
||||
}
|
||||
if lines.is_empty() {
|
||||
lines.push(vec![]);
|
||||
}
|
||||
lines
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Top-level TUI render pipeline: layouts the terminal into chat / input
|
||||
//! / status regions, dispatches overlay rendering with glassmorphism-style
|
||||
//! centered panels, and floats toast notifications over the top-right corner.
|
||||
|
||||
pub mod chat;
|
||||
pub mod markdown;
|
||||
pub mod sidebar;
|
||||
pub mod status;
|
||||
pub mod theme;
|
||||
pub mod workflow;
|
||||
pub mod overlays;
|
||||
|
||||
use crate::state::AppStateRest;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
use theme::Theme;
|
||||
use zesdex_infrastructure::ToastKind;
|
||||
|
||||
const SIDEBAR_MIN_WIDTH: u16 = 90;
|
||||
|
||||
/// Top-level render entry point called once per TUI frame.
|
||||
pub fn draw(frame: &mut Frame, state: &AppStateRest) {
|
||||
let area = frame.area();
|
||||
|
||||
let show_sidebar = area.width > SIDEBAR_MIN_WIDTH;
|
||||
let (main_area, sidebar_area) = if show_sidebar {
|
||||
let has_workflow = !state.workflow_engine.agents.is_empty();
|
||||
let sidebar_width = if has_workflow { 48 } else { 30 };
|
||||
let h_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(40), Constraint::Length(sidebar_width)])
|
||||
.split(area);
|
||||
(h_chunks[0], Some(h_chunks[1]))
|
||||
} else {
|
||||
(area, None)
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(main_area);
|
||||
|
||||
let chat_area = chunks[0];
|
||||
let input_area = chunks[1];
|
||||
let status_area = chunks[2];
|
||||
|
||||
if state.misc.overlay.is_active() {
|
||||
let overlay = state.misc.overlay;
|
||||
overlays::render_overlay(frame, chat_area, overlay, state);
|
||||
} else {
|
||||
render_main_panel(frame, chat_area, state);
|
||||
}
|
||||
|
||||
render_input_bar(frame, input_area, state);
|
||||
status::draw_status_bar(frame, status_area, state);
|
||||
|
||||
if let Some(sidebar_rect) = sidebar_area {
|
||||
sidebar::draw_sidebar(frame, sidebar_rect, state);
|
||||
}
|
||||
|
||||
render_toasts(frame, state);
|
||||
}
|
||||
|
||||
fn render_main_panel(frame: &mut Frame, area: Rect, state: &AppStateRest) {
|
||||
chat::draw_chat(frame, area, state);
|
||||
}
|
||||
|
||||
fn render_input_bar(frame: &mut Frame, area: Rect, state: &AppStateRest) {
|
||||
if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() {
|
||||
let n = state.input.autocomplete_candidates.len().min(10) as u16;
|
||||
let dropdown_height = n + 2;
|
||||
let dropdown_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y.saturating_sub(dropdown_height),
|
||||
width: area.width.min(45),
|
||||
height: dropdown_height,
|
||||
};
|
||||
let dropdown_title = match state.input.autocomplete_kind {
|
||||
crate::state::AutocompleteKind::Command => " ⌘ Commands ",
|
||||
crate::state::AutocompleteKind::FileMention => " 📁 Files ",
|
||||
};
|
||||
let dropdown_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.title(Span::styled(
|
||||
dropdown_title,
|
||||
Style::default().fg(Theme::PRIMARY),
|
||||
))
|
||||
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let selected = state.input.autocomplete_idx;
|
||||
for (i, candidate) in state
|
||||
.input
|
||||
.autocomplete_candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(10)
|
||||
{
|
||||
let prefix = if i == selected { " ▸ " } else { " " };
|
||||
let style = if i == selected {
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.bg(Theme::HIGHLIGHT_DIM)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Theme::TEXT)
|
||||
};
|
||||
let label = format!("{prefix}{candidate}");
|
||||
lines.push(Line::from(Span::styled(label, style)));
|
||||
}
|
||||
let dropdown = Paragraph::new(lines).block(dropdown_block);
|
||||
frame.render_widget(dropdown, dropdown_area);
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.style(Style::default().bg(Theme::SURFACE));
|
||||
|
||||
let input_text = &state.input.buffer;
|
||||
let cursor_pos = state.input.cursor;
|
||||
|
||||
let prompt = Span::styled(
|
||||
" ❯ ",
|
||||
Style::default()
|
||||
.fg(Theme::PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let mut spans = vec![prompt];
|
||||
|
||||
if input_text.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
"Type a message or /command...",
|
||||
Style::default()
|
||||
.fg(Theme::TEXT_DIM)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
} else {
|
||||
let (before, after) = input_text.split_at(cursor_pos);
|
||||
spans.push(Span::raw(before.to_string()));
|
||||
let cursor_char = if after.is_empty() { " " } else { &after[..1] };
|
||||
spans.push(Span::styled(
|
||||
cursor_char,
|
||||
Style::default()
|
||||
.bg(Theme::HIGHLIGHT)
|
||||
.fg(Theme::BG)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
if after.len() > 1 {
|
||||
spans.push(Span::raw(after[1..].to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(line).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let active: Vec<&zesdex_infrastructure::Toast> = state
|
||||
.misc
|
||||
.toasts
|
||||
.iter()
|
||||
.filter(|t| !t.expired(now_ms))
|
||||
.collect();
|
||||
if active.is_empty() {
|
||||
return;
|
||||
}
|
||||
let area = frame.area();
|
||||
let toast_w: u16 = 48;
|
||||
let x = area.width.saturating_sub(toast_w).saturating_sub(2);
|
||||
let mut y: u16 = 1;
|
||||
|
||||
for toast in active.iter().rev().take(4) {
|
||||
let line_count = toast.message.lines().count().max(1) as u16;
|
||||
let h = line_count + 2;
|
||||
let toast_area = Rect {
|
||||
x,
|
||||
y,
|
||||
width: toast_w,
|
||||
height: h,
|
||||
};
|
||||
if toast_area.bottom() > area.height {
|
||||
break;
|
||||
}
|
||||
|
||||
frame.render_widget(Clear, toast_area);
|
||||
|
||||
let (border_color, icon) = match toast.kind {
|
||||
ToastKind::Success => (Theme::SUCCESS, " ✓ "),
|
||||
ToastKind::Warning => (Theme::WARNING, " ⚠ "),
|
||||
ToastKind::Error => (Theme::ERROR, " ✗ "),
|
||||
ToastKind::Info => (Theme::INFO, " ℹ "),
|
||||
ToastKind::Lesson => (Theme::ACCENT_PURPLE, " 📘 "),
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(border_color))
|
||||
.title(Span::styled(icon, Style::default().fg(border_color)))
|
||||
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
|
||||
|
||||
let paragraph = Paragraph::new(toast.message.as_str())
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
frame.render_widget(paragraph, toast_area);
|
||||
y = y.saturating_add(h).saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `items` into the slice that fits within `max_visible` entries and
|
||||
/// the count of items hidden beyond that limit.
|
||||
pub(crate) fn split_for_display<T>(items: &[T], max_visible: usize) -> (&[T], usize) {
|
||||
if items.len() <= max_visible {
|
||||
(items, 0)
|
||||
} else {
|
||||
(&items[..max_visible], items.len() - max_visible)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the dim trailing hint line a sidebar widget shows when its
|
||||
/// content is truncated.
|
||||
pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static> {
|
||||
Line::from(Span::styled(
|
||||
format!(" +{hidden} more — {command}"),
|
||||
Style::default()
|
||||
.fg(Theme::TEXT_DIM)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Overlay: list of active / completed bash background jobs.
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Bash Jobs overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = super::overlay_block(block, "Bash Jobs", Theme::ACCENT_ORANGE);
|
||||
let lines: Vec<Line> = state
|
||||
.session_runtime
|
||||
.as_ref()
|
||||
.map(|r| {
|
||||
r.bash_jobs
|
||||
.iter()
|
||||
.map(|job| {
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" [{}] {} — {}",
|
||||
job.id,
|
||||
job.command,
|
||||
if job.running { "running" } else { "done" },
|
||||
),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let paragraph = if lines.is_empty() {
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
" No active bash jobs.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)))
|
||||
.block(block)
|
||||
} else {
|
||||
Paragraph::new(lines).block(block)
|
||||
};
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Overlay: confirm-before-clear dialog for the chat transcript.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Clear Transcript confirmation dialog.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
_state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Clear Transcript ",
|
||||
Style::default()
|
||||
.fg(Theme::WARNING)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::WARNING));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Clear all messages from the transcript?",
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
" Enter to confirm · Esc to cancel",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Overlay: inline editor mode — shows the current input buffer with cursor
|
||||
//! position and save/dismiss key hints.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Editor overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Editor ",
|
||||
Style::default()
|
||||
.fg(Theme::PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::PRIMARY));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Editor Mode — Ctrl+S save, Esc dismiss",
|
||||
Style::default()
|
||||
.fg(Theme::TEXT_MUTED)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
" Buffer:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" {}", state.input.buffer),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" Cursor: pos {} / {}",
|
||||
state.input.cursor,
|
||||
state.input.buffer.len()
|
||||
),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Overlay: effort-level selector — lets the user pick a reasoning/quality tier.
|
||||
use crate::state::{current_effort, EFFORT_LEVELS};
|
||||
use crate::view::theme::Theme;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Render the Effort Level overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Effort Level ",
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_PURPLE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
||||
let levels = EFFORT_LEVELS;
|
||||
let current_idx = current_effort(state);
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(
|
||||
" Use ↑↓ to change effort level",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
];
|
||||
for (i, l) in levels.iter().enumerate() {
|
||||
let selected = i == current_idx;
|
||||
lines.push(Line::from(Span::styled(
|
||||
if selected {
|
||||
format!(" ▸ {l} (active)")
|
||||
} else {
|
||||
format!(" {l}")
|
||||
},
|
||||
if selected {
|
||||
Style::default()
|
||||
.fg(Theme::HIGHLIGHT)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Theme::TEXT)
|
||||
},
|
||||
)));
|
||||
}
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Overlay: keyboard shortcut reference.
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Help overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = super::overlay_block(block, "Help", Theme::INFO);
|
||||
let content = state.help_text;
|
||||
let paragraph = Paragraph::new(content)
|
||||
.block(block)
|
||||
.style(Style::default().bg(Theme::BG))
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Overlay: API key input dialog — prompts the user for a provider API key
|
||||
//! with masked display (shows first 4 chars only).
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the API Key input overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" API Key ",
|
||||
Style::default()
|
||||
.fg(Theme::WARNING)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::WARNING));
|
||||
let input_text = &state.input.buffer;
|
||||
let display = if input_text.is_empty() {
|
||||
" Type your API key..."
|
||||
} else {
|
||||
if input_text.len() > 8 {
|
||||
&input_text[..4]
|
||||
} else {
|
||||
input_text.as_str()
|
||||
}
|
||||
};
|
||||
let masked = if input_text.is_empty() {
|
||||
display.to_string()
|
||||
} else {
|
||||
let suffix = if input_text.len() > 8 { "****" } else { "" };
|
||||
format!("{display}{suffix}")
|
||||
};
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Enter API key for authentication:",
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(vec![
|
||||
Span::styled(" Key: ", Style::default().fg(Theme::TEXT_DIM)),
|
||||
Span::styled(
|
||||
masked,
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Overlay: Learning / lesson management — two-panel view with a scrollable
|
||||
//! lesson list (left) and detail pane (right).
|
||||
use crate::state::{get_learning_items, LearningItem};
|
||||
use crate::view::theme::Theme;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Render the Learning overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
drop(block);
|
||||
|
||||
let h_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
|
||||
.split(area);
|
||||
|
||||
let left_block = Block::default()
|
||||
.title(Span::styled(
|
||||
" Lessons ",
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_PURPLE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.style(Style::default().bg(Theme::BG));
|
||||
|
||||
let right_block = Block::default()
|
||||
.title(Span::styled(
|
||||
" Details ",
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.style(Style::default().bg(Theme::BG));
|
||||
|
||||
let items = get_learning_items(state);
|
||||
let mut left_lines = Vec::new();
|
||||
if items.is_empty() {
|
||||
left_lines.push(Line::from(Span::styled(
|
||||
" No lessons found.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
} else {
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let is_selected = i == state.misc.selected_index;
|
||||
let prefix = if is_selected { " ▸ " } else { " " };
|
||||
let (label, style) = match item {
|
||||
LearningItem::Pending { name, .. } => (
|
||||
format!("{prefix}[Pending] {name}"),
|
||||
if is_selected {
|
||||
Style::default()
|
||||
.fg(Theme::WARNING)
|
||||
.bg(Theme::HIGHLIGHT_DIM)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Theme::WARNING)
|
||||
},
|
||||
),
|
||||
LearningItem::Stored { name, lifecycle, .. } => {
|
||||
let status = if lifecycle == "stale" { "Stale" } else { "Active" };
|
||||
(
|
||||
format!("{prefix}[{status}] {name}"),
|
||||
if is_selected {
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.bg(Theme::HIGHLIGHT_DIM)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Theme::TEXT)
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
left_lines.push(Line::from(Span::styled(label, style)));
|
||||
}
|
||||
}
|
||||
|
||||
let max_lines = h_chunks[0].height.saturating_sub(2) as usize;
|
||||
let selected = state.misc.selected_index;
|
||||
let start_idx = if selected >= max_lines {
|
||||
selected - max_lines + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end_idx = (start_idx + max_lines).min(left_lines.len());
|
||||
let visible_lines = if left_lines.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
left_lines[start_idx..end_idx].to_vec()
|
||||
};
|
||||
|
||||
let left_paragraph = Paragraph::new(visible_lines).block(left_block);
|
||||
frame.render_widget(left_paragraph, h_chunks[0]);
|
||||
|
||||
let mut right_lines = Vec::new();
|
||||
if let Some(item) = items.get(selected) {
|
||||
match item {
|
||||
LearningItem::Pending { name, content, scope, confidence } => {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Name:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {name}"),
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Status: Pending Approval",
|
||||
Style::default().fg(Theme::WARNING),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Scope: {scope}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Confidence: {confidence}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Content:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
for line in content.lines() {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {line}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
}
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" [Enter]/[a] Accept · [r]/[Del] Reject",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
LearningItem::Stored { name, content, lifecycle, scope, description } => {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Name:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {name}"),
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
let status_color = if lifecycle == "stale" { Theme::WARNING } else { Theme::SUCCESS };
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Status: {lifecycle}"),
|
||||
Style::default().fg(status_color),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Scope: {scope}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Description: {description}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Content:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
for line in content.lines() {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {line}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
}
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" [d]/[Del] Delete Lesson",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Select a lesson on the left.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
let right_paragraph = Paragraph::new(right_lines)
|
||||
.block(right_block)
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(right_paragraph, h_chunks[1]);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Overlay: loading / processing spinner — shown during blocking operations.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Loading overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Loading ",
|
||||
Style::default()
|
||||
.fg(Theme::WARNING)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::WARNING));
|
||||
let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let frame_idx = (state.misc.tick_count as usize) % spinner.len();
|
||||
let content = format!(" {} Processing, please wait...", spinner[frame_idx]);
|
||||
let paragraph = Paragraph::new(content).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Overlay: MCP (Model Context Protocol) server management.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the MCP Servers overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" MCP Servers ",
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::INFO));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" MCP Server Management",
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
format!(" Session dir: {}", state.session_dir.display()),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
" No MCP servers configured.",
|
||||
Style::default().fg(Theme::TEXT_MUTED),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
" Press Ctrl+P to configure provider settings.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Overlay rendering: each overlay variant gets its own module with a
|
||||
//! `pub fn render(frame, area, block, state)` entry point, dispatched by
|
||||
//! the top-level `render_overlay` function in this module.
|
||||
|
||||
pub mod bash;
|
||||
pub mod clear_confirm;
|
||||
pub mod editor;
|
||||
pub mod effort;
|
||||
pub mod help;
|
||||
pub mod key_input;
|
||||
pub mod learning;
|
||||
pub mod loading;
|
||||
pub mod mcp;
|
||||
pub mod model_selector;
|
||||
pub mod quit_confirm;
|
||||
pub mod rewind;
|
||||
pub mod settings;
|
||||
pub mod todo;
|
||||
pub mod usage;
|
||||
|
||||
use crate::state::{AppStateRest, Overlay};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Borders, Clear};
|
||||
use ratatui::Frame;
|
||||
use super::theme::Theme;
|
||||
|
||||
/// Decorate an overlay block with a styled title and matching border color.
|
||||
pub fn overlay_block(block: Block<'static>, title: &str, color: ratatui::style::Color) -> Block<'static> {
|
||||
block
|
||||
.title(Span::styled(
|
||||
format!(" {title} "),
|
||||
Style::default()
|
||||
.fg(color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(color))
|
||||
}
|
||||
|
||||
/// Compute a centered rectangle within `area` at the given percentage width and height.
|
||||
pub fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
|
||||
let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2;
|
||||
let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2;
|
||||
|
||||
Rect {
|
||||
x: area.x.saturating_add(x_pad),
|
||||
y: area.y.saturating_add(y_pad),
|
||||
width: area.width.saturating_sub(x_pad * 2).max(40),
|
||||
height: area.height.saturating_sub(y_pad * 2).max(10),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the active modal overlay as a centered panel.
|
||||
pub fn render_overlay(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
overlay: Overlay,
|
||||
state: &AppStateRest,
|
||||
) {
|
||||
let overlay_area = centered_rect(area, 75, 70);
|
||||
frame.render_widget(Clear, overlay_area);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.style(Style::default().bg(Theme::BG));
|
||||
|
||||
match overlay {
|
||||
Overlay::None => {}
|
||||
Overlay::Help => {
|
||||
help::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Settings => {
|
||||
settings::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Bash => {
|
||||
bash::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::QuitConfirm => {
|
||||
quit_confirm::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::KeyInput => {
|
||||
key_input::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Editor => {
|
||||
editor::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Effort => {
|
||||
effort::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Mcp => {
|
||||
mcp::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Todo => {
|
||||
todo::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Rewind => {
|
||||
rewind::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Learning => {
|
||||
learning::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Usage => {
|
||||
usage::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Loading => {
|
||||
loading::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::ModelSelector => {
|
||||
model_selector::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::ClearConfirm => {
|
||||
clear_confirm::render(frame, overlay_area, block, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Overlay: model/provider selector — lists available providers from config
|
||||
//! and lets the user pick one with ↑/↓/Enter.
|
||||
use crate::view::theme::Theme;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Render the Model Selector overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Model Selector ",
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_PURPLE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" Current: {} / {}",
|
||||
state.settings.provider, state.settings.model
|
||||
),
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
" Providers:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
];
|
||||
let providers: Vec<(&String, &zesdex_domain::cms::ProviderConfig)> =
|
||||
state.app_config.providers.iter().collect();
|
||||
for (i, (name, cfg)) in providers.iter().enumerate() {
|
||||
let is_current = *name == &state.settings.provider;
|
||||
let is_selected = i == state.misc.selected_index;
|
||||
let prefix = if is_selected { " ▸ " } else { " " };
|
||||
let model_str = cfg.default_model.as_deref().unwrap_or("(any)");
|
||||
let label = format!("{prefix}{name} ({model_str})");
|
||||
let style = if is_current {
|
||||
Style::default()
|
||||
.fg(Theme::HIGHLIGHT)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_selected {
|
||||
Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT)
|
||||
} else {
|
||||
Style::default().fg(Theme::TEXT)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(label, style)));
|
||||
}
|
||||
lines.push(Line::from(Span::raw("")));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" ↑↓ navigate · Enter select · Esc close",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Overlay: quit confirmation dialog.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Quit confirmation overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
_state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Quit ",
|
||||
Style::default()
|
||||
.fg(Theme::ERROR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::ERROR));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Are you sure you want to quit?",
|
||||
Style::default()
|
||||
.fg(Theme::ERROR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
" Press Enter to confirm, Esc to cancel.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Overlay: Rewind / session history — shows recent messages and lets the user
|
||||
//! pick a point to rewind the transcript back to.
|
||||
use crate::view::theme::Theme;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use zesdex_domain::core::Role;
|
||||
|
||||
/// Render the Rewind overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Rewind ",
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_ORANGE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::ACCENT_ORANGE));
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(
|
||||
" Use ↑↓ to navigate, Enter to rewind to that point",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
];
|
||||
let messages = &state.transcript_cache.messages;
|
||||
if messages.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" No messages in current session.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
} else {
|
||||
let start = if messages.len() > 8 { messages.len() - 8 } else { 0 };
|
||||
for msg in &messages[start..] {
|
||||
let role_str = match msg.role {
|
||||
Role::User => "User",
|
||||
Role::Assistant => "Asst",
|
||||
Role::System => "Sys",
|
||||
Role::Tool => "Tool",
|
||||
};
|
||||
let preview: String = msg.content.chars().take(70).collect();
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" [{role_str}] {preview}"),
|
||||
Style::default().fg(if msg.role == Role::User { Theme::INFO } else { Theme::TEXT }),
|
||||
)));
|
||||
}
|
||||
if messages.len() > 8 {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" ... and {} more messages", messages.len() - 8),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
}
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Overlay: settings overview — displays the current provider, model,
|
||||
//! max tokens, temperature, internet mode, and review toggle.
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Settings overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = super::overlay_block(block, "Settings", Theme::PRIMARY);
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
format!(" Provider: {}", state.settings.provider),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Model: {}", state.settings.model),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" Max tokens: {}",
|
||||
state.settings.max_tokens.map_or_else(|| "auto".to_string(), |v| v.to_string())
|
||||
),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" Temperature: {}",
|
||||
state.settings.temperature.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))
|
||||
),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Internet: {:?}", state.settings.internet_mode),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Review: {}", state.settings.flags.review_enabled),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Overlay: Tasks (todo) view — shows the full todo list content.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Tasks / Todo overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Tasks ",
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_PURPLE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
||||
let content = if state.misc.todo_content.is_empty() {
|
||||
" No tasks yet."
|
||||
} else {
|
||||
&state.misc.todo_content
|
||||
};
|
||||
let paragraph = Paragraph::new(content)
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Overlay: usage statistics — detailed token usage, API call count, edit/
|
||||
//! review/lesson activity counters, and session elapsed time.
|
||||
use crate::view::sidebar::compute_usage_summary;
|
||||
use crate::view::theme::Theme;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Render the Usage overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Usage ",
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::INFO));
|
||||
let runtime = state.session_runtime.as_ref();
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let summary = runtime.map(|r| compute_usage_summary(&r.usage, r.session_start, now_ms));
|
||||
let (edit_count, lesson_count, review_count, consec_empty) =
|
||||
runtime.map_or((0, 0, 0, 0), |r| {
|
||||
(r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
|
||||
});
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Token Usage",
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
];
|
||||
if let Some(s) = &summary {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Main agent: {} tokens", s.main_tokens),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Self-learning: {} tokens", s.self_learning_tokens),
|
||||
Style::default().fg(Theme::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Total: {} tokens", s.total_tokens),
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" API calls: {}", s.api_calls),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
} else {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" No active session.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
lines.push(Line::from(Span::raw("")));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Activity",
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Edits: {edit_count}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Reviews: {review_count}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Lessons: {lesson_count}"),
|
||||
Style::default().fg(Theme::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Empty reviews: {consec_empty}"),
|
||||
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::TEXT_DIM }),
|
||||
)));
|
||||
if let Some(s) = &summary {
|
||||
lines.push(Line::from(Span::raw("")));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Session: {}h {}m {}s", s.elapsed_hours, s.elapsed_minutes, s.elapsed_seconds),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Persistent right-hand dashboard sidebar: Workflow, Tasks, and Usage
|
||||
//! widgets stacked in three vertical thirds.
|
||||
use super::theme::Theme;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Render the persistent right-hand dashboard: Workflow, Tasks, and Usage.
|
||||
pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
||||
let has_workflow = !state.workflow_engine.agents.is_empty();
|
||||
|
||||
let constraints = if has_workflow {
|
||||
vec![
|
||||
Constraint::Ratio(1, 2),
|
||||
Constraint::Ratio(1, 4),
|
||||
Constraint::Ratio(1, 4),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
]
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(area);
|
||||
|
||||
super::workflow::draw_workflow_panel(frame, chunks[0], state);
|
||||
draw_tasks_widget(frame, chunks[1], state);
|
||||
draw_usage_widget(frame, chunks[2], state);
|
||||
}
|
||||
|
||||
fn draw_tasks_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
" Tasks ",
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_PURPLE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER));
|
||||
let budget = (block.inner(area).height as usize).max(1);
|
||||
|
||||
let content = &state.misc.todo_content;
|
||||
let task_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
|
||||
let lines: Vec<Line> = if task_lines.is_empty() {
|
||||
vec![Line::from(Span::styled(
|
||||
" No tasks yet.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
))]
|
||||
} else {
|
||||
let show_hint = task_lines.len() > budget;
|
||||
let item_budget = if show_hint {
|
||||
budget.saturating_sub(1).max(1)
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let (visible, hidden) = super::split_for_display(&task_lines, item_budget);
|
||||
let mut lines: Vec<Line> = visible
|
||||
.iter()
|
||||
.map(|l| {
|
||||
Line::from(Span::styled(
|
||||
format!(" {l}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
if show_hint {
|
||||
lines.push(super::overflow_hint_line(hidden, "/todo"));
|
||||
}
|
||||
lines
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
" Usage ",
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER));
|
||||
|
||||
let lines: Vec<Line> = if let Some(ref rt) = state.session_runtime {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let summary = compute_usage_summary(&rt.usage, rt.session_start, now_ms);
|
||||
vec![
|
||||
Line::from(Span::styled(
|
||||
format!(" {:>6}: {} tok", "total", summary.total_tokens),
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" {:>6}: {} tok", "main", summary.main_tokens),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" {:>6}: {} tok", "learn", summary.self_learning_tokens),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" {:>6}: {}", "calls", summary.api_calls),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" {:>6}: {}h {:02}m {:02}s",
|
||||
"time", summary.elapsed_hours, summary.elapsed_minutes, summary.elapsed_seconds
|
||||
),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
]
|
||||
} else {
|
||||
vec![Line::from(Span::styled(
|
||||
" No active session.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
))]
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
pub(crate) struct UsageSummary {
|
||||
pub main_tokens: u64,
|
||||
pub self_learning_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub api_calls: u64,
|
||||
pub elapsed_hours: i64,
|
||||
pub elapsed_minutes: i64,
|
||||
pub elapsed_seconds: i64,
|
||||
}
|
||||
|
||||
pub(crate) fn compute_usage_summary(
|
||||
usage: &zesdex_domain::core::UsageStats,
|
||||
session_start: i64,
|
||||
now_ms: i64,
|
||||
) -> UsageSummary {
|
||||
let total_tokens = usage.tokens_in.saturating_add(usage.tokens_out);
|
||||
let self_learning_tokens = usage.review_tokens;
|
||||
let main_tokens = total_tokens.saturating_sub(self_learning_tokens);
|
||||
let elapsed_ms = now_ms.saturating_sub(session_start);
|
||||
let elapsed_hours = elapsed_ms / 3_600_000;
|
||||
let elapsed_minutes = (elapsed_ms % 3_600_000) / 60_000;
|
||||
let elapsed_seconds = (elapsed_ms % 60_000) / 1000;
|
||||
UsageSummary {
|
||||
main_tokens,
|
||||
self_learning_tokens,
|
||||
total_tokens,
|
||||
api_calls: usage.api_calls,
|
||||
elapsed_hours,
|
||||
elapsed_minutes,
|
||||
elapsed_seconds,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Status bar rendering for the TUI — modern segmented bar design.
|
||||
//!
|
||||
//! Flow: `draw_status_bar` reads live connection/turn state off
|
||||
//! `AppStateRest` every frame and paints a single-line bar at the
|
||||
//! bottom of the screen with three visual segments.
|
||||
|
||||
use super::theme::Theme;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Render the single-line status bar.
|
||||
pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
||||
use ratatui::layout::{Alignment, Constraint, Direction, Layout};
|
||||
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
let (status_text, badge_bg, status_fg) = if state.turn_in_flight() {
|
||||
let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()];
|
||||
(format!(" {f} PROG "), Theme::MODE_YOLO, Theme::BG)
|
||||
} else if state.misc.api_connected {
|
||||
(" READY ".to_string(), Theme::MODE_AUTO, Theme::BG)
|
||||
} else {
|
||||
(" NOAPI ".to_string(), Theme::TEXT_DIM, Theme::BG)
|
||||
};
|
||||
|
||||
let status_badge = Span::styled(
|
||||
status_text,
|
||||
Style::default()
|
||||
.fg(status_fg)
|
||||
.bg(badge_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let left_spans = vec![
|
||||
Span::styled(
|
||||
" ⚡zesdex ",
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
status_badge,
|
||||
];
|
||||
|
||||
let max_tokens = crate::state::resolve_context_window(&state.app_config, &state.settings);
|
||||
|
||||
let right_str = if let Some(ref rt) = state.session_runtime {
|
||||
let current_tokens: usize = rt
|
||||
.messages
|
||||
.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(crate::state::count_tokens)
|
||||
.sum();
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 {
|
||||
parts.push(format!(
|
||||
"↑{} ↓{}",
|
||||
rt.usage.last_tokens_in, rt.usage.last_tokens_out
|
||||
));
|
||||
}
|
||||
parts.push(format!("{current_tokens}/{max_tokens}"));
|
||||
parts.push(state.settings.provider.clone());
|
||||
parts.push(state.settings.model.clone());
|
||||
|
||||
format!(" {} ", parts.join(" · "))
|
||||
} else {
|
||||
format!(
|
||||
" 0/{max_tokens} · {} · {} ",
|
||||
state.settings.provider, state.settings.model
|
||||
)
|
||||
};
|
||||
|
||||
let left_line = Line::from(left_spans);
|
||||
let right_line = Line::from(Span::styled(
|
||||
right_str,
|
||||
Style::default().fg(Theme::TEXT_MUTED),
|
||||
));
|
||||
|
||||
let center_line = if state.misc.lesson_running {
|
||||
Line::from(vec![Span::styled(
|
||||
" 📘 Generating Lesson... ",
|
||||
Style::default()
|
||||
.fg(Theme::MODE_YOLO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])
|
||||
} else {
|
||||
Line::from("")
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(25),
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(60),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let block = Block::default().style(Style::default().bg(Theme::STATUS_BAR_BG).fg(Theme::TEXT));
|
||||
|
||||
let left_para = ratatui::widgets::Paragraph::new(left_line).block(block.clone());
|
||||
frame.render_widget(left_para, chunks[0]);
|
||||
|
||||
let center_para = ratatui::widgets::Paragraph::new(center_line)
|
||||
.block(block.clone())
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(center_para, chunks[1]);
|
||||
|
||||
let right_para = ratatui::widgets::Paragraph::new(right_line)
|
||||
.block(block)
|
||||
.alignment(Alignment::Right);
|
||||
frame.render_widget(right_para, chunks[2]);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Central color theme for the TUI — Tokyo Night palette.
|
||||
//!
|
||||
//! Design: muted blue-purple dark background with desaturated blue/cyan/
|
||||
//! purple accents (not neon) — the popular Tokyo Night editor/terminal
|
||||
//! theme. Chosen for a calmer "professional dev tool" read.
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// Central palette of terminal colors used across all TUI render functions.
|
||||
pub struct Theme;
|
||||
|
||||
impl Theme {
|
||||
// ── Base surface colors ──────────────────────────────────────────────
|
||||
pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26);
|
||||
pub const SURFACE: Color = Color::Rgb(0x1f, 0x23, 0x35);
|
||||
pub const SURFACE_ELEVATED: Color = Color::Rgb(0x29, 0x2e, 0x42);
|
||||
|
||||
// ── Text colors ──────────────────────────────────────────────────────
|
||||
pub const TEXT: Color = Color::Rgb(0xc0, 0xca, 0xf5);
|
||||
pub const TEXT_MUTED: Color = Color::Rgb(0xa9, 0xb1, 0xd6);
|
||||
pub const TEXT_DIM: Color = Color::Rgb(0x56, 0x5f, 0x89);
|
||||
|
||||
// ── Accent colors ────────────────────────────────────────────────────
|
||||
pub const PRIMARY: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
|
||||
pub const SUCCESS: Color = Color::Rgb(0x9e, 0xce, 0x6a);
|
||||
pub const WARNING: Color = Color::Rgb(0xe0, 0xaf, 0x68);
|
||||
pub const ERROR: Color = Color::Rgb(0xf7, 0x76, 0x8e);
|
||||
pub const INFO: Color = Color::Rgb(0x7d, 0xcf, 0xff);
|
||||
|
||||
// ── Extended accent palette ──────────────────────────────────────────
|
||||
pub const ACCENT_PURPLE: Color = Color::Rgb(0xbb, 0x9a, 0xf7);
|
||||
pub const ACCENT_ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64);
|
||||
pub const ACCENT_TEAL: Color = Color::Rgb(0x73, 0xda, 0xca);
|
||||
|
||||
// ── Border colors ────────────────────────────────────────────────────
|
||||
pub const BORDER: Color = Color::Rgb(0x3b, 0x42, 0x61);
|
||||
|
||||
// ── Role badge colors ────────────────────────────────────────────────
|
||||
pub const ROLE_USER: Color = Color::Rgb(0x9e, 0xce, 0x6a);
|
||||
pub const ROLE_ASSISTANT: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
|
||||
pub const ROLE_SYSTEM: Color = Color::Rgb(0x7d, 0xcf, 0xff);
|
||||
pub const ROLE_TOOL: Color = Color::Rgb(0xe0, 0xaf, 0x68);
|
||||
|
||||
// ── Status colors ────────────────────────────────────────────────────
|
||||
pub const STATUS_BAR_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
|
||||
pub const MODE_AUTO: Color = Color::Rgb(0x9e, 0xce, 0x6a);
|
||||
pub const MODE_YOLO: Color = Color::Rgb(0xf7, 0x76, 0x8e);
|
||||
|
||||
// ── Code / markdown ──────────────────────────────────────────────────
|
||||
pub const CODE_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
|
||||
pub const CODE_BAR: Color = Color::Rgb(0x29, 0x2e, 0x42);
|
||||
pub const BLOCKQUOTE_BAR: Color = Color::Rgb(0x7d, 0xcf, 0xff);
|
||||
|
||||
// ── Misc ─────────────────────────────────────────────────────────────
|
||||
pub const HIGHLIGHT: Color = Color::Rgb(0x3d, 0x59, 0xa1);
|
||||
pub const HIGHLIGHT_DIM: Color = Color::Rgb(0x29, 0x2e, 0x42);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Workflow status panel rendering — agent cards with state badges.
|
||||
use super::theme::Theme;
|
||||
use crate::state::AgentState;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
fn state_icon(state: AgentState) -> &'static str {
|
||||
match state {
|
||||
AgentState::Idle => "○",
|
||||
AgentState::Running => "▶",
|
||||
AgentState::Completed => "✓",
|
||||
AgentState::Failed => "✗",
|
||||
}
|
||||
}
|
||||
|
||||
fn state_label(state: AgentState) -> &'static str {
|
||||
match state {
|
||||
AgentState::Idle => "Idle",
|
||||
AgentState::Running => "Running",
|
||||
AgentState::Completed => "Done",
|
||||
AgentState::Failed => "Failed",
|
||||
}
|
||||
}
|
||||
|
||||
fn state_color(state: AgentState) -> Color {
|
||||
match state {
|
||||
AgentState::Idle => Theme::TEXT_DIM,
|
||||
AgentState::Running => Theme::WARNING,
|
||||
AgentState::Completed => Theme::SUCCESS,
|
||||
AgentState::Failed => Theme::ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the workflow status panel.
|
||||
pub fn draw_workflow_panel(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
|
||||
let title = Span::styled(
|
||||
" Workflow ",
|
||||
Style::default()
|
||||
.fg(Theme::PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.title(title);
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(4)])
|
||||
.split(inner);
|
||||
|
||||
// Header area
|
||||
let mut header_lines: Vec<Line> = Vec::new();
|
||||
header_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"/workflow run ",
|
||||
Style::default()
|
||||
.fg(Theme::PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("<prompt>", Style::default().fg(Theme::TEXT_DIM)),
|
||||
]));
|
||||
header_lines.push(Line::from(vec![
|
||||
Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)),
|
||||
if state.turn_in_flight() {
|
||||
Span::styled(
|
||||
"● Running",
|
||||
Style::default()
|
||||
.fg(Theme::WARNING)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
Span::styled("● Idle", Style::default().fg(Theme::SUCCESS))
|
||||
},
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!(
|
||||
"Agents: {} | Findings: {}",
|
||||
state.workflow_engine.agents.len(),
|
||||
state.workflow_engine.findings.len(),
|
||||
),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
),
|
||||
]));
|
||||
|
||||
let header = Paragraph::new(header_lines);
|
||||
frame.render_widget(header, chunks[0]);
|
||||
|
||||
// Body: agent cards
|
||||
if state.workflow_engine.agents.is_empty() {
|
||||
let session_lines = build_session_lines(state);
|
||||
let placeholder = Paragraph::new(session_lines).wrap(Wrap { trim: false });
|
||||
frame.render_widget(placeholder, chunks[1]);
|
||||
} else {
|
||||
let mut card_lines: Vec<Line> = Vec::new();
|
||||
for agent in &state.workflow_engine.agents {
|
||||
let color = state_color(agent.state);
|
||||
let icon = state_icon(agent.state);
|
||||
let label = state_label(agent.state);
|
||||
|
||||
let duration_str = match (agent.started_at, agent.completed_at) {
|
||||
(Some(s), Some(e)) => format!(" {}ms", e.saturating_sub(s)),
|
||||
(Some(_), None) => " (running)".to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
card_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {icon} "),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {}", agent.name),
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!(" [{label}]"), Style::default().fg(color)),
|
||||
Span::styled(duration_str, Style::default().fg(Theme::TEXT_DIM)),
|
||||
]));
|
||||
|
||||
if let Some(ref err) = agent.error {
|
||||
card_lines.push(Line::from(vec![
|
||||
Span::styled(" ⚠ ", Style::default().fg(Theme::ERROR)),
|
||||
Span::styled(err.clone(), Style::default().fg(Theme::ERROR)),
|
||||
]));
|
||||
} else if let Some(ref prog) = agent.progress {
|
||||
for line in prog.lines().take(2) {
|
||||
card_lines.push(Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(
|
||||
line.to_string(),
|
||||
Style::default()
|
||||
.fg(Theme::TEXT_DIM)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let list = Paragraph::new(card_lines);
|
||||
frame.render_widget(list, chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_session_lines(state: &crate::state::AppStateRest) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from(Span::styled(
|
||||
" No workflow running.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
lines.push(Line::from(Span::raw("")));
|
||||
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
let tool_count = rt.tool_call_results.len();
|
||||
let pending = rt.pending_tool_queue.len();
|
||||
let bash_count = rt.bash_jobs.len();
|
||||
let msg_count = rt.messages.len();
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Messages ", Style::default().fg(Theme::TEXT_DIM)),
|
||||
Span::styled(
|
||||
msg_count.to_string(),
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Tool calls", Style::default().fg(Theme::TEXT_DIM)),
|
||||
Span::styled(
|
||||
format!(" {tool_count}"),
|
||||
Style::default().fg(Theme::SUCCESS),
|
||||
),
|
||||
]));
|
||||
if pending > 0 {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Pending ", Style::default().fg(Theme::TEXT_DIM)),
|
||||
Span::styled(format!(" {pending}"), Style::default().fg(Theme::WARNING)),
|
||||
]));
|
||||
}
|
||||
if bash_count > 0 {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Bash jobs ", Style::default().fg(Theme::TEXT_DIM)),
|
||||
Span::styled(
|
||||
format!(" {bash_count}"),
|
||||
Style::default().fg(Theme::WARNING),
|
||||
),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" (no active session)",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
|
||||
lines.push(Line::from(Span::raw("")));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" The Hive is dormant. Complex tasks will stir it.",
|
||||
Style::default()
|
||||
.fg(Theme::TEXT_DIM)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
)));
|
||||
|
||||
lines
|
||||
}
|
||||
Reference in New Issue
Block a user