use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum TuiAction {
SubmitPrompt,
InsertPromptNewline,
Exit,
ClearInputOrExit,
FocusPrompt,
FocusActivityTree,
FocusActivityDetail,
CyclePrimaryAgent,
CycleThinkingLevel,
ToggleHelp,
MoveSelectionUp,
MoveSelectionDown,
CollapseSelected,
ExpandSelected,
ExpandAll,
CollapseAll,
SelectFailed,
SelectRunning,
ScrollDetailUp,
ScrollDetailDown,
ScrollTranscriptUp,
ScrollTranscriptDown,
FocusTranscriptLayout,
FocusActivityLayout,
MovePromptCursorLeft,
MovePromptCursorRight,
MovePromptCursorUp,
MovePromptCursorDown,
OpenSetModel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum KeyContext {
Global,
Prompt,
Transcript,
ActivityTree,
ActivityDetail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct KeyPattern {
code: KeyCode,
modifiers: KeyModifiers,
}
impl KeyPattern {
const fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
Self { code, modifiers }
}
fn matches(self, key: KeyEvent) -> bool {
key.code == self.code && key.modifiers == self.modifiers
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct KeyBinding {
pub(crate) action: TuiAction,
pub(crate) context: KeyContext,
pub(crate) key: KeyPattern,
pub(crate) label: &'static str,
pub(crate) description: &'static str,
}
const DEFAULT_KEYBINDINGS: &[KeyBinding] = &[
KeyBinding {
action: TuiAction::ToggleHelp,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::F(1), KeyModifiers::NONE),
label: "F1",
description: "toggle help",
},
KeyBinding {
action: TuiAction::Exit,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
label: "Ctrl-D",
description: "exit",
},
KeyBinding {
action: TuiAction::ClearInputOrExit,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
label: "Ctrl-C",
description: "clear prompt, or exit when empty",
},
KeyBinding {
action: TuiAction::FocusActivityTree,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::Char('1'), KeyModifiers::ALT),
label: "Alt-1",
description: "focus activity tree pane",
},
KeyBinding {
action: TuiAction::FocusActivityDetail,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::Char('2'), KeyModifiers::ALT),
label: "Alt-2",
description: "focus selected activity detail pane",
},
KeyBinding {
action: TuiAction::FocusActivityTree,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::Char('t'), KeyModifiers::CONTROL),
label: "Ctrl-T",
description: "focus activity tree pane",
},
KeyBinding {
action: TuiAction::FocusPrompt,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::Char('p'), KeyModifiers::ALT),
label: "Alt-P",
description: "focus prompt and close help",
},
KeyBinding {
action: TuiAction::FocusPrompt,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::Esc, KeyModifiers::NONE),
label: "Esc",
description: "focus prompt and close help",
},
KeyBinding {
action: TuiAction::CyclePrimaryAgent,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::BackTab, KeyModifiers::SHIFT),
label: "Shift-Tab",
description: "cycle primary agent selection",
},
KeyBinding {
action: TuiAction::CycleThinkingLevel,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::Char('t'), KeyModifiers::ALT),
label: "Alt-T",
description: "cycle thinking level",
},
KeyBinding {
action: TuiAction::OpenSetModel,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::Char('m'), KeyModifiers::ALT),
label: "Alt-M",
description: "open model picker",
},
KeyBinding {
action: TuiAction::FocusActivityLayout,
context: KeyContext::Global,
key: KeyPattern::new(
KeyCode::Right,
KeyModifiers::CONTROL.union(KeyModifiers::SHIFT),
),
label: "Ctrl-Shift-Right",
description: "activity-focused layout",
},
KeyBinding {
action: TuiAction::FocusTranscriptLayout,
context: KeyContext::Global,
key: KeyPattern::new(
KeyCode::Left,
KeyModifiers::CONTROL.union(KeyModifiers::SHIFT),
),
label: "Ctrl-Shift-Left",
description: "transcript-focused layout",
},
KeyBinding {
action: TuiAction::FocusActivityLayout,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::Right, KeyModifiers::ALT),
label: "Alt-Right",
description: "activity-focused layout alias",
},
KeyBinding {
action: TuiAction::FocusTranscriptLayout,
context: KeyContext::Global,
key: KeyPattern::new(KeyCode::Left, KeyModifiers::ALT),
label: "Alt-Left",
description: "transcript-focused layout alias",
},
KeyBinding {
action: TuiAction::SubmitPrompt,
context: KeyContext::Prompt,
key: KeyPattern::new(KeyCode::Enter, KeyModifiers::NONE),
label: "Enter",
description: "submit prompt",
},
KeyBinding {
action: TuiAction::InsertPromptNewline,
context: KeyContext::Prompt,
key: KeyPattern::new(KeyCode::Enter, KeyModifiers::SHIFT),
label: "Shift-Enter",
description: "insert newline",
},
KeyBinding {
action: TuiAction::MovePromptCursorLeft,
context: KeyContext::Prompt,
key: KeyPattern::new(KeyCode::Left, KeyModifiers::NONE),
label: "Left",
description: "move prompt cursor left",
},
KeyBinding {
action: TuiAction::MovePromptCursorRight,
context: KeyContext::Prompt,
key: KeyPattern::new(KeyCode::Right, KeyModifiers::NONE),
label: "Right",
description: "move prompt cursor right",
},
KeyBinding {
action: TuiAction::MovePromptCursorUp,
context: KeyContext::Prompt,
key: KeyPattern::new(KeyCode::Up, KeyModifiers::NONE),
label: "Up",
description: "move prompt cursor up",
},
KeyBinding {
action: TuiAction::MovePromptCursorDown,
context: KeyContext::Prompt,
key: KeyPattern::new(KeyCode::Down, KeyModifiers::NONE),
label: "Down",
description: "move prompt cursor down",
},
KeyBinding {
action: TuiAction::ScrollTranscriptUp,
context: KeyContext::Prompt,
key: KeyPattern::new(KeyCode::Up, KeyModifiers::ALT),
label: "Alt-Up",
description: "scroll transcript up",
},
KeyBinding {
action: TuiAction::ScrollTranscriptDown,
context: KeyContext::Prompt,
key: KeyPattern::new(KeyCode::Down, KeyModifiers::ALT),
label: "Alt-Down",
description: "scroll transcript down",
},
KeyBinding {
action: TuiAction::ScrollTranscriptUp,
context: KeyContext::Transcript,
key: KeyPattern::new(KeyCode::Up, KeyModifiers::NONE),
label: "Up",
description: "scroll transcript up",
},
KeyBinding {
action: TuiAction::ScrollTranscriptDown,
context: KeyContext::Transcript,
key: KeyPattern::new(KeyCode::Down, KeyModifiers::NONE),
label: "Down",
description: "scroll transcript down",
},
KeyBinding {
action: TuiAction::MoveSelectionDown,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Char('j'), KeyModifiers::ALT),
label: "Alt-J",
description: "move activity selection down",
},
KeyBinding {
action: TuiAction::MoveSelectionUp,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Char('k'), KeyModifiers::ALT),
label: "Alt-K",
description: "move activity selection up",
},
KeyBinding {
action: TuiAction::MoveSelectionUp,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Up, KeyModifiers::NONE),
label: "Up",
description: "move activity selection up",
},
KeyBinding {
action: TuiAction::MoveSelectionDown,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Down, KeyModifiers::NONE),
label: "Down",
description: "move activity selection down",
},
KeyBinding {
action: TuiAction::CollapseSelected,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Left, KeyModifiers::NONE),
label: "Left",
description: "collapse selected activity",
},
KeyBinding {
action: TuiAction::ExpandSelected,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Right, KeyModifiers::NONE),
label: "Right",
description: "expand selected activity",
},
KeyBinding {
action: TuiAction::CollapseSelected,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Char('h'), KeyModifiers::ALT),
label: "Alt-H",
description: "collapse selected activity",
},
KeyBinding {
action: TuiAction::ExpandSelected,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Char('l'), KeyModifiers::ALT),
label: "Alt-L",
description: "expand selected activity",
},
KeyBinding {
action: TuiAction::ExpandAll,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Char('e'), KeyModifiers::ALT),
label: "Alt-E",
description: "expand all activities",
},
KeyBinding {
action: TuiAction::CollapseAll,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Char('c'), KeyModifiers::ALT),
label: "Alt-C",
description: "collapse all activities",
},
KeyBinding {
action: TuiAction::SelectFailed,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Char('f'), KeyModifiers::ALT),
label: "Alt-F",
description: "select first failed activity",
},
KeyBinding {
action: TuiAction::SelectRunning,
context: KeyContext::ActivityTree,
key: KeyPattern::new(KeyCode::Char('r'), KeyModifiers::ALT),
label: "Alt-R",
description: "select first running activity",
},
KeyBinding {
action: TuiAction::ScrollDetailUp,
context: KeyContext::ActivityDetail,
key: KeyPattern::new(KeyCode::Up, KeyModifiers::NONE),
label: "Up",
description: "scroll selected activity detail up",
},
KeyBinding {
action: TuiAction::ScrollDetailDown,
context: KeyContext::ActivityDetail,
key: KeyPattern::new(KeyCode::Down, KeyModifiers::NONE),
label: "Down",
description: "scroll selected activity detail down",
},
KeyBinding {
action: TuiAction::ScrollDetailUp,
context: KeyContext::ActivityDetail,
key: KeyPattern::new(KeyCode::PageUp, KeyModifiers::NONE),
label: "PageUp",
description: "scroll selected activity detail up",
},
KeyBinding {
action: TuiAction::ScrollDetailDown,
context: KeyContext::ActivityDetail,
key: KeyPattern::new(KeyCode::PageDown, KeyModifiers::NONE),
label: "PageDown",
description: "scroll selected activity detail down",
},
KeyBinding {
action: TuiAction::ScrollDetailUp,
context: KeyContext::ActivityDetail,
key: KeyPattern::new(KeyCode::Up, KeyModifiers::ALT),
label: "Alt-Up",
description: "scroll selected activity detail up",
},
KeyBinding {
action: TuiAction::ScrollDetailDown,
context: KeyContext::ActivityDetail,
key: KeyPattern::new(KeyCode::Down, KeyModifiers::ALT),
label: "Alt-Down",
description: "scroll selected activity detail down",
},
];
#[cfg(test)]
pub(super) fn default_keybindings() -> &'static [KeyBinding] {
DEFAULT_KEYBINDINGS
}
fn bracketed_label(label: &str) -> String {
format!("[{label}]")
}
static HELP_TEXT: LazyLock<String> = LazyLock::new(build_help_text);
pub(super) fn help_text() -> &'static str {
&HELP_TEXT
}
fn build_help_text() -> String {
let mut lines = vec![
"Mission Control Help".to_string(),
"".to_string(),
"Focus: [Alt-1] Activity Tree, [Alt-2] Selected Activity Detail, [Alt-P] Prompt.".to_string(),
"Prompt: type normally when focused; [Enter] submits; [Shift-Enter] inserts a newline; arrows move the cursor.".to_string(),
"Transcript: [Alt-Up]/[Alt-Down] scroll transcript while Prompt is focused; mouse wheel over Transcript also scrolls transcript.".to_string(),
"Activity Tree: with Activity Tree focused, [Up]/[Down] move selection; [Alt-E]/[Alt-C] expand/collapse all; [Alt-F]/[Alt-R] select failed/running.".to_string(),
"Selected Activity Detail: with Selected Activity Detail focused, [Up]/[Down]/[PageUp]/[PageDown] scroll detail.".to_string(),
"Long prompt: mouse wheel over Prompt scrolls the editor.".to_string(),
"Activity: [Up]/[Down] move selection; [Alt-E]/[Alt-C] expand/collapse all; [Alt-F]/[Alt-R] select failed/running. Compatibility aliases: [Ctrl-T] focuses activity tree; [Esc] focuses prompt and closes help; [Shift-Tab] cycles primary agent selection; [Alt-T] cycles thinking level; [Alt-M] opens the model picker when no prompt is running.".to_string(),
"Layout: [Ctrl-Shift-Right] uses activity-focused columns; [Ctrl-Shift-Left] restores transcript-focused columns. [Alt-Right]/[Alt-Left] remain secondary aliases where terminals deliver them.".to_string(),
"Help: [F1] toggles this overlay; mouse wheel over Help scrolls this text.".to_string(),
"Autocomplete: first-character / shows supported TUI slash commands; @ anywhere tags cwd files with fuzzy matching; $ anywhere tags enabled skills with fuzzy matching; [Up]/[Down] navigate; [Tab]/[Enter] accept only when suggestions are visible; [Esc] dismiss.".to_string(),
"System prompt modal: [Esc]/[q] close; [Up]/[Down]/[Alt-Up]/[Alt-Down]/[PageUp]/[PageDown]/[Home]/[End] and mouse wheel scroll.".to_string(),
"Sessions modal: two columns when space permits; [Tab]/[Shift-Tab] focus list or preview; [Up]/[Down] moves list or scrolls preview; mouse wheel scrolls hovered column; [Enter] switches; [Esc] closes. [Shift-Tab] depends on terminal delivery.".to_string(),
"TUI slash commands: /help opens this overlay; /login openai-codex authenticates; /logout openai-codex removes local auth; /setmodel selects models; /system-prompt opens read-only prompt inspection; /skills manages skills; /sessions opens the preview session switcher; /new starts a fresh session and clears transcript/activity/detail/help scroll; /quit exits.".to_string(),
"Mouse: wheel follows the hovered scrollable pane; click activity items to select, click selected parents to expand/collapse.".to_string(),
"".to_string(),
];
for context in [
KeyContext::Global,
KeyContext::Prompt,
KeyContext::Transcript,
KeyContext::ActivityTree,
KeyContext::ActivityDetail,
] {
lines.push(format!("{context:?}"));
for binding in DEFAULT_KEYBINDINGS
.iter()
.filter(|binding| binding.context == context)
{
lines.push(format!(
" {:<18} {}",
bracketed_label(binding.label),
binding.description
));
}
lines.push(String::new());
}
lines.join("\n")
}
pub(super) fn action_for_key(key: KeyEvent, active_context: KeyContext) -> Option<TuiAction> {
DEFAULT_KEYBINDINGS
.iter()
.find(|binding| {
(binding.context == KeyContext::Global || binding.context == active_context)
&& binding.key.matches(key)
})
.map(|binding| binding.action)
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn help_text_brackets_visible_key_labels() {
let help = help_text();
for label in [
"[Alt-1]",
"[Alt-2]",
"[Shift-Enter]",
"[Alt-C]",
"[Ctrl-Shift-Right]",
"[Enter]",
"[Alt-P]",
"[Alt-M]",
"[Esc]",
] {
assert!(help.contains(label), "missing {label}");
}
assert!(help.contains("[Alt-P] Prompt"));
assert!(!help.contains("[Ctrl-P] Prompt"));
assert!(!help.contains("[Ctrl Ctrl] Prompt"));
assert!(!help.contains("[Alt-3]"));
assert!(!help.contains("focus transcript pane"));
assert!(help.contains(" [Enter]"));
}
#[test]
fn prompt_focus_keybindings_match_reachable_focus_panes() {
assert_eq!(
action_for_key(
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::ALT),
KeyContext::ActivityTree
),
Some(TuiAction::FocusPrompt)
);
assert_eq!(
action_for_key(
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
KeyContext::ActivityTree
),
None
);
assert_eq!(
action_for_key(
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
KeyContext::ActivityTree
),
Some(TuiAction::FocusPrompt)
);
}
#[test]
fn alt_number_keybindings_match_reachable_focus_panes() {
assert_eq!(
action_for_key(
KeyEvent::new(KeyCode::Char('1'), KeyModifiers::ALT),
KeyContext::Prompt
),
Some(TuiAction::FocusActivityTree)
);
assert_eq!(
action_for_key(
KeyEvent::new(KeyCode::Char('2'), KeyModifiers::ALT),
KeyContext::Prompt
),
Some(TuiAction::FocusActivityDetail)
);
assert_eq!(
action_for_key(
KeyEvent::new(KeyCode::Char('3'), KeyModifiers::ALT),
KeyContext::Prompt
),
None
);
}
}