#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Situation {
pub unconfigured: bool,
pub running: bool,
pub panes: usize,
pub split: bool,
pub blocked: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
NewRun,
Stop,
Fix,
Runs,
Repos,
Agents,
Settings,
Mode,
Models,
Fresh,
NewPane,
NextPane,
ClosePane,
MovePaneLeft,
MovePaneRight,
WiderPane,
NarrowerPane,
EvenPanes,
NextDetail,
Promote,
Prune,
Reload,
Setup,
Keys,
Quit,
}
impl Command {
pub const ALL: [Command; 25] = [
Command::NewRun,
Command::Stop,
Command::Fix,
Command::Runs,
Command::Repos,
Command::Agents,
Command::Settings,
Command::Mode,
Command::Models,
Command::Fresh,
Command::NewPane,
Command::NextPane,
Command::ClosePane,
Command::MovePaneLeft,
Command::MovePaneRight,
Command::WiderPane,
Command::NarrowerPane,
Command::EvenPanes,
Command::NextDetail,
Command::Promote,
Command::Prune,
Command::Reload,
Command::Setup,
Command::Keys,
Command::Quit,
];
pub fn name(self) -> &'static str {
match self {
Command::NewRun => "write a task",
Command::Stop => "stop the run",
Command::Fix => "fix what is in the way",
Command::Runs => "runs",
Command::Repos => "repositories",
Command::Agents => "agents",
Command::Settings => "settings",
Command::Mode => "switch mode",
Command::Models => "models",
Command::Fresh => "start a fresh thread",
Command::NewPane => "new pane",
Command::NextPane => "next pane",
Command::ClosePane => "close this pane",
Command::MovePaneLeft => "move pane left",
Command::MovePaneRight => "move pane right",
Command::WiderPane => "widen this pane",
Command::NarrowerPane => "narrow this pane",
Command::EvenPanes => "even out the panes",
Command::NextDetail => "next section",
Command::Promote => "promote run",
Command::Prune => "prune worktrees",
Command::Reload => "reload runs",
Command::Setup => "set up this directory",
Command::Keys => "keys",
Command::Quit => "quit",
}
}
pub fn key(self) -> &'static str {
match self {
Command::NewRun => "n",
Command::Stop => "s",
Command::Fix => "x",
Command::Runs => "l",
Command::Repos => "w",
Command::Agents => "a",
Command::Settings => ",",
Command::Mode => "m",
Command::Models => "o",
Command::Fresh => "f",
Command::NewPane => "t",
Command::NextPane => "]",
Command::ClosePane => "X",
Command::MovePaneLeft => "<",
Command::MovePaneRight => ">",
Command::WiderPane => "+",
Command::NarrowerPane => "-",
Command::EvenPanes => "=",
Command::NextDetail => "tab",
Command::Promote => "p",
Command::Prune => "u",
Command::Reload => "r",
Command::Setup => "i",
Command::Keys => "?",
Command::Quit => "q",
}
}
pub fn leader(self) -> char {
match self {
Command::NewRun => 'n',
Command::Stop => 's',
Command::Fix => 'x',
Command::Runs => 'l',
Command::Repos => 'w',
Command::Agents => 'a',
Command::Settings => ',',
Command::Mode => 'm',
Command::Models => 'o',
Command::Fresh => 'f',
Command::NewPane => 't',
Command::NextPane => ']',
Command::ClosePane => 'c',
Command::MovePaneLeft => '<',
Command::MovePaneRight => '>',
Command::WiderPane => '+',
Command::NarrowerPane => '-',
Command::EvenPanes => '=',
Command::NextDetail => 'd',
Command::Promote => 'p',
Command::Prune => 'u',
Command::Reload => 'r',
Command::Setup => 'i',
Command::Keys => 'h',
Command::Quit => 'q',
}
}
pub fn about(self) -> &'static str {
match self {
Command::NewRun => "say what the agent should do next",
Command::Stop => "ask the running agent to stop",
Command::Fix => "walk through what is stopping a run from working",
Command::Runs => "look up a run recorded here",
Command::Repos => "choose which repository to work in",
Command::Agents => "choose who writes and who reviews",
Command::Settings => "what this thread and this project are set to",
Command::Mode => "ask, plan, loop or auto \u{2014} what enter does next",
Command::Models => {
"pick the profile and model that write, from what each profile lists"
}
Command::Fresh => "forget the chain; start again from HEAD",
Command::NewPane => "another line of work, open beside this one",
Command::NextPane => "move to the next line of work",
Command::ClosePane => "close this line of work",
Command::MovePaneLeft => "swap this pane with the one to its left",
Command::MovePaneRight => "swap this pane with the one to its right",
Command::WiderPane => "give this pane more of the width",
Command::NarrowerPane => "give this pane less of the width",
Command::EvenPanes => "give every pane the same width",
Command::NextDetail => "checks, then events, then the diff",
Command::Promote => "give an approved run a branch; merges nothing",
Command::Prune => "clear the worktrees finished runs left behind",
Command::Reload => "read the run records again",
Command::Setup => "write the files a project needs to be run",
Command::Keys => "every key this screen answers to",
Command::Quit => "leave the browser",
}
}
pub fn slug(self) -> &'static str {
match self {
Command::NewRun => "new",
Command::Stop => "stop",
Command::Fix => "fix",
Command::Runs => "runs",
Command::Repos => "repos",
Command::Agents => "agents",
Command::Settings => "settings",
Command::Mode => "mode",
Command::Models => "models",
Command::Fresh => "fresh",
Command::NewPane => "pane",
Command::NextPane => "next",
Command::ClosePane => "close",
Command::MovePaneLeft => "left",
Command::MovePaneRight => "right",
Command::WiderPane => "wider",
Command::NarrowerPane => "narrower",
Command::EvenPanes => "even",
Command::NextDetail => "detail",
Command::Promote => "promote",
Command::Prune => "prune",
Command::Reload => "reload",
Command::Setup => "init",
Command::Keys => "keys",
Command::Quit => "quit",
}
}
pub fn named(word: &str) -> Option<Command> {
let word = word.trim().trim_start_matches('/').to_lowercase();
if word == "help" || word == "?" {
return Some(Command::Keys);
}
Command::ALL
.into_iter()
.find(|command| command.slug() == word || command.key() == word)
}
pub fn offered(situation: Situation) -> Vec<Command> {
Command::ALL
.into_iter()
.filter(|command| match command {
Command::Setup => situation.unconfigured,
Command::Stop => situation.running,
Command::NewRun => !situation.running && !situation.unconfigured,
Command::Agents => !situation.unconfigured,
Command::Fix => situation.blocked,
Command::Fresh => !situation.running,
Command::NextPane
| Command::ClosePane
| Command::MovePaneLeft
| Command::MovePaneRight => situation.panes > 1,
Command::WiderPane | Command::NarrowerPane | Command::EvenPanes => situation.split,
Command::Models => !situation.unconfigured,
_ => true,
})
.collect()
}
pub fn matching(query: &str, situation: Situation) -> Vec<Command> {
let needle = query.trim().to_lowercase();
Command::offered(situation)
.into_iter()
.filter(|c| {
needle.is_empty()
|| c.name().to_lowercase().contains(&needle)
|| c.about().to_lowercase().contains(&needle)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
const IDLE: Situation = Situation {
unconfigured: false,
running: false,
blocked: false,
panes: 1,
split: false,
};
#[test]
fn setup_is_offered_only_where_there_is_something_to_set_up() {
let unconfigured = Situation {
unconfigured: true,
..IDLE
};
assert!(Command::offered(unconfigured).contains(&Command::Setup));
assert!(!Command::offered(IDLE).contains(&Command::Setup));
}
#[test]
fn a_run_can_be_stopped_only_while_there_is_one() {
let running = Situation {
running: true,
..IDLE
};
assert!(Command::offered(running).contains(&Command::Stop));
assert!(!Command::offered(IDLE).contains(&Command::Stop));
}
#[test]
fn a_second_run_is_not_offered_while_the_first_is_going() {
let running = Situation {
running: true,
..IDLE
};
assert!(!Command::offered(running).contains(&Command::NewRun));
assert!(Command::offered(IDLE).contains(&Command::NewRun));
}
#[test]
fn a_run_is_not_offered_in_a_directory_that_cannot_run_one() {
let unconfigured = Situation {
unconfigured: true,
..IDLE
};
assert!(!Command::offered(unconfigured).contains(&Command::NewRun));
}
#[test]
fn the_palette_matches_on_what_a_command_does_not_only_on_its_name() {
let found = Command::matching("branch", IDLE);
assert_eq!(found, vec![Command::Promote]);
}
#[test]
fn an_empty_query_offers_everything_that_makes_sense() {
assert_eq!(Command::matching("", IDLE).len(), Command::ALL.len() - 10);
}
#[test]
fn a_pane_is_sized_only_while_the_panes_share_the_screen() {
let several = Situation { panes: 2, ..IDLE };
assert!(Command::offered(several).contains(&Command::MovePaneLeft));
assert!(!Command::offered(several).contains(&Command::WiderPane));
let split = Situation {
split: true,
..several
};
for command in [
Command::WiderPane,
Command::NarrowerPane,
Command::EvenPanes,
] {
assert!(Command::offered(split).contains(&command), "{command:?}");
}
}
#[test]
fn fixing_is_offered_only_where_there_is_something_in_the_way() {
let blocked = Situation {
blocked: true,
..IDLE
};
assert!(Command::offered(blocked).contains(&Command::Fix));
assert!(!Command::offered(IDLE).contains(&Command::Fix));
}
#[test]
fn moving_between_panes_is_offered_only_where_there_are_several() {
let several = Situation { panes: 3, ..IDLE };
assert!(Command::offered(several).contains(&Command::NextPane));
assert!(Command::offered(several).contains(&Command::ClosePane));
assert!(!Command::offered(IDLE).contains(&Command::NextPane));
assert!(Command::offered(IDLE).contains(&Command::NewPane));
}
#[test]
fn a_thread_is_not_thrown_away_from_under_a_running_run() {
let running = Situation {
running: true,
..IDLE
};
assert!(!Command::offered(running).contains(&Command::Fresh));
assert!(Command::offered(IDLE).contains(&Command::Fresh));
}
#[test]
fn a_typed_word_reaches_the_command_it_names() {
assert_eq!(Command::named("/settings"), Some(Command::Settings));
assert_eq!(Command::named("settings"), Some(Command::Settings));
assert_eq!(Command::named("/QUIT"), Some(Command::Quit));
assert_eq!(Command::named("/l"), Some(Command::Runs));
assert_eq!(Command::named("/help"), Some(Command::Keys));
assert_eq!(Command::named("/nonsense"), None);
}
#[test]
fn every_command_has_a_slug_of_its_own() {
let mut slugs: Vec<&str> = Command::ALL.iter().map(|c| c.slug()).collect();
let count = slugs.len();
slugs.sort_unstable();
slugs.dedup();
assert_eq!(slugs.len(), count, "two commands share a slug");
}
#[test]
fn every_command_has_a_leader_letter_and_a_key_of_its_own() {
let mut letters: Vec<char> = Command::ALL.iter().map(|c| c.leader()).collect();
let mut keys: Vec<&str> = Command::ALL.iter().map(|c| c.key()).collect();
for (what, count) in [("leader", letters.len()), ("key", keys.len())] {
let unique = if what == "leader" {
letters.sort_unstable();
letters.dedup();
letters.len()
} else {
keys.sort_unstable();
keys.dedup();
keys.len()
};
assert_eq!(unique, count, "two commands share a {what}");
}
}
}