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