feat(tui): introduce comprehensive state management for TUI interface
- Add AppStateRest as the central state struct for managing TUI state. - Implement InputState for handling user input, autocomplete, and history. - Create MiscState to manage overlays, notifications, and editor state. - Introduce ScrollState for viewport scrolling functionality. - Develop TranscriptCache for efficient message rendering in the chat pane. - Implement SimpleAgent and SimpleWorkflowEngine for agent lifecycle management. - Add helper functions for managing effort levels and token counting. - Organize state-related modules for better maintainability and clarity.
This commit is contained in:
@@ -1,490 +0,0 @@
|
||||
//! The `Action` enum — a single well-typed event in the TUI, produced by
|
||||
//! key input and applied to `AppStateRest` by the event loop.
|
||||
//!
|
||||
//! # Flow
|
||||
//! `controller::input::handle_key` returns `Vec<Action>` → 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;
|
||||
use std::process::Command;
|
||||
use tracing::debug;
|
||||
|
||||
/// A single well-typed event in the TUI that mutates `AppStateRest`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Action {
|
||||
/// Hard exit — immediately terminates the process.
|
||||
ForceQuit,
|
||||
/// Submit a user message to the LLM, starting a new agent turn.
|
||||
SubmitInput(String),
|
||||
/// Delete one character before the cursor in the input buffer.
|
||||
DeleteChar,
|
||||
/// Delete one character after the cursor in the input buffer.
|
||||
DeleteCharRight,
|
||||
/// Move the cursor one position left in the input buffer.
|
||||
CursorLeft,
|
||||
/// Move the cursor one position right in the input buffer.
|
||||
CursorRight,
|
||||
/// Navigate up through command history.
|
||||
HistoryUp,
|
||||
/// Navigate down through command history.
|
||||
HistoryDown,
|
||||
/// Scroll the transcript pane up.
|
||||
ScrollUp,
|
||||
/// Scroll the transcript pane down.
|
||||
ScrollDown,
|
||||
/// Open a named overlay.
|
||||
OpenOverlay(Overlay),
|
||||
/// Close the currently active overlay.
|
||||
CloseOverlay,
|
||||
/// Insert a system-generated note into the transcript.
|
||||
SystemNote {
|
||||
/// Note category: "error", "info", "clear", "hive_mind_converged", etc.
|
||||
kind: String,
|
||||
/// The message text to display.
|
||||
message: String,
|
||||
},
|
||||
/// Show the quit-confirmation overlay.
|
||||
QuitConfirm,
|
||||
/// Terminal resize event.
|
||||
Resize(u16, u16),
|
||||
/// Periodic timer tick — drains queued `TurnEvent`s.
|
||||
Tick,
|
||||
/// Accept a lesson (learned behaviour pattern) by name.
|
||||
LessonAccept {
|
||||
name: String,
|
||||
},
|
||||
/// Reject a lesson by name.
|
||||
LessonReject {
|
||||
name: String,
|
||||
},
|
||||
/// Delete a previously stored lesson by name.
|
||||
LessonDelete {
|
||||
name: String,
|
||||
},
|
||||
/// Start the OAuth device-code login flow for a named provider.
|
||||
StartOAuth {
|
||||
provider: String,
|
||||
},
|
||||
/// Open the inline file editor for `path`.
|
||||
OpenEditor {
|
||||
path: String,
|
||||
},
|
||||
/// Register a new MCP server by name and shell command.
|
||||
McpAdd {
|
||||
name: String,
|
||||
command: String,
|
||||
},
|
||||
/// Open the model-picker overlay.
|
||||
ModelList,
|
||||
/// Set the abort flag on the currently running turn.
|
||||
AbortTurn,
|
||||
/// Request AI-summary compaction of the conversation history.
|
||||
Compact,
|
||||
/// Show the git diff preview overlay.
|
||||
ShowDiff,
|
||||
/// Scroll the diff overlay.
|
||||
DiffScroll(i32),
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
|
||||
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) => {
|
||||
// Invalidate display cache so pre_render_chat rebuilds at new width.
|
||||
if state.last_render_width != w {
|
||||
state.transcript_cache.dirty = true;
|
||||
state.last_render_width = w;
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::Tick => {
|
||||
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
|
||||
|
||||
// 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();
|
||||
|
||||
let had_events = !events.is_empty();
|
||||
|
||||
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);
|
||||
rt.usage.last_tokens_in = tokens_in;
|
||||
rt.usage.last_tokens_out = tokens_out;
|
||||
rt.usage.api_calls = rt.usage.api_calls.saturating_add(1);
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Error(msg) => {
|
||||
state.toast_error(msg);
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamStart => {
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::Assistant,
|
||||
String::new(),
|
||||
));
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamToken(text) => {
|
||||
state.append_to_last_transcript(&text, false);
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamReasoning(text) => {
|
||||
state.append_to_last_transcript(&text, true);
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamDone(_msg) => {
|
||||
// The final message is already accumulated in the transcript.
|
||||
// We might want to trigger a save or something here, but for UI, we just mark dirty.
|
||||
state.dirty = true;
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Compacted(msgs) => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.messages = msgs;
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::TodoUpdate(content) => {
|
||||
state.misc.todo_content = content;
|
||||
state.toast_success("TODO list updated.");
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::PlanUpdate(content) => {
|
||||
state.misc.plan_content = content;
|
||||
state.toast_success("Project plan updated.");
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Done => {
|
||||
state.turn_in_flight_flag.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
// Mark dirty so spinner disappears
|
||||
state.dirty = true;
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::WorkflowAgentUpdate {
|
||||
agent_id,
|
||||
agent_name,
|
||||
status,
|
||||
} => {
|
||||
// Find existing agent by ID, or create new one
|
||||
let idx = state
|
||||
.workflow_engine
|
||||
.agents
|
||||
.iter()
|
||||
.position(|a| a.name == agent_id);
|
||||
|
||||
match status {
|
||||
zesdex_infrastructure::AgentStatus::Pending => {
|
||||
if idx.is_none() {
|
||||
state.workflow_engine.agents.push(
|
||||
crate::state::SimpleAgent::with_display(
|
||||
agent_id,
|
||||
agent_name,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Running => {
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents[i].state =
|
||||
crate::state::AgentState::Running;
|
||||
state.workflow_engine.agents[i].display_name =
|
||||
agent_name;
|
||||
state.workflow_engine.agents[i].started_at =
|
||||
Some(chrono::Utc::now().timestamp_millis());
|
||||
} else {
|
||||
let mut agent =
|
||||
crate::state::SimpleAgent::with_display(
|
||||
agent_id,
|
||||
agent_name,
|
||||
);
|
||||
agent.state = crate::state::AgentState::Running;
|
||||
agent.started_at =
|
||||
Some(chrono::Utc::now().timestamp_millis());
|
||||
state.workflow_engine.agents.push(agent);
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Completed => {
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents[i].state =
|
||||
crate::state::AgentState::Completed;
|
||||
state.workflow_engine.agents[i].display_name =
|
||||
agent_name;
|
||||
state.workflow_engine.agents[i].completed_at =
|
||||
Some(chrono::Utc::now().timestamp_millis());
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Failed(msg) => {
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents[i].state =
|
||||
crate::state::AgentState::Failed;
|
||||
state.workflow_engine.agents[i].display_name =
|
||||
agent_name;
|
||||
state.workflow_engine.agents[i].error = Some(msg);
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Cancelled => {
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
_ => {
|
||||
debug!("unhandled turn event variant");
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark dirty only when there's something that changed:
|
||||
// - new events were processed (messages, errors, etc.)
|
||||
// - turn is in-flight (spinner needs to animate each tick)
|
||||
// When idle with no events, skip the dirty flag to avoid useless renders.
|
||||
if had_events || state.turn_in_flight() {
|
||||
state.mark_dirty();
|
||||
}
|
||||
}
|
||||
Action::SubmitInput(text) => {
|
||||
// Push user message to transcript display
|
||||
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||
zesdex_domain::core::Role::User,
|
||||
text.clone(),
|
||||
));
|
||||
state.input.submit();
|
||||
// Spawn real agent turn on a background thread
|
||||
crate::turn::spawn_agent_turn(state, text);
|
||||
// Invalidate token count cache since we added a message
|
||||
state.token_count_dirty = true;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DeleteChar => {
|
||||
state.input.delete_left();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DeleteCharRight => {
|
||||
state.input.delete_right();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::CursorLeft => {
|
||||
state.input.cursor = state.input.cursor.saturating_sub(1);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::CursorRight => {
|
||||
if state.input.cursor < state.input.buffer.len() {
|
||||
state.input.cursor += 1;
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::HistoryUp => {
|
||||
state.input.history_up();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::HistoryDown => {
|
||||
state.input.history_down();
|
||||
state.mark_dirty();
|
||||
}
|
||||
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());
|
||||
}
|
||||
Action::ShowDiff => {
|
||||
// Run git diff to get current changes
|
||||
let diff_output = git_diff_output(state);
|
||||
state.misc.diff_content = diff_output;
|
||||
state.misc.diff_scroll = 0;
|
||||
state.misc.overlay = crate::state::Overlay::Diff;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DiffScroll(amount) => {
|
||||
let max_scroll = state
|
||||
.misc
|
||||
.diff_content
|
||||
.lines()
|
||||
.count()
|
||||
.saturating_sub(1);
|
||||
let new_scroll = (state.misc.diff_scroll as i32 + amount).max(0) as usize;
|
||||
state.misc.diff_scroll = new_scroll.min(max_scroll);
|
||||
state.mark_dirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `git diff` and return the output for display in the diff overlay.
|
||||
///
|
||||
/// Flow: runs `git diff HEAD` (staged + unstaged changes), and falls back
|
||||
/// to `git diff` if HEAD has no commits yet. Returns a summary of what
|
||||
/// changed with colored +/- markers.
|
||||
fn git_diff_output(state: &crate::state::AppStateRest) -> String {
|
||||
let root = state
|
||||
.workspace_roots
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
// Try diff against HEAD
|
||||
let head_result = Command::new("git")
|
||||
.args(["diff", "HEAD"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
|
||||
match head_result {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Changes (against HEAD) ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback: no HEAD yet (new repo)
|
||||
let diff_result = Command::new("git")
|
||||
.args(["diff"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
if let Ok(out) = diff_result {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Unstaged Changes ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get staged changes too
|
||||
let staged_result = Command::new("git")
|
||||
.args(["diff", "--cached"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
|
||||
if let Ok(out) = staged_result {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Staged Changes ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Also get status summary
|
||||
let status_result = Command::new("git")
|
||||
.args(["status", "--short"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
|
||||
if let Ok(out) = status_result {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Summary ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if output.is_empty() {
|
||||
output = "No changes detected in the working tree.".to_string();
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Git diff output helper for the diff overlay.
|
||||
//!
|
||||
//! Extracted to its own module so the action dispatcher stays focused on
|
||||
//! state mutation logic rather than shell-out I/O.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// Run `git diff` and return the output for display in the diff overlay.
|
||||
///
|
||||
/// Flow: runs `git diff HEAD` (staged + unstaged changes), and falls back
|
||||
/// to `git diff` if HEAD has no commits yet. Returns a summary of what
|
||||
/// changed with coloured +/- markers.
|
||||
pub fn git_diff_output(workspace_roots: &[std::path::PathBuf]) -> String {
|
||||
let root = workspace_roots
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
// Try diff against HEAD
|
||||
let head_result = Command::new("git")
|
||||
.args(["diff", "HEAD"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
|
||||
match head_result {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Changes (against HEAD) ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback: no HEAD yet (new repo)
|
||||
let diff_result = Command::new("git")
|
||||
.args(["diff"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
if let Ok(out) = diff_result {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Unstaged Changes ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get staged changes too
|
||||
let staged_result = Command::new("git")
|
||||
.args(["diff", "--cached"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
|
||||
if let Ok(out) = staged_result {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Staged Changes ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Also get status summary
|
||||
let status_result = Command::new("git")
|
||||
.args(["status", "--short"])
|
||||
.current_dir(&root)
|
||||
.output();
|
||||
|
||||
if let Ok(out) = status_result {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("=== Summary ===\n");
|
||||
output.push_str(&stdout);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if output.is_empty() {
|
||||
output = "No changes detected in the working tree.".to_string();
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Extracted action handlers for complex `Action` variants.
|
||||
//!
|
||||
//! The `apply_action` dispatcher delegates to these functions for variants
|
||||
//! whose logic exceeds a few lines, keeping the match statement compact
|
||||
//! and readable.
|
||||
|
||||
use crate::state::{AppStateRest, ChatMessageDisplay};
|
||||
use tracing::debug;
|
||||
use zesdex_domain::core::Role;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tick handler — drains turn events from the shared queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process the `Action::Tick` event: increment tick counter, drain all
|
||||
/// queued `TurnEvent` values from the shared queue, and apply each one
|
||||
/// to the application state.
|
||||
pub fn handle_tick(state: &mut AppStateRest) {
|
||||
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
|
||||
|
||||
// Drain turn events — collect into a local Vec first to avoid holding
|
||||
// the mutex guard across the entire dispatch loop.
|
||||
let events: Vec<zesdex_infrastructure::TurnEvent> = state
|
||||
.turn_events
|
||||
.lock()
|
||||
.map(|mut q| q.drain(..).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let had_events = !events.is_empty();
|
||||
|
||||
for event in events {
|
||||
apply_turn_event(state, event);
|
||||
}
|
||||
|
||||
// Mark dirty when there were events OR a turn is in-flight (spinner
|
||||
// animation); skip the flag when idle to avoid useless re-renders.
|
||||
if had_events || state.turn_in_flight() {
|
||||
state.mark_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TurnEvent dispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply a single `TurnEvent` to `AppStateRest`. Extracted from the
|
||||
/// `Tick` handler so each event variant's logic is self-contained.
|
||||
fn apply_turn_event(state: &mut AppStateRest, event: zesdex_infrastructure::TurnEvent) {
|
||||
match event {
|
||||
zesdex_infrastructure::TurnEvent::SystemNote { kind, message } => {
|
||||
if kind == "hive_mind_converged" {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.hive_mind_converged = true;
|
||||
}
|
||||
} else {
|
||||
state
|
||||
.push_transcript(ChatMessageDisplay::new(Role::System, message));
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::AssistantMessage(msg) => {
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
msg.role,
|
||||
msg.content.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::ToolResult { output, .. } => {
|
||||
state.push_transcript(ChatMessageDisplay::new(Role::Tool, output));
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Usage {
|
||||
tokens_in,
|
||||
tokens_out,
|
||||
} => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
|
||||
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
|
||||
rt.usage.last_tokens_in = tokens_in;
|
||||
rt.usage.last_tokens_out = tokens_out;
|
||||
rt.usage.api_calls = rt.usage.api_calls.saturating_add(1);
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Error(msg) => {
|
||||
state.toast_error(msg);
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamStart => {
|
||||
state
|
||||
.push_transcript(ChatMessageDisplay::new(Role::Assistant, String::new()));
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamToken(text) => {
|
||||
state.append_to_last_transcript(&text, false);
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamReasoning(text) => {
|
||||
state.append_to_last_transcript(&text, true);
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::StreamDone(_msg) => {
|
||||
state.dirty = true;
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Compacted(msgs) => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.messages = msgs;
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::TodoUpdate(content) => {
|
||||
state.misc.todo_content = content;
|
||||
state.toast_success("TODO list updated.");
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::PlanUpdate(content) => {
|
||||
state.misc.plan_content = content;
|
||||
state.toast_success("Project plan updated.");
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Done => {
|
||||
state
|
||||
.turn_in_flight_flag
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
state.dirty = true;
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::WorkflowAgentUpdate {
|
||||
agent_id,
|
||||
agent_name,
|
||||
status,
|
||||
} => {
|
||||
apply_workflow_update(state, agent_id, agent_name, status);
|
||||
}
|
||||
_ => {
|
||||
debug!("unhandled turn event variant");
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow-agent update handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Update the workflow sidebar state from a `WorkflowAgentUpdate` event.
|
||||
fn apply_workflow_update(
|
||||
state: &mut AppStateRest,
|
||||
agent_id: String,
|
||||
agent_name: String,
|
||||
status: zesdex_infrastructure::AgentStatus,
|
||||
) {
|
||||
let idx = state
|
||||
.workflow_engine
|
||||
.agents
|
||||
.iter()
|
||||
.position(|a| a.name == agent_id);
|
||||
|
||||
match status {
|
||||
zesdex_infrastructure::AgentStatus::Pending => {
|
||||
if idx.is_none() {
|
||||
state
|
||||
.workflow_engine
|
||||
.agents
|
||||
.push(crate::state::SimpleAgent::with_display(agent_id, agent_name));
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Running => {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents[i].state = crate::state::AgentState::Running;
|
||||
state.workflow_engine.agents[i].display_name = agent_name;
|
||||
state.workflow_engine.agents[i].started_at = Some(now);
|
||||
} else {
|
||||
let mut agent =
|
||||
crate::state::SimpleAgent::with_display(agent_id, agent_name);
|
||||
agent.state = crate::state::AgentState::Running;
|
||||
agent.started_at = Some(now);
|
||||
state.workflow_engine.agents.push(agent);
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Completed => {
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents[i].state = crate::state::AgentState::Completed;
|
||||
state.workflow_engine.agents[i].display_name = agent_name;
|
||||
state.workflow_engine.agents[i].completed_at =
|
||||
Some(chrono::Utc::now().timestamp_millis());
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Failed(msg) => {
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents[i].state = crate::state::AgentState::Failed;
|
||||
state.workflow_engine.agents[i].display_name = agent_name;
|
||||
state.workflow_engine.agents[i].error = Some(msg);
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::AgentStatus::Cancelled => {
|
||||
if let Some(i) = idx {
|
||||
state.workflow_engine.agents.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! The `Action` enum — a single well-typed event in the TUI, produced by
|
||||
//! key input and applied to `AppStateRest` by the event loop.
|
||||
//!
|
||||
//! # Flow
|
||||
//! `controller::input::handle_key` returns `Vec<Action>` \u{2192} the event loop
|
||||
//! calls `apply_action(&mut state, action)` for each one \u{2192} state is mutated
|
||||
//! in place.
|
||||
//!
|
||||
//! # Organisation
|
||||
//!
|
||||
//! ```text
|
||||
//! action/
|
||||
//! ├── mod.rs — Action enum + apply_action dispatcher
|
||||
//! ├── handlers.rs — Extracted handlers for complex action variants
|
||||
//! └── git.rs — git diff output helper (shell-out I/O)
|
||||
//! ```
|
||||
//!
|
||||
//! # Design
|
||||
//! Every state mutation funnels through this single chokepoint so the view
|
||||
//! layer never mutates state directly and the controller never needs to know
|
||||
//! *how* state is updated \u{2014} only *what* action to produce.
|
||||
|
||||
mod git;
|
||||
mod handlers;
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
use crate::state::Overlay;
|
||||
use crate::state::{AppStateRest, ChatMessageDisplay};
|
||||
use zesdex_domain::core::Role;
|
||||
|
||||
/// A single well-typed event in the TUI that mutates `AppStateRest`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Action {
|
||||
/// Hard exit — immediately terminates the process.
|
||||
ForceQuit,
|
||||
/// Submit a user message to the LLM, starting a new agent turn.
|
||||
SubmitInput(String),
|
||||
/// Delete one character before the cursor in the input buffer.
|
||||
DeleteChar,
|
||||
/// Delete one character after the cursor in the input buffer.
|
||||
DeleteCharRight,
|
||||
/// Move the cursor one position left in the input buffer.
|
||||
CursorLeft,
|
||||
/// Move the cursor one position right in the input buffer.
|
||||
CursorRight,
|
||||
/// Navigate up through command history.
|
||||
HistoryUp,
|
||||
/// Navigate down through command history.
|
||||
HistoryDown,
|
||||
/// Scroll the transcript pane up.
|
||||
ScrollUp,
|
||||
/// Scroll the transcript pane down.
|
||||
ScrollDown,
|
||||
/// Open a named overlay.
|
||||
OpenOverlay(Overlay),
|
||||
/// Close the currently active overlay.
|
||||
CloseOverlay,
|
||||
/// Insert a system-generated note into the transcript.
|
||||
SystemNote {
|
||||
/// Note category: "error", "info", "clear", "hive_mind_converged", etc.
|
||||
kind: String,
|
||||
/// The message text to display.
|
||||
message: String,
|
||||
},
|
||||
/// Show the quit-confirmation overlay.
|
||||
QuitConfirm,
|
||||
/// Terminal resize event.
|
||||
Resize(u16, u16),
|
||||
/// Periodic timer tick — drains queued `TurnEvent`s.
|
||||
Tick,
|
||||
/// Accept a lesson (learned behaviour pattern) by name.
|
||||
LessonAccept { name: String },
|
||||
/// Reject a lesson by name.
|
||||
LessonReject { name: String },
|
||||
/// Delete a previously stored lesson by name.
|
||||
LessonDelete { name: String },
|
||||
/// Start the OAuth device-code login flow for a named provider.
|
||||
StartOAuth { provider: String },
|
||||
/// Open the inline file editor for `path`.
|
||||
OpenEditor { path: String },
|
||||
/// Register a new MCP server by name and shell command.
|
||||
McpAdd { name: String, command: String },
|
||||
/// Open the model-picker overlay.
|
||||
ModelList,
|
||||
/// Set the abort flag on the currently running turn.
|
||||
AbortTurn,
|
||||
/// Request AI-summary compaction of the conversation history.
|
||||
Compact,
|
||||
/// Show the git diff preview overlay.
|
||||
ShowDiff,
|
||||
/// Scroll the diff overlay by `amount` lines (negative = up, positive = down).
|
||||
DiffScroll(i32),
|
||||
}
|
||||
|
||||
/// Apply an `Action` to `AppStateRest`.
|
||||
///
|
||||
/// Flow: pattern-match the variant \u{2192} mutate state in place.
|
||||
/// This is the single chokepoint for all state mutations.
|
||||
///
|
||||
/// Return: nothing; `state` is mutated in place.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
debug!("apply_action: {:?}", action);
|
||||
match action {
|
||||
// ── Lifecycle ─────────────────────────────────────────────────
|
||||
Action::ForceQuit => state.quit = true,
|
||||
Action::QuitConfirm => {
|
||||
state.misc.overlay = Overlay::QuitConfirm;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::Resize(w, _h) => {
|
||||
if state.last_render_width != w {
|
||||
state.transcript_cache.dirty = true;
|
||||
state.last_render_width = w;
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
// ── Timer tick (delegates to extracted handler) ────────────────
|
||||
Action::Tick => handlers::handle_tick(state),
|
||||
|
||||
// ── Input ──────────────────────────────────────────────────────
|
||||
Action::SubmitInput(text) => {
|
||||
state
|
||||
.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
|
||||
state.input.submit();
|
||||
crate::turn::spawn_agent_turn(state, text);
|
||||
state.token_count_dirty = true;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DeleteChar => {
|
||||
state.input.delete_left();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DeleteCharRight => {
|
||||
state.input.delete_right();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::CursorLeft => {
|
||||
state.input.cursor = state.input.cursor.saturating_sub(1);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::CursorRight => {
|
||||
if state.input.cursor < state.input.buffer.len() {
|
||||
state.input.cursor += 1;
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::HistoryUp => {
|
||||
state.input.history_up();
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::HistoryDown => {
|
||||
state.input.history_down();
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
// ── Scroll ─────────────────────────────────────────────────────
|
||||
Action::ScrollUp => {
|
||||
state.scroll.scroll_up(3);
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::ScrollDown => {
|
||||
state.scroll.scroll_down(3);
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
// ── Overlay ────────────────────────────────────────────────────
|
||||
Action::OpenOverlay(overlay) => {
|
||||
state.misc.overlay = overlay;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::CloseOverlay => {
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::SystemNote { kind: _, message } => {
|
||||
state
|
||||
.push_transcript(ChatMessageDisplay::new(Role::System, message));
|
||||
}
|
||||
Action::ModelList => {
|
||||
state.misc.overlay = Overlay::ModelSelector;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::OpenEditor { path } => {
|
||||
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
state.misc.editor = Some(crate::state::EditorState::new(
|
||||
std::path::PathBuf::from(&path),
|
||||
content,
|
||||
));
|
||||
state.misc.overlay = Overlay::Editor;
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
// ── Lessons ────────────────────────────────────────────────────
|
||||
Action::LessonAccept { name } => state.toast_info(format!("Lesson accepted: {name}")),
|
||||
Action::LessonReject { name } => state.toast_info(format!("Lesson rejected: {name}")),
|
||||
Action::LessonDelete { name } => state.toast_info(format!("Lesson deleted: {name}")),
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────
|
||||
Action::StartOAuth { provider } => {
|
||||
state.toast_info(format!("OAuth login started for {provider}"))
|
||||
}
|
||||
|
||||
// ── MCP ────────────────────────────────────────────────────────
|
||||
Action::McpAdd { name, command } => {
|
||||
state.toast_info(format!("MCP server added: {name} ({command})"))
|
||||
}
|
||||
|
||||
// ── Turn control ────────────────────────────────────────────────
|
||||
Action::AbortTurn => {
|
||||
state
|
||||
.abort_flag
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
state.toast_info("Aborting current turn...".to_string());
|
||||
}
|
||||
Action::Compact => state.toast_info("Compacting conversation...".to_string()),
|
||||
|
||||
// ── Diff ───────────────────────────────────────────────────────
|
||||
Action::ShowDiff => {
|
||||
let diff_output = git::git_diff_output(&state.workspace_roots);
|
||||
state.misc.diff_content = diff_output;
|
||||
state.misc.diff_scroll = 0;
|
||||
state.misc.overlay = Overlay::Diff;
|
||||
state.mark_dirty();
|
||||
}
|
||||
Action::DiffScroll(amount) => {
|
||||
let max_scroll = state
|
||||
.misc
|
||||
.diff_content
|
||||
.lines()
|
||||
.count()
|
||||
.saturating_sub(1);
|
||||
let new_scroll = (state.misc.diff_scroll as i32 + amount).max(0) as usize;
|
||||
state.misc.diff_scroll = new_scroll.min(max_scroll);
|
||||
state.mark_dirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
//! Standalone helper functions that operate on AppStateRest.
|
||||
//!
|
||||
//! These are kept separate from the AppStateRest impl block to keep that
|
||||
//! file focused on state definition and direct mutations. Helpers that
|
||||
//! aggregate, compute, or read from the filesystem live here.
|
||||
|
||||
use crate::state::AppStateRest;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effort levels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Name of each reasoning-effort tier.
|
||||
pub const EFFORT_LEVELS: &[&str] = &[
|
||||
"Auto \u{2014} let the provider decide",
|
||||
"Low \u{2014} fast, minimal reasoning",
|
||||
"Medium \u{2014} balanced speed & reasoning",
|
||||
"High \u{2014} thorough reasoning",
|
||||
"Maximum \u{2014} deep analysis",
|
||||
];
|
||||
|
||||
/// Return the current effort index from state.
|
||||
pub fn current_effort(state: &AppStateRest) -> usize {
|
||||
state
|
||||
.misc
|
||||
.effort_level
|
||||
.saturating_sub(1)
|
||||
.min(EFFORT_LEVELS.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Cycle effort level up or down.
|
||||
pub fn cycle_effort(state: &mut AppStateRest, forward: bool) {
|
||||
let n = EFFORT_LEVELS.len();
|
||||
if forward {
|
||||
state.misc.effort_level = (state.misc.effort_level % n) + 1;
|
||||
} else {
|
||||
state.misc.effort_level = if state.misc.effort_level <= 1 {
|
||||
n
|
||||
} else {
|
||||
state.misc.effort_level - 1
|
||||
};
|
||||
}
|
||||
state.mark_dirty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Learning items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A lesson entry displayed in the Learning overlay.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LearningItem {
|
||||
/// A newly-generated lesson pending user approval.
|
||||
Pending {
|
||||
name: String,
|
||||
content: String,
|
||||
scope: String,
|
||||
confidence: f64,
|
||||
},
|
||||
/// A lesson that has been accepted and stored.
|
||||
Stored {
|
||||
name: String,
|
||||
content: String,
|
||||
lifecycle: String,
|
||||
scope: String,
|
||||
description: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Read lesson markdown files from the `lessons/` subdirectory under the
|
||||
/// memory directory.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
||||
let lessons_dir = state.memory_dir.join("lessons");
|
||||
if !lessons_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&lessons_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
items.push(LearningItem::Stored {
|
||||
name: path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
content,
|
||||
lifecycle: "filesystem".to_string(),
|
||||
scope: "filesystem".to_string(),
|
||||
description: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Cycle the selected index within bounds.
|
||||
pub fn cycle_selected_index(current: usize, n: usize, forward: bool) -> usize {
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
if forward {
|
||||
(current + 1) % n
|
||||
} else if current == 0 {
|
||||
n - 1
|
||||
} else {
|
||||
current - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of rewind points available.
|
||||
pub fn rewind_count(state: &AppStateRest) -> usize {
|
||||
state.transcript_cache.messages.len()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token counting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve the window size for context window management.
|
||||
///
|
||||
/// Uses the model name from settings to determine max context window,
|
||||
/// falling back to settings-configured max or 128k default.
|
||||
#[tracing::instrument(skip(_app_config, settings))]
|
||||
pub fn resolve_context_window(
|
||||
_app_config: &zesdex_domain::cms::AppConfig,
|
||||
settings: &zesdex_domain::cms::Settings,
|
||||
) -> usize {
|
||||
if let Some(max) = settings.max_tokens {
|
||||
if max > 0 {
|
||||
return max as usize;
|
||||
}
|
||||
}
|
||||
256_000
|
||||
}
|
||||
|
||||
/// Count tokens using tiktoken, fall back to character estimation.
|
||||
///
|
||||
/// Flow: try tiktoken-rs `cl100k_base` BPE encoding \u{2192} return accurate count.
|
||||
/// On failure (~4 chars per token heuristic), fall back to character-based
|
||||
/// estimation so the UI never blocks on an unavailable tokeniser.
|
||||
#[tracing::instrument]
|
||||
pub fn count_tokens(text: &str) -> usize {
|
||||
if let Ok(bpe) = tiktoken_rs::cl100k_base() {
|
||||
return bpe.encode_with_special_tokens(text).len();
|
||||
}
|
||||
// Fallback: ~4 chars per token
|
||||
text.len().div_ceil(4)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Chat input buffer, cursor, history, and autocomplete state.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Which source populated the autocomplete dropdown.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutocompleteKind {
|
||||
/// Builtin slash-command (e.g. `/model`, `/help`).
|
||||
Command,
|
||||
/// `@file` mention from the workspace file index.
|
||||
FileMention,
|
||||
}
|
||||
|
||||
/// Builtin slash-commands recognised by the chat input autocomplete.
|
||||
const COMMANDS: &[&str] = &[
|
||||
"/help", "/quit", "/clear", "/login", "/login zen", "/login openai",
|
||||
"/edit", "/mcp add", "/model", "/model ls", "/model add",
|
||||
"/todo", "/usage", "/compact",
|
||||
];
|
||||
|
||||
/// The user's input buffer, cursor position, history, and autocomplete
|
||||
/// state for the chat prompt.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InputState {
|
||||
/// Raw UTF-8 input buffer content.
|
||||
pub buffer: String,
|
||||
/// Byte offset of the cursor within `buffer`.
|
||||
pub cursor: usize,
|
||||
/// Previously submitted input lines, oldest-first.
|
||||
pub history: Vec<String>,
|
||||
/// Index into `history` when browsing (None = at the current input).
|
||||
pub history_idx: Option<usize>,
|
||||
/// Current autocomplete candidate list.
|
||||
pub autocomplete_candidates: Vec<String>,
|
||||
/// Focused index within `autocomplete_candidates`.
|
||||
pub autocomplete_idx: usize,
|
||||
/// Whether the autocomplete dropdown is visible.
|
||||
pub autocomplete_visible: bool,
|
||||
/// Which kind of autocomplete is active.
|
||||
pub autocomplete_kind: AutocompleteKind,
|
||||
/// Byte offset of the `@` character that triggered file mention autocomplete.
|
||||
pub mention_start: usize,
|
||||
/// Optional path to a persistent history file.
|
||||
pub history_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// Create an empty input state.
|
||||
pub fn new() -> Self {
|
||||
InputState {
|
||||
buffer: String::new(),
|
||||
cursor: 0,
|
||||
history: Vec::new(),
|
||||
history_idx: None,
|
||||
autocomplete_candidates: Vec::new(),
|
||||
autocomplete_idx: 0,
|
||||
autocomplete_visible: false,
|
||||
autocomplete_kind: AutocompleteKind::Command,
|
||||
mention_start: 0,
|
||||
history_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the autocomplete dropdown and clear its state.
|
||||
pub fn close_autocomplete(&mut self) {
|
||||
self.autocomplete_visible = false;
|
||||
self.autocomplete_candidates.clear();
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_kind = AutocompleteKind::Command;
|
||||
self.mention_start = 0;
|
||||
}
|
||||
|
||||
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`.
|
||||
pub fn open_autocomplete(&mut self) {
|
||||
let trimmed = self.buffer.trim().to_string();
|
||||
if trimmed.is_empty() || !trimmed.starts_with('/') {
|
||||
self.close_autocomplete();
|
||||
return;
|
||||
}
|
||||
let prefix = trimmed.to_lowercase();
|
||||
self.autocomplete_candidates = COMMANDS
|
||||
.iter()
|
||||
.filter(|c| c.starts_with(&prefix))
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
self.autocomplete_kind = AutocompleteKind::Command;
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||
}
|
||||
|
||||
/// Find the `@mention` token (if any) immediately before the cursor.
|
||||
pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> {
|
||||
let before_cursor = &self.buffer[..self.cursor];
|
||||
let at_pos = before_cursor.rfind('@')?;
|
||||
let between = &before_cursor[at_pos + 1..];
|
||||
if between.chars().any(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
let boundary_ok = at_pos == 0
|
||||
|| before_cursor[..at_pos]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(char::is_whitespace);
|
||||
if !boundary_ok {
|
||||
return None;
|
||||
}
|
||||
Some((at_pos, between.to_string()))
|
||||
}
|
||||
|
||||
/// Open or refresh the `@file` mention dropdown from `files`.
|
||||
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
|
||||
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
||||
use nucleo_matcher::{Config, Matcher};
|
||||
let Some((start, query)) = self.mention_query_at_cursor() else {
|
||||
self.close_autocomplete();
|
||||
return;
|
||||
};
|
||||
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
|
||||
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
|
||||
let matched_files = pattern.match_list(files.iter(), &mut matcher);
|
||||
self.autocomplete_candidates = matched_files
|
||||
.into_iter()
|
||||
.take(10)
|
||||
.map(|(f, _)| f.clone())
|
||||
.collect();
|
||||
self.autocomplete_kind = AutocompleteKind::FileMention;
|
||||
self.mention_start = start;
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||
}
|
||||
|
||||
/// Move the autocomplete selection up (forward=false) or down (forward=true).
|
||||
pub fn cycle_autocomplete(&mut self, forward: bool) {
|
||||
let n = self.autocomplete_candidates.len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
if forward {
|
||||
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
|
||||
} else {
|
||||
self.autocomplete_idx = if self.autocomplete_idx == 0 {
|
||||
n - 1
|
||||
} else {
|
||||
self.autocomplete_idx - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept the currently selected autocomplete candidate.
|
||||
pub fn select_autocomplete(&mut self) -> bool {
|
||||
let Some(candidate) = self
|
||||
.autocomplete_candidates
|
||||
.get(self.autocomplete_idx)
|
||||
.cloned()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match self.autocomplete_kind {
|
||||
AutocompleteKind::Command => {
|
||||
self.buffer = candidate;
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
AutocompleteKind::FileMention => {
|
||||
if self.cursor < self.mention_start || self.mention_start > self.buffer.len() {
|
||||
self.close_autocomplete();
|
||||
return false;
|
||||
}
|
||||
let replacement = format!("@{candidate} ");
|
||||
self.buffer
|
||||
.replace_range(self.mention_start..self.cursor, &replacement);
|
||||
self.cursor = self.mention_start + replacement.len();
|
||||
}
|
||||
}
|
||||
self.close_autocomplete();
|
||||
true
|
||||
}
|
||||
|
||||
/// Tab-complete: open dropdown or cycle forward.
|
||||
pub fn tab_complete(&mut self) {
|
||||
if self.autocomplete_visible {
|
||||
self.cycle_autocomplete(true);
|
||||
} else {
|
||||
self.open_autocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
pub fn insert(&mut self, c: char) {
|
||||
self.buffer.insert(self.cursor, c);
|
||||
self.cursor += c.len_utf8();
|
||||
}
|
||||
|
||||
/// Delete the character to the left of the cursor (backspace).
|
||||
pub fn delete_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character at the cursor position (forward delete).
|
||||
pub fn delete_right(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit the current buffer and return the submitted text.
|
||||
pub fn submit(&mut self) -> String {
|
||||
let result = self.buffer.clone();
|
||||
if !result.is_empty() {
|
||||
if self.history.last() != Some(&result) {
|
||||
self.history.push(result.clone());
|
||||
if let Some(ref path) = self.history_file {
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(file, "{result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.history_idx = None;
|
||||
}
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
result
|
||||
}
|
||||
|
||||
/// Navigate backward through input history.
|
||||
pub fn history_up(&mut self) {
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
let idx = match self.history_idx {
|
||||
Some(i) if i > 0 => i - 1,
|
||||
None => self.history.len() - 1,
|
||||
Some(_) => return,
|
||||
};
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
|
||||
/// Navigate forward through input history.
|
||||
pub fn history_down(&mut self) {
|
||||
match self.history_idx {
|
||||
Some(i) if i < self.history.len() - 1 => {
|
||||
let idx = i + 1;
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
Some(_) => {
|
||||
self.history_idx = None;
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InputState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Miscellaneous state: overlay enum, misc state bag, editor state.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use zesdex_infrastructure::Toast;
|
||||
|
||||
/// Which modal overlay, if any, is currently shown over the main TUI view.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Overlay {
|
||||
/// No overlay; the main chat view is shown.
|
||||
None,
|
||||
/// Key bindings help screen.
|
||||
Help,
|
||||
/// Settings/configuration panel.
|
||||
Settings,
|
||||
/// Background bash job viewer.
|
||||
Bash,
|
||||
/// "Are you sure you want to quit?" confirmation.
|
||||
QuitConfirm,
|
||||
/// Raw key-code input capture (for binding custom keys).
|
||||
KeyInput,
|
||||
/// Inline editor (opened via `/edit`).
|
||||
Editor,
|
||||
/// Reasoning effort level selector.
|
||||
Effort,
|
||||
/// MCP server management panel.
|
||||
Mcp,
|
||||
/// Task list overlay.
|
||||
Todo,
|
||||
/// Project plan overlay.
|
||||
Plan,
|
||||
/// Session rewind / history scrubber.
|
||||
Rewind,
|
||||
/// Learning / lesson management panel.
|
||||
Learning,
|
||||
/// Token usage statistics panel.
|
||||
Usage,
|
||||
/// Generic loading spinner overlay.
|
||||
Loading,
|
||||
/// Model selector dropdown.
|
||||
ModelSelector,
|
||||
/// "Clear conversation?" confirmation.
|
||||
ClearConfirm,
|
||||
/// Git diff preview overlay.
|
||||
Diff,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
/// Human-readable name for this overlay variant.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Overlay::None => "none",
|
||||
Overlay::Help => "help",
|
||||
Overlay::Settings => "settings",
|
||||
Overlay::Bash => "bash",
|
||||
Overlay::QuitConfirm => "quit_confirm",
|
||||
Overlay::KeyInput => "key_input",
|
||||
Overlay::Editor => "editor",
|
||||
Overlay::Effort => "effort",
|
||||
Overlay::Mcp => "mcp",
|
||||
Overlay::Todo => "todo",
|
||||
Overlay::Plan => "plan",
|
||||
Overlay::Rewind => "rewind",
|
||||
Overlay::Learning => "learning",
|
||||
Overlay::Usage => "usage",
|
||||
Overlay::Loading => "loading",
|
||||
Overlay::ModelSelector => "model_selector",
|
||||
Overlay::ClearConfirm => "clear_confirm",
|
||||
Overlay::Diff => "diff",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any overlay (i.e. anything other than `None`) is active.
|
||||
pub fn is_active(self) -> bool {
|
||||
!matches!(self, Overlay::None)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Overlay {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple inline editor state for the TUI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorState {
|
||||
/// Path to the file being edited.
|
||||
pub path: PathBuf,
|
||||
/// Current buffer content.
|
||||
pub content: String,
|
||||
/// Cursor position (byte offset).
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Create a new editor state for the given path.
|
||||
pub fn new(path: PathBuf, content: String) -> Self {
|
||||
let cursor = content.len();
|
||||
EditorState { path, content, cursor }
|
||||
}
|
||||
|
||||
/// Return the full buffer content.
|
||||
pub fn as_string(&self) -> String {
|
||||
self.content.clone()
|
||||
}
|
||||
|
||||
/// Delete one character to the left of the cursor.
|
||||
pub fn delete_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
self.content.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The "miscellaneous" slice of app state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiscState {
|
||||
/// Currently active modal overlay (None = main chat view).
|
||||
pub overlay: Overlay,
|
||||
/// Active toast notifications.
|
||||
pub toasts: Vec<Toast>,
|
||||
/// Timestamp (ms) of the last staleness sweep for lesson cache.
|
||||
pub last_staleness_sweep_ms: i64,
|
||||
/// Whether the agent is currently "thinking".
|
||||
pub thinking: bool,
|
||||
/// Current LLM reasoning effort level (1-5).
|
||||
pub effort_level: usize,
|
||||
/// Currently focused index in list-type overlays.
|
||||
pub selected_index: usize,
|
||||
/// Optional inline editor state.
|
||||
pub editor: Option<EditorState>,
|
||||
/// Whether the API connection is established.
|
||||
pub api_connected: bool,
|
||||
/// Monotonically increasing tick count, incremented each render frame.
|
||||
pub tick_count: u64,
|
||||
/// Cached content of the TODO file.
|
||||
pub todo_content: String,
|
||||
/// Cached content of the PLAN file.
|
||||
pub plan_content: String,
|
||||
/// Whether a lesson background task is currently running.
|
||||
pub lesson_running: bool,
|
||||
/// Text waiting to be written to the system clipboard.
|
||||
pub pending_clipboard_copy: Option<String>,
|
||||
/// Cached git diff content for the preview overlay.
|
||||
pub diff_content: String,
|
||||
/// Scroll offset for the diff overlay.
|
||||
pub diff_scroll: usize,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
/// Create a fresh `MiscState` with no overlay, no toasts.
|
||||
pub fn new() -> Self {
|
||||
MiscState {
|
||||
overlay: Overlay::None,
|
||||
toasts: Vec::new(),
|
||||
last_staleness_sweep_ms: 0,
|
||||
thinking: false,
|
||||
effort_level: 1,
|
||||
selected_index: 0,
|
||||
editor: None,
|
||||
api_connected: false,
|
||||
tick_count: 0,
|
||||
todo_content: String::new(),
|
||||
plan_content: String::new(),
|
||||
lesson_running: false,
|
||||
pending_clipboard_copy: None,
|
||||
diff_content: String::new(),
|
||||
diff_scroll: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a toast notification to the active list.
|
||||
pub fn push_toast(&mut self, toast: Toast) {
|
||||
self.toasts.push(toast);
|
||||
}
|
||||
|
||||
/// Remove 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! TUI-perspective application state: `AppStateRest` and all the types it
|
||||
//! owns. This is the single source-of-truth struct for the TUI interface,
|
||||
//! mutated from `action::apply_action` and read by `view/` every render frame.
|
||||
//!
|
||||
//! # Organisation
|
||||
//!
|
||||
//! ```text
|
||||
//! state/
|
||||
//! ├── mod.rs — AppStateRest (the central struct) + re-exports
|
||||
//! ├── input.rs — InputState, AutocompleteKind
|
||||
//! ├── transcript.rs — TranscriptCache, ChatMessageDisplay
|
||||
//! ├── scroll.rs — ScrollState
|
||||
//! ├── misc.rs — MiscState, EditorState, Overlay
|
||||
//! ├── workflow.rs — SimpleAgent, AgentState, SimpleWorkflowEngine
|
||||
//! └── helpers.rs — Standalone functions operating on AppStateRest
|
||||
//! ```
|
||||
//!
|
||||
//! # Flow
|
||||
//! Construction in `run.rs::create_local_session` \u{2192} mutated by
|
||||
//! `action::apply_action` \u{2192} read-only in every `view/*::draw*` function.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
use zesdex_domain::cms::{AppConfig, Settings};
|
||||
use ratatui::text::Line;
|
||||
use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent};
|
||||
|
||||
pub mod helpers;
|
||||
pub mod input;
|
||||
pub mod misc;
|
||||
pub mod scroll;
|
||||
pub mod transcript;
|
||||
pub mod workflow;
|
||||
|
||||
// Re-export all public types from sub-modules at the `state` level so
|
||||
// consumers that previously used `crate::state::InputState` etc. still work.
|
||||
pub use input::{AutocompleteKind, InputState};
|
||||
pub use misc::{EditorState, MiscState, Overlay};
|
||||
pub use scroll::ScrollState;
|
||||
pub use transcript::{ChatMessageDisplay, TranscriptCache};
|
||||
pub use workflow::{AgentState, SimpleAgent, SimpleWorkflowEngine};
|
||||
|
||||
// Re-export the most commonly used helpers at the `state` level.
|
||||
pub use helpers::{
|
||||
count_tokens, current_effort, cycle_effort, cycle_selected_index, get_learning_items,
|
||||
rewind_count, resolve_context_window, EFFORT_LEVELS, LearningItem,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppStateRest — the single source-of-truth TUI state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The single source-of-truth state struct for the TUI interface.
|
||||
///
|
||||
/// Mutated from `controller/input.rs` and `actions/mod.rs` (via `Action`).
|
||||
/// Read-only from every `view/*` render function.
|
||||
#[derive(Clone)]
|
||||
pub struct AppStateRest {
|
||||
/// Persistent user settings.
|
||||
pub settings: Settings,
|
||||
/// Per-project app configuration.
|
||||
pub app_config: AppConfig,
|
||||
/// Absolute paths to each open workspace root directory.
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
/// Unique session identifier.
|
||||
pub session_id: String,
|
||||
/// Path to the session's data directory.
|
||||
pub session_dir: PathBuf,
|
||||
/// Path to the session memory directory.
|
||||
pub memory_dir: PathBuf,
|
||||
/// Path to the git worktrees directory.
|
||||
pub worktrees_dir: PathBuf,
|
||||
/// Shared async cache of directory listings.
|
||||
pub dir_cache: Arc<tokio::sync::RwLock<DirCache>>,
|
||||
/// Shared workspace file-path index for `@file` mention autocomplete.
|
||||
pub mention_index: MentionIndex,
|
||||
/// Optional per-session runtime state.
|
||||
pub session_runtime: Option<SessionRuntime>,
|
||||
/// Ring buffer of recent chat messages for the transcript pane.
|
||||
pub transcript_cache: TranscriptCache,
|
||||
/// Viewport scroll offset tracker.
|
||||
pub scroll: ScrollState,
|
||||
/// Chat input buffer, cursor, history, and autocomplete.
|
||||
pub input: InputState,
|
||||
/// Miscellaneous state: overlay, toasts, flags, editor, tick.
|
||||
pub misc: MiscState,
|
||||
/// Queue of events emitted by the running agent turn.
|
||||
pub turn_events: Arc<std::sync::Mutex<VecDeque<TurnEvent>>>,
|
||||
/// Whether an agent turn is currently in flight.
|
||||
/// Uses AtomicBool for lock-free check from render loop.
|
||||
pub turn_in_flight_flag: Arc<AtomicBool>,
|
||||
/// Cached display lines for the chat transcript panel.
|
||||
pub display_lines_cache: Vec<Line<'static>>,
|
||||
/// Cached token count for the current message history.
|
||||
pub cached_token_count: usize,
|
||||
/// Whether the token count cache is stale and needs recalculation.
|
||||
pub token_count_dirty: bool,
|
||||
/// Terminal width at the time of the last display_lines_cache rebuild.
|
||||
pub last_render_width: u16,
|
||||
/// Number of messages that were in the cache when it was last built.
|
||||
pub cached_msg_count: usize,
|
||||
/// Terminal width at the time of the last full cache build.
|
||||
pub render_width_at_cache: u16,
|
||||
/// Atomic flag set when the user aborts the current turn.
|
||||
pub abort_flag: Arc<AtomicBool>,
|
||||
/// Simplified workflow engine state for display.
|
||||
pub workflow_engine: SimpleWorkflowEngine,
|
||||
/// Whether the state has been modified since the last render sweep.
|
||||
pub dirty: bool,
|
||||
/// Whether the application has been requested to quit.
|
||||
pub quit: bool,
|
||||
/// Cached help text content.
|
||||
pub help_text: &'static str,
|
||||
}
|
||||
|
||||
/// Default help text shown in the Help overlay.
|
||||
pub const DEFAULT_HELP_TEXT: &str = r#" Zesdex TUI \u{2014} Keyboard Shortcuts
|
||||
|
||||
\u{2500}\u{2500}\u{2500} General \u{2500}\u{2500}\u{2500}
|
||||
Ctrl+C Quit confirm
|
||||
Ctrl+D Close overlay
|
||||
Ctrl+Y Copy last assistant message
|
||||
Esc Abort turn / Close overlay
|
||||
Tab Autocomplete
|
||||
|
||||
\u{2500}\u{2500}\u{2500} Navigation \u{2500}\u{2500}\u{2500}
|
||||
\u{2191} / \u{2193} History browse / Overlay navigate
|
||||
Ctrl+\u{2191}/\u{2193} Scroll transcript
|
||||
PgUp / PgDown Scroll transcript
|
||||
Enter Submit / Select autocomplete
|
||||
|
||||
\u{2500}\u{2500}\u{2500} Overlays \u{2500}\u{2500}\u{2500}
|
||||
/help Show this help
|
||||
/settings Open settings overlay
|
||||
/todo Open tasks (todo) overlay
|
||||
/usage Open usage statistics
|
||||
/bash Open bash jobs overlay
|
||||
/mcp Open MCP server management
|
||||
/model Open model selector
|
||||
/compact Compact conversation
|
||||
/clear Clear transcript
|
||||
/rewind Rewind conversation history
|
||||
|
||||
\u{2500}\u{2500}\u{2500} Editor Mode \u{2500}\u{2500}\u{2500}
|
||||
/edit <path> Open file for inline editing
|
||||
Ctrl+S Save changes
|
||||
Esc Dismiss editor
|
||||
"#;
|
||||
|
||||
impl Default for AppStateRest {
|
||||
fn default() -> Self {
|
||||
AppStateRest {
|
||||
settings: Settings::default(),
|
||||
app_config: AppConfig::default(),
|
||||
workspace_roots: Vec::new(),
|
||||
session_id: String::new(),
|
||||
session_dir: PathBuf::new(),
|
||||
memory_dir: PathBuf::new(),
|
||||
worktrees_dir: PathBuf::new(),
|
||||
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
|
||||
mention_index: MentionIndex::new(),
|
||||
session_runtime: None,
|
||||
transcript_cache: TranscriptCache::new(200),
|
||||
scroll: ScrollState::new(),
|
||||
input: InputState::new(),
|
||||
misc: MiscState::new(),
|
||||
turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())),
|
||||
turn_in_flight_flag: Arc::new(AtomicBool::new(false)),
|
||||
abort_flag: Arc::new(AtomicBool::new(false)),
|
||||
workflow_engine: SimpleWorkflowEngine::new(),
|
||||
dirty: true,
|
||||
quit: false,
|
||||
help_text: DEFAULT_HELP_TEXT,
|
||||
display_lines_cache: Vec::new(),
|
||||
cached_token_count: 0,
|
||||
token_count_dirty: true,
|
||||
last_render_width: 0,
|
||||
cached_msg_count: 0,
|
||||
render_width_at_cache: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppStateRest {
|
||||
/// Construct initial TUI state.
|
||||
pub fn new(
|
||||
workspace_roots: Vec<PathBuf>,
|
||||
session_dir: &std::path::Path,
|
||||
memory_dir: PathBuf,
|
||||
) -> Self {
|
||||
let settings = Settings::default();
|
||||
let app_config = AppConfig::default();
|
||||
let worktrees_dir = memory_dir
|
||||
.parent()
|
||||
.unwrap_or(&memory_dir)
|
||||
.join("worktrees");
|
||||
let session_id = session_dir.file_name().map_or_else(
|
||||
|| {
|
||||
warn!("[state] session_dir has no file_name, using empty session_id");
|
||||
String::new()
|
||||
},
|
||||
|n| n.to_string_lossy().to_string(),
|
||||
);
|
||||
|
||||
AppStateRest {
|
||||
settings,
|
||||
app_config,
|
||||
workspace_roots,
|
||||
session_id,
|
||||
session_dir: session_dir.to_path_buf(),
|
||||
memory_dir: memory_dir.clone(),
|
||||
worktrees_dir,
|
||||
turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())),
|
||||
turn_in_flight_flag: Arc::new(AtomicBool::new(false)),
|
||||
abort_flag: Arc::new(AtomicBool::new(false)),
|
||||
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
|
||||
mention_index: MentionIndex::new(),
|
||||
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
|
||||
workflow_engine: SimpleWorkflowEngine::new(),
|
||||
transcript_cache: TranscriptCache::new(200),
|
||||
scroll: ScrollState::new(),
|
||||
input: InputState::new(),
|
||||
misc: MiscState::new(),
|
||||
dirty: true,
|
||||
quit: false,
|
||||
help_text: DEFAULT_HELP_TEXT,
|
||||
display_lines_cache: Vec::new(),
|
||||
cached_token_count: 0,
|
||||
token_count_dirty: true,
|
||||
last_render_width: 0,
|
||||
cached_msg_count: 0,
|
||||
render_width_at_cache: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an agent turn is currently running.
|
||||
/// Uses lock-free AtomicBool load \u{2014} safe to call every render frame.
|
||||
pub fn turn_in_flight(&self) -> bool {
|
||||
self.turn_in_flight_flag.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Append a message to the transcript.
|
||||
/// Eviction is O(1) via VecDeque::pop_front.
|
||||
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
|
||||
self.transcript_cache.push(msg);
|
||||
self.token_count_dirty = true;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Append text to the last assistant message in the transcript, if one exists.
|
||||
pub fn append_to_last_transcript(&mut self, text: &str, is_reasoning: bool) {
|
||||
self.transcript_cache.append_to_last(text, is_reasoning);
|
||||
if self.transcript_cache.dirty {
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the app state as dirty, triggering a TUI re-render.
|
||||
pub fn mark_dirty(&mut self) {
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Queue a toast notification.
|
||||
pub fn push_toast(&mut self, toast: Toast) {
|
||||
self.misc.push_toast(toast);
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
||||
/// Push an info toast.
|
||||
pub fn toast_info(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Info, msg.into()));
|
||||
}
|
||||
|
||||
/// Push a success toast.
|
||||
pub fn toast_success(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Success, msg.into()));
|
||||
}
|
||||
|
||||
/// Push a warning toast.
|
||||
pub fn toast_warning(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Warning, msg.into()));
|
||||
}
|
||||
|
||||
/// Push an error toast.
|
||||
pub fn toast_error(&mut self, msg: impl Into<String>) {
|
||||
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Error, msg.into()));
|
||||
}
|
||||
|
||||
/// Persist settings to disk.
|
||||
pub fn save_settings(&self) {
|
||||
if let Ok(store_dir) = std::fs::canonicalize(self.store_base_dir()) {
|
||||
let repo =
|
||||
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new();
|
||||
use zesdex_domain::SettingsRepository;
|
||||
if let Err(e) = repo.save(&store_dir, &self.settings) {
|
||||
tracing::warn!("Failed to save settings: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the base directory for session stores.
|
||||
pub fn store_base_dir(&self) -> PathBuf {
|
||||
self.session_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map_or_else(
|
||||
|| {
|
||||
warn!("[state] no grandparent, using session_dir");
|
||||
self.session_dir.clone()
|
||||
},
|
||||
std::path::Path::to_path_buf,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Viewport scroll state: current offset and visible-line count.
|
||||
|
||||
/// Viewport scroll state: current offset and visible-line count.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollState {
|
||||
/// Current scroll offset (how many lines have been scrolled past).
|
||||
pub offset: usize,
|
||||
/// Maximum number of lines that fit in the visible viewport area.
|
||||
pub max_visible: usize,
|
||||
}
|
||||
|
||||
impl ScrollState {
|
||||
/// Create a `ScrollState` with zero offset and 30 rows visible.
|
||||
pub fn new() -> Self {
|
||||
ScrollState {
|
||||
offset: 0,
|
||||
max_visible: 30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
||||
pub fn scroll_up(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_add(amount);
|
||||
}
|
||||
|
||||
/// Scroll the viewport down by `amount` lines (decreasing the offset).
|
||||
pub fn scroll_down(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_sub(amount);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScrollState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Transcript display types: bounded ring-buffer cache of rendered messages.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use zesdex_domain::core::Role;
|
||||
|
||||
/// A single transcript entry rendered in the TUI chat pane.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ChatMessageDisplay {
|
||||
/// Message author: User or Assistant.
|
||||
pub role: Role,
|
||||
/// Rendered text content (plain text, no markdown).
|
||||
pub content: String,
|
||||
/// Model reasoning (e.g. from DeepSeek-R1 <think> block).
|
||||
pub reasoning: String,
|
||||
/// Millisecond timestamp when this display entry was created.
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl ChatMessageDisplay {
|
||||
/// Build a display entry, stamping it with the current time.
|
||||
pub fn new(role: Role, content: String) -> Self {
|
||||
ChatMessageDisplay {
|
||||
role,
|
||||
content,
|
||||
reasoning: String::new(),
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded ring of recent chat messages used to render the transcript view.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranscriptCache {
|
||||
/// Ordered display messages (newest appended, oldest evicted when full).
|
||||
/// Uses VecDeque for O(1) front eviction instead of O(n) Vec::remove(0).
|
||||
pub messages: VecDeque<ChatMessageDisplay>,
|
||||
/// Maximum messages to retain before evicting the oldest.
|
||||
pub max_lines: usize,
|
||||
/// Whether the cache has changed since the last render sweep.
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl TranscriptCache {
|
||||
/// Create an empty transcript cache holding at most `max_lines` messages.
|
||||
pub fn new(max_lines: usize) -> Self {
|
||||
TranscriptCache {
|
||||
messages: VecDeque::new(),
|
||||
max_lines,
|
||||
dirty: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a message, evicting the oldest if at capacity.
|
||||
/// Eviction is O(1) via VecDeque::pop_front instead of O(n) Vec::remove(0).
|
||||
pub fn push(&mut self, msg: ChatMessageDisplay) {
|
||||
self.messages.push_back(msg);
|
||||
while self.messages.len() > self.max_lines {
|
||||
self.messages.pop_front();
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Append text to the last assistant message, if one exists.
|
||||
pub fn append_to_last(&mut self, text: &str, is_reasoning: bool) {
|
||||
if let Some(msg) = self.messages.back_mut() {
|
||||
if msg.role == Role::Assistant {
|
||||
if is_reasoning {
|
||||
msg.reasoning.push_str(text);
|
||||
} else {
|
||||
msg.content.push_str(text);
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Simplified agent lifecycle display types for the workflow sidebar.
|
||||
|
||||
/// Simplified agent lifecycle state for TUI display.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AgentState {
|
||||
Idle,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A single agent entry in the workflow sidebar.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimpleAgent {
|
||||
/// Agent unique ID (e.g. "auto-review", "Node-0-1").
|
||||
pub name: String,
|
||||
/// Human-readable display label (e.g. "Auto-Review", "Backend API Agent").
|
||||
pub display_name: String,
|
||||
/// Current lifecycle state.
|
||||
pub state: AgentState,
|
||||
/// Millisecond timestamp when the agent started.
|
||||
pub started_at: Option<i64>,
|
||||
/// Millisecond timestamp when the agent completed.
|
||||
pub completed_at: Option<i64>,
|
||||
/// Optional error message if the agent failed.
|
||||
pub error: Option<String>,
|
||||
/// Optional progress text (current tool, step description).
|
||||
pub progress: Option<String>,
|
||||
}
|
||||
|
||||
impl SimpleAgent {
|
||||
/// Create a new agent with the given name (used as both ID and display name).
|
||||
pub fn new(name: String) -> Self {
|
||||
SimpleAgent {
|
||||
display_name: name.clone(),
|
||||
name,
|
||||
state: AgentState::Idle,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new agent with separate ID and display label.
|
||||
pub fn with_display(name: String, display_name: String) -> Self {
|
||||
SimpleAgent {
|
||||
name,
|
||||
display_name,
|
||||
state: AgentState::Idle,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simplified workflow engine state for TUI display.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimpleWorkflowEngine {
|
||||
/// Active agents in the workflow.
|
||||
pub agents: Vec<SimpleAgent>,
|
||||
/// Summary findings produced by completed agents.
|
||||
pub findings: Vec<String>,
|
||||
}
|
||||
|
||||
impl SimpleWorkflowEngine {
|
||||
/// Create an empty workflow engine state.
|
||||
pub fn new() -> Self {
|
||||
SimpleWorkflowEngine {
|
||||
agents: Vec::new(),
|
||||
findings: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SimpleWorkflowEngine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,74 @@
|
||||
//! TUI agent turn interface adapter — delegates execution to `zesdex_infrastructure::agent`.
|
||||
//! TUI agent turn interface adapter — resolves LLM provider configuration,
|
||||
//! builds the tool context, and spawns the agent turn on a background task.
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use tracing::info;
|
||||
|
||||
use zesdex_domain::core::ChatMessage;
|
||||
use zesdex_domain::agent::AgentTurnParams;
|
||||
use zesdex_infrastructure::llm::provider::LlmClient;
|
||||
use zesdex_infrastructure::tools::executor::InfrastructureToolExecutor;
|
||||
use zesdex_infrastructure::tools::{all_tools, tool_defs, ToolCtx};
|
||||
use zesdex_application::agent::turn_service::AgentTurnServiceImpl;
|
||||
use zesdex_application::agent::AgentTurnService;
|
||||
|
||||
use crate::state::AppStateRest;
|
||||
|
||||
/// Spawn an agent turn on a background thread by delegating to `zesdex-infrastructure`.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve the API key from settings or environment for the given provider.
|
||||
fn resolve_api_key(state: &AppStateRest, provider_name: &str) -> String {
|
||||
if let Some(key) = state.settings.api_keys.get(provider_name) {
|
||||
return key.clone();
|
||||
}
|
||||
if let Some(ref cfg) = state.app_config.providers.get(provider_name) {
|
||||
if let Some(ref default_key) = cfg.default_api_key {
|
||||
if !default_key.is_empty() {
|
||||
return default_key.clone();
|
||||
}
|
||||
}
|
||||
if let Some(ref env_name) = cfg.api_key_env {
|
||||
if let Ok(val) = std::env::var(env_name) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Resolve the API base URL from the provider config.
|
||||
fn resolve_api_base(state: &AppStateRest, provider_name: &str) -> Option<String> {
|
||||
state
|
||||
.app_config
|
||||
.providers
|
||||
.get(provider_name)
|
||||
.map(|cfg| cfg.api_base.clone())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Turn spawning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawn an agent turn on a background Tokio task.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Compare-exchange the in-flight flag (no-op if already running).
|
||||
/// 2. Resolve provider (API key, model, base URL) from settings.
|
||||
/// 3. Build messages including the user's input text.
|
||||
/// 4. Construct `AgentTurnParams` with the turn-event queue and abort flag.
|
||||
/// 5. Create `LlmClient`, `ToolCtx`, and `InfrastructureToolExecutor`.
|
||||
/// 6. Assemble `AgentTurnServiceImpl` and spawn it via `tokio::spawn`.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
||||
// compare_exchange: only mark in-flight if not already running
|
||||
// Only one turn at a time — compare_exchange is lock-free
|
||||
if state
|
||||
.turn_in_flight_flag
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
return; // already running
|
||||
return;
|
||||
}
|
||||
|
||||
let turn_events = state.turn_events.clone();
|
||||
@@ -25,29 +77,13 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
||||
let session_dir = state.session_dir.clone();
|
||||
let workspace_roots = state.workspace_roots.clone();
|
||||
|
||||
// Resolve LLM provider configuration from settings
|
||||
// ── Resolve provider configuration ─────────────────────────────────
|
||||
let provider_name = &state.settings.provider;
|
||||
let provider_cfg = state.app_config.providers.get(provider_name).cloned();
|
||||
|
||||
let mut api_key = String::new();
|
||||
if let Some(key) = state.settings.api_keys.get(provider_name) {
|
||||
api_key = key.clone();
|
||||
} else if let Some(ref cfg) = provider_cfg {
|
||||
if let Some(ref default_key) = cfg.default_api_key {
|
||||
api_key = default_key.clone();
|
||||
}
|
||||
if api_key.is_empty() {
|
||||
if let Some(ref env_name) = cfg.api_key_env {
|
||||
if let Ok(val) = std::env::var(env_name) {
|
||||
api_key = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let api_key = resolve_api_key(state, provider_name);
|
||||
let model = state.settings.model.clone();
|
||||
let api_base = provider_cfg.map(|cfg| cfg.api_base.clone());
|
||||
let api_base = resolve_api_base(state, provider_name);
|
||||
|
||||
// ── Build message list ─────────────────────────────────────────────
|
||||
let mut messages: Vec<ChatMessage> = state
|
||||
.session_runtime
|
||||
.as_ref()
|
||||
@@ -59,8 +95,9 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
||||
rt.messages = messages.clone();
|
||||
}
|
||||
|
||||
info!("delegating agent turn to infrastructure engine (model: {})", model);
|
||||
info!("Spawning agent turn (model: {model})");
|
||||
|
||||
// ── Assemble dependencies (composition root) ──────────────────────
|
||||
let params = AgentTurnParams {
|
||||
messages,
|
||||
session_dir: session_dir.clone(),
|
||||
@@ -73,32 +110,22 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
||||
api_base: api_base.clone(),
|
||||
};
|
||||
|
||||
let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new(
|
||||
api_key,
|
||||
model,
|
||||
api_base,
|
||||
));
|
||||
let client = std::sync::Arc::new(LlmClient::new(api_key, model, api_base));
|
||||
|
||||
let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder()
|
||||
let tool_ctx = ToolCtx::builder()
|
||||
.session_dir(session_dir)
|
||||
.workspaces(workspace_roots)
|
||||
.turn_events(turn_events)
|
||||
.build();
|
||||
|
||||
let tool_executor = std::sync::Arc::new(
|
||||
zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx),
|
||||
);
|
||||
let tool_executor =
|
||||
std::sync::Arc::new(InfrastructureToolExecutor::new(tool_ctx));
|
||||
|
||||
let tools = zesdex_infrastructure::tools::all_tools();
|
||||
let defs = zesdex_infrastructure::tools::tool_defs(&tools);
|
||||
let tools = all_tools();
|
||||
let defs = tool_defs(&tools);
|
||||
|
||||
let turn_service = zesdex_application::agent::turn_service::AgentTurnServiceImpl::new(
|
||||
client,
|
||||
tool_executor,
|
||||
defs,
|
||||
);
|
||||
let turn_service = AgentTurnServiceImpl::new(client, tool_executor, defs);
|
||||
|
||||
use zesdex_application::agent::AgentTurnService;
|
||||
tokio::spawn(async move {
|
||||
let _ = turn_service.run_turn(params).await;
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn render(
|
||||
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)
|
||||
(r.edit_count, r.lessons.total, r.review_count, r.consecutive_empty_reviews)
|
||||
});
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled(
|
||||
|
||||
Reference in New Issue
Block a user