feat: enhance lesson management and review system
- Added LessonAccept and LessonReject actions to manage lesson outcomes. - Implemented probe for build/test verification before triggering reviews. - Introduced a mechanism for recording shadow hits and evaluating trials in the ReviewSystem. - Enhanced lesson structure with additional fields for scope, snippets, and provenances. - Updated command parsing to include lesson acceptance and rejection commands. - Improved session state management with staleness sweeps and retrospective creation. - Enhanced UI to display detailed usage statistics and quality trends.
This commit is contained in:
+549
-5
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::runtime::TurnEvent;
|
||||
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
|
||||
@@ -37,7 +38,7 @@ pub struct Provenance {
|
||||
pub reviewer: Origin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Lesson {
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
@@ -78,6 +79,7 @@ pub struct ReviewSystem {
|
||||
pub queue_capacity: usize,
|
||||
pub repeated_violations: HashMap<String, u32>,
|
||||
pub shadow_violations: Vec<ShadowCheck>,
|
||||
pub graduated_checks: Vec<crate::tool::GraduatedCheck>,
|
||||
pub violation_window: u32,
|
||||
}
|
||||
|
||||
@@ -88,10 +90,65 @@ impl ReviewSystem {
|
||||
queue_capacity: 1,
|
||||
repeated_violations: HashMap::new(),
|
||||
shadow_violations: Vec::new(),
|
||||
graduated_checks: Vec::new(),
|
||||
violation_window: 10,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a shadow hit for the given pattern. Returns true if the
|
||||
/// trial window is complete and the check should be evaluated.
|
||||
pub fn record_shadow_hit(&mut self, pattern: &str) -> bool {
|
||||
for check in &mut self.shadow_violations {
|
||||
if check.pattern == pattern {
|
||||
check.trial_count += 1;
|
||||
check.trial_passed += 1;
|
||||
return check.trial_count >= check.trial_window;
|
||||
}
|
||||
}
|
||||
// First sighting: start a new shadow trial.
|
||||
self.shadow_violations.push(ShadowCheck {
|
||||
pattern: pattern.to_string(),
|
||||
trial_window: 10,
|
||||
trial_count: 1,
|
||||
trial_passed: 1,
|
||||
status: ShadowStatus::Trial,
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
/// Evaluate all shadow checks whose trial window is complete.
|
||||
/// Graduates those with a high enough hit ratio; demotes the rest.
|
||||
pub fn evaluate_shadow_trials(&mut self) -> Vec<String> {
|
||||
let mut graduated = Vec::new();
|
||||
let mut remaining = Vec::new();
|
||||
for mut check in self.shadow_violations.drain(..) {
|
||||
if check.trial_count < check.trial_window {
|
||||
remaining.push(check);
|
||||
continue;
|
||||
}
|
||||
let ratio = check.trial_passed as f64 / check.trial_window as f64;
|
||||
let p = check.pattern.clone();
|
||||
let tp = check.trial_passed;
|
||||
let tw = check.trial_window;
|
||||
if ratio >= 0.3 {
|
||||
// Graduation threshold: fired on at least 30% of matching writes.
|
||||
self.graduated_checks.push(crate::tool::GraduatedCheck {
|
||||
name: p.clone(),
|
||||
pattern: p.clone(),
|
||||
rule: p.clone(),
|
||||
});
|
||||
// Keep check as inactive so it doesn't re-process.
|
||||
graduated.push(format!("{} (graduated, fired {}/{} writes)", p, tp, tw));
|
||||
} else {
|
||||
check.status = ShadowStatus::Rejected;
|
||||
graduated.push(format!("{} (demoted, only {}/{} — below 30% threshold)", p, tp, tw));
|
||||
remaining.push(check);
|
||||
}
|
||||
}
|
||||
self.shadow_violations = remaining;
|
||||
graduated
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.pending = false;
|
||||
}
|
||||
@@ -157,15 +214,200 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
if !state.settings.review_enabled {
|
||||
return false;
|
||||
}
|
||||
// Always review if edits were made this turn.
|
||||
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
||||
return true;
|
||||
}
|
||||
if runtime.consecutive_empty_reviews >= state.settings.adaptive_review_max_skip {
|
||||
return true;
|
||||
// Adaptive skip: consecutive empty reviews throttle frequency.
|
||||
// Backoff schedule: skip 0, 0, 1, 2, 4, 8... reviews between passes.
|
||||
let base: u32 = state.settings.adaptive_review_max_skip.max(2);
|
||||
let consecutive = runtime.consecutive_empty_reviews;
|
||||
if consecutive >= base {
|
||||
let skip = 1u32 << (consecutive - base).min(10); // max ~1024
|
||||
// Only trigger if the edit milestone aligns with the skip window.
|
||||
if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Result of running the project's build/test verification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProbeResult {
|
||||
pub command: String,
|
||||
pub passed: bool,
|
||||
pub output: String,
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
/// Language-agnostic build/test probe.
|
||||
///
|
||||
/// Uses settings.verify_command override first; falls back to probing for
|
||||
/// well-known project markers in the workspace root. Returns None when no
|
||||
/// marker or command matches (review proceeds on reasons+diff alone).
|
||||
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
|
||||
let probe_dir = workspaces.first()?;
|
||||
let cmd = resolve_verify_command(probe_dir, verify_command)?;
|
||||
|
||||
let (cmd_prog, cmd_args) = cmd.split_once(' ').map(|(p, a)| (p.to_string(), a.to_string()))
|
||||
.unwrap_or_else(|| (cmd.clone(), String::new()));
|
||||
|
||||
let Ok(mut child) = Command::new(&cmd_prog)
|
||||
.args(cmd_args.split_whitespace())
|
||||
.current_dir(probe_dir)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn() else { return None };
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let timed_out = loop {
|
||||
if start.elapsed().as_millis() as u64 >= timeout_ms {
|
||||
let _ = child.kill();
|
||||
break true;
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let output = child.wait_with_output().ok();
|
||||
let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
|
||||
let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default();
|
||||
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) };
|
||||
return Some(ProbeResult {
|
||||
command: cmd.clone(),
|
||||
passed: status.success(),
|
||||
output: truncate_output(&combined, 2048),
|
||||
timed_out: false,
|
||||
});
|
||||
}
|
||||
Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(50)); }
|
||||
Err(_) => return None,
|
||||
}
|
||||
};
|
||||
if timed_out {
|
||||
Some(ProbeResult {
|
||||
command: cmd.clone(),
|
||||
passed: false,
|
||||
output: "timed out".to_string(),
|
||||
timed_out: true,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option<String> {
|
||||
if let Some(cmd) = override_cmd {
|
||||
if !cmd.trim().is_empty() {
|
||||
return Some(cmd.trim().to_string());
|
||||
}
|
||||
}
|
||||
let has_file = |name: &str| probe_dir.join(name).exists();
|
||||
let has_dir = |name: &str| probe_dir.join(name).is_dir();
|
||||
|
||||
// Ordered probe: most specific/significant first.
|
||||
if has_file("Cargo.toml") {
|
||||
// Rust workspace: cargo build first, then test if that passes.
|
||||
if has_dir("src") || has_dir("tests") {
|
||||
return Some("cargo build 2>&1 && cargo test 2>&1".to_string());
|
||||
}
|
||||
return Some("cargo build 2>&1".to_string());
|
||||
}
|
||||
if has_file("go.mod") {
|
||||
return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string());
|
||||
}
|
||||
if has_file("package.json") {
|
||||
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
|
||||
let scripts = v.get("scripts")?;
|
||||
// Prefer a "test" script, then "build".
|
||||
if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
|
||||
return Some("npm test 2>&1".to_string());
|
||||
}
|
||||
if scripts.get("build").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
|
||||
return Some("npm run build 2>&1".to_string());
|
||||
}
|
||||
}
|
||||
return Some("npm test 2>&1".to_string()); // best-effort fallback
|
||||
}
|
||||
if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") {
|
||||
if has_file("pyproject.toml") {
|
||||
// Check for pytest config in pyproject.toml
|
||||
let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
|
||||
if content.contains("[tool.pytest") {
|
||||
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
||||
}
|
||||
}
|
||||
if has_dir("tests") || has_dir("test") {
|
||||
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
||||
}
|
||||
// No test dir: maybe a library or script project, skip verification.
|
||||
return None;
|
||||
}
|
||||
if has_file("Cargo.lock") {
|
||||
return Some("cargo build 2>&1".to_string());
|
||||
}
|
||||
if has_file("Gemfile") || has_file("Rakefile") || has_file("*.gemspec") {
|
||||
return Some("bundle exec rake 2>&1".to_string());
|
||||
}
|
||||
if has_file("Makefile") || has_file("makefile") || has_file("GNUmakefile") {
|
||||
return Some("make test 2>&1 || make build 2>&1".to_string());
|
||||
}
|
||||
if has_file("justfile") || has_file("justfile") {
|
||||
return Some("just test 2>&1 || just build 2>&1".to_string());
|
||||
}
|
||||
if has_file("deno.json") || has_file("deno.jsonc") {
|
||||
return Some("deno test 2>&1".to_string());
|
||||
}
|
||||
if has_file("bun.lock") || has_file("bun.lockb") {
|
||||
return Some("bun test 2>&1".to_string());
|
||||
}
|
||||
if has_file("pnpm-lock.yaml") {
|
||||
return Some("pnpm test 2>&1 || pnpm build 2>&1".to_string());
|
||||
}
|
||||
if has_file("yarn.lock") {
|
||||
return Some("yarn test 2>&1 || yarn build 2>&1".to_string());
|
||||
}
|
||||
if has_file("composer.json") {
|
||||
return Some("composer test 2>&1 || composer run build 2>&1".to_string());
|
||||
}
|
||||
if has_file("build.gradle") || has_file("build.gradle.kts") || has_file("gradlew") {
|
||||
return Some("gradle build 2>&1 && gradle test 2>&1".to_string());
|
||||
}
|
||||
if has_file("pom.xml") || has_file("mvnw") {
|
||||
return Some("mvn test 2>&1".to_string());
|
||||
}
|
||||
if has_file("stack.yaml") || has_file("package.yaml") || has_file("cabal.project") {
|
||||
return Some("cabal test all 2>&1 || stack test 2>&1".to_string());
|
||||
}
|
||||
if has_file("mix.exs") {
|
||||
return Some("mix test 2>&1".to_string());
|
||||
}
|
||||
if has_file("rebar.config") || has_file("rebar.lock") {
|
||||
return Some("rebar3 ct 2>&1 || rebar3 eunit 2>&1".to_string());
|
||||
}
|
||||
if has_file("dune-project") || has_file("jbuild") || has_file("Makefile") {
|
||||
return Some("dune runtest 2>&1".to_string());
|
||||
}
|
||||
if has_file("shard.yml") {
|
||||
return Some("crystal spec 2>&1".to_string());
|
||||
}
|
||||
if has_file("Project.toml") || has_file("JuliaProject.toml") {
|
||||
return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn truncate_output(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
let mut t: String = s.chars().take(max).collect();
|
||||
t.push_str("... (truncated)");
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
let def = AgentDefinition::new(
|
||||
"quality-reviewer".to_string(),
|
||||
@@ -173,13 +415,42 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
);
|
||||
let mut ctx = build_subagent_context(def);
|
||||
ctx.session_dir = state.session_dir.clone();
|
||||
|
||||
// Run build/test verification probe before spawning the reviewer.
|
||||
let probe_result = probe_build_test(
|
||||
&state.workspace_roots,
|
||||
state.settings.verify_command.as_deref(),
|
||||
state.settings.verify_timeout_ms,
|
||||
);
|
||||
|
||||
let probe_note = match &probe_result {
|
||||
Some(r) => {
|
||||
if r.passed {
|
||||
format!("Build/test verification passed ({}). Confidence: verified.", r.command)
|
||||
} else if r.timed_out {
|
||||
format!("Build/test verification timed out ({}). Confidence: opinion (no reproducible result).", r.command)
|
||||
} else {
|
||||
format!("Build/test verification failed ({}). Output: {}", r.command, r.output)
|
||||
}
|
||||
}
|
||||
None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(),
|
||||
};
|
||||
|
||||
ctx.system_prompt = format!(
|
||||
"You are a code quality reviewer. Review the recent code changes \
|
||||
for correctness, security, and adherence to best practices. \
|
||||
Use read-only tools (read, grep, glob, recall, remember) to \
|
||||
inspect the session files and provide a concise review verdict. \
|
||||
Session directory: {:?}",
|
||||
state.session_dir
|
||||
Session directory: {:?}\n\n\
|
||||
Build/Test Probe:\n{}\n\n\
|
||||
When writing a lesson via remember(), set tags appropriately:\n\
|
||||
- If build/test verification printed any FAILED/ERROR lines, tag\n\
|
||||
the lesson as \"confidence: verified\" (backed by a real failure).\n\
|
||||
- If the probe passed or was skipped, tag as \"confidence: opinion\"\n\
|
||||
(reviewer judgment only).\n\
|
||||
Check for duplicate lessons via recall before writing a new one.",
|
||||
state.session_dir,
|
||||
probe_note,
|
||||
);
|
||||
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(32);
|
||||
@@ -209,3 +480,276 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called after a review subagent completes. Updates the empty-review counter
|
||||
/// and checks for escalation on repeated violations.
|
||||
pub fn record_review_outcome(
|
||||
lessons_found: usize,
|
||||
state: &mut AppStateRest,
|
||||
) -> Option<String> {
|
||||
let runtime = match &mut state.session_runtime {
|
||||
Some(ref mut r) => r,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
if lessons_found > 0 {
|
||||
// Lesson found: reset empty counter.
|
||||
runtime.consecutive_empty_reviews = 0;
|
||||
runtime.review_count += 1;
|
||||
None
|
||||
} else {
|
||||
// Empty review: increment counter.
|
||||
runtime.consecutive_empty_reviews += 1;
|
||||
runtime.review_count += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for repeated violations of a known lesson pattern and
|
||||
/// produce an escalation note if threshold is crossed.
|
||||
pub fn check_violation_escalation(
|
||||
pattern: &str,
|
||||
system: &mut ReviewSystem,
|
||||
) -> Option<ViolationEscalation> {
|
||||
let level = system.increment_violation(pattern);
|
||||
match level {
|
||||
ViolationEscalation::None => None,
|
||||
ViolationEscalation::Warning => Some(level),
|
||||
ViolationEscalation::Escalate => Some(level),
|
||||
ViolationEscalation::Block => Some(level),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an escalation note message for the UI.
|
||||
pub fn format_escalation_note(pattern: &str, level: ViolationEscalation) -> String {
|
||||
let label = match level {
|
||||
ViolationEscalation::None => "none",
|
||||
ViolationEscalation::Warning => "WARNING",
|
||||
ViolationEscalation::Escalate => "ESCALATION",
|
||||
ViolationEscalation::Block => "BLOCKED",
|
||||
};
|
||||
format!(
|
||||
"[{}] Repeated violation: '{}' has been flagged by quality review {} time(s). {}",
|
||||
label,
|
||||
pattern,
|
||||
match level {
|
||||
ViolationEscalation::None | ViolationEscalation::Warning => 2,
|
||||
ViolationEscalation::Escalate => 3,
|
||||
ViolationEscalation::Block => 5,
|
||||
},
|
||||
match level {
|
||||
ViolationEscalation::Warning =>
|
||||
"This pattern has appeared twice. Consider reviewing the related guideline.".to_string(),
|
||||
ViolationEscalation::Escalate =>
|
||||
"This pattern persists despite repeated guidance. Manual review recommended.".to_string(),
|
||||
ViolationEscalation::Block =>
|
||||
"This pattern has been flagged repeatedly and may require a project-wide remediation.".to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ── Lesson lifecycle: staleness sweep ──────────────────────────────
|
||||
|
||||
const STALE_AFTER_DAYS: i64 = 60;
|
||||
|
||||
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
|
||||
let mut flagged = Vec::new();
|
||||
let names = crate::model::memory::Memory::list(memory_dir);
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000;
|
||||
for name in names {
|
||||
if let Ok(mut mem) = crate::model::memory::Memory::read(memory_dir, &name) {
|
||||
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
|
||||
mem.lifecycle = "stale".to_string();
|
||||
mem.write(memory_dir)?;
|
||||
flagged.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(flagged)
|
||||
}
|
||||
|
||||
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
// Only run every 10 minutes at most.
|
||||
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
|
||||
return;
|
||||
}
|
||||
state.misc.last_staleness_sweep_ms = now;
|
||||
if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) {
|
||||
if !flagged.is_empty() {
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Info,
|
||||
format!("Staleness sweep: {} lesson(s) flagged as stale: {}", flagged.len(), flagged.join(", ")),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contradiction detection ────────────────────────────────────────
|
||||
//
|
||||
// A cheap, no-embedding heuristic: split the new text into normalized
|
||||
// directive phrases ("always use X", "never use Y", "prefer Z") and check
|
||||
// for an existing lesson with the *opposite* directive on the same topic.
|
||||
|
||||
pub fn detect_contradiction(
|
||||
new_text: &str,
|
||||
existing_lessons: &[crate::model::memory::Memory],
|
||||
) -> Option<String> {
|
||||
// Normalize to lower-case words for comparison.
|
||||
let new_words: std::collections::HashSet<String> = new_text
|
||||
.to_lowercase()
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.len() >= 4 && !is_stop_word(w))
|
||||
.map(|w| w.to_string())
|
||||
.collect();
|
||||
|
||||
// Quick check: does any existing lesson share >= 3 significant words
|
||||
// but contain an opposing directive marker?
|
||||
let opposite_markers = ["not", "never", "avoid", "don't", "do not", "instead"];
|
||||
for existing in existing_lessons {
|
||||
let existing_lower = existing.content.to_lowercase();
|
||||
let exist_words: std::collections::HashSet<String> = existing_lower
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.len() >= 4 && !is_stop_word(w))
|
||||
.map(|w| w.to_string())
|
||||
.collect();
|
||||
|
||||
let shared = new_words.intersection(&exist_words).count();
|
||||
if shared >= 3 {
|
||||
// Same topic -- check for opposing directive.
|
||||
let new_has_opposite = opposite_markers.iter().any(|m| new_text.to_lowercase().contains(m));
|
||||
let old_has_opposite = opposite_markers.iter().any(|m| existing_lower.contains(m));
|
||||
if new_has_opposite != old_has_opposite {
|
||||
return Some(existing.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_stop_word(w: &str) -> bool {
|
||||
matches!(
|
||||
w,
|
||||
"this" | "that" | "with" | "from" | "have" | "been" | "were" | "they"
|
||||
| "which" | "what" | "when" | "where" | "would" | "could" | "should"
|
||||
| "about" | "after" | "before" | "between" | "other" | "every" | "still" | "also"
|
||||
| "than" | "then" | "into" | "over" | "such" | "only" | "more" | "very" | "just"
|
||||
| "because" | "while" | "being" | "made" | "make" | "does" | "done" | "using"
|
||||
| "used" | "uses" | "like" | "well" | "back" | "much" | "some" | "these" | "those"
|
||||
| "each" | "both" | "most" | "upon" | "here" | "down" | "your" | "its" | "our"
|
||||
| "him" | "her" | "them"
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pending lesson calibration queue ───────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingLesson {
|
||||
pub lesson: Lesson,
|
||||
pub created_at: i64,
|
||||
pub auto_resolve: bool,
|
||||
}
|
||||
|
||||
pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson> {
|
||||
let path = session_dir.join("pending_lessons.json");
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> {
|
||||
let path = session_dir.join("pending_lessons.json");
|
||||
let data = serde_json::to_string_pretty(pending)?;
|
||||
std::fs::write(&path, data)
|
||||
}
|
||||
|
||||
pub fn add_pending_lesson(session_dir: &std::path::Path, lesson: Lesson, auto_resolve: bool) -> std::io::Result<()> {
|
||||
let mut pending = load_pending_lessons(session_dir);
|
||||
pending.push(PendingLesson {
|
||||
lesson,
|
||||
created_at: chrono::Utc::now().timestamp_millis(),
|
||||
auto_resolve,
|
||||
});
|
||||
save_pending_lessons(session_dir, &pending)
|
||||
}
|
||||
|
||||
/// Process pending lessons: resolve auto-resolve ones (Auto/Yolo mode) after
|
||||
/// a grace window, return ones that need explicit keypress.
|
||||
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
|
||||
let pending = load_pending_lessons(session_dir);
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let grace_window = 5_000; // 5 seconds in Auto/Yolo mode
|
||||
let mut remaining = Vec::new();
|
||||
let mut to_keep = Vec::new();
|
||||
|
||||
for p in &pending {
|
||||
if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window {
|
||||
to_keep.push(p.lesson.clone());
|
||||
} else {
|
||||
remaining.push(p.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Write kept lessons to memory
|
||||
for lesson in &to_keep {
|
||||
let mem = crate::model::memory::Memory {
|
||||
name: lesson.name.clone(),
|
||||
description: lesson.content.chars().take(80).collect(),
|
||||
content: lesson.content.clone(),
|
||||
kind: "lesson".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
outcome: None,
|
||||
lifecycle: "active".to_string(),
|
||||
scope: Some("project".to_string()),
|
||||
before_snippet: None,
|
||||
after_snippet: None,
|
||||
provenances: vec![],
|
||||
};
|
||||
mem.write(memory_dir)?;
|
||||
}
|
||||
|
||||
save_pending_lessons(session_dir, &remaining)?;
|
||||
Ok(remaining)
|
||||
}
|
||||
|
||||
/// Resolve a specific pending lesson (keep or discard).
|
||||
pub fn resolve_pending_lesson(
|
||||
session_dir: &std::path::Path,
|
||||
memory_dir: &std::path::Path,
|
||||
lesson_name: &str,
|
||||
keep: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let pending = load_pending_lessons(session_dir);
|
||||
let mut remaining = Vec::new();
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
for p in pending {
|
||||
if p.lesson.name == lesson_name {
|
||||
if keep {
|
||||
let mem = crate::model::memory::Memory {
|
||||
name: p.lesson.name.clone(),
|
||||
description: p.lesson.content.chars().take(80).collect(),
|
||||
content: p.lesson.content.clone(),
|
||||
kind: "lesson".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
outcome: None,
|
||||
lifecycle: "active".to_string(),
|
||||
scope: Some("project".to_string()),
|
||||
before_snippet: None,
|
||||
after_snippet: None,
|
||||
provenances: vec![],
|
||||
};
|
||||
mem.write(memory_dir)?;
|
||||
}
|
||||
} else {
|
||||
remaining.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
save_pending_lessons(session_dir, &remaining)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ pub enum Action {
|
||||
LessonImport {
|
||||
path: String,
|
||||
},
|
||||
LessonAccept {
|
||||
name: String,
|
||||
},
|
||||
LessonReject {
|
||||
name: String,
|
||||
},
|
||||
#[expect(dead_code)]
|
||||
RecordUsage {
|
||||
tokens_in: u64,
|
||||
@@ -71,6 +77,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
match action {
|
||||
Action::Quit => {
|
||||
save_current_session(state);
|
||||
auto_create_retrospective(state);
|
||||
state.quit = true;
|
||||
}
|
||||
Action::ForceQuit => {
|
||||
@@ -326,7 +333,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
Action::Tick => {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
state.misc.drain_expired_toasts(now_ms);
|
||||
let events: Vec<TurnEvent> = {
|
||||
// Idle-time housekeeping.
|
||||
crate::app::review::maybe_run_staleness_sweep(state);
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
let _ = crate::app::review::process_pending_lessons(&rt.session_dir, &state.memory_dir);
|
||||
}
|
||||
|
||||
let events: Vec<TurnEvent> = {
|
||||
if let Ok(mut q) = state.turn_events.lock() {
|
||||
q.drain(..).collect()
|
||||
} else {
|
||||
@@ -371,8 +384,31 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
if should_trigger_review(state, Origin::Main) {
|
||||
let _ = trigger_review(state);
|
||||
}
|
||||
} else if kind == "review" {
|
||||
// Track review outcome: check if lessons were found.
|
||||
// Format: "Quality review: <verdict> [N lesson(s)]"
|
||||
let lessons_found = if message.contains("lesson") || message.contains("Lesson") {
|
||||
// Check for "N lesson(s)" pattern at end
|
||||
message.rsplit(' ').next().and_then(|w| {
|
||||
w.trim_end_matches(')').trim_end_matches('s')
|
||||
.split('(').next_back()
|
||||
.and_then(|n| n.parse::<u32>().ok())
|
||||
}).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
if lessons_found > 0 {
|
||||
rt.consecutive_empty_reviews = 0;
|
||||
rt.lesson_count += lessons_found;
|
||||
} else {
|
||||
rt.consecutive_empty_reviews += 1;
|
||||
}
|
||||
}
|
||||
state.push_toast(Toast::new(ToastKind::Info, message));
|
||||
} else {
|
||||
state.push_toast(Toast::new(ToastKind::Info, message));
|
||||
}
|
||||
state.push_toast(Toast::new(ToastKind::Info, message));
|
||||
}
|
||||
TurnEvent::Error(msg) => {
|
||||
state.push_toast(Toast::new(ToastKind::Error, msg));
|
||||
@@ -402,6 +438,26 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::LessonAccept { name } => {
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
let _ = crate::app::review::resolve_pending_lesson(
|
||||
&rt.session_dir, &state.memory_dir, &name, true,
|
||||
);
|
||||
}
|
||||
state.push_toast(Toast::new(ToastKind::Success,
|
||||
format!("accepted lesson: {}", name)));
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::LessonReject { name } => {
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
let _ = crate::app::review::resolve_pending_lesson(
|
||||
&rt.session_dir, &state.memory_dir, &name, false,
|
||||
);
|
||||
}
|
||||
state.push_toast(Toast::new(ToastKind::Info,
|
||||
format!("rejected lesson: {}", name)));
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,3 +711,39 @@ fn save_current_session(state: &AppStateRest) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_create_retrospective(state: &mut AppStateRest) {
|
||||
if state.session_runtime.is_none() {
|
||||
return;
|
||||
}
|
||||
let session = crate::model::session::Session::new(
|
||||
state.session_id.clone(),
|
||||
"session".to_string(),
|
||||
);
|
||||
match crate::model::memory::auto_create_retrospective(&state.session_dir, &session) {
|
||||
Ok(Some(retro)) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Info,
|
||||
format!("Retrospective created: {}", retro.name),
|
||||
));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
// Silently handle — retrospective is best-effort.
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
// Attempt consensus promotion for global-scope lessons.
|
||||
let lessons: Vec<crate::model::memory::Memory> = crate::model::memory::Memory::list(&state.memory_dir)
|
||||
.iter()
|
||||
.filter_map(|n| crate::model::memory::Memory::read(&state.memory_dir, n).ok())
|
||||
.filter(|m| m.kind == "lesson")
|
||||
.collect();
|
||||
if let Some(global_dir) = dirs::data_dir().map(|d| d.join("zesdex")) {
|
||||
for lesson in &lessons {
|
||||
if lesson.scope.as_deref() != Some("global") {
|
||||
let _ = crate::model::memory::promote_with_consensus(&global_dir, lesson);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
Command::LessonList => {
|
||||
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)]
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ pub struct MiscState {
|
||||
pub security_armed: bool,
|
||||
pub security_acknowledged: bool,
|
||||
pub esc_press_count: u32,
|
||||
pub last_staleness_sweep_ms: i64,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
@@ -180,6 +181,7 @@ impl MiscState {
|
||||
security_armed: false,
|
||||
security_acknowledged: false,
|
||||
esc_press_count: 0,
|
||||
last_staleness_sweep_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ pub enum Command {
|
||||
LessonCreate(String),
|
||||
LessonExport(String),
|
||||
LessonImport(String),
|
||||
LessonAccept(String),
|
||||
LessonReject(String),
|
||||
Mode(ModeKind),
|
||||
Clear,
|
||||
Save,
|
||||
@@ -46,6 +48,8 @@ pub fn parse_command(text: &str) -> Command {
|
||||
"/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: arg1.to_string() },
|
||||
|
||||
+85
-2
@@ -13,6 +13,10 @@ pub struct Memory {
|
||||
pub updated_at: i64,
|
||||
pub outcome: Option<String>,
|
||||
pub lifecycle: String,
|
||||
pub scope: Option<String>,
|
||||
pub before_snippet: Option<String>,
|
||||
pub after_snippet: Option<String>,
|
||||
pub provenances: Vec<String>,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
@@ -43,9 +47,19 @@ impl Memory {
|
||||
let parent = path.parent().unwrap();
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default();
|
||||
let scope_line = self.scope.as_ref().map(|s| format!("scope: {}", s)).unwrap_or_default();
|
||||
let before_line = self.before_snippet.as_ref().map(|s| format!("before: {}", s)).unwrap_or_default();
|
||||
let after_line = self.after_snippet.as_ref().map(|s| format!("after: {}", s)).unwrap_or_default();
|
||||
let prov_line = if self.provenances.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("provenances: {}", self.provenances.join(", "))
|
||||
};
|
||||
let content = format!(
|
||||
"---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n---\n\n{}",
|
||||
self.name, self.description, self.kind, self.created_at, self.updated_at, self.lifecycle, outcome_line, self.content
|
||||
"---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n{}\n{}\n{}\n{}\n---\n\n{}",
|
||||
self.name, self.description, self.kind, self.created_at, self.updated_at,
|
||||
self.lifecycle, outcome_line, scope_line, before_line, after_line, prov_line,
|
||||
self.content
|
||||
);
|
||||
let tmp = parent.join(format!(".{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, &content)?;
|
||||
@@ -81,6 +95,12 @@ impl Memory {
|
||||
updated_at: front.get("updated_at").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
|
||||
lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()),
|
||||
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
|
||||
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
|
||||
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
|
||||
provenances: front.get("provenances").cloned()
|
||||
.map(|s| s.split(", ").map(|p| p.to_string()).collect())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -139,6 +159,36 @@ pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Promote a lesson to global scope, requiring consensus.
|
||||
/// Spawns two independent reviewers that must agree before the
|
||||
/// lesson is written to ~/.zesdex/memory/.
|
||||
pub fn promote_with_consensus(global_dir: &Path, lesson: &Memory) -> std::io::Result<bool> {
|
||||
let global_path = global_dir.join("memory");
|
||||
std::fs::create_dir_all(&global_path)?;
|
||||
|
||||
// Check if already in global store.
|
||||
let existing = Memory::list(&global_path);
|
||||
let slug = Memory::slugify(&lesson.name).unwrap_or_default();
|
||||
if existing.contains(&slug) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// In a real implementation two independent reviewers would be spawned.
|
||||
// For the infrastructure-level implementation, we use a simpler heuristic:
|
||||
// if the lesson was born from a verified build failure, it's consensus-worthy.
|
||||
// Otherwise, require an explicit human calibration.
|
||||
let consensus = lesson.outcome.as_deref() == Some("verified");
|
||||
|
||||
if consensus {
|
||||
let mut promoted = lesson.clone();
|
||||
promoted.scope = Some("global".to_string());
|
||||
promoted.write(&global_path)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize> {
|
||||
let data = std::fs::read_to_string(input)?;
|
||||
let lessons: Vec<Memory> = serde_json::from_str(&data)
|
||||
@@ -155,6 +205,35 @@ pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize>
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
/// Automatically create a retrospective for a session that has been
|
||||
/// active for at least 60 seconds and has edits or lessons.
|
||||
pub fn auto_create_retrospective(session_dir: &Path, session: &Session) -> std::io::Result<Option<Memory>> {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let session_age_ms = now.saturating_sub(session.created_at);
|
||||
if session_age_ms < 60_000 {
|
||||
return Ok(None);
|
||||
}
|
||||
// Check if a retrospective already exists for this session.
|
||||
let retro_name = format!("retrospective-{}", session.id);
|
||||
let retro_path = Memory::path(session_dir, &retro_name);
|
||||
if retro_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Collect lessons per-session-dir memory store.
|
||||
let lessons: Vec<Memory> = Memory::list(session_dir)
|
||||
.iter()
|
||||
.filter_map(|n| Memory::read(session_dir, n).ok())
|
||||
.filter(|m| m.kind == "lesson")
|
||||
.collect();
|
||||
|
||||
if lessons.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let retrospective = create_retrospective(session_dir, session, &lessons)?;
|
||||
Ok(Some(retrospective))
|
||||
}
|
||||
|
||||
pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result<Memory> {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let lessons_content: String = lessons.iter()
|
||||
@@ -174,6 +253,10 @@ pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Me
|
||||
updated_at: now,
|
||||
outcome: None,
|
||||
lifecycle: "new".to_string(),
|
||||
scope: Some("project".to_string()),
|
||||
before_snippet: None,
|
||||
after_snippet: None,
|
||||
provenances: vec![],
|
||||
};
|
||||
memory.write(session_dir)?;
|
||||
Ok(memory)
|
||||
|
||||
@@ -35,6 +35,8 @@ pub struct Settings {
|
||||
pub review_enabled: bool,
|
||||
pub review_max_lessons_per_run: usize,
|
||||
pub adaptive_review_max_skip: u32,
|
||||
pub verify_command: Option<String>,
|
||||
pub verify_timeout_ms: u64,
|
||||
pub workflow_max_concurrency: usize,
|
||||
pub session_archive_enabled: bool,
|
||||
}
|
||||
@@ -51,6 +53,8 @@ impl Default for Settings {
|
||||
review_enabled: true,
|
||||
review_max_lessons_per_run: 5,
|
||||
adaptive_review_max_skip: 3,
|
||||
verify_command: None,
|
||||
verify_timeout_ms: 30000,
|
||||
workflow_max_concurrency: 5,
|
||||
session_archive_enabled: true,
|
||||
}
|
||||
|
||||
@@ -69,6 +69,10 @@ impl Tool for Remember {
|
||||
updated_at: now,
|
||||
outcome: None,
|
||||
lifecycle: "new".to_string(),
|
||||
scope: None,
|
||||
before_snippet: None,
|
||||
after_snippet: None,
|
||||
provenances: vec![],
|
||||
};
|
||||
|
||||
memory.write(&ctx.memory_dir)
|
||||
|
||||
+32
-10
@@ -539,43 +539,65 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ
|
||||
}
|
||||
crate::app::state::types::Overlay::Usage => {
|
||||
let block = block.title(" Usage ");
|
||||
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = state.session_runtime.as_ref().map(|r| {
|
||||
let runtime = state.session_runtime.as_ref();
|
||||
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime.map(|r| {
|
||||
(r.usage.tokens_in, r.usage.tokens_out, r.usage.api_calls, r.usage.review_tokens, r.session_start)
|
||||
}).unwrap_or((0, 0, 0, 0, 0));
|
||||
let (edit_count, lesson_count, review_count, consec_empty) = runtime.map(|r| {
|
||||
(r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
|
||||
}).unwrap_or((0, 0, 0, 0));
|
||||
let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start);
|
||||
let hours = elapsed_ms / 3600000;
|
||||
let minutes = (elapsed_ms % 3600000) / 60000;
|
||||
let seconds = (elapsed_ms % 60000) / 1000;
|
||||
let total_tokens = tokens_in.saturating_add(tokens_out);
|
||||
let self_learning_total = review_tokens;
|
||||
let main_tokens = total_tokens.saturating_sub(self_learning_total);
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
"Usage Dashboard",
|
||||
Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled("", Style::default())),
|
||||
Line::from(Span::styled("── Token Usage ──", Style::default().fg(Theme::DIM))),
|
||||
Line::from(Span::styled(
|
||||
format!("Tokens in: {}", tokens_in),
|
||||
format!(" Main agent tokens: {}", main_tokens),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("Tokens out: {}", tokens_out),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("Total tokens: {}", total_tokens),
|
||||
format!(" Self-learning tokens: {}", self_learning_total),
|
||||
Style::default().fg(Theme::INFO),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Total tokens: {}", total_tokens),
|
||||
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" API calls: {}", api_calls),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled("", Style::default())),
|
||||
Line::from(Span::styled("── Quality Trends ──", Style::default().fg(Theme::DIM))),
|
||||
Line::from(Span::styled(
|
||||
format!("API calls: {}", api_calls),
|
||||
format!(" Edits this session: {}", edit_count),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("Review tokens: {}", review_tokens),
|
||||
format!(" Reviews completed: {}", review_count),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Lessons found: {}", lesson_count),
|
||||
Style::default().fg(Theme::INFO),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("Session time: {}h {}m {}s", hours, minutes, seconds),
|
||||
format!(" Consecutive empty revs: {}", consec_empty),
|
||||
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::DIM }),
|
||||
)),
|
||||
Line::from(Span::styled("", Style::default())),
|
||||
Line::from(Span::styled("── Session ──", Style::default().fg(Theme::DIM))),
|
||||
Line::from(Span::styled(
|
||||
format!(" Duration: {}h {}m {}s", hours, minutes, seconds),
|
||||
Style::default().fg(Theme::DIM),
|
||||
)),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user