use super::App;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Resume,
Attach,
Send,
Handoff,
Expand,
Mark,
Terminate,
Delete,
}
pub struct Item {
pub action: Action,
pub label: &'static str,
pub key: &'static str,
pub blocked: Option<String>,
pub rule: bool,
}
impl Item {
pub fn enabled(&self) -> bool {
self.blocked.is_none()
}
}
pub fn items(app: &App) -> Vec<Item> {
let Some(session) = app.selected_session() else {
return Vec::new();
};
let remote = App::remote_refusal(session);
let subagent = app.on_subagent();
let running = session.is_running();
let deleting = app.deleting.contains(&session.key());
let has_pid = super::session_root_pid(session).is_some();
let far = |also: Option<String>| remote.clone().or(also);
vec![
Item {
action: Action::Resume,
label: "Resume in a tab",
key: "R",
blocked: far(None),
rule: false,
},
Item {
action: Action::Attach,
label: "Attach to it",
key: "a",
blocked: far(subagent.then(|| "a subagent has no terminal of its own".to_string())),
rule: false,
},
Item {
action: Action::Send,
label: "Type into it",
key: "s",
blocked: far(match () {
_ if subagent => Some("a subagent cannot be typed into on its own".into()),
_ if !has_pid => Some("no local process to type into".into()),
_ => None,
}),
rule: false,
},
Item {
action: Action::Handoff,
label: "Hand off to another agent",
key: "O",
blocked: far(subagent.then(|| "hand off the session, not a subagent".to_string())),
rule: false,
},
Item {
action: Action::Expand,
label: "Show its subagents",
key: "e",
blocked: None,
rule: false,
},
Item {
action: Action::Mark,
label: "Mark for a batch action",
key: "space",
blocked: None,
rule: false,
},
Item {
action: Action::Terminate,
label: "Terminate the agent",
key: "ctrl+k",
blocked: far(match () {
_ if !running => Some("it is not running".into()),
_ if !has_pid => Some("no local process to signal".into()),
_ => None,
}),
rule: true,
},
Item {
action: Action::Delete,
label: "Delete the transcript",
key: "d",
blocked: far(match () {
_ if subagent => Some("a subagent cannot be deleted on its own".into()),
_ if deleting => Some("deletion is already in progress".into()),
_ if running => Some("it is still running".into()),
_ => None,
}),
rule: false,
},
]
}
pub fn step(items: &[Item], cursor: usize, delta: isize) -> usize {
let n = items.len();
if n == 0 {
return 0;
}
for hop in 1..=n {
let next = (cursor as isize + delta * hop as isize).rem_euclid(n as isize) as usize;
if items[next].enabled() {
return next;
}
}
cursor
}
pub fn first_enabled(items: &[Item]) -> usize {
items.iter().position(Item::enabled).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn item(blocked: Option<&str>) -> Item {
Item {
action: Action::Mark,
label: "x",
key: "x",
blocked: blocked.map(str::to_string),
rule: false,
}
}
#[test]
fn stepping_skips_over_blocked_entries() {
let items = [item(None), item(Some("no")), item(Some("no")), item(None)];
assert_eq!(step(&items, 0, 1), 3, "past the two blocked entries");
assert_eq!(step(&items, 3, 1), 0, "and wraps");
assert_eq!(step(&items, 0, -1), 3, "backwards too");
}
#[test]
fn a_menu_with_nothing_runnable_leaves_the_cursor_alone() {
let items = [item(Some("no")), item(Some("no"))];
assert_eq!(step(&items, 0, 1), 0);
assert_eq!(first_enabled(&items), 0);
}
#[test]
fn the_cursor_opens_on_the_first_thing_that_works() {
let items = [item(Some("no")), item(None), item(None)];
assert_eq!(first_enabled(&items), 1);
}
#[test]
fn stepping_an_empty_menu_is_not_a_panic() {
assert_eq!(step(&[], 0, 1), 0);
assert_eq!(first_enabled(&[]), 0);
}
}