use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use super::state::{HubState, HubView};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HubAction {
None,
Close,
OpenTranscript(String),
}
const PAGE_STEP: isize = 10;
pub const FOLLOW_TAIL: isize = 0;
pub const JUMP_TOP: isize = isize::MAX;
pub fn handle_key(state: &mut HubState, key: KeyEvent) -> HubAction {
if key.kind != KeyEventKind::Press {
return HubAction::None;
}
match &state.view {
HubView::Table => handle_table_key(state, key),
HubView::Transcript { .. } => handle_transcript_key(state, key),
}
}
fn handle_table_key(state: &mut HubState, key: KeyEvent) -> HubAction {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
if !state.rows.is_empty() {
let max = state.rows.len() - 1;
if state.selected < max {
state.selected += 1;
}
}
HubAction::None
}
KeyCode::Char('k') | KeyCode::Up => {
state.selected = state.selected.saturating_sub(1);
HubAction::None
}
KeyCode::Enter => {
if let Some(row) = state.rows.get(state.selected) {
HubAction::OpenTranscript(row.id.clone())
} else {
HubAction::None
}
}
KeyCode::Esc | KeyCode::Char('q') => HubAction::Close,
_ => HubAction::None,
}
}
fn handle_transcript_key(state: &mut HubState, key: KeyEvent) -> HubAction {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
state.view = HubView::Table;
state.transcript_scroll = FOLLOW_TAIL;
state.transcript_follow = true;
HubAction::None
}
KeyCode::Char('f') => {
state.transcript_follow = !state.transcript_follow;
if state.transcript_follow {
state.transcript_scroll = FOLLOW_TAIL;
}
HubAction::None
}
KeyCode::Char('j') | KeyCode::Down => {
if state.transcript_follow {
HubAction::None
} else {
state.transcript_scroll = state.transcript_scroll.saturating_add(1);
HubAction::None
}
}
KeyCode::Char('k') | KeyCode::Up => {
state.transcript_follow = false;
state.transcript_scroll = state.transcript_scroll.saturating_sub(1);
HubAction::None
}
KeyCode::PageDown => {
if state.transcript_follow {
HubAction::None
} else {
state.transcript_scroll = state.transcript_scroll.saturating_add(PAGE_STEP);
HubAction::None
}
}
KeyCode::PageUp => {
state.transcript_follow = false;
state.transcript_scroll = state.transcript_scroll.saturating_sub(PAGE_STEP);
HubAction::None
}
KeyCode::Char('G') => {
state.transcript_scroll = FOLLOW_TAIL;
state.transcript_follow = true;
HubAction::None
}
KeyCode::Char('g') => {
state.transcript_scroll = JUMP_TOP;
state.transcript_follow = false;
HubAction::None
}
_ => HubAction::None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::overlay::agent_hub::state::HubRow;
use crossterm::event::KeyModifiers;
use oxi_sdk::{HubKind, HubStatus};
use std::collections::HashMap;
use std::path::PathBuf;
fn press(code: KeyCode) -> KeyEvent {
let mut k = KeyEvent::new(code, KeyModifiers::NONE);
k.kind = KeyEventKind::Press;
k
}
fn state_with_rows(n: usize) -> HubState {
let rows = (0..n)
.map(|i| HubRow {
id: format!("agent-{i}"),
kind: HubKind::Subagent,
status: HubStatus::Running,
display_name: format!("agent-{i}"),
current_task: None,
age_text: "0s ago".into(),
session_file: Some(PathBuf::from("/tmp/x.jsonl")),
})
.collect();
HubState {
rows,
view: HubView::Table,
selected: 0,
row_order: HashMap::new(),
transcript_scroll: 0,
transcript_follow: true,
}
}
#[test]
fn j_moves_selection_down_and_clamps_at_last() {
let mut s = state_with_rows(2);
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('j'))),
HubAction::None
);
assert_eq!(s.selected, 1);
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('j'))),
HubAction::None
);
assert_eq!(s.selected, 1);
}
#[test]
fn down_arrow_moves_selection() {
let mut s = state_with_rows(3);
assert_eq!(handle_key(&mut s, press(KeyCode::Down)), HubAction::None);
assert_eq!(s.selected, 1);
}
#[test]
fn k_moves_selection_up_and_saturates_at_zero() {
let mut s = state_with_rows(2);
s.selected = 1;
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('k'))),
HubAction::None
);
assert_eq!(s.selected, 0);
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('k'))),
HubAction::None
);
assert_eq!(s.selected, 0);
}
#[test]
fn up_arrow_moves_selection_up() {
let mut s = state_with_rows(2);
s.selected = 1;
assert_eq!(handle_key(&mut s, press(KeyCode::Up)), HubAction::None);
assert_eq!(s.selected, 0);
}
#[test]
fn enter_opens_transcript_for_selected_row() {
let mut s = state_with_rows(3);
s.selected = 1;
assert_eq!(
handle_key(&mut s, press(KeyCode::Enter)),
HubAction::OpenTranscript("agent-1".into())
);
}
#[test]
fn esc_closes_from_table() {
let mut s = state_with_rows(1);
assert_eq!(handle_key(&mut s, press(KeyCode::Esc)), HubAction::Close);
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('q'))),
HubAction::Close
);
}
#[test]
fn esc_returns_to_table_from_transcript() {
let mut s = state_with_rows(2);
s.view = HubView::Transcript {
agent_id: "agent-0".into(),
};
assert_eq!(handle_key(&mut s, press(KeyCode::Esc)), HubAction::None);
assert!(matches!(s.view, HubView::Table));
}
#[test]
fn q_returns_to_table_from_transcript() {
let mut s = state_with_rows(2);
s.view = HubView::Transcript {
agent_id: "agent-0".into(),
};
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('q'))),
HubAction::None
);
assert!(matches!(s.view, HubView::Table));
}
#[test]
fn f_toggles_tail_follow() {
let mut s = state_with_rows(1);
s.view = HubView::Transcript {
agent_id: "agent-0".into(),
};
s.transcript_follow = false;
s.transcript_scroll = 5;
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('f'))),
HubAction::None
);
assert!(s.transcript_follow);
assert_eq!(s.transcript_scroll, FOLLOW_TAIL);
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('f'))),
HubAction::None
);
assert!(!s.transcript_follow);
assert_eq!(s.transcript_scroll, FOLLOW_TAIL);
}
#[test]
fn g_jumps_to_start_and_disables_follow() {
let mut s = state_with_rows(1);
s.view = HubView::Transcript {
agent_id: "agent-0".into(),
};
s.transcript_scroll = 5;
s.transcript_follow = true;
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('g'))),
HubAction::None
);
assert_eq!(s.transcript_scroll, JUMP_TOP);
assert!(!s.transcript_follow);
}
#[test]
fn capital_g_jumps_to_tail_and_reenables_follow() {
let mut s = state_with_rows(1);
s.view = HubView::Transcript {
agent_id: "agent-0".into(),
};
s.transcript_scroll = 0;
s.transcript_follow = false;
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('G'))),
HubAction::None
);
assert!(s.transcript_follow);
assert_eq!(s.transcript_scroll, FOLLOW_TAIL);
}
#[test]
fn page_keys_step_by_page() {
let mut s = state_with_rows(1);
s.view = HubView::Transcript {
agent_id: "agent-0".into(),
};
s.transcript_follow = false;
s.transcript_scroll = 0;
assert_eq!(
handle_key(&mut s, press(KeyCode::PageDown)),
HubAction::None
);
assert_eq!(s.transcript_scroll, PAGE_STEP);
assert_eq!(handle_key(&mut s, press(KeyCode::PageUp)), HubAction::None);
assert_eq!(s.transcript_scroll, 0);
}
#[test]
fn release_event_is_ignored() {
let mut s = state_with_rows(3);
s.selected = 1;
let mut k = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
k.kind = KeyEventKind::Release;
assert_eq!(handle_key(&mut s, k), HubAction::None);
assert_eq!(s.selected, 1);
}
#[test]
fn empty_rows_j_does_not_move() {
let mut s = state_with_rows(0);
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('j'))),
HubAction::None
);
assert_eq!(s.selected, 0);
}
fn transcript_state() -> HubState {
let mut s = state_with_rows(1);
s.view = HubView::Transcript {
agent_id: "agent-0".into(),
};
s.transcript_follow = true;
s.transcript_scroll = FOLLOW_TAIL;
s
}
#[test]
fn k_in_follow_mode_steps_one_line_below_tail() {
let mut s = transcript_state();
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('k'))),
HubAction::None
);
assert!(!s.transcript_follow, "k must disengage tail-follow");
assert_eq!(s.transcript_scroll, -1isize, "k from FOLLOW_TAIL (0) → -1");
}
#[test]
fn page_up_in_follow_mode_steps_ten_lines_below_tail() {
let mut s = transcript_state();
assert_eq!(handle_key(&mut s, press(KeyCode::PageUp)), HubAction::None);
assert!(!s.transcript_follow);
assert_eq!(
s.transcript_scroll, -10isize,
"PgUp from FOLLOW_TAIL (0) → -10"
);
}
#[test]
fn j_in_follow_mode_is_noop_pinned_at_tail() {
let mut s = transcript_state();
assert_eq!(
handle_key(&mut s, press(KeyCode::Char('j'))),
HubAction::None
);
assert!(
s.transcript_follow,
"j in follow mode must not disengage follow"
);
assert_eq!(s.transcript_scroll, FOLLOW_TAIL);
}
#[test]
fn page_down_in_follow_mode_is_noop_pinned_at_tail() {
let mut s = transcript_state();
assert_eq!(
handle_key(&mut s, press(KeyCode::PageDown)),
HubAction::None
);
assert!(s.transcript_follow);
assert_eq!(s.transcript_scroll, FOLLOW_TAIL);
}
#[test]
fn round_trip_g_then_j_stays_at_top() {
let mut s = transcript_state();
handle_key(&mut s, press(KeyCode::Char('g')));
assert!(!s.transcript_follow);
assert_eq!(s.transcript_scroll, JUMP_TOP);
handle_key(&mut s, press(KeyCode::Char('j')));
assert!(!s.transcript_follow);
assert_eq!(
s.transcript_scroll, JUMP_TOP,
"saturating_add on MAX stays at MAX"
);
}
}