use crossterm::event::{
Event as CtEvent, KeyCode as CtKeyCode, KeyEventKind, KeyModifiers as CtMods,
MouseEventKind as CtMouseKind,
};
use mermaid_domain::{Key, KeyCode, KeyMods, Msg, Paste};
#[must_use]
pub fn event_to_msg(event: CtEvent) -> Option<Msg> {
match event {
CtEvent::Key(key) => {
if key.kind != KeyEventKind::Press {
return None;
}
Some(Msg::Key(Key {
code: translate_key_code(key.code)?,
modifiers: translate_mods(key.modifiers),
}))
},
CtEvent::Paste(text) => {
if text.is_empty() {
None
} else {
Some(Msg::Paste(Paste::Text(text)))
}
},
CtEvent::Mouse(mouse) => match mouse.kind {
CtMouseKind::ScrollUp => Some(Msg::MouseScroll {
delta: mermaid_model::constants::UI_MOUSE_SCROLL_LINES as i16,
}),
CtMouseKind::ScrollDown => Some(Msg::MouseScroll {
delta: -(mermaid_model::constants::UI_MOUSE_SCROLL_LINES as i16),
}),
_ => None,
},
CtEvent::Resize(w, h) => Some(Msg::Resize {
width: w,
height: h,
}),
CtEvent::FocusGained => Some(Msg::FocusChanged(true)),
CtEvent::FocusLost => Some(Msg::FocusChanged(false)),
}
}
pub fn coalesce_key_burst(
first: CtEvent,
drain: impl FnMut() -> Option<CtEvent>,
) -> (Option<Msg>, Vec<Msg>) {
let (primary, trailing, _) = coalesce_key_burst_seamed(first, drain);
(primary, trailing)
}
pub(crate) fn coalesce_key_burst_seamed(
first: CtEvent,
drain: impl FnMut() -> Option<CtEvent>,
) -> (Option<Msg>, Vec<Msg>, bool) {
if coalescible_char(&first).is_none() {
return (event_to_msg(first), Vec::new(), false);
}
let mut buf = String::new();
let mut last_was_cr = false;
let breaker = drain_burst_into(&mut buf, &mut last_was_cr, first.clone(), drain);
let trailing: Vec<Msg> = breaker.and_then(event_to_msg).into_iter().collect();
if buf.chars().count() <= 1 {
(event_to_msg(first), trailing, last_was_cr)
} else {
(Some(Msg::Paste(Paste::Text(buf))), trailing, last_was_cr)
}
}
fn drain_burst_into(
buf: &mut String,
last_was_cr: &mut bool,
first: CtEvent,
mut drain: impl FnMut() -> Option<CtEvent>,
) -> Option<CtEvent> {
match coalescible_char(&first) {
Some(c) => push_burst_char(buf, last_was_cr, c),
None => return Some(first),
}
while let Some(evt) = drain() {
if let CtEvent::Key(k) = &evt
&& k.kind != KeyEventKind::Press
{
continue;
}
match coalescible_char(&evt) {
Some(c) => push_burst_char(buf, last_was_cr, c),
None => return Some(evt),
}
}
None
}
const PASTE_CHUNK_BRIDGE: std::time::Duration = std::time::Duration::from_millis(25);
#[must_use]
pub async fn bridge_paste_chunks<S>(
text: &mut String,
mut last_was_cr: bool,
events: &mut S,
) -> Vec<Msg>
where
S: futures::Stream<Item = std::io::Result<CtEvent>> + Unpin,
{
use futures::{FutureExt, StreamExt};
loop {
let Ok(arrival) = tokio::time::timeout(PASTE_CHUNK_BRIDGE, events.next()).await else {
return Vec::new();
};
let Some(Ok(evt)) = arrival else {
return Vec::new();
};
if let CtEvent::Key(k) = &evt
&& k.kind != KeyEventKind::Press
{
continue;
}
if coalescible_char(&evt).is_none() {
return event_to_msg(evt).into_iter().collect();
}
let breaker = drain_burst_into(text, &mut last_was_cr, evt, || {
events.next().now_or_never().flatten().and_then(Result::ok)
});
if let Some(b) = breaker {
return event_to_msg(b).into_iter().collect();
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BurstChar {
Plain(char),
CrNewline,
LfNewline,
}
fn push_burst_char(buf: &mut String, last_was_cr: &mut bool, ch: BurstChar) {
match ch {
BurstChar::Plain(c) => {
buf.push(c);
*last_was_cr = false;
},
BurstChar::CrNewline => {
buf.push('\n');
*last_was_cr = true;
},
BurstChar::LfNewline => {
if !*last_was_cr {
buf.push('\n');
}
*last_was_cr = false;
},
}
}
fn coalescible_char(event: &CtEvent) -> Option<BurstChar> {
let CtEvent::Key(key) = event else {
return None;
};
if key.kind != KeyEventKind::Press {
return None;
}
if key.modifiers.intersects(CtMods::ALT) {
return None;
}
if key.modifiers.intersects(CtMods::CONTROL) {
return (key.code == CtKeyCode::Enter).then_some(BurstChar::LfNewline);
}
match key.code {
CtKeyCode::Char(c) => Some(BurstChar::Plain(c)),
CtKeyCode::Enter => Some(BurstChar::CrNewline),
CtKeyCode::Tab => Some(BurstChar::Plain('\t')),
_ => None,
}
}
fn translate_key_code(code: CtKeyCode) -> Option<KeyCode> {
Some(match code {
CtKeyCode::Char(c) => KeyCode::Char(c),
CtKeyCode::Enter => KeyCode::Enter,
CtKeyCode::Esc => KeyCode::Escape,
CtKeyCode::Backspace => KeyCode::Backspace,
CtKeyCode::Delete => KeyCode::Delete,
CtKeyCode::Tab => KeyCode::Tab,
CtKeyCode::BackTab => KeyCode::BackTab,
CtKeyCode::Left => KeyCode::Left,
CtKeyCode::Right => KeyCode::Right,
CtKeyCode::Up => KeyCode::Up,
CtKeyCode::Down => KeyCode::Down,
CtKeyCode::Home => KeyCode::Home,
CtKeyCode::End => KeyCode::End,
CtKeyCode::PageUp => KeyCode::PageUp,
CtKeyCode::PageDown => KeyCode::PageDown,
CtKeyCode::F(n) => KeyCode::F(n),
_ => return Some(KeyCode::Unknown),
})
}
fn translate_mods(mods: CtMods) -> KeyMods {
KeyMods {
ctrl: mods.contains(CtMods::CONTROL),
alt: mods.contains(CtMods::ALT),
shift: mods.contains(CtMods::SHIFT),
}
}
#[cfg(test)]
mod tests {
use super::*;
use mermaid_domain::SlashCmd;
#[test]
fn parses_theme_and_editor_commands() {
assert_eq!(
mermaid_domain::parse_slash_command("theme"),
SlashCmd::Theme(None)
);
assert_eq!(
mermaid_domain::parse_slash_command("theme light"),
SlashCmd::Theme(Some("light".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("editor"),
SlashCmd::Editor
);
}
#[test]
fn parses_agents_command_with_kill_tail() {
assert_eq!(
mermaid_domain::parse_slash_command("agents"),
SlashCmd::Agents(None)
);
assert_eq!(
mermaid_domain::parse_slash_command("agents kill a1"),
SlashCmd::Agents(Some("kill a1".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("agents kill all"),
SlashCmd::Agents(Some("kill all".to_string()))
);
}
#[test]
fn translates_printable_char_key() {
let ev = CtEvent::Key(crossterm::event::KeyEvent {
code: CtKeyCode::Char('a'),
modifiers: CtMods::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
});
let msg = event_to_msg(ev).expect("msg");
match msg {
Msg::Key(k) => {
assert_eq!(k.code, KeyCode::Char('a'));
assert!(k.modifiers.is_empty());
},
_ => panic!("wrong variant"),
}
}
#[test]
fn translates_ctrl_c() {
let ev = CtEvent::Key(crossterm::event::KeyEvent {
code: CtKeyCode::Char('c'),
modifiers: CtMods::CONTROL,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
});
let msg = event_to_msg(ev).expect("msg");
match msg {
Msg::Key(k) => {
assert_eq!(k.code, KeyCode::Char('c'));
assert!(k.modifiers.ctrl);
assert!(!k.modifiers.alt);
assert!(!k.modifiers.shift);
},
_ => panic!("wrong variant"),
}
}
#[test]
fn translates_ctrl_shift_c_with_shift_intact() {
let ev = CtEvent::Key(crossterm::event::KeyEvent {
code: CtKeyCode::Char('c'),
modifiers: CtMods::CONTROL | CtMods::SHIFT,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
});
let msg = event_to_msg(ev).expect("msg");
match msg {
Msg::Key(k) => {
assert_eq!(k.code, KeyCode::Char('c'));
assert!(k.modifiers.ctrl);
assert!(k.modifiers.shift);
},
_ => panic!("wrong variant"),
}
}
#[test]
fn skips_release_events() {
let ev = CtEvent::Key(crossterm::event::KeyEvent {
code: CtKeyCode::Char('a'),
modifiers: CtMods::NONE,
kind: KeyEventKind::Release,
state: crossterm::event::KeyEventState::NONE,
});
assert!(event_to_msg(ev).is_none());
}
#[test]
fn resize_translates_to_resize_msg() {
let ev = CtEvent::Resize(80, 24);
let msg = event_to_msg(ev).expect("msg");
match msg {
Msg::Resize { width, height } => {
assert_eq!(width, 80);
assert_eq!(height, 24);
},
_ => panic!("wrong variant"),
}
}
#[test]
fn empty_paste_dropped() {
let ev = CtEvent::Paste(String::new());
assert!(event_to_msg(ev).is_none());
}
#[test]
fn paste_translates_to_text_paste() {
let ev = CtEvent::Paste("hello".to_string());
let msg = event_to_msg(ev).expect("msg");
match msg {
Msg::Paste(Paste::Text(s)) => assert_eq!(s, "hello"),
_ => panic!("wrong variant"),
}
}
fn key(code: CtKeyCode) -> CtEvent {
CtEvent::Key(crossterm::event::KeyEvent {
code,
modifiers: CtMods::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn key_with(code: CtKeyCode, modifiers: CtMods, kind: KeyEventKind) -> CtEvent {
CtEvent::Key(crossterm::event::KeyEvent {
code,
modifiers,
kind,
state: crossterm::event::KeyEventState::NONE,
})
}
#[test]
fn coalesce_single_char_stays_a_key() {
let (primary, trailing) = coalesce_key_burst(key(CtKeyCode::Char('a')), || None);
assert!(matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Char('a')));
assert!(trailing.is_empty());
}
#[test]
fn coalesce_lone_enter_still_submits_as_key() {
let (primary, _) = coalesce_key_burst(key(CtKeyCode::Enter), || None);
assert!(
matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Enter),
"a lone Enter must remain a key, not become a paste"
);
}
#[test]
fn coalesce_burst_of_chars_becomes_one_paste() {
let mut rest = vec![key(CtKeyCode::Char('e')), key(CtKeyCode::Char('y'))].into_iter();
let (primary, trailing) = coalesce_key_burst(key(CtKeyCode::Char('h')), || rest.next());
match primary {
Some(Msg::Paste(Paste::Text(s))) => assert_eq!(s, "hey"),
other => panic!("expected paste, got {other:?}"),
}
assert!(trailing.is_empty());
}
#[test]
fn coalesce_preserves_pasted_newlines_without_submitting() {
let mut rest = vec![key(CtKeyCode::Enter), key(CtKeyCode::Char('b'))].into_iter();
let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
match primary {
Some(Msg::Paste(Paste::Text(s))) => assert_eq!(s, "a\nb"),
other => panic!("expected multi-line paste, got {other:?}"),
}
}
#[test]
fn coalesce_stops_at_non_char_and_enqueues_it() {
let mut rest = vec![key(CtKeyCode::Char('b')), key(CtKeyCode::Esc)].into_iter();
let (primary, trailing) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
assert!(matches!(primary, Some(Msg::Paste(Paste::Text(ref s))) if s == "ab"));
assert_eq!(trailing.len(), 1);
assert!(matches!(trailing[0], Msg::Key(k) if k.code == KeyCode::Escape));
}
#[test]
fn coalesce_skips_release_events_mid_burst() {
let mut rest = vec![
key_with(CtKeyCode::Char('x'), CtMods::NONE, KeyEventKind::Release),
key(CtKeyCode::Char('b')),
]
.into_iter();
let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
assert!(
matches!(primary, Some(Msg::Paste(Paste::Text(ref s))) if s == "ab"),
"release events must be skipped, not appended or treated as burst-enders"
);
}
#[test]
fn coalesce_preserves_pasted_tabs() {
let mut rest = vec![key(CtKeyCode::Tab), key(CtKeyCode::Char('b'))].into_iter();
let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
match primary {
Some(Msg::Paste(Paste::Text(s))) => assert_eq!(s, "a\tb"),
other => panic!("expected paste with tab, got {other:?}"),
}
}
#[test]
fn coalesce_lone_tab_stays_a_key() {
let (primary, _) = coalesce_key_burst(key(CtKeyCode::Tab), || None);
assert!(matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Tab));
}
#[test]
fn ctrl_enter_in_a_burst_folds_as_a_newline() {
let mut rest = vec![
key_with(CtKeyCode::Enter, CtMods::CONTROL, KeyEventKind::Press),
key(CtKeyCode::Char('b')),
]
.into_iter();
let (primary, trailing) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
assert!(
matches!(primary, Some(Msg::Paste(Paste::Text(ref s))) if s == "a\nb"),
"got {primary:?}"
);
assert!(trailing.is_empty());
}
#[test]
fn a_crlf_pair_in_a_burst_folds_to_one_newline() {
let mut rest = vec![
key(CtKeyCode::Enter),
key_with(CtKeyCode::Enter, CtMods::CONTROL, KeyEventKind::Press),
key(CtKeyCode::Char('b')),
]
.into_iter();
let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
assert!(
matches!(primary, Some(Msg::Paste(Paste::Text(ref s))) if s == "a\nb"),
"CRLF must fold to one newline, got {primary:?}"
);
}
#[test]
fn blank_lines_survive_the_crlf_fold() {
let mut rest = vec![
key(CtKeyCode::Enter),
key_with(CtKeyCode::Enter, CtMods::CONTROL, KeyEventKind::Press),
key(CtKeyCode::Enter),
key_with(CtKeyCode::Enter, CtMods::CONTROL, KeyEventKind::Press),
key(CtKeyCode::Char('b')),
]
.into_iter();
let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
assert!(
matches!(primary, Some(Msg::Paste(Paste::Text(ref s))) if s == "a\n\nb"),
"got {primary:?}"
);
let mut rest = vec![
key_with(CtKeyCode::Enter, CtMods::CONTROL, KeyEventKind::Press),
key_with(CtKeyCode::Enter, CtMods::CONTROL, KeyEventKind::Press),
key(CtKeyCode::Char('b')),
]
.into_iter();
let (primary, _) = coalesce_key_burst(key(CtKeyCode::Char('a')), || rest.next());
assert!(
matches!(primary, Some(Msg::Paste(Paste::Text(ref s))) if s == "a\n\nb"),
"bare LFs each count, got {primary:?}"
);
}
#[test]
fn coalesce_lone_ctrl_enter_stays_a_key() {
let (primary, _) = coalesce_key_burst(
key_with(CtKeyCode::Enter, CtMods::CONTROL, KeyEventKind::Press),
|| None,
);
assert!(
matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Enter && k.modifiers.ctrl),
"got {primary:?}"
);
}
fn scripted(
events: Vec<(u64, CtEvent)>,
) -> std::pin::Pin<Box<dyn futures::Stream<Item = std::io::Result<CtEvent>> + Send>> {
use futures::StreamExt;
Box::pin(
futures::stream::unfold(events.into_iter(), |mut it| async move {
let (delay_ms, evt) = it.next()?;
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
Some((Ok(evt), it))
})
.fuse(),
)
}
#[tokio::test(start_paused = true)]
async fn bridge_folds_a_chunk_gap_newline_into_the_paste() {
let mut text = String::from("line one");
let mut stream = scripted(vec![
(
2,
key_with(CtKeyCode::Enter, CtMods::CONTROL, KeyEventKind::Press),
),
(1, key(CtKeyCode::Char('x'))),
]);
let trailing = bridge_paste_chunks(&mut text, false, &mut stream).await;
assert_eq!(text, "line one\nx");
assert!(trailing.is_empty());
}
#[tokio::test(start_paused = true)]
async fn bridge_dedups_a_crlf_pair_split_at_the_seam() {
let mut text = String::from("line one\n");
let mut stream = scripted(vec![
(
2,
key_with(CtKeyCode::Enter, CtMods::CONTROL, KeyEventKind::Press),
),
(1, key(CtKeyCode::Char('x'))),
]);
let trailing = bridge_paste_chunks(&mut text, true, &mut stream).await;
assert_eq!(text, "line one\nx", "the split CRLF must stay one newline");
assert!(trailing.is_empty());
}
#[tokio::test(start_paused = true)]
async fn bridge_gives_up_when_the_quiet_outlasts_the_window() {
use futures::StreamExt;
let mut text = String::from("chunk");
let mut stream = scripted(vec![(50, key(CtKeyCode::Char('x')))]);
let trailing = bridge_paste_chunks(&mut text, false, &mut stream).await;
assert_eq!(text, "chunk", "a 50ms-late keystroke is typing, not paste");
assert!(trailing.is_empty());
let late = stream.next().await;
assert!(
matches!(late, Some(Ok(CtEvent::Key(k))) if k.code == CtKeyCode::Char('x')),
"the late keystroke must still be deliverable, got {late:?}"
);
}
#[tokio::test(start_paused = true)]
async fn bridge_hands_back_deliberate_input_inside_the_window() {
let mut text = String::from("pasted");
let mut stream = scripted(vec![(2, key(CtKeyCode::Esc))]);
let trailing = bridge_paste_chunks(&mut text, false, &mut stream).await;
assert_eq!(text, "pasted");
assert_eq!(trailing.len(), 1);
assert!(matches!(trailing[0], Msg::Key(k) if k.code == KeyCode::Escape));
}
#[test]
fn coalesce_ctrl_combo_passes_through_without_draining() {
let drained = std::cell::Cell::new(false);
let (primary, trailing) = coalesce_key_burst(
key_with(CtKeyCode::Char('c'), CtMods::CONTROL, KeyEventKind::Press),
|| {
drained.set(true);
None
},
);
assert!(
!drained.get(),
"a non-coalescible first event must not drain the queue"
);
assert!(
matches!(primary, Some(Msg::Key(k)) if k.code == KeyCode::Char('c') && k.modifiers.ctrl)
);
assert!(trailing.is_empty());
}
#[test]
fn parse_slash_model_no_arg() {
assert_eq!(
mermaid_domain::parse_slash_command("model"),
SlashCmd::Model(None)
);
}
#[test]
fn parse_slash_model_with_arg() {
assert_eq!(
mermaid_domain::parse_slash_command("model anthropic/opus"),
SlashCmd::Model(Some("anthropic/opus".to_string())),
);
}
#[test]
fn parse_slash_quit_alias_q() {
assert_eq!(mermaid_domain::parse_slash_command("q"), SlashCmd::Quit);
}
#[test]
fn parse_slash_usage_and_context() {
use mermaid_domain::ContextCmd;
assert_eq!(
mermaid_domain::parse_slash_command("usage"),
SlashCmd::Usage
);
assert_eq!(
mermaid_domain::parse_slash_command("context"),
SlashCmd::Context(ContextCmd::Show)
);
assert_eq!(
mermaid_domain::parse_slash_command("context 65536"),
SlashCmd::Context(ContextCmd::Set(65536))
);
assert_eq!(
mermaid_domain::parse_slash_command("context auto"),
SlashCmd::Context(ContextCmd::Auto)
);
assert_eq!(
mermaid_domain::parse_slash_command("context max"),
SlashCmd::Context(ContextCmd::Max)
);
assert_eq!(
mermaid_domain::parse_slash_command("context offload on"),
SlashCmd::Context(ContextCmd::Offload(true))
);
assert_eq!(
mermaid_domain::parse_slash_command("context offload off"),
SlashCmd::Context(ContextCmd::Offload(false))
);
assert_eq!(
mermaid_domain::parse_slash_command("context wat"),
SlashCmd::Context(ContextCmd::Show)
);
assert_eq!(
mermaid_domain::parse_slash_command("doctor"),
SlashCmd::Doctor
);
}
#[test]
fn parse_slash_compact_and_aliases() {
assert_eq!(
mermaid_domain::parse_slash_command("compact"),
SlashCmd::Compact(None)
);
assert_eq!(
mermaid_domain::parse_slash_command("compact focus on tests"),
SlashCmd::Compact(Some("focus on tests".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("compress"),
SlashCmd::Compact(None)
);
assert_eq!(
mermaid_domain::parse_slash_command("summarize"),
SlashCmd::Compact(None)
);
}
#[test]
fn parse_memory_commands() {
assert_eq!(
mermaid_domain::parse_slash_command("memory"),
SlashCmd::Memory
);
assert_eq!(
mermaid_domain::parse_slash_command("memories"),
SlashCmd::Memory
); assert_eq!(
mermaid_domain::parse_slash_command("remember prefer ripgrep"),
SlashCmd::Remember(Some("prefer ripgrep".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("remember"),
SlashCmd::Remember(None)
);
assert_eq!(
mermaid_domain::parse_slash_command("forget prefer-ripgrep"),
SlashCmd::Forget(Some("prefer-ripgrep".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("forget"),
SlashCmd::Forget(None)
);
assert_eq!(
mermaid_domain::parse_slash_command("consolidate-memory"),
SlashCmd::ConsolidateMemory
);
assert_eq!(
mermaid_domain::parse_slash_command("prune-memory"),
SlashCmd::ConsolidateMemory
); }
#[test]
fn parse_runtime_task_commands() {
assert_eq!(
mermaid_domain::parse_slash_command("tasks"),
SlashCmd::Tasks
);
assert_eq!(
mermaid_domain::parse_slash_command("task task-123"),
SlashCmd::Task(Some("task-123".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("pause task-123"),
SlashCmd::Pause(Some("task-123".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("resume task-123"),
SlashCmd::Resume(Some("task-123".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("cancel"),
SlashCmd::Cancel(None)
);
assert_eq!(
mermaid_domain::parse_slash_command("handoff task-123"),
SlashCmd::Handoff(Some("task-123".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("report"),
SlashCmd::Report(None)
);
assert_eq!(
mermaid_domain::parse_slash_command("procs"),
SlashCmd::Processes
);
assert_eq!(
mermaid_domain::parse_slash_command("approvals"),
SlashCmd::Approvals
);
assert_eq!(
mermaid_domain::parse_slash_command("approve approval-1"),
SlashCmd::Approve(Some("approval-1".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("deny approval-1"),
SlashCmd::Deny(Some("approval-1".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("checkpoint src/lib.rs"),
SlashCmd::Checkpoint(Some("src/lib.rs".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("checkpoints"),
SlashCmd::Checkpoints
);
assert_eq!(
mermaid_domain::parse_slash_command("restore checkpoint-1"),
SlashCmd::Restore(Some("checkpoint-1".to_string()))
);
assert_eq!(
mermaid_domain::parse_slash_command("plugins"),
SlashCmd::Plugins
);
}
#[test]
fn parse_slash_reasoning_valid_level() {
assert_eq!(
mermaid_domain::parse_slash_command("reasoning high"),
SlashCmd::Reasoning(Some(mermaid_model::models::ReasoningLevel::High)),
);
}
#[test]
fn parse_slash_visible_reasoning_and_alias() {
assert_eq!(
mermaid_domain::parse_slash_command("visible-reasoning on"),
SlashCmd::VisibleReasoning(Some("on".to_string())),
);
assert_eq!(
mermaid_domain::parse_slash_command("visiblereasoning"),
SlashCmd::VisibleReasoning(None),
);
}
#[test]
fn parse_slash_reasoning_invalid_level_is_none_arg() {
assert_eq!(
mermaid_domain::parse_slash_command("reasoning bogus"),
SlashCmd::Reasoning(None),
);
}
#[test]
fn parse_safety_command() {
assert_eq!(
mermaid_domain::parse_slash_command("safety auto"),
SlashCmd::Safety(Some(mermaid_runtime::SafetyMode::Auto)),
);
assert_eq!(
mermaid_domain::parse_slash_command("permission read_only"),
SlashCmd::Safety(Some(mermaid_runtime::SafetyMode::ReadOnly)),
);
assert_eq!(
mermaid_domain::parse_slash_command("safety"),
SlashCmd::Safety(None)
);
assert_eq!(
mermaid_domain::parse_slash_command("safety bogus"),
SlashCmd::Safety(None)
);
}
#[test]
fn parse_slash_unknown_command() {
match mermaid_domain::parse_slash_command("nope") {
SlashCmd::Unknown(name) => assert_eq!(name, "nope"),
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn key_mods_combine_correctly() {
let mods = translate_mods(CtMods::CONTROL | CtMods::SHIFT);
assert!(mods.ctrl);
assert!(mods.shift);
assert!(!mods.alt);
}
}