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,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;
|
||||
Reference in New Issue
Block a user