feat: implement interactive lesson management and update command handling
This commit is contained in:
@@ -23,10 +23,7 @@ Slash commands:
|
|||||||
/help Show this help
|
/help Show this help
|
||||||
/quit Quit session
|
/quit Quit session
|
||||||
/mode <name> Switch mode (chat, bash, workflow)
|
/mode <name> Switch mode (chat, bash, workflow)
|
||||||
/lesson <text> Create a lesson
|
/lesson Interactive lesson manager
|
||||||
/lesson list List lessons
|
|
||||||
/lesson export Export lessons
|
|
||||||
/lesson import Import lessons
|
|
||||||
/clear Clear transcript";
|
/clear Clear transcript";
|
||||||
|
|
||||||
/// Route an incoming action while the help overlay is open.
|
/// Route an incoming action while the help overlay is open.
|
||||||
|
|||||||
@@ -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<LearningItem> {
|
||||||
|
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
|
||||||
|
}
|
||||||
+1
-22
@@ -1,8 +1,6 @@
|
|||||||
//! TUI mode definitions and per-mode input/action handlers, one submodule
|
//! TUI mode definitions and per-mode input/action handlers, one submodule
|
||||||
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
|
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
pub mod bash;
|
pub mod bash;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod effort;
|
pub mod effort;
|
||||||
@@ -14,23 +12,4 @@ pub mod quit_confirm;
|
|||||||
pub mod rewind;
|
pub mod rewind;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod todo;
|
pub mod todo;
|
||||||
|
pub mod learning;
|
||||||
/// 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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ use std::collections::VecDeque;
|
|||||||
|
|
||||||
use crate::app::harness::Verdict;
|
use crate::app::harness::Verdict;
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
use crate::app::mode::ModeKind;
|
|
||||||
use crate::app::review::{should_trigger_review, trigger_review};
|
use crate::app::review::{should_trigger_review, trigger_review};
|
||||||
use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
|
use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
|
||||||
use crate::app::state::runtime::TurnEvent;
|
use crate::app::state::runtime::TurnEvent;
|
||||||
@@ -38,7 +37,6 @@ use crate::dto::chat::message::{ChatMessage, Role};
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Action {
|
pub enum Action {
|
||||||
ForceQuit,
|
ForceQuit,
|
||||||
SwitchMode(ModeKind),
|
|
||||||
SubmitInput(String),
|
SubmitInput(String),
|
||||||
DeleteChar,
|
DeleteChar,
|
||||||
DeleteCharRight,
|
DeleteCharRight,
|
||||||
@@ -57,18 +55,16 @@ pub enum Action {
|
|||||||
QuitConfirm,
|
QuitConfirm,
|
||||||
Resize(u16, u16),
|
Resize(u16, u16),
|
||||||
Tick,
|
Tick,
|
||||||
LessonExport {
|
|
||||||
path: String,
|
|
||||||
},
|
|
||||||
LessonImport {
|
|
||||||
path: String,
|
|
||||||
},
|
|
||||||
LessonAccept {
|
LessonAccept {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
LessonReject {
|
LessonReject {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
LessonDelete {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
StartOAuth {
|
StartOAuth {
|
||||||
provider: String,
|
provider: String,
|
||||||
},
|
},
|
||||||
@@ -106,25 +102,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.shutdown_lsp();
|
state.shutdown_lsp();
|
||||||
state.quit = true;
|
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) => {
|
Action::SubmitInput(text) => {
|
||||||
state.input.submit();
|
state.input.submit();
|
||||||
let text = text.trim().to_string();
|
let text = text.trim().to_string();
|
||||||
@@ -175,6 +153,9 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
}
|
}
|
||||||
Action::OpenOverlay(overlay) => {
|
Action::OpenOverlay(overlay) => {
|
||||||
state.misc.overlay = overlay;
|
state.misc.overlay = overlay;
|
||||||
|
if overlay == Overlay::Learning || overlay == Overlay::Rewind || overlay == Overlay::ModelSelector {
|
||||||
|
state.misc.selected_index = 0;
|
||||||
|
}
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
Action::OpenEditor { path } => {
|
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.scroll.set_max_visible(w as usize);
|
||||||
state.dirty = true;
|
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 } => {
|
Action::StartOAuth { provider } => {
|
||||||
let turn_events = state.turn_events.clone();
|
let turn_events = state.turn_events.clone();
|
||||||
let provider_clone = provider.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,
|
&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,
|
state.push_toast(Toast::new(ToastKind::Success,
|
||||||
format!("accepted lesson: {}", name)));
|
format!("accepted lesson: {}", name)));
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
@@ -593,10 +539,21 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
&rt.session_dir, &state.memory_dir, &name, false,
|
&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,
|
state.push_toast(Toast::new(ToastKind::Info,
|
||||||
format!("rejected lesson: {}", name)));
|
format!("rejected lesson: {}", name)));
|
||||||
state.dirty = true;
|
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 } => {
|
Action::RunWorkflow { script } => {
|
||||||
// Open the Workflow overlay so the user can see progress.
|
// Open the Workflow overlay so the user can see progress.
|
||||||
state.misc.overlay = Overlay::Workflow;
|
state.misc.overlay = Overlay::Workflow;
|
||||||
|
|||||||
@@ -21,29 +21,11 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
|||||||
Command::Quit => {
|
Command::Quit => {
|
||||||
vec![Action::QuitConfirm]
|
vec![Action::QuitConfirm]
|
||||||
}
|
}
|
||||||
Command::LessonCreate(text) => {
|
Command::LessonInteractive => {
|
||||||
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 => {
|
|
||||||
vec![Action::OpenOverlay(Overlay::Learning)]
|
vec![Action::OpenOverlay(Overlay::Learning)]
|
||||||
}
|
}
|
||||||
Command::LessonAccept(name) => {
|
Command::McpOpen => {
|
||||||
vec![Action::LessonAccept { name }]
|
vec![Action::OpenOverlay(Overlay::Mcp)]
|
||||||
}
|
|
||||||
Command::LessonReject(name) => {
|
|
||||||
vec![Action::LessonReject { name }]
|
|
||||||
}
|
|
||||||
Command::Mode(mode) => {
|
|
||||||
vec![Action::SwitchMode(mode)]
|
|
||||||
}
|
}
|
||||||
Command::ClearConfirm => {
|
Command::ClearConfirm => {
|
||||||
vec![Action::OpenOverlay(Overlay::ClearConfirm)]
|
vec![Action::OpenOverlay(Overlay::ClearConfirm)]
|
||||||
|
|||||||
@@ -80,11 +80,6 @@ const COMMANDS: &[&str] = &[
|
|||||||
"/quit",
|
"/quit",
|
||||||
"/clear",
|
"/clear",
|
||||||
"/lesson",
|
"/lesson",
|
||||||
"/lesson ls",
|
|
||||||
"/lesson export",
|
|
||||||
"/lesson import",
|
|
||||||
"/lesson accept",
|
|
||||||
"/lesson reject",
|
|
||||||
"/login",
|
"/login",
|
||||||
"/login zen",
|
"/login zen",
|
||||||
"/login openai",
|
"/login openai",
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
//! Slash-command parser that maps TUI `/foo` input lines into `Command`
|
//! Slash-command parser that maps TUI `/foo` input lines into `Command`
|
||||||
//! variants for the action dispatch system.
|
//! variants for the action dispatch system.
|
||||||
|
|
||||||
use crate::app::mode::ModeKind;
|
|
||||||
|
|
||||||
/// A parsed slash command from the TUI input buffer.
|
/// A parsed slash command from the TUI input buffer.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
Help,
|
Help,
|
||||||
Quit,
|
Quit,
|
||||||
LessonCreate(String),
|
LessonInteractive,
|
||||||
LessonExport(String),
|
McpOpen,
|
||||||
LessonImport(String),
|
|
||||||
LessonAccept(String),
|
|
||||||
LessonReject(String),
|
|
||||||
LessonList,
|
|
||||||
Mode(ModeKind),
|
|
||||||
Clear,
|
Clear,
|
||||||
ClearConfirm,
|
ClearConfirm,
|
||||||
Login { provider: String },
|
Login { provider: String },
|
||||||
@@ -54,31 +47,13 @@ pub fn parse_command(text: &str) -> Command {
|
|||||||
"/quit" => Command::Quit,
|
"/quit" => Command::Quit,
|
||||||
"/clear" if arg1.is_empty() => Command::ClearConfirm,
|
"/clear" if arg1.is_empty() => Command::ClearConfirm,
|
||||||
"/clear" => Command::Clear,
|
"/clear" => Command::Clear,
|
||||||
"/mode" if arg1.is_empty() => Command::Unknown("/mode requires a subcommand: chat, bash, help, settings".to_string()),
|
"/lesson" => Command::LessonInteractive,
|
||||||
"/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,
|
|
||||||
"/login" if arg1.is_empty() => Command::Login { provider: String::new() },
|
"/login" if arg1.is_empty() => Command::Login { provider: String::new() },
|
||||||
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
|
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
|
||||||
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
|
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
|
||||||
"/edit" => Command::Edit(".".to_string()),
|
"/edit" => Command::Edit(".".to_string()),
|
||||||
"/mcp" if arg1.is_empty() => {
|
"/mcp" if arg1.is_empty() => {
|
||||||
Command::Mode(ModeKind::Mcp)
|
Command::McpOpen
|
||||||
}
|
}
|
||||||
"/mcp" if arg1 == "add" && !arg2.is_empty() => {
|
"/mcp" if arg1 == "add" && !arg2.is_empty() => {
|
||||||
let rest = arg2.trim();
|
let rest = arg2.trim();
|
||||||
|
|||||||
@@ -69,6 +69,70 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
match key.code {
|
||||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
vec![Action::QuitConfirm]
|
vec![Action::QuitConfirm]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Navigation:
|
|||||||
Input:
|
Input:
|
||||||
/help Show help
|
/help Show help
|
||||||
/clear Clear screen
|
/clear Clear screen
|
||||||
|
/lesson Interactive lesson manager
|
||||||
/model Select AI model provider
|
/model Select AI model provider
|
||||||
/workflow Open workflow panel
|
/workflow Open workflow panel
|
||||||
/workflow run <p> Run a workflow with prompt <p>
|
/workflow run <p> Run a workflow with prompt <p>
|
||||||
|
|||||||
+122
-63
@@ -372,69 +372,128 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ
|
|||||||
frame.render_widget(paragraph, overlay_area);
|
frame.render_widget(paragraph, overlay_area);
|
||||||
}
|
}
|
||||||
crate::app::state::types::Overlay::Learning => {
|
crate::app::state::types::Overlay::Learning => {
|
||||||
let block = block.title(" Learning ");
|
let h_chunks = Layout::default()
|
||||||
let (total, by_user, by_fb, by_proj, by_ref, act, stale, contra, human, verified, unverified) = state.session_runtime.as_ref().map(|r| {
|
.direction(Direction::Horizontal)
|
||||||
(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)
|
.constraints([
|
||||||
}).unwrap_or_default();
|
Constraint::Percentage(40), // Left: List
|
||||||
let lines = vec![
|
Constraint::Percentage(60), // Right: Details
|
||||||
Line::from(Span::styled(
|
])
|
||||||
"Lessons Dashboard",
|
.split(overlay_area);
|
||||||
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
|
||||||
)),
|
let left_block = Block::default()
|
||||||
Line::from(Span::styled("", Style::default())),
|
.title(" Lessons ")
|
||||||
Line::from(Span::styled(
|
.borders(Borders::ALL)
|
||||||
format!("Total lessons: {}", total),
|
.border_style(Style::default().fg(Theme::PRIMARY))
|
||||||
Style::default().fg(Theme::INFO),
|
.style(Style::default().bg(Theme::BG));
|
||||||
)),
|
|
||||||
Line::from(Span::styled("", Style::default())),
|
let right_block = Block::default()
|
||||||
Line::from(Span::styled("By Type:", Style::default().fg(Theme::DIM))),
|
.title(" Lesson Details ")
|
||||||
Line::from(Span::styled(
|
.borders(Borders::ALL)
|
||||||
format!(" User: {}", by_user),
|
.border_style(Style::default().fg(Theme::PRIMARY))
|
||||||
Style::default().fg(Theme::TEXT),
|
.style(Style::default().bg(Theme::BG));
|
||||||
)),
|
|
||||||
Line::from(Span::styled(
|
let items = crate::app::mode::learning::get_learning_items(state);
|
||||||
format!(" Feedback: {}", by_fb),
|
let mut left_lines = Vec::new();
|
||||||
Style::default().fg(Theme::TEXT),
|
if items.is_empty() {
|
||||||
)),
|
left_lines.push(Line::from(Span::styled(
|
||||||
Line::from(Span::styled(
|
"No lessons found.",
|
||||||
format!(" Project: {}", by_proj),
|
Style::default().fg(Theme::DIM),
|
||||||
Style::default().fg(Theme::TEXT),
|
)));
|
||||||
)),
|
} else {
|
||||||
Line::from(Span::styled(
|
for (i, item) in items.iter().enumerate() {
|
||||||
format!(" Reference: {}", by_ref),
|
let is_selected = i == state.misc.selected_index;
|
||||||
Style::default().fg(Theme::TEXT),
|
let prefix = if is_selected { "▸ " } else { " " };
|
||||||
)),
|
let (label, style) = match item {
|
||||||
Line::from(Span::styled("", Style::default())),
|
crate::app::mode::learning::LearningItem::Pending { name, .. } => {
|
||||||
Line::from(Span::styled("Lifecycle:", Style::default().fg(Theme::DIM))),
|
(
|
||||||
Line::from(Span::styled(
|
format!("{}[Pending] {}", prefix, name),
|
||||||
format!(" Active: {}", act),
|
if is_selected {
|
||||||
Style::default().fg(Theme::TEXT),
|
Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
|
||||||
)),
|
} else {
|
||||||
Line::from(Span::styled(
|
Style::default().fg(Theme::WARNING)
|
||||||
format!(" Stale: {}", stale),
|
}
|
||||||
Style::default().fg(Theme::TEXT),
|
)
|
||||||
)),
|
}
|
||||||
Line::from(Span::styled(
|
crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => {
|
||||||
format!(" Contradicted: {}", contra),
|
let status = if lifecycle == "stale" { "Stale" } else { "Active" };
|
||||||
Style::default().fg(Theme::TEXT),
|
(
|
||||||
)),
|
format!("{}[{}] {}", prefix, status, name),
|
||||||
Line::from(Span::styled("", Style::default())),
|
if is_selected {
|
||||||
Line::from(Span::styled("Confidence:", Style::default().fg(Theme::DIM))),
|
Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
|
||||||
Line::from(Span::styled(
|
} else {
|
||||||
format!(" Human: {}", human),
|
Style::default().fg(Theme::TEXT)
|
||||||
Style::default().fg(Theme::TEXT),
|
}
|
||||||
)),
|
)
|
||||||
Line::from(Span::styled(
|
}
|
||||||
format!(" Verified: {}", verified),
|
};
|
||||||
Style::default().fg(Theme::TEXT),
|
left_lines.push(Line::from(Span::styled(label, style)));
|
||||||
)),
|
}
|
||||||
Line::from(Span::styled(
|
}
|
||||||
format!(" Unverified: {}", unverified),
|
|
||||||
Style::default().fg(Theme::TEXT),
|
// 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 paragraph = Paragraph::new(lines).block(block);
|
let start_idx = if selected >= max_lines {
|
||||||
frame.render_widget(paragraph, overlay_area);
|
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 => {
|
crate::app::state::types::Overlay::Usage => {
|
||||||
let block = block.title(" Usage ");
|
let block = block.title(" Usage ");
|
||||||
|
|||||||
Reference in New Issue
Block a user