pub mod app;
pub mod data;
pub mod event;
pub mod preview;
pub mod term;
pub mod tree;
mod action;
mod render;
use crate::error::{ItemKind, Result};
use crate::paths::Paths;
pub fn run(
paths: &Paths,
seed_query: Option<&str>,
seed_kind: Option<ItemKind>,
seed_source: Option<&str>,
) -> Result<()> {
let _guard = term::TermGuard::enter()?;
let mut app = app::App::new(
seed_query.unwrap_or("").to_string(),
seed_kind,
seed_source.map(|s| s.to_string()),
);
let snapshot = data::load(paths)?;
app.apply_snapshot(snapshot);
event_loop(paths, &mut app)?;
Ok(())
}
fn event_loop(paths: &Paths, app: &mut app::App) -> Result<()> {
use crossterm::event::{self, Event as CEvent};
use std::time::{Duration, Instant};
let tick_rate = Duration::from_millis(1000);
let mut last_tick = Instant::now();
loop {
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or(Duration::ZERO);
if event::poll(timeout).unwrap_or(false)
&& let Ok(evt) = event::read()
{
match evt {
CEvent::Key(k) => {
handle_key(paths, app, k);
}
CEvent::Resize(w, h) => {
app.set_size(w, h);
}
_ => {}
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
if !app.is_mutating()
&& let Some(snapshot) = data::try_poll(paths)
{
app.apply_snapshot_if_changed(snapshot);
}
}
render::draw(app)?;
if app.should_quit() {
break;
}
}
Ok(())
}
fn handle_key(paths: &Paths, app: &mut app::App, k: crossterm::event::KeyEvent) {
use crossterm::event::{KeyCode, KeyModifiers};
if k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL) {
if app.ctrl_c_armed {
app.quit = true;
} else {
app.ctrl_c_armed = true;
app.set_status("Press Ctrl-C again to force exit.".to_string());
}
return;
}
app.ctrl_c_armed = false;
if app.lobe_input_active {
match k.code {
KeyCode::Enter => {
app.submit_lobe_add();
}
KeyCode::Esc => {
app.apply_intent(crate::tui::event::Intent::CancelAction);
}
KeyCode::Backspace => {
app.apply_intent(crate::tui::event::Intent::LobeInputBackspace);
}
KeyCode::Char(c) => {
app.apply_intent(crate::tui::event::Intent::LobeInputChar(c));
}
_ => {}
}
return;
}
if app.lobes_modal_visible {
match k.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.apply_intent(crate::tui::event::Intent::CancelAction);
}
KeyCode::Up | KeyCode::Char('k') => {
app.apply_intent(crate::tui::event::Intent::LobeSelectUp);
}
KeyCode::Down | KeyCode::Char('j') => {
app.apply_intent(crate::tui::event::Intent::LobeSelectDown);
}
KeyCode::Char('a') => {
app.apply_intent(crate::tui::event::Intent::ActionLobeAdd);
}
KeyCode::Char('D') => {
app.apply_intent(crate::tui::event::Intent::ActionLobeRemove);
}
_ => {}
}
return;
}
if app.spec_input_active {
match k.code {
KeyCode::Enter => {
let spec = app.spec_input_text.trim().to_string();
app.spec_input_active = false;
app.spec_input_text.clear();
if spec.is_empty() {
app.status = None;
} else {
run_interactive_meld(paths, app, spec);
}
}
KeyCode::Esc => {
app.apply_intent(crate::tui::event::Intent::CancelAction);
}
KeyCode::Backspace => {
app.apply_intent(crate::tui::event::Intent::SpecInputBackspace);
}
KeyCode::Char(c) => {
app.apply_intent(crate::tui::event::Intent::SpecInputChar(c));
}
_ => {}
}
return;
}
if app.search_focused {
match k.code {
KeyCode::Enter | KeyCode::Tab => {
app.apply_intent(crate::tui::event::Intent::SearchSubmit);
}
KeyCode::Esc => {
app.apply_intent(crate::tui::event::Intent::SearchClear);
}
KeyCode::Backspace => {
app.apply_intent(crate::tui::event::Intent::SearchBackspace);
}
KeyCode::Char(c) => {
app.apply_intent(crate::tui::event::Intent::SearchChar(c));
}
_ => {}
}
return;
}
if app.dialog.is_some() {
match k.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('n') => app.close_dialog(),
KeyCode::Up | KeyCode::Char('k') => app.dialog_up(),
KeyCode::Down | KeyCode::Char('j') => app.dialog_down(),
KeyCode::Enter | KeyCode::Char('y') => {
app.activate_dialog();
if let Some(item_ref) = app.pending_learn_ref.take() {
run_learn_preview(paths, app, &item_ref);
}
}
_ => {}
}
return;
}
if app.modal_visible && k.code == KeyCode::Esc {
app.apply_intent(crate::tui::event::Intent::CancelAction);
return;
}
let intent = crate::tui::event::key_to_intent(k);
match intent {
crate::tui::event::Intent::Quit => {
app.quit = true;
}
crate::tui::event::Intent::ConfirmAction => {
if let Some(pending) = app.take_pending_action() {
let result = if action_needs_suspension(&pending.kind) {
term::with_suspended(|| {
let r = action::execute_interactive(paths, pending);
pause_for_return();
r
})
} else {
action::execute(paths, pending)
};
app.active_preview = None;
match result {
Ok((snapshot, msg)) => {
app.apply_snapshot(snapshot);
app.set_status(if msg.is_empty() {
"Done.".to_string()
} else {
msg
});
}
Err(e) => {
app.set_error(format!("{e}"));
}
}
} else {
app.confirm_selected();
}
}
other => {
app.apply_intent(other);
if let Some(spec) = app.pending_preview_spec.take() {
run_preview(paths, app, spec);
}
if let Some(item_ref) = app.pending_learn_ref.take() {
run_learn_preview(paths, app, &item_ref);
}
}
}
}
pub(crate) fn action_needs_suspension(kind: &crate::tui::app::ActionKind) -> bool {
use crate::tui::app::ActionKind;
matches!(kind, ActionKind::Meld { .. } | ActionKind::Unmeld { .. })
}
fn pause_for_return() {
use std::io::Write;
print!("\n[press Enter to return to the browser] ");
let _ = std::io::stdout().flush();
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
}
fn run_interactive_meld(paths: &Paths, app: &mut app::App, spec: String) {
let action = app::PendingAction::new(app::ActionKind::Meld { spec }, String::new());
let result = term::with_suspended(|| {
let r = action::execute_interactive(paths, action);
pause_for_return();
r
});
app.active_preview = None;
match result {
Ok((snapshot, msg)) => {
app.apply_snapshot(snapshot);
app.set_status(if msg.is_empty() {
"Done.".to_string()
} else {
msg
});
}
Err(e) => {
app.set_error(format!("{e}"));
}
}
}
fn run_preview(paths: &Paths, app: &mut app::App, spec: String) {
match preview::preview(paths, &spec) {
Ok(prev) => {
let name = prev.name.clone();
let items_count = prev.items.len();
app.active_preview = Some(prev);
app.apply_intent(crate::tui::event::Intent::PreviewReady {
spec,
name: format!("{name} ({items_count} items)"),
});
}
Err(e) => {
app.apply_intent(crate::tui::event::Intent::PreviewError {
message: format!("{e}"),
});
}
}
}
fn run_learn_preview(paths: &Paths, app: &mut app::App, item_ref: &str) {
match crate::commands::learn_preview(paths, item_ref) {
Ok(plan) => {
if plan.adds_dependencies {
app.set_learn_dep_tree(Some(plan.tree));
} else {
app.set_learn_dep_tree(None);
}
}
Err(e) => {
app.pending_action = None;
app.modal_visible = false;
app.set_error(format!("{e}"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ItemKind;
use crate::tui::app::{ActionKind, App, PendingAction};
use crate::tui::data::{Snapshot, SnapshotAvailable, SnapshotInstalled};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn temp_paths() -> Paths {
let base = std::env::temp_dir().join(format!("mind-tui-test-{}", std::process::id()));
Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn seeded_app() -> App {
let mut app = App::new(String::new(), None, None);
app.apply_snapshot(Snapshot {
generation: 1,
installed: vec![SnapshotInstalled {
key: "skill:review".to_string(),
name: "review".to_string(),
source: "local/agents".to_string(),
kind: ItemKind::Skill,
commit: "abc12345".to_string(),
description: Some("Review skill".to_string()),
}],
available: vec![SnapshotAvailable {
key: "agent:dev".to_string(),
name: "dev".to_string(),
source: "local/agents".to_string(),
kind: ItemKind::Agent,
description: Some("Dev agent".to_string()),
path: std::path::PathBuf::from("/fake/path"),
}],
unmanaged: vec![],
source_names: vec!["local/agents".to_string()],
suggestions: vec![],
lobes: vec![],
});
app
}
#[test]
fn search_focused_routes_action_letter_to_query() {
let paths = temp_paths();
let mut app = seeded_app();
handle_key(&paths, &mut app, key(KeyCode::Char('/')));
assert!(app.search_focused, "'/' must focus the search box");
handle_key(&paths, &mut app, key(KeyCode::Char('d')));
assert_eq!(
app.search, "d",
"'d' must be appended to the query while search-focused"
);
assert!(
app.pending_action.is_none(),
"'d' must NOT initiate a forget while searching"
);
assert!(
!app.modal_visible,
"no confirm modal should open from typing in search"
);
}
#[test]
fn search_focused_q_does_not_quit() {
let paths = temp_paths();
let mut app = seeded_app();
handle_key(&paths, &mut app, key(KeyCode::Char('/')));
handle_key(&paths, &mut app, key(KeyCode::Char('q')));
assert!(
!app.should_quit(),
"'q' must not quit while search is focused"
);
assert_eq!(app.search, "q", "'q' must be typed into the query");
}
#[test]
fn confirm_modal_esc_cancels_and_keeps_search_filter() {
let paths = temp_paths();
let mut app = seeded_app();
app.search = "rev".to_string();
app.pending_action = Some(PendingAction::new(
ActionKind::Sync,
"Sync all?".to_string(),
));
app.modal_visible = true;
handle_key(&paths, &mut app, key(KeyCode::Esc));
assert!(
app.pending_action.is_none(),
"Esc must cancel the pending action"
);
assert!(!app.modal_visible, "Esc must dismiss the confirm modal");
assert_eq!(
app.search, "rev",
"Esc-to-cancel must leave the search filter intact"
);
}
#[test]
fn double_ctrl_c_force_exits_from_input_mode() {
let paths = temp_paths();
let mut app = seeded_app();
app.spec_input_active = true; let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
handle_key(&paths, &mut app, ctrl_c);
assert!(!app.should_quit(), "one Ctrl-C must not exit");
assert!(app.ctrl_c_armed, "one Ctrl-C arms the force-exit");
assert_eq!(
app.spec_input_text, "",
"Ctrl-C must not be typed into the input box"
);
handle_key(&paths, &mut app, ctrl_c);
assert!(
app.should_quit(),
"a second consecutive Ctrl-C must force exit"
);
}
#[test]
fn a_key_between_ctrl_c_disarms_force_exit() {
let paths = temp_paths();
let mut app = seeded_app();
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
handle_key(&paths, &mut app, ctrl_c);
assert!(app.ctrl_c_armed);
handle_key(&paths, &mut app, key(KeyCode::Char('j'))); assert!(!app.ctrl_c_armed, "another key must disarm the force-exit");
handle_key(&paths, &mut app, ctrl_c);
assert!(
!app.should_quit(),
"a lone Ctrl-C after a reset must not exit"
);
}
struct OwnedTemp(std::path::PathBuf);
impl Drop for OwnedTemp {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn run_learn_preview_lands_tree_in_pending_action() {
use std::process::Command;
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base =
std::env::temp_dir().join(format!("mind-tui-mod-dep-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let _owned = OwnedTemp(base.clone());
let paths = Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
crate::paths::mkdir_p(&paths.mind_home).unwrap();
crate::config::Config {
lobes: vec![paths.claude_home.to_str().unwrap().to_string()],
..Default::default()
}
.save(&paths)
.unwrap();
let src = base.join("dep-source");
std::fs::create_dir_all(src.join("skills/review")).unwrap();
std::fs::write(
src.join("skills/review/SKILL.md"),
"---\ndescription: review skill\n---\n# review\nHand off to {{ns:dev}}.\n",
)
.unwrap();
std::fs::create_dir_all(src.join("agents")).unwrap();
std::fs::write(
src.join("agents/dev.md"),
"---\nname: dev\ndescription: dev agent\n---\n# dev\n",
)
.unwrap();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&src)
.output()
.expect("git");
};
git(&["-c", "init.defaultBranch=main", "init", "-q"]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "t"]);
git(&["add", "-A"]);
git(&["commit", "-qm", "initial"]);
crate::commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
None,
None,
None,
None,
false,
)
.expect("meld");
let source_name = crate::source::Registry::load(&paths).unwrap().sources[0]
.name
.clone();
let mut app = app::App::new(String::new(), None, None);
app.pending_action = Some(app::PendingAction::new(
app::ActionKind::Learn {
item_key: "skill:review".to_string(),
source: source_name.clone(),
},
"Install skill:review?".to_string(),
));
let item_ref = app::learn_ref("skill:review", &source_name);
run_learn_preview(&paths, &mut app, &item_ref);
let tree = app
.pending_action
.as_ref()
.expect("pending action survives a successful preview")
.dep_tree
.clone()
.expect("the dependency tree must be stashed onto the Learn confirm");
assert!(
tree.contains("review"),
"tree must mention the selected skill: {tree}"
);
assert!(
tree.contains("dev"),
"tree must mention the pulled-in dependency agent: {tree}"
);
}
#[test]
fn run_learn_preview_no_deps_leaves_tree_none() {
use std::process::Command;
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base =
std::env::temp_dir().join(format!("mind-tui-mod-nodep-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let _owned = OwnedTemp(base.clone());
let paths = Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
crate::paths::mkdir_p(&paths.mind_home).unwrap();
crate::config::Config {
lobes: vec![paths.claude_home.to_str().unwrap().to_string()],
..Default::default()
}
.save(&paths)
.unwrap();
let src = base.join("plain-source");
std::fs::create_dir_all(src.join("skills/solo")).unwrap();
std::fs::write(
src.join("skills/solo/SKILL.md"),
"---\ndescription: solo skill\n---\n# solo\nNo references here.\n",
)
.unwrap();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&src)
.output()
.expect("git");
};
git(&["-c", "init.defaultBranch=main", "init", "-q"]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "t"]);
git(&["add", "-A"]);
git(&["commit", "-qm", "initial"]);
crate::commands::meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
None,
None,
None,
None,
false,
)
.expect("meld");
let source_name = crate::source::Registry::load(&paths).unwrap().sources[0]
.name
.clone();
let mut app = app::App::new(String::new(), None, None);
app.pending_action = Some(app::PendingAction::new(
app::ActionKind::Learn {
item_key: "skill:solo".to_string(),
source: source_name.clone(),
},
"Install skill:solo?".to_string(),
));
app.set_learn_dep_tree(Some("stale tree".to_string()));
let item_ref = app::learn_ref("skill:solo", &source_name);
run_learn_preview(&paths, &mut app, &item_ref);
let pending = app
.pending_action
.as_ref()
.expect("a no-deps preview must keep the pending Learn action");
assert!(
pending.dep_tree.is_none(),
"a selection that references nothing must carry NO dependency tree, got: {:?}",
pending.dep_tree
);
assert!(
app.error.is_none(),
"a successful no-deps preview sets no error"
);
}
#[test]
fn run_learn_preview_error_clears_pending_and_surfaces_error() {
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base =
std::env::temp_dir().join(format!("mind-tui-mod-err-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let _owned = OwnedTemp(base.clone());
let paths = Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
crate::paths::mkdir_p(&paths.mind_home).unwrap();
crate::config::Config {
lobes: vec![paths.claude_home.to_str().unwrap().to_string()],
..Default::default()
}
.save(&paths)
.unwrap();
let mut app = app::App::new(String::new(), None, None);
app.pending_action = Some(app::PendingAction::new(
app::ActionKind::Learn {
item_key: "skill:ghost".to_string(),
source: "no/such-source".to_string(),
},
"Install skill:ghost?".to_string(),
));
app.modal_visible = true;
run_learn_preview(&paths, &mut app, "no/such-source#skill:ghost");
assert!(
app.pending_action.is_none(),
"a preview error must clear the pending action (no stale confirm to apply)"
);
assert!(
!app.modal_visible,
"a preview error must hide the confirm modal"
);
assert!(
app.error.is_some(),
"a preview error must be surfaced inline, got error: {:?}",
app.error
);
}
#[test]
fn meld_needs_suspension() {
assert!(
action_needs_suspension(&ActionKind::Meld {
spec: "git@github.com:foo/bar".to_string()
}),
"Meld must be classified as requiring suspension"
);
}
#[test]
fn unmeld_needs_suspension() {
assert!(
action_needs_suspension(&ActionKind::Unmeld {
name: "foo/bar".to_string(),
forget: true,
}),
"Unmeld with forget=true must be classified as requiring suspension"
);
assert!(
action_needs_suspension(&ActionKind::Unmeld {
name: "foo/bar".to_string(),
forget: false,
}),
"Unmeld with forget=false must also be classified as requiring suspension"
);
}
#[test]
fn non_suspending_actions_do_not_need_suspension() {
for kind in [
ActionKind::Learn {
item_key: "skill:review".to_string(),
source: "src".to_string(),
},
ActionKind::Forget {
item_key: "skill:review".to_string(),
},
ActionKind::Sync,
ActionKind::Upgrade,
ActionKind::LobeAdd {
path: "/some/path".to_string(),
},
ActionKind::LobeRemove {
path: "/some/path".to_string(),
},
] {
assert!(
!action_needs_suspension(&kind),
"{kind:?} must NOT require suspension (runs captured)"
);
}
}
}