diff --git a/src/app/mode/help.rs b/src/app/mode/help.rs index 635d051..1364422 100644 --- a/src/app/mode/help.rs +++ b/src/app/mode/help.rs @@ -23,10 +23,7 @@ Slash commands: /help Show this help /quit Quit session /mode Switch mode (chat, bash, workflow) - /lesson Create a lesson - /lesson list List lessons - /lesson export Export lessons - /lesson import Import lessons + /lesson Interactive lesson manager /clear Clear transcript"; /// Route an incoming action while the help overlay is open. diff --git a/src/app/mode/learning.rs b/src/app/mode/learning.rs new file mode 100644 index 0000000..332467b --- /dev/null +++ b/src/app/mode/learning.rs @@ -0,0 +1,70 @@ +use crate::app::state::rest::AppStateRest; + +/// A unified representation of a lesson item for the interactive TUI overlay. +#[derive(Debug, Clone)] +pub enum LearningItem { + Pending { + name: String, + content: String, + scope: String, + confidence: String, + }, + Stored { + name: String, + content: String, + lifecycle: String, + scope: String, + description: String, + }, +} + +/// Dynamically read all pending and stored lessons. +pub fn get_learning_items(state: &AppStateRest) -> Vec { + let mut items = Vec::new(); + + // 1. Load pending lessons from session directory + let pending = if let Some(ref rt) = state.session_runtime { + crate::app::review::load_pending_lessons(&rt.session_dir) + } else { + Vec::new() + }; + + for p in pending { + let scope_str = match p.lesson.scope { + crate::app::review::LessonScope::Project => "project", + crate::app::review::LessonScope::Global => "global", + }.to_string(); + + let conf_str = match p.lesson.confidence { + crate::app::review::Confidence::Human => "human", + crate::app::review::Confidence::Verified => "verified", + crate::app::review::Confidence::Unverified => "unverified", + crate::app::review::Confidence::Auto => "auto", + }.to_string(); + + items.push(LearningItem::Pending { + name: p.lesson.name, + content: p.lesson.content, + scope: scope_str, + confidence: conf_str, + }); + } + + // 2. Load stored memory lessons from long-term memory directory + let names = crate::model::memory::Memory::list(&state.memory_dir); + for name in names { + if let Ok(mem) = crate::model::memory::Memory::read(&state.memory_dir, &name) { + if mem.kind == "lesson" { + items.push(LearningItem::Stored { + name: mem.name, + content: mem.content, + lifecycle: mem.lifecycle, + scope: mem.scope.unwrap_or_else(|| "project".to_string()), + description: mem.description, + }); + } + } + } + + items +} diff --git a/src/app/mode/mod.rs b/src/app/mode/mod.rs index b651b03..ee1ac93 100644 --- a/src/app/mode/mod.rs +++ b/src/app/mode/mod.rs @@ -1,8 +1,6 @@ //! TUI mode definitions and per-mode input/action handlers, one submodule //! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.). -use serde::{Deserialize, Serialize}; - pub mod bash; pub mod editor; pub mod effort; @@ -14,23 +12,4 @@ pub mod quit_confirm; pub mod rewind; pub mod settings; pub mod todo; - -/// Which input/overlay mode the TUI is currently in; drives both key -/// routing (`controller/input.rs`) and rendering. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ModeKind { - Chat, - Bash, - Workflow, - Help, - Settings, - QuitConfirm, - - KeyInput, - Editor, - Effort, - Mcp, - Todo, - Rewind, - Loading, -} +pub mod learning; diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index a1bf7da..fb95823 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -20,7 +20,6 @@ use std::collections::VecDeque; use crate::app::harness::Verdict; use sha2::Digest; -use crate::app::mode::ModeKind; use crate::app::review::{should_trigger_review, trigger_review}; use crate::app::state::rest::{AppStateRest, ChatMessageDisplay}; use crate::app::state::runtime::TurnEvent; @@ -38,7 +37,6 @@ use crate::dto::chat::message::{ChatMessage, Role}; #[derive(Debug, Clone)] pub enum Action { ForceQuit, - SwitchMode(ModeKind), SubmitInput(String), DeleteChar, DeleteCharRight, @@ -57,18 +55,16 @@ pub enum Action { QuitConfirm, Resize(u16, u16), Tick, - LessonExport { - path: String, - }, - LessonImport { - path: String, - }, + LessonAccept { name: String, }, LessonReject { name: String, }, + LessonDelete { + name: String, + }, StartOAuth { provider: String, }, @@ -106,25 +102,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { state.shutdown_lsp(); state.quit = true; } - Action::SwitchMode(mode) => { - state.misc.overlay = match mode { - ModeKind::Chat - | ModeKind::Bash => Overlay::None, - ModeKind::Workflow => Overlay::Workflow, - ModeKind::Help => Overlay::Help, - ModeKind::Settings => Overlay::Settings, - ModeKind::QuitConfirm => Overlay::QuitConfirm, - ModeKind::KeyInput => Overlay::KeyInput, - ModeKind::Editor => Overlay::Editor, - ModeKind::Effort => Overlay::Effort, - ModeKind::Mcp => Overlay::Mcp, - ModeKind::Todo => Overlay::Todo, - ModeKind::Rewind => Overlay::Rewind, - ModeKind::Loading => Overlay::Loading, - }; - state.dirty = true; - } Action::SubmitInput(text) => { state.input.submit(); let text = text.trim().to_string(); @@ -175,6 +153,9 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { } Action::OpenOverlay(overlay) => { state.misc.overlay = overlay; + if overlay == Overlay::Learning || overlay == Overlay::Rewind || overlay == Overlay::ModelSelector { + state.misc.selected_index = 0; + } state.dirty = true; } Action::OpenEditor { path } => { @@ -245,45 +226,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { state.scroll.set_max_visible(w as usize); state.dirty = true; } - Action::LessonExport { path } => { - let dest = std::path::Path::new(&path); - if let Some(parent) = dest.parent() { - let _ = std::fs::create_dir_all(parent); - } - match crate::model::memory::export_lessons(&state.memory_dir, dest) { - Ok(_) => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Success, - format!("lessons exported to {}", path), - )); - } - Err(e) => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Error, - format!("export failed: {}", e), - )); - } - } - state.dirty = true; - } - Action::LessonImport { path } => { - let src = std::path::Path::new(&path); - match crate::model::memory::import_lessons(&state.memory_dir, src) { - Ok(count) => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Success, - format!("imported {} lessons from {}", count, path), - )); - } - Err(e) => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Error, - format!("import failed: {}", e), - )); - } - } - state.dirty = true; - } + Action::StartOAuth { provider } => { let turn_events = state.turn_events.clone(); let provider_clone = provider.clone(); @@ -583,6 +526,9 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { &rt.session_dir, &state.memory_dir, &name, true, ); } + if let Some(ref mut rt) = state.session_runtime { + refresh_lesson_counters(&state.memory_dir, rt); + } state.push_toast(Toast::new(ToastKind::Success, format!("accepted lesson: {}", name))); state.dirty = true; @@ -593,10 +539,21 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { &rt.session_dir, &state.memory_dir, &name, false, ); } + if let Some(ref mut rt) = state.session_runtime { + refresh_lesson_counters(&state.memory_dir, rt); + } state.push_toast(Toast::new(ToastKind::Info, format!("rejected lesson: {}", name))); state.dirty = true; } + Action::LessonDelete { name } => { + let _ = crate::model::memory::Memory::remove(&state.memory_dir, &name); + if let Some(ref mut rt) = state.session_runtime { + refresh_lesson_counters(&state.memory_dir, rt); + } + state.push_toast(Toast::new(ToastKind::Info, format!("deleted lesson: {}", name))); + state.dirty = true; + } Action::RunWorkflow { script } => { // Open the Workflow overlay so the user can see progress. state.misc.overlay = Overlay::Workflow; diff --git a/src/app/runtime/commands.rs b/src/app/runtime/commands.rs index 9da31b2..5629d87 100644 --- a/src/app/runtime/commands.rs +++ b/src/app/runtime/commands.rs @@ -21,29 +21,11 @@ pub fn apply_command(command: Command) -> Vec { Command::Quit => { vec![Action::QuitConfirm] } - Command::LessonCreate(text) => { - vec![Action::SystemNote { - kind: "lesson".to_string(), - message: format!("/lesson {}", text), - }] - } - Command::LessonExport(path) => { - vec![Action::LessonExport { path }] - } - Command::LessonImport(path) => { - vec![Action::LessonImport { path }] - } - Command::LessonList => { + Command::LessonInteractive => { vec![Action::OpenOverlay(Overlay::Learning)] } - Command::LessonAccept(name) => { - vec![Action::LessonAccept { name }] - } - Command::LessonReject(name) => { - vec![Action::LessonReject { name }] - } - Command::Mode(mode) => { - vec![Action::SwitchMode(mode)] + Command::McpOpen => { + vec![Action::OpenOverlay(Overlay::Mcp)] } Command::ClearConfirm => { vec![Action::OpenOverlay(Overlay::ClearConfirm)] diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index 8ef461f..3b595e6 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -80,11 +80,6 @@ const COMMANDS: &[&str] = &[ "/quit", "/clear", "/lesson", - "/lesson ls", - "/lesson export", - "/lesson import", - "/lesson accept", - "/lesson reject", "/login", "/login zen", "/login openai", diff --git a/src/controller/command.rs b/src/controller/command.rs index fc0acb4..c5c5ad7 100644 --- a/src/controller/command.rs +++ b/src/controller/command.rs @@ -1,20 +1,13 @@ //! Slash-command parser that maps TUI `/foo` input lines into `Command` //! variants for the action dispatch system. -use crate::app::mode::ModeKind; - /// A parsed slash command from the TUI input buffer. #[derive(Debug, Clone, PartialEq)] pub enum Command { Help, Quit, - LessonCreate(String), - LessonExport(String), - LessonImport(String), - LessonAccept(String), - LessonReject(String), - LessonList, - Mode(ModeKind), + LessonInteractive, + McpOpen, Clear, ClearConfirm, Login { provider: String }, @@ -54,31 +47,13 @@ pub fn parse_command(text: &str) -> Command { "/quit" => Command::Quit, "/clear" if arg1.is_empty() => Command::ClearConfirm, "/clear" => Command::Clear, - "/mode" if arg1.is_empty() => Command::Unknown("/mode requires a subcommand: chat, bash, help, settings".to_string()), - "/mode" => { - let mode = match arg1 { - "chat" | "c" => ModeKind::Chat, - "bash" | "b" => ModeKind::Bash, - "workflow" | "w" => ModeKind::Workflow, - "help" | "h" => ModeKind::Help, - "settings" | "s" => ModeKind::Settings, - _ => return Command::Unknown(format!("unknown mode: {}", arg1)), - }; - Command::Mode(mode) - } - "/lesson" if arg1 == "export" && !arg2.is_empty() => Command::LessonExport(arg2.to_string()), - "/lesson" if arg1 == "import" && !arg2.is_empty() => Command::LessonImport(arg2.to_string()), - "/lesson" if arg1 == "list" || arg1 == "ls" => Command::LessonList, - "/lesson" if arg1 == "accept" && !arg2.is_empty() => Command::LessonAccept(arg2.to_string()), - "/lesson" if arg1 == "reject" && !arg2.is_empty() => Command::LessonReject(arg2.to_string()), - "/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()), - "/lesson" => Command::LessonList, + "/lesson" => Command::LessonInteractive, "/login" if arg1.is_empty() => Command::Login { provider: String::new() }, "/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() }, "/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()), "/edit" => Command::Edit(".".to_string()), "/mcp" if arg1.is_empty() => { - Command::Mode(ModeKind::Mcp) + Command::McpOpen } "/mcp" if arg1 == "add" && !arg2.is_empty() => { let rest = arg2.trim(); diff --git a/src/controller/input.rs b/src/controller/input.rs index 0f11136..d4e674c 100644 --- a/src/controller/input.rs +++ b/src/controller/input.rs @@ -69,6 +69,70 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } } + if state.misc.overlay == Overlay::Learning { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + return vec![Action::QuitConfirm]; + } + KeyCode::Esc => { + return vec![Action::CloseOverlay]; + } + KeyCode::Up => { + let items = crate::app::mode::learning::get_learning_items(state); + let n = items.len(); + state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; + state.dirty = true; + return vec![]; + } + KeyCode::Down => { + let items = crate::app::mode::learning::get_learning_items(state); + let n = items.len(); + state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; + state.dirty = true; + return vec![]; + } + KeyCode::Enter | KeyCode::Char('a') => { + let items = crate::app::mode::learning::get_learning_items(state); + if let Some(item) = items.get(state.misc.selected_index) { + match item { + crate::app::mode::learning::LearningItem::Pending { name, .. } => { + return vec![Action::LessonAccept { name: name.clone() }]; + } + _ => {} + } + } + return vec![]; + } + KeyCode::Char('r') => { + let items = crate::app::mode::learning::get_learning_items(state); + if let Some(item) = items.get(state.misc.selected_index) { + match item { + crate::app::mode::learning::LearningItem::Pending { name, .. } => { + return vec![Action::LessonReject { name: name.clone() }]; + } + _ => {} + } + } + return vec![]; + } + KeyCode::Char('d') | KeyCode::Delete | KeyCode::Backspace => { + let items = crate::app::mode::learning::get_learning_items(state); + if let Some(item) = items.get(state.misc.selected_index) { + match item { + crate::app::mode::learning::LearningItem::Pending { name, .. } => { + return vec![Action::LessonReject { name: name.clone() }]; + } + crate::app::mode::learning::LearningItem::Stored { name, .. } => { + return vec![Action::LessonDelete { name: name.clone() }]; + } + } + } + return vec![]; + } + _ => return vec![], + } + } + match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { vec![Action::QuitConfirm] diff --git a/src/resources.rs b/src/resources.rs index 6369b33..06d26f6 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -24,6 +24,7 @@ Navigation: Input: /help Show help /clear Clear screen + /lesson Interactive lesson manager /model Select AI model provider /workflow Open workflow panel /workflow run

Run a workflow with prompt

diff --git a/src/view/mod.rs b/src/view/mod.rs index 7fb8e49..dfe36bf 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -372,69 +372,128 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::Learning => { - let block = block.title(" Learning "); - let (total, by_user, by_fb, by_proj, by_ref, act, stale, contra, human, verified, unverified) = state.session_runtime.as_ref().map(|r| { - (r.lesson_count, r.lessons_user, r.lessons_feedback, r.lessons_project, r.lessons_reference, r.lessons_active, r.lessons_stale, r.lessons_contradicted, r.lessons_human, r.lessons_verified, r.lessons_unverified) - }).unwrap_or_default(); - let lines = vec![ - Line::from(Span::styled( - "Lessons Dashboard", - Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled("", Style::default())), - Line::from(Span::styled( - format!("Total lessons: {}", total), - Style::default().fg(Theme::INFO), - )), - Line::from(Span::styled("", Style::default())), - Line::from(Span::styled("By Type:", Style::default().fg(Theme::DIM))), - Line::from(Span::styled( - format!(" User: {}", by_user), - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - format!(" Feedback: {}", by_fb), - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - format!(" Project: {}", by_proj), - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - format!(" Reference: {}", by_ref), - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled("", Style::default())), - Line::from(Span::styled("Lifecycle:", Style::default().fg(Theme::DIM))), - Line::from(Span::styled( - format!(" Active: {}", act), - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - format!(" Stale: {}", stale), - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - format!(" Contradicted: {}", contra), - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled("", Style::default())), - Line::from(Span::styled("Confidence:", Style::default().fg(Theme::DIM))), - Line::from(Span::styled( - format!(" Human: {}", human), - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - format!(" Verified: {}", verified), - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - format!(" Unverified: {}", unverified), - Style::default().fg(Theme::TEXT), - )), - ]; - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, overlay_area); + let h_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(40), // Left: List + Constraint::Percentage(60), // Right: Details + ]) + .split(overlay_area); + + let left_block = Block::default() + .title(" Lessons ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::PRIMARY)) + .style(Style::default().bg(Theme::BG)); + + let right_block = Block::default() + .title(" Lesson Details ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::PRIMARY)) + .style(Style::default().bg(Theme::BG)); + + let items = crate::app::mode::learning::get_learning_items(state); + let mut left_lines = Vec::new(); + if items.is_empty() { + left_lines.push(Line::from(Span::styled( + "No lessons found.", + Style::default().fg(Theme::DIM), + ))); + } else { + for (i, item) in items.iter().enumerate() { + let is_selected = i == state.misc.selected_index; + let prefix = if is_selected { "▸ " } else { " " }; + let (label, style) = match item { + crate::app::mode::learning::LearningItem::Pending { name, .. } => { + ( + format!("{}[Pending] {}", prefix, name), + if is_selected { + Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Theme::WARNING) + } + ) + } + crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => { + let status = if lifecycle == "stale" { "Stale" } else { "Active" }; + ( + format!("{}[{}] {}", prefix, status, name), + if is_selected { + Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Theme::TEXT) + } + ) + } + }; + left_lines.push(Line::from(Span::styled(label, style))); + } + } + + // Scroll the left list so the selected index is always visible + let max_lines = h_chunks[0].height.saturating_sub(2) as usize; + let selected = state.misc.selected_index; + let start_idx = if selected >= max_lines { + selected - max_lines + 1 + } else { + 0 + }; + let end_idx = (start_idx + max_lines).min(left_lines.len()); + let visible_lines = if left_lines.is_empty() { + Vec::new() + } else { + left_lines[start_idx..end_idx].to_vec() + }; + + let left_paragraph = Paragraph::new(visible_lines).block(left_block); + frame.render_widget(left_paragraph, h_chunks[0]); + + let mut right_lines = Vec::new(); + if let Some(item) = items.get(selected) { + match item { + crate::app::mode::learning::LearningItem::Pending { name, content, scope, confidence } => { + right_lines.push(Line::from(Span::styled("Name:", Style::default().fg(Theme::DIM)))); + right_lines.push(Line::from(Span::styled(name, Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)))); + right_lines.push(Line::from("")); + right_lines.push(Line::from(Span::styled("Status: Pending Approval", Style::default().fg(Theme::WARNING)))); + right_lines.push(Line::from(Span::styled(format!("Scope: {}", scope), Style::default().fg(Theme::TEXT)))); + right_lines.push(Line::from(Span::styled(format!("Confidence: {}", confidence), Style::default().fg(Theme::TEXT)))); + right_lines.push(Line::from("")); + right_lines.push(Line::from(Span::styled("Content:", Style::default().fg(Theme::DIM)))); + for line in content.lines() { + right_lines.push(Line::from(Span::styled(line, Style::default().fg(Theme::TEXT)))); + } + right_lines.push(Line::from("")); + right_lines.push(Line::from(Span::styled("Keys:", Style::default().fg(Theme::DIM)))); + right_lines.push(Line::from(Span::styled(" [Enter] or [a] to Accept", Style::default().fg(Theme::SUCCESS)))); + right_lines.push(Line::from(Span::styled(" [Backspace]/[Delete]/[r] to Reject", Style::default().fg(Theme::ERROR)))); + } + crate::app::mode::learning::LearningItem::Stored { name, content, lifecycle, scope, description } => { + right_lines.push(Line::from(Span::styled("Name:", Style::default().fg(Theme::DIM)))); + right_lines.push(Line::from(Span::styled(name, Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)))); + right_lines.push(Line::from("")); + let status_color = if lifecycle == "stale" { Theme::WARNING } else { Theme::SUCCESS }; + right_lines.push(Line::from(Span::styled(format!("Status: {}", lifecycle), Style::default().fg(status_color)))); + right_lines.push(Line::from(Span::styled(format!("Scope: {}", scope), Style::default().fg(Theme::TEXT)))); + right_lines.push(Line::from(Span::styled(format!("Description: {}", description), Style::default().fg(Theme::TEXT)))); + right_lines.push(Line::from("")); + right_lines.push(Line::from(Span::styled("Content:", Style::default().fg(Theme::DIM)))); + for line in content.lines() { + right_lines.push(Line::from(Span::styled(line, Style::default().fg(Theme::TEXT)))); + } + right_lines.push(Line::from("")); + right_lines.push(Line::from(Span::styled("Keys:", Style::default().fg(Theme::DIM)))); + right_lines.push(Line::from(Span::styled(" [Backspace]/[Delete]/[d] to Delete Lesson", Style::default().fg(Theme::ERROR)))); + } + } + } else { + right_lines.push(Line::from(Span::styled( + "Select a lesson on the left to see details.", + Style::default().fg(Theme::DIM), + ))); + } + let right_paragraph = Paragraph::new(right_lines).block(right_block).wrap(Wrap { trim: false }); + frame.render_widget(right_paragraph, h_chunks[1]); } crate::app::state::types::Overlay::Usage => { let block = block.title(" Usage ");