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

feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
@@ -0,0 +1,161 @@
//! Slash-command parser that maps TUI `/foo` input lines into `Command`
//! variants for the action dispatch system.
//!
//! Flow: the TUI input handler in `controller::input` calls `parse_command`
//! on every `/`-prefixed line, then maps the resulting `Command` to an
//! `Action` for the event loop to apply to `AppStateRest`.
/// A parsed slash command from the TUI input buffer.
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
/// `/help` — show keybindings / help overlay.
Help,
/// `/quit` — exit the application.
Quit,
/// `/mcp` (no args) — open MCP configuration panel.
McpOpen,
/// `/clear` (with args) — clear with a specific scope.
Clear,
/// `/clear` (no args) — show confirmation prompt before clearing.
ClearConfirm,
/// `/login <provider>` — trigger OAuth login for the given provider.
Login { provider: String },
/// `/edit <path>` — open the given file for review/inline editing.
Edit(String),
/// `/mcp add <name> <command>` — add a new MCP server definition.
McpAdd { name: String, command: String },
/// `/model` — list available LLM models.
ModelList,
/// `/compact` — trigger conversation compaction.
Compact,
/// `/todo` — open the todo-list overlay.
TodoOpen,
/// `/usage` — open the usage-stats overlay.
UsageOpen,
/// Catch-all: unrecognised or non-slash input.
Unknown(String),
}
/// Parse a slash-prefixed input line into a `Command` value.
///
/// Flow: trim -> check for leading `/` -> split on space (max 3 parts) ->
/// match the first token against known commands -> extract arguments from
/// the remaining parts.
pub fn parse_command(text: &str) -> Command {
let text = text.trim();
if !text.starts_with('/') {
return Command::Unknown(text.to_string());
}
let parts: Vec<&str> = text.splitn(3, ' ').collect();
let cmd = parts[0];
let arg1 = parts.get(1).copied().unwrap_or("");
let arg2 = parts.get(2).copied().unwrap_or("");
let result = match cmd {
"/help" => Command::Help,
"/quit" => Command::Quit,
"/clear" if arg1.is_empty() => Command::ClearConfirm,
"/clear" => Command::Clear,
"/login" if arg1.is_empty() => Command::Login {
provider: String::new(),
},
"/login" if !arg1.is_empty() => Command::Login {
provider: arg1.to_string(),
},
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
"/edit" => Command::Edit(".".to_string()),
"/mcp" if arg1.is_empty() => Command::McpOpen,
"/mcp" if arg1 == "add" && !arg2.is_empty() => {
let rest = arg2.trim();
if let Some(space) = rest.find(' ') {
let name = rest[..space].to_string();
let command = rest[space + 1..].trim().to_string();
Command::McpAdd { name, command }
} else {
Command::McpAdd {
name: rest.to_string(),
command: String::new(),
}
}
}
"/model" => Command::ModelList,
"/compact" => Command::Compact,
"/todo" => Command::TodoOpen,
"/usage" => Command::UsageOpen,
_ => Command::Unknown(cmd.to_string()),
};
tracing::debug!(%text, command = ?result, "parse_command");
result
}
/// Map a parsed `Command` into `Action` values for the event loop.
pub fn apply_command(cmd: Command) -> Vec<crate::action::Action> {
match cmd {
Command::Help => {
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Help)]
}
Command::Quit => {
vec![crate::action::Action::QuitConfirm]
}
Command::McpOpen => {
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Mcp)]
}
Command::Clear => {
vec![crate::action::Action::SystemNote {
kind: "clear".to_string(),
message: "Transcript cleared.".to_string(),
}]
}
Command::ClearConfirm => {
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::ClearConfirm)]
}
Command::Login { provider } => {
vec![crate::action::Action::StartOAuth { provider }]
}
Command::Edit(path) => {
vec![crate::action::Action::OpenEditor { path }]
}
Command::McpAdd { name, command } => {
vec![crate::action::Action::McpAdd { name, command }]
}
Command::ModelList => {
vec![crate::action::Action::ModelList]
}
Command::Compact => {
vec![crate::action::Action::Compact]
}
Command::TodoOpen => {
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Todo)]
}
Command::UsageOpen => {
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Usage)]
}
Command::Unknown(text) => {
if text.starts_with('/') {
vec![crate::action::Action::SystemNote {
kind: "error".to_string(),
message: format!("Unknown command: {text}"),
}]
} else {
vec![crate::action::Action::SubmitInput(text)]
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_todo_open() {
assert_eq!(parse_command("/todo"), Command::TodoOpen);
}
#[test]
fn parses_usage_open() {
assert_eq!(parse_command("/usage"), Command::UsageOpen);
}
}
+447
View File
@@ -0,0 +1,447 @@
//! Key event dispatcher: maps crossterm `KeyEvent` values into `Action`
//! variants, with special handling for overlays, auto-complete, and the
//! inline editor.
//!
//! Flow:
//! 1. `handle_key` is called on each key press.
//! 2. Overlays with full-screen input (Editor, Learning) intercept *all* keys
//! before the main match.
//! 3. The main match handles navigation, auto-complete, editing, and shortcuts.
//! 4. Multi-key actions return `Vec<Action>`.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::action::Action;
use crate::controller::command::{apply_command, parse_command};
use crate::state::{AutocompleteKind, Overlay, AppStateRest};
/// Mark state dirty and return an empty action list.
fn mark(state: &mut AppStateRest) -> Vec<Action> {
state.mark_dirty();
Vec::new()
}
/// Translate a terminal `KeyEvent` into zero or more `Action` values
/// based on the current application state.
///
/// Return: `Vec<Action>` so a single key can produce multiple queued actions.
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
tracing::debug!(code = ?key.code, mods = ?key.modifiers, overlay = ?state.misc.overlay, "handle_key");
// ── Editor overlay ───────────────────────────────────────────────────
if state.misc.overlay == Overlay::Editor {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return vec![Action::QuitConfirm];
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(ref ed) = state.misc.editor.clone() {
let content = ed.as_string();
if let Err(e) = std::fs::write(&ed.path, &content) {
state.toast_error(format!("Save failed: {e}"));
} else {
state.toast_success(format!("Saved {}", ed.path.display()));
}
state.mark_dirty();
}
return vec![];
}
KeyCode::Esc => {
// Dismiss editor
state.misc.editor = None;
state.misc.overlay = Overlay::None;
return vec![];
}
KeyCode::Backspace => {
if let Some(ref mut ed) = state.misc.editor {
ed.delete_left();
state.mark_dirty();
}
return vec![];
}
KeyCode::Enter => {
if let Some(ref mut ed) = state.misc.editor {
ed.content.insert(ed.cursor, '\n');
ed.cursor += 1;
state.mark_dirty();
}
return vec![];
}
KeyCode::Char(c) => {
if let Some(ref mut ed) = state.misc.editor {
ed.content.insert(ed.cursor, c);
ed.cursor += c.len_utf8();
state.mark_dirty();
}
return vec![];
}
_ => return vec![],
}
}
// ── Learning overlay ──────────────────────────────────────────────────
if state.misc.overlay == Overlay::Learning {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return vec![Action::QuitConfirm];
}
KeyCode::Esc => {
return vec![Action::CloseOverlay];
}
KeyCode::Up => {
let items = crate::state::get_learning_items(state);
let n = items.len();
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, false);
return mark(state);
}
KeyCode::Down => {
let items = crate::state::get_learning_items(state);
let n = items.len();
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, true);
return mark(state);
}
KeyCode::Enter | KeyCode::Char('a') => {
let items = crate::state::get_learning_items(state);
if let Some(crate::state::LearningItem::Pending { name, .. }) =
items.get(state.misc.selected_index)
{
return vec![Action::LessonAccept { name: name.clone() }];
}
return vec![];
}
KeyCode::Char('r') => {
let items = crate::state::get_learning_items(state);
if let Some(crate::state::LearningItem::Pending { name, .. }) =
items.get(state.misc.selected_index)
{
return vec![Action::LessonReject { name: name.clone() }];
}
return vec![];
}
KeyCode::Char('d') | KeyCode::Delete | KeyCode::Backspace => {
let items = crate::state::get_learning_items(state);
if let Some(item) = items.get(state.misc.selected_index) {
match item {
crate::state::LearningItem::Pending { name, .. } => {
return vec![Action::LessonReject { name: name.clone() }];
}
crate::state::LearningItem::Stored { name, .. } => {
return vec![Action::LessonDelete { name: name.clone() }];
}
}
}
return vec![];
}
_ => return vec![],
}
}
// ── Normal (non-overlay) dispatch ────────────────────────────────────
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::QuitConfirm]
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::CloseOverlay]
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let last_assistant = state
.transcript_cache
.messages
.iter()
.rev()
.find(|m| m.role == zesdex_domain::core::Role::Assistant);
match last_assistant {
Some(msg) => {
state.misc.pending_clipboard_copy = Some(msg.content.clone());
}
None => {
state.toast_info("No assistant message to copy yet".to_string());
}
}
Vec::new()
}
KeyCode::Enter => {
if state.input.autocomplete_visible {
state.input.select_autocomplete();
return mark(state);
}
if state.misc.overlay.is_active() {
return handle_overlay_enter(state);
}
let text = state.input.buffer.clone();
if text.starts_with('/') {
return apply_command(parse_command(&text));
}
vec![Action::SubmitInput(text)]
}
KeyCode::Backspace => {
if state.input.autocomplete_visible {
state.input.close_autocomplete();
return mark(state);
}
vec![Action::DeleteChar]
}
KeyCode::Delete => {
if state.input.autocomplete_visible {
state.input.close_autocomplete();
return mark(state);
}
vec![Action::DeleteCharRight]
}
KeyCode::Left => {
vec![Action::CursorLeft]
}
KeyCode::Right => {
vec![Action::CursorRight]
}
KeyCode::Up => {
if state.input.autocomplete_visible {
state.input.cycle_autocomplete(false);
state.mark_dirty();
Vec::new()
} else if state.misc.overlay == Overlay::Effort {
crate::state::cycle_effort(state, false);
Vec::new()
} else if state.misc.overlay == Overlay::Rewind {
let n = crate::state::rewind_count(state);
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, false);
state.mark_dirty();
Vec::new()
} else if state.misc.overlay == Overlay::ModelSelector {
let n = state.app_config.providers.len();
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, false);
state.mark_dirty();
Vec::new()
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
vec![Action::ScrollUp]
} else {
vec![Action::HistoryUp]
}
}
KeyCode::Down => {
if state.input.autocomplete_visible {
state.input.cycle_autocomplete(true);
state.mark_dirty();
Vec::new()
} else if state.misc.overlay == Overlay::Effort {
crate::state::cycle_effort(state, true);
Vec::new()
} else if state.misc.overlay == Overlay::Rewind {
let n = crate::state::rewind_count(state);
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, true);
state.mark_dirty();
Vec::new()
} else if state.misc.overlay == Overlay::ModelSelector {
let n = state.app_config.providers.len();
state.misc.selected_index = crate::state::cycle_selected_index(state.misc.selected_index, n, true);
state.mark_dirty();
Vec::new()
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
vec![Action::ScrollDown]
} else {
vec![Action::HistoryDown]
}
}
KeyCode::PageUp => {
vec![Action::ScrollUp]
}
KeyCode::PageDown => {
vec![Action::ScrollDown]
}
KeyCode::Esc => {
if state.turn_in_flight() {
vec![Action::AbortTurn]
} else if state.input.autocomplete_visible {
state.input.close_autocomplete();
state.mark_dirty();
Vec::new()
} else if state.misc.overlay.is_active() {
vec![Action::CloseOverlay]
} else {
Vec::new()
}
}
KeyCode::Tab => {
if state.input.buffer.starts_with('/') {
if state.input.autocomplete_visible {
state.input.cycle_autocomplete(true);
} else {
state.input.tab_complete();
}
state.mark_dirty();
} else if state.input.autocomplete_kind == AutocompleteKind::FileMention
&& state.input.autocomplete_visible
{
state.input.cycle_autocomplete(true);
state.mark_dirty();
}
Vec::new()
}
KeyCode::Char(c) => {
if state.input.autocomplete_visible {
state.input.close_autocomplete();
state.mark_dirty();
}
state.input.insert(c);
state.mark_dirty();
if state.input.buffer.starts_with('/') {
state.input.open_autocomplete();
} else if state.input.mention_query_at_cursor().is_some() {
state
.input
.open_mention_autocomplete(&state.mention_index.snapshot());
}
Vec::new()
}
_ => Vec::new(),
}
}
/// Handle pressing Enter while a modal overlay is active.
fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
tracing::debug!(overlay = ?state.misc.overlay, "handle_overlay_enter");
match state.misc.overlay {
Overlay::Bash => {
let command = state.input.buffer.clone();
state.toast_info(format!("Submitting bash command: {command}"));
state.input.buffer.clear();
state.input.cursor = 0;
state.mark_dirty();
Vec::new()
}
Overlay::Settings => {
state.mark_dirty();
Vec::new()
}
Overlay::Todo => {
state.mark_dirty();
Vec::new()
}
Overlay::QuitConfirm => {
state.quit = true;
state.mark_dirty();
Vec::new()
}
Overlay::KeyInput => {
let text = state.input.buffer.clone();
if !text.is_empty() {
state
.settings
.api_keys
.insert(state.settings.provider.clone(), text);
}
state.toast_success("API key saved".to_string());
state.input.buffer.clear();
state.input.cursor = 0;
state.misc.overlay = Overlay::None;
state.save_settings();
state.mark_dirty();
Vec::new()
}
Overlay::Mcp => {
state.toast_info("Connecting MCP...".to_string());
Vec::new()
}
Overlay::Rewind => {
let idx = state.misc.selected_index;
let n = state.transcript_cache.messages.len();
if idx < n {
let rewind_to = n - idx - 1;
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::System,
format!("Rewound to message {rewind_to}"),
));
}
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
Overlay::ModelSelector => {
let providers: Vec<String> = state.app_config.providers.keys().cloned().collect();
if let Some(provider) = providers.get(state.misc.selected_index) {
if let Some(cfg) = state.app_config.providers.get(provider) {
let model = cfg.default_model.clone().unwrap_or_else(|| {
"claude-opus-4-8".to_string()
});
state.settings.provider.clone_from(provider);
state.settings.model.clone_from(&model);
if let Some(ref key) = cfg.default_api_key {
state.settings.api_keys.insert(provider.clone(), key.clone());
} else if let Some(env_key) = cfg
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
{
state.settings.api_keys.insert(provider.clone(), env_key);
}
state.save_settings();
state.toast_success(format!("Switched to {provider} / {model}"));
}
}
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
Overlay::ClearConfirm => {
state.toast_info("Transcript cleared".to_string());
state.transcript_cache.messages.clear();
state.transcript_cache.dirty = true;
state.misc.overlay = Overlay::None;
state.mark_dirty();
Vec::new()
}
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_state() -> AppStateRest {
let tmp = std::env::temp_dir().join(format!("zesdex-input-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"))
}
#[test]
fn ctrl_y_sets_pending_clipboard_copy_to_last_assistant_message() {
let mut state = test_state();
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::User,
"hi".to_string(),
));
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::Assistant,
"first reply".to_string(),
));
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::Tool,
"tool output".to_string(),
));
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::Assistant,
"second reply".to_string(),
));
handle_key(
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
&mut state,
);
assert_eq!(
state.misc.pending_clipboard_copy,
Some("second reply".to_string())
);
}
#[test]
fn ctrl_y_with_no_assistant_message_pushes_info_toast() {
let mut state = test_state();
handle_key(
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
&mut state,
);
assert!(state.misc.pending_clipboard_copy.is_none());
assert_eq!(state.misc.toasts.len(), 1);
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Keyboard input handling and command parsing for the TUI.
//!
//! The controller layer bridges raw terminal key events (from `crossterm`) to
//! application actions. It contains two sub-modules:
//!
//! - `input` — key-event dispatch, prompt-line editing, history navigation,
//! tab-completion, and action invocation.
//! - `command` — the `/slash` command parser that translates user-typed
//! commands into structured `Action` variants.
pub mod command;
pub mod input;