use super::App;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Resume,
Restart,
Attach,
Send,
Handoff,
Read,
Expand,
Mark,
Terminate,
Delete,
UpdateRemote,
}
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 = session.root_pid().is_some();
let pid_only = session.process_only();
let far = |also: Option<String>| remote.clone().or(also);
let mut items = vec![
Item {
action: Action::Resume,
label: "Resume in a tab",
key: "R",
blocked: far(pid_only.then(|| "no transcript claims this process".to_string())),
rule: false,
},
Item {
action: Action::Restart,
label: "Restart it in its tab",
key: "alt+R",
blocked: far(match () {
_ if subagent => Some("restart the session, not a subagent".into()),
_ if !has_pid => Some("no local process to restart".into()),
_ => match session.root_pid().and_then(|pid| app.tab_running(pid)) {
None => Some("it is not running in a tab here".into()),
Some((at, pane)) => app.tabs[at]
.panes
.get(pane)
.filter(|pane| !pane.owns_agent())
.map(|_| "that tab only looks at it".into()),
},
}),
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::Read,
label: "Read the conversation",
key: "i",
blocked: subagent.then(|| "a subagent has no transcript of its own".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,
},
];
if let Some(r) = &session.remote {
items.push(Item {
action: Action::UpdateRemote,
label: "Update cctop on its host",
key: "",
blocked: app.remote_update_refusal(&r.host),
rule: true,
});
}
items
}
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);
}
}