use crossterm::event::KeyCode;
use tmai_core::agents::{AgentStatus, ApprovalType};
use tmai_core::api::has_checkbox_format;
use tmai_core::detectors::get_detector;
use tmai_core::state::AppState;
pub fn normalize_keycode(code: KeyCode) -> KeyCode {
match code {
KeyCode::Char(c) => KeyCode::Char(normalize_fullwidth_char(c)),
other => other,
}
}
fn normalize_fullwidth_char(c: char) -> char {
match c {
'0'..='9' => ((c as u32 - '0' as u32) + '0' as u32) as u8 as char,
'A'..='Z' => ((c as u32 - 'A' as u32) + 'A' as u32) as u8 as char,
'a'..='z' => ((c as u32 - 'a' as u32) + 'a' as u32) as u8 as char,
'\u{3000}' => ' ', _ => c,
}
}
pub enum KeyAction {
None,
SendKeys { target: String, keys: String },
#[allow(dead_code)]
SendKeysLiteral { target: String, keys: String },
MultiSelectSubmit { target: String, downs_needed: usize },
MultiSelectSubmitTab { target: String },
NavigateSelection {
target: String,
steps: i32,
confirm: bool,
},
FocusPane { target: String },
EmitAudit { target: String, action: String },
}
pub struct NumberSelectionResult {
pub action: KeyAction,
pub enter_input_mode: bool,
}
pub fn char_to_digit(c: char) -> usize {
if c.is_ascii_digit() {
c.to_digit(10).unwrap_or(0) as usize
} else {
(c as u32 - '0' as u32) as usize
}
}
pub fn resolve_number_selection(state: &AppState, num: usize) -> NumberSelectionResult {
let noop = NumberSelectionResult {
action: KeyAction::None,
enter_input_mode: false,
};
let Some(target) = state.selected_target() else {
return noop;
};
let target = target.to_string();
let question_info = state.agents.get(&target).and_then(|agent| {
if agent.is_virtual {
return None;
}
if let AgentStatus::AwaitingApproval {
approval_type:
ApprovalType::UserQuestion {
choices,
multi_select,
cursor_position,
},
..
} = &agent.status
{
Some((choices.clone(), *multi_select, *cursor_position))
} else {
None
}
});
let Some((choices, multi_select, cursor_position)) = question_info else {
return NumberSelectionResult {
action: KeyAction::EmitAudit {
target,
action: "number_selection".to_string(),
},
enter_input_mode: false,
};
};
let count = choices.len();
let total_options = count + 1;
if num > total_options {
return noop;
}
let is_other = num == total_options
|| choices
.get(num - 1)
.map(|c| c.to_lowercase().contains("type something"))
.unwrap_or(false);
let cursor = if cursor_position == 0 {
1
} else {
cursor_position
};
let steps = num as i32 - cursor as i32;
if is_other {
NumberSelectionResult {
action: KeyAction::NavigateSelection {
target,
steps,
confirm: true,
},
enter_input_mode: true,
}
} else if multi_select {
NumberSelectionResult {
action: KeyAction::NavigateSelection {
target,
steps,
confirm: false,
},
enter_input_mode: false,
}
} else {
NumberSelectionResult {
action: KeyAction::NavigateSelection {
target,
steps,
confirm: true,
},
enter_input_mode: false,
}
}
}
pub fn resolve_space_toggle(state: &AppState) -> NumberSelectionResult {
let noop = NumberSelectionResult {
action: KeyAction::None,
enter_input_mode: false,
};
let Some(target) = state.selected_target() else {
return noop;
};
let target = target.to_string();
let question_info = state.agents.get(&target).and_then(|agent| {
if agent.is_virtual {
return None;
}
if let AgentStatus::AwaitingApproval {
approval_type:
ApprovalType::UserQuestion {
choices,
multi_select: true,
cursor_position,
},
..
} = &agent.status
{
Some((choices.clone(), *cursor_position))
} else {
None
}
});
let Some((choices, cursor_position)) = question_info else {
return noop;
};
let cursor = if cursor_position == 0 {
1
} else {
cursor_position
};
let is_text_input = cursor > choices.len()
|| choices
.get(cursor - 1)
.is_some_and(|c| c.to_lowercase().contains("type something"));
if is_text_input {
return NumberSelectionResult {
action: KeyAction::SendKeys {
target,
keys: "Enter".to_string(),
},
enter_input_mode: true,
};
}
let key = if has_checkbox_format(&choices) {
"Enter"
} else {
"Space"
};
NumberSelectionResult {
action: KeyAction::SendKeys {
target,
keys: key.to_string(),
},
enter_input_mode: false,
}
}
pub fn resolve_yes_no(state: &AppState, key: char) -> KeyAction {
let Some(target) = state.selected_target() else {
return KeyAction::None;
};
let target = target.to_string();
let agent_info = state.agents.get(&target).and_then(|a| {
if a.is_virtual {
None
} else {
Some((&a.status, a.agent_type.clone()))
}
});
let Some((status, agent_type)) = agent_info else {
return KeyAction::None;
};
match status {
AgentStatus::AwaitingApproval {
approval_type:
ApprovalType::UserQuestion {
choices,
cursor_position,
..
},
..
} => {
let needle = if key == 'y' { "yes" } else { "no" };
let match_pos = choices
.iter()
.position(|c| choice_starts_with_word(c, needle));
let Some(idx) = match_pos else {
if key == 'y' {
let detector = get_detector(&agent_type);
return KeyAction::SendKeys {
target,
keys: detector.approval_keys().to_string(),
};
}
return KeyAction::None;
};
let target_pos = idx + 1; let cursor = if *cursor_position == 0 {
1
} else {
*cursor_position
};
let steps = target_pos as i32 - cursor as i32;
KeyAction::NavigateSelection {
target,
steps,
confirm: true,
}
}
AgentStatus::AwaitingApproval { .. } => {
if key == 'y' {
let detector = get_detector(&agent_type);
KeyAction::SendKeys {
target,
keys: detector.approval_keys().to_string(),
}
} else {
KeyAction::None
}
}
_ => {
KeyAction::EmitAudit {
target,
action: format!("{}_key", key),
}
}
}
}
pub fn resolve_enter_submit(state: &AppState) -> KeyAction {
let Some(target) = state.selected_target() else {
return KeyAction::None;
};
let target = target.to_string();
let multi_info = state.agents.get(&target).and_then(|agent| {
if agent.is_virtual {
return None;
}
if let AgentStatus::AwaitingApproval {
approval_type:
ApprovalType::UserQuestion {
choices,
multi_select: true,
cursor_position,
},
..
} = &agent.status
{
Some((choices.len(), *cursor_position))
} else {
None
}
});
match multi_info {
Some((choice_count, cursor_pos)) => {
let is_checkbox = state
.agents
.get(&target)
.and_then(|agent| {
if let AgentStatus::AwaitingApproval {
approval_type: ApprovalType::UserQuestion { choices, .. },
..
} = &agent.status
{
Some(has_checkbox_format(choices))
} else {
None
}
})
.unwrap_or(false);
if is_checkbox {
KeyAction::MultiSelectSubmitTab { target }
} else {
let downs_needed = choice_count.saturating_sub(cursor_pos.saturating_sub(1));
KeyAction::MultiSelectSubmit {
target,
downs_needed,
}
}
}
None => {
KeyAction::EmitAudit {
target,
action: "enter_key".to_string(),
}
}
}
}
pub fn resolve_focus_pane(state: &AppState) -> KeyAction {
if let Some(agent) = state.selected_agent() {
if !agent.is_virtual {
return KeyAction::FocusPane {
target: agent.target.clone(),
};
}
}
KeyAction::None
}
fn choice_starts_with_word(choice: &str, word: &str) -> bool {
let lower = choice.trim().to_lowercase();
if lower == word {
return true;
}
if let Some(rest) = lower.strip_prefix(word) {
return rest.chars().next().is_none_or(|c| !c.is_alphabetic());
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_fullwidth_digits() {
assert_eq!(normalize_fullwidth_char('0'), '0');
assert_eq!(normalize_fullwidth_char('1'), '1');
assert_eq!(normalize_fullwidth_char('9'), '9');
}
#[test]
fn test_normalize_fullwidth_lowercase() {
assert_eq!(normalize_fullwidth_char('a'), 'a');
assert_eq!(normalize_fullwidth_char('y'), 'y');
assert_eq!(normalize_fullwidth_char('z'), 'z');
}
#[test]
fn test_normalize_fullwidth_uppercase() {
assert_eq!(normalize_fullwidth_char('A'), 'A');
assert_eq!(normalize_fullwidth_char('G'), 'G');
assert_eq!(normalize_fullwidth_char('T'), 'T');
assert_eq!(normalize_fullwidth_char('Z'), 'Z');
}
#[test]
fn test_normalize_fullwidth_space() {
assert_eq!(normalize_fullwidth_char('\u{3000}'), ' ');
}
#[test]
fn test_normalize_halfwidth_unchanged() {
assert_eq!(normalize_fullwidth_char('a'), 'a');
assert_eq!(normalize_fullwidth_char('Z'), 'Z');
assert_eq!(normalize_fullwidth_char('5'), '5');
assert_eq!(normalize_fullwidth_char(' '), ' ');
}
#[test]
fn test_normalize_non_ascii_unchanged() {
assert_eq!(normalize_fullwidth_char('あ'), 'あ');
assert_eq!(normalize_fullwidth_char('漢'), '漢');
assert_eq!(normalize_fullwidth_char('✳'), '✳');
}
#[test]
fn test_normalize_keycode() {
assert_eq!(normalize_keycode(KeyCode::Char('y')), KeyCode::Char('y'));
assert_eq!(normalize_keycode(KeyCode::Char('G')), KeyCode::Char('G'));
assert_eq!(normalize_keycode(KeyCode::Char('1')), KeyCode::Char('1'));
assert_eq!(normalize_keycode(KeyCode::Enter), KeyCode::Enter);
assert_eq!(normalize_keycode(KeyCode::Esc), KeyCode::Esc);
}
}