pub(in crate::tui) mod autocomplete_sources;
pub(in crate::tui) mod clipboard;
mod keybindings;
mod modal;
pub(in crate::tui) mod mouse;
pub(in crate::tui) mod submit_command;
pub(crate) use keybindings::REWIND_MODE_HELP;
use self::keybindings::{
KeyCommand, KeyContext, action_for_key, help_text as keybindings_help_text,
};
use super::{
autocomplete::AutocompleteCandidate,
state::{MissionControlState, TuiFocusPane},
};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, ModifierKeyCode};
use ratatui::layout::Rect;
#[cfg(test)]
use std::time::Duration;
use std::{fmt, time::Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PromptViewport {
pub(crate) visible_rows: u16,
pub(crate) wrap_width: u16,
}
impl PromptViewport {
fn safe(self) -> Self {
Self {
visible_rows: self.visible_rows.max(1),
wrap_width: self.wrap_width.max(1),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TranscriptViewport {
pub(crate) visible_rows: u16,
pub(crate) wrap_width: u16,
}
impl TranscriptViewport {
fn safe(self) -> Self {
Self {
visible_rows: self.visible_rows.max(1),
wrap_width: self.wrap_width.max(1),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ActivityTreeViewport {
pub(crate) visible_rows: u16,
}
impl ActivityTreeViewport {
fn safe(self) -> Self {
Self {
visible_rows: self.visible_rows.max(1),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DetailViewport {
pub(crate) visible_rows: u16,
pub(crate) wrap_width: u16,
}
impl DetailViewport {
fn safe(self) -> Self {
Self {
visible_rows: self.visible_rows,
wrap_width: self.wrap_width.max(1),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SystemPromptViewport {
pub(crate) visible_rows: u16,
pub(crate) wrap_width: u16,
}
impl SystemPromptViewport {
fn safe(self) -> Self {
Self {
visible_rows: self.visible_rows.max(1),
wrap_width: self.wrap_width.max(1),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PickerViewport {
pub(crate) visible_rows: u16,
}
impl PickerViewport {
fn safe(self) -> Self {
Self {
visible_rows: self.visible_rows.max(1),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SessionPickerViewport {
pub(crate) list_rows: u16,
pub(crate) preview_rows: u16,
pub(crate) preview_width: u16,
pub(crate) preview_visible: bool,
}
impl SessionPickerViewport {
fn safe(self) -> Self {
Self {
list_rows: self.list_rows.max(1),
preview_rows: self.preview_rows.max(1),
preview_width: self.preview_width.max(1),
preview_visible: self.preview_visible,
}
}
}
const DEFAULT_PROMPT_VIEWPORT: PromptViewport = PromptViewport {
visible_rows: 1,
wrap_width: 80,
};
const DEFAULT_TRANSCRIPT_VIEWPORT: TranscriptViewport = TranscriptViewport {
visible_rows: 1,
wrap_width: 80,
};
const DEFAULT_ACTIVITY_TREE_VIEWPORT: ActivityTreeViewport =
ActivityTreeViewport { visible_rows: 1 };
const DEFAULT_DETAIL_VIEWPORT: DetailViewport = DetailViewport {
visible_rows: 1,
wrap_width: 80,
};
const DEFAULT_SYSTEM_PROMPT_VIEWPORT: SystemPromptViewport = SystemPromptViewport {
visible_rows: 10,
wrap_width: 80,
};
pub(crate) const DEFAULT_PICKER_VISIBLE_ROWS: u16 = 10;
#[cfg(test)]
const DEFAULT_PICKER_VIEWPORT: PickerViewport = PickerViewport {
visible_rows: DEFAULT_PICKER_VISIBLE_ROWS,
};
#[cfg(test)]
const DEFAULT_SESSION_PICKER_VIEWPORT: SessionPickerViewport = SessionPickerViewport {
list_rows: DEFAULT_PICKER_VISIBLE_ROWS,
preview_rows: DEFAULT_PICKER_VISIBLE_ROWS,
preview_width: 80,
preview_visible: true,
};
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct RedactedInputText(String);
impl RedactedInputText {
pub(crate) fn new(text: String) -> Self {
Self(text)
}
pub(crate) fn into_inner(self) -> String {
self.0
}
}
impl fmt::Debug for RedactedInputText {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("<redacted>")
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum InputAction {
None,
Submit(String),
SelectModel(String),
SelectSession(String),
PreviewTheme(String),
ConfirmTheme,
CancelTheme,
SetSkillEnabled { name: String, enabled: bool },
ToggleSkillsSettingsScope,
SetMcpServerEnabled { name: String, enabled: bool },
SetToolEnabled { name: String, enabled: bool },
ToggleToolsSettingsScope,
SetSubagentProfileEnabled { id: String, enabled: bool },
ToggleSubagentsSettingsScope,
SetModelEnabled { id: String, enabled: bool },
ToggleModelsSettingsScope,
PrimaryAgentSelectionChanged(Option<String>),
ThinkingLevelSelectionChanged(crate::thinking::ThinkingLevel),
SelectConnectProvider(String),
SelectLogoutProvider(String),
MoveConnectProviderUp(usize),
MoveConnectProviderDown(usize),
FocusNextConnectProviderField,
FocusPreviousConnectProviderField,
InsertConnectProviderChar(char),
InsertConnectProviderText(RedactedInputText),
BackspaceConnectProviderField,
DeleteConnectProviderField,
MoveConnectProviderFieldLeft,
MoveConnectProviderFieldRight,
MoveConnectProviderFieldHome,
MoveConnectProviderFieldEnd,
SubmitOAuthFallback,
SubmitConnectProviderStep,
CopyOAuthUrl,
CopySelectedPrompt(String),
OpenOAuthUrl,
CancelConnectProvider,
RetryConnectProvider,
BackConnectProvider,
CloseConnectProvider,
ConfirmLogout(String),
CancelLogout,
ConfirmConnectProviderReplacement,
CancelConnectProviderReplacement,
ConfirmRewind,
CancelRewind,
CancelRunningPrompt,
CancelStartupPrompt,
OpenSetModel,
Exit,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum InputCommand {
None,
Display(DisplayCommand),
Action(InputAction),
ActionWithDisplay {
action: InputAction,
display: DisplayCommand,
},
}
impl InputCommand {
pub(crate) fn display(display: DisplayCommand) -> Self {
Self::Display(display)
}
pub(crate) fn action(action: InputAction) -> Self {
if matches!(action, InputAction::None) {
Self::None
} else {
Self::Action(action)
}
}
pub(crate) fn action_with_display(action: InputAction, display: DisplayCommand) -> Self {
if matches!(action, InputAction::None) {
Self::Display(display)
} else {
Self::ActionWithDisplay { action, display }
}
}
pub(crate) fn into_parts(self) -> (InputAction, Option<DisplayCommand>) {
match self {
Self::None => (InputAction::None, None),
Self::Display(display) => (InputAction::None, Some(display)),
Self::Action(action) => (action, None),
Self::ActionWithDisplay { action, display } => (action, Some(display)),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum DisplayCommand {
Batch(Vec<DisplayCommand>),
ClearStandaloneCtrl {
suppress_release: bool,
},
StandaloneCtrl {
kind: KeyEventKind,
now: Instant,
},
Focus(FocusCommand),
Prompt(PromptCommand),
Activity(ActivityCommand),
Scroll(ScrollCommand),
ToggleSummary,
FocusSummarySection {
files: bool,
},
ScrollSummary {
files: bool,
down: bool,
rows: u16,
width: u16,
height: u16,
},
Modal(ModalCommand),
Mouse(MouseCommand),
SetPrimaryAgent(Option<String>),
SetThinkingLevel(crate::thinking::ThinkingLevel),
RefreshAutocomplete,
SetStatus(String),
}
impl DisplayCommand {
pub(crate) fn batch(commands: impl IntoIterator<Item = DisplayCommand>) -> Self {
Self::Batch(commands.into_iter().collect())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FocusCommand {
Prompt { close_help: bool },
RightColumnTab(crate::tui::state::RightColumnTab),
NextPrimaryPane,
ActivityTree,
ToggleHelp,
ToggleActivityColumn,
TranscriptLayout,
ActivityLayout,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PromptEditCommand {
InsertChar(char),
InsertNewline,
Backspace,
Delete,
MoveCursorLeft,
MoveCursorRight,
MoveCursorUp,
MoveCursorDown,
MoveWordLeft,
MoveWordRight,
Undo,
Redo,
DeleteWord,
DeleteNextWord,
ExtendSelectionLeft,
ExtendSelectionRight,
ExtendSelectionUp,
ExtendSelectionDown,
SelectAll,
Clear,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum PromptCommand {
Edit {
operation: PromptEditCommand,
viewport: PromptViewport,
refresh_autocomplete: bool,
},
InsertText {
text: String,
viewport: PromptViewport,
refresh_autocomplete: bool,
},
TakeForSubmit,
AcceptAutocomplete {
viewport: PromptViewport,
},
MoveAutocompleteUp,
MoveAutocompleteDown,
DismissAutocomplete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ActivityCommand {
MoveUp {
visible_rows: u16,
},
MoveDown {
visible_rows: u16,
},
CollapseSelected {
visible_rows: u16,
},
ExpandSelected {
visible_rows: u16,
},
ExpandAll {
visible_rows: u16,
},
CollapseAll {
visible_rows: u16,
},
SelectStatus {
status: crate::output::ActivityStatus,
visible_rows: u16,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ScrollTarget {
Transcript,
ActivityTree,
Detail,
Help,
SystemPrompt,
Rewind,
Usage,
SessionPreview,
SubagentViewer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ScrollCommand {
pub(crate) target: ScrollTarget,
pub(crate) down: bool,
pub(crate) rows: u16,
pub(crate) visible_rows: u16,
pub(crate) wrap_width: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SettingsModalKind {
Skills,
Mcp,
Tools,
Subagents,
Models,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ModalCommand {
SessionPickerFocusNext,
SessionPickerFocusPrevious,
SessionPickerMove {
focus: crate::tui::state::SessionPickerFocus,
down: bool,
amount: usize,
visible_rows: usize,
preview_rows: u16,
preview_width: u16,
},
CloseSessionPicker,
CloseSubagentViewer,
SubagentViewerSelectNext,
SubagentViewerSelectPrevious,
SubagentViewerScrollHome,
SubagentViewerScrollEnd {
viewport: SystemPromptViewport,
},
ThemeMove {
amount: usize,
down: bool,
visible_rows: usize,
},
ThemeInsertChar(char),
ThemeBackspace,
ThemeDelete,
ThemeCursorLeft,
ThemeCursorRight,
ThemeCursorHome,
ThemeCursorEnd,
MoveSettings {
kind: SettingsModalKind,
down: bool,
visible_rows: usize,
},
SetSettingRow {
kind: SettingsModalKind,
key: String,
enabled: bool,
},
CloseSettings(SettingsModalKind),
CloseSystemPrompt,
CloseUsage,
RewindPickerMoveOrScroll {
down: bool,
viewport: SystemPromptViewport,
},
CycleRewindMode,
CloseRewind,
CloseLogoutConfirmation,
CloseLogoutPicker,
LogoutPickerMove {
down: bool,
visible_rows: usize,
},
CloseModelPicker,
ModelPickerMove {
down: bool,
visible_rows: usize,
},
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum MouseCommand {
Consume,
ClearSelection,
StartPromptSelection {
byte: usize,
},
UpdatePromptSelection {
byte: usize,
},
FinishPromptSelection {
byte: Option<usize>,
visible_rows: u16,
wrap_width: u16,
},
StartTranscriptSelection {
position: crate::tui::selection::TextPosition,
},
UpdateTranscriptSelection {
position: crate::tui::selection::TextPosition,
},
FinishTranscriptSelection {
wrap_width: u16,
},
ClickActivityTree {
row: u16,
visible_rows: u16,
},
ClickActivityDetail {
row: u16,
visible_rows: u16,
wrap_width: u16,
},
SelectSessionRow {
visible_index: usize,
visible_rows: usize,
},
PinSubagentCard {
batch_id: crate::output::ActivityId,
task_id: crate::output::ActivityId,
},
OpenSubagentViewer {
batch_id: crate::output::ActivityId,
task_id: crate::output::ActivityId,
},
SelectSubagentViewerTab {
visible_index: usize,
},
}
pub(crate) fn help_text() -> &'static str {
keybindings_help_text()
}
#[cfg(test)]
use self::keybindings::default_keybindings;
#[cfg(test)]
pub(crate) fn handle_key(key: KeyEvent, state: &mut MissionControlState) -> InputAction {
handle_key_with_viewport(key, state, None, &[])
}
#[cfg(test)]
pub(crate) fn handle_key_with_viewport(
key: KeyEvent,
state: &mut MissionControlState,
prompt_viewport: Option<PromptViewport>,
autocomplete_candidates: &[AutocompleteCandidate],
) -> InputAction {
handle_key_with_viewports(key, state, prompt_viewport, None, autocomplete_candidates)
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct KeyViewports {
pub(crate) prompt: Option<PromptViewport>,
pub(crate) transcript: Option<TranscriptViewport>,
pub(crate) activity_tree: Option<ActivityTreeViewport>,
pub(crate) detail: Option<DetailViewport>,
pub(crate) summary: Option<Rect>,
pub(crate) system_prompt: Option<SystemPromptViewport>,
pub(crate) rewind_modal: Option<SystemPromptViewport>,
pub(crate) usage_modal: Option<SystemPromptViewport>,
pub(crate) connect_provider: Option<PickerViewport>,
pub(crate) connect_provider_modal_visible: bool,
pub(crate) connect_provider_custom_field_visible: bool,
pub(crate) connect_provider_oauth_controls_visible: bool,
pub(crate) connect_provider_oauth_fallback_visible: bool,
pub(crate) logout_picker: Option<PickerViewport>,
pub(crate) logout_confirmation_action_row: Option<Rect>,
pub(crate) model_picker: Option<PickerViewport>,
pub(crate) sessions_modal: Option<SessionPickerViewport>,
pub(crate) subagent_viewer: Option<SystemPromptViewport>,
pub(crate) models_modal: Option<PickerViewport>,
pub(crate) skills_modal: Option<PickerViewport>,
pub(crate) skills_scope_visible: bool,
pub(crate) mcp_modal: Option<PickerViewport>,
pub(crate) tools_modal: Option<PickerViewport>,
pub(crate) tools_scope_visible: bool,
pub(crate) subagents_modal: Option<PickerViewport>,
pub(crate) subagents_scope_visible: bool,
pub(crate) models_scope_visible: bool,
pub(crate) theme_picker: Option<PickerViewport>,
pub(crate) theme_query_visible: bool,
}
#[cfg(test)]
fn test_default_key_viewports() -> KeyViewports {
KeyViewports {
connect_provider: Some(DEFAULT_PICKER_VIEWPORT),
connect_provider_modal_visible: true,
connect_provider_custom_field_visible: true,
connect_provider_oauth_controls_visible: true,
connect_provider_oauth_fallback_visible: true,
logout_picker: Some(DEFAULT_PICKER_VIEWPORT),
logout_confirmation_action_row: Some(Rect::new(0, 0, 1, 1)),
model_picker: Some(DEFAULT_PICKER_VIEWPORT),
sessions_modal: Some(DEFAULT_SESSION_PICKER_VIEWPORT),
models_modal: Some(DEFAULT_PICKER_VIEWPORT),
models_scope_visible: true,
skills_modal: Some(DEFAULT_PICKER_VIEWPORT),
skills_scope_visible: true,
mcp_modal: Some(DEFAULT_PICKER_VIEWPORT),
tools_modal: Some(DEFAULT_PICKER_VIEWPORT),
tools_scope_visible: true,
subagents_modal: Some(DEFAULT_PICKER_VIEWPORT),
subagents_scope_visible: true,
theme_picker: Some(DEFAULT_PICKER_VIEWPORT),
theme_query_visible: true,
..KeyViewports::default()
}
}
pub(crate) fn classify_key_with_all_viewports(
key: KeyEvent,
state: &MissionControlState,
viewports: KeyViewports,
autocomplete_candidates: &[AutocompleteCandidate],
) -> InputCommand {
classify_key_inner_at(
key,
state,
viewports,
autocomplete_candidates,
Instant::now(),
)
}
#[cfg(test)]
pub(crate) fn classify_key_at(
key: KeyEvent,
state: &MissionControlState,
viewports: KeyViewports,
autocomplete_candidates: &[AutocompleteCandidate],
now: Instant,
) -> InputCommand {
classify_key_inner_at(key, state, viewports, autocomplete_candidates, now)
}
fn classify_key_inner_at(
key: KeyEvent,
state: &MissionControlState,
viewports: KeyViewports,
autocomplete_candidates: &[AutocompleteCandidate],
now: Instant,
) -> InputCommand {
let standalone_ctrl = is_standalone_ctrl_key(key);
if key.kind == KeyEventKind::Release && !standalone_ctrl {
return InputCommand::None;
}
if standalone_ctrl {
return InputCommand::display(DisplayCommand::StandaloneCtrl {
kind: key.kind,
now,
});
}
let viewport = viewports.prompt.unwrap_or(DEFAULT_PROMPT_VIEWPORT).safe();
let transcript_viewport = viewports
.transcript
.unwrap_or(DEFAULT_TRANSCRIPT_VIEWPORT)
.safe();
let activity_tree_viewport = viewports
.activity_tree
.unwrap_or(DEFAULT_ACTIVITY_TREE_VIEWPORT)
.safe();
let detail_viewport = viewports.detail.unwrap_or(DEFAULT_DETAIL_VIEWPORT).safe();
let system_prompt_viewport = viewports
.system_prompt
.unwrap_or(DEFAULT_SYSTEM_PROMPT_VIEWPORT)
.safe();
let rewind_modal_viewport = viewports
.rewind_modal
.unwrap_or(DEFAULT_SYSTEM_PROMPT_VIEWPORT)
.safe();
let usage_modal_viewport = viewports
.usage_modal
.unwrap_or(DEFAULT_SYSTEM_PROMPT_VIEWPORT)
.safe();
let connect_provider_viewport = viewports.connect_provider.map(PickerViewport::safe);
let logout_picker_viewport = viewports.logout_picker.map(PickerViewport::safe);
let model_picker_viewport = viewports.model_picker.map(PickerViewport::safe);
let skills_modal_viewport = viewports.skills_modal.map(PickerViewport::safe);
let mcp_modal_viewport = viewports.mcp_modal.map(PickerViewport::safe);
let tools_modal_viewport = viewports.tools_modal.map(PickerViewport::safe);
let subagents_modal_viewport = viewports.subagents_modal.map(PickerViewport::safe);
let models_modal_viewport = viewports.models_modal.map(PickerViewport::safe);
let sessions_modal_viewport = viewports.sessions_modal.map(SessionPickerViewport::safe);
let theme_picker_viewport = viewports.theme_picker.map(PickerViewport::safe);
let modal_viewports = modal::ModalKeyViewports {
system_prompt: system_prompt_viewport,
rewind_modal: rewind_modal_viewport,
usage_modal: usage_modal_viewport,
connect_provider: connect_provider_viewport,
connect_provider_modal_visible: viewports.connect_provider_modal_visible,
connect_provider_custom_field_visible: viewports.connect_provider_custom_field_visible,
connect_provider_oauth_controls_visible: viewports.connect_provider_oauth_controls_visible,
connect_provider_oauth_fallback_visible: viewports.connect_provider_oauth_fallback_visible,
logout_picker: logout_picker_viewport,
logout_confirmation_action_row: viewports.logout_confirmation_action_row,
model_picker: model_picker_viewport,
sessions_modal: sessions_modal_viewport,
subagent_viewer: viewports
.subagent_viewer
.unwrap_or(DEFAULT_SYSTEM_PROMPT_VIEWPORT)
.safe(),
skills_modal: skills_modal_viewport,
skills_scope_visible: viewports.skills_scope_visible,
mcp_modal: mcp_modal_viewport,
tools_modal: tools_modal_viewport,
tools_scope_visible: viewports.tools_scope_visible,
subagents_modal: subagents_modal_viewport,
subagents_scope_visible: viewports.subagents_scope_visible,
models_modal: models_modal_viewport,
models_scope_visible: viewports.models_scope_visible,
theme_picker: theme_picker_viewport,
theme_query_visible: viewports.theme_query_visible,
};
let is_alt_c = key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::ALT;
if is_alt_c {
if state.running_prompt_cancelable() {
return prepend_non_standalone_clear(
key,
InputCommand::action(InputAction::CancelRunningPrompt),
);
}
if state.startup_prompt_queued {
return prepend_non_standalone_clear(
key,
InputCommand::action(InputAction::CancelStartupPrompt),
);
}
}
if let Some(command) = modal::classify_modal_key(key, state, modal_viewports) {
return prepend_non_standalone_clear(key, command);
}
if state.is_prompt_focused() && state.autocomplete_visible() {
let command =
match (key.code, key.modifiers) {
(KeyCode::Tab | KeyCode::Enter, KeyModifiers::NONE) => InputCommand::display(
DisplayCommand::Prompt(PromptCommand::AcceptAutocomplete { viewport }),
),
(KeyCode::Up, KeyModifiers::NONE) => {
InputCommand::display(DisplayCommand::Prompt(PromptCommand::MoveAutocompleteUp))
}
(KeyCode::Down, KeyModifiers::NONE) => InputCommand::display(
DisplayCommand::Prompt(PromptCommand::MoveAutocompleteDown),
),
(KeyCode::Esc, KeyModifiers::NONE) => InputCommand::display(
DisplayCommand::Prompt(PromptCommand::DismissAutocomplete),
),
_ => InputCommand::None,
};
if !matches!(command, InputCommand::None) {
return prepend_non_standalone_clear(key, command);
}
}
if key.code == KeyCode::Tab && key.modifiers == KeyModifiers::NONE {
return prepend_non_standalone_clear(
key,
InputCommand::display(DisplayCommand::Focus(FocusCommand::NextPrimaryPane)),
);
}
if key.code == KeyCode::Char('c')
&& key.modifiers == KeyModifiers::CONTROL
&& let Some(text) = state.prompt_selection_text()
{
return prepend_non_standalone_clear(
key,
InputCommand::action(InputAction::CopySelectedPrompt(text)),
);
}
let active_context = match state.focus_pane {
TuiFocusPane::Prompt => KeyContext::Prompt,
TuiFocusPane::Transcript => KeyContext::Transcript,
TuiFocusPane::ActivityTree => KeyContext::ActivityTree,
TuiFocusPane::ActivityDetail => KeyContext::ActivityDetail,
TuiFocusPane::Summary => KeyContext::Summary,
};
if state.is_prompt_focused()
&& key.code == KeyCode::Enter
&& shifted_enter_modifiers(key.modifiers)
{
return prepend_non_standalone_clear(
key,
InputCommand::display(DisplayCommand::Prompt(PromptCommand::Edit {
operation: PromptEditCommand::InsertNewline,
viewport,
refresh_autocomplete: true,
})),
);
}
if let Some(action) = action_for_key(key, active_context) {
let command = classify_keybinding_action(
action,
state,
viewport,
transcript_viewport,
activity_tree_viewport,
detail_viewport,
viewports.summary.unwrap_or(Rect::new(0, 0, 40, 10)),
);
return prepend_non_standalone_clear(key, command);
}
if !state.is_prompt_focused()
&& let Some(ch) =
printable_char_for_text_input(key).filter(|ch| char_autofocuses_prompt(*ch))
{
return prepend_non_standalone_clear(
key,
InputCommand::display(DisplayCommand::batch([
DisplayCommand::Focus(FocusCommand::Prompt { close_help: true }),
DisplayCommand::Prompt(PromptCommand::Edit {
operation: PromptEditCommand::InsertChar(ch),
viewport,
refresh_autocomplete: true,
}),
])),
);
}
if state.is_prompt_focused() {
let command = match key.code {
KeyCode::Backspace if key.modifiers == KeyModifiers::NONE => {
Some(PromptEditCommand::Backspace)
}
KeyCode::Delete if key.modifiers == KeyModifiers::NONE => {
Some(PromptEditCommand::Delete)
}
KeyCode::Char(_) => {
printable_char_for_text_input(key).map(PromptEditCommand::InsertChar)
}
_ => None,
};
if let Some(operation) = command {
return prepend_non_standalone_clear(
key,
InputCommand::display(DisplayCommand::Prompt(PromptCommand::Edit {
operation,
viewport,
refresh_autocomplete: true,
})),
);
}
}
let _ = autocomplete_candidates;
prepend_non_standalone_clear(key, InputCommand::None)
}
fn is_standalone_ctrl_key(key: KeyEvent) -> bool {
match key.code {
KeyCode::Modifier(ModifierKeyCode::LeftControl | ModifierKeyCode::RightControl) => {
!key.modifiers.intersects(
KeyModifiers::SHIFT
| KeyModifiers::ALT
| KeyModifiers::SUPER
| KeyModifiers::HYPER
| KeyModifiers::META,
)
}
_ => false,
}
}
fn shifted_enter_modifiers(modifiers: KeyModifiers) -> bool {
modifiers.contains(KeyModifiers::SHIFT)
}
pub(super) fn char_autofocuses_prompt(ch: char) -> bool {
ch.is_alphanumeric() || ch == '/'
}
pub(super) fn printable_char_for_text_input(key: KeyEvent) -> Option<char> {
let KeyCode::Char(ch) = key.code else {
return None;
};
if key.modifiers.is_empty() {
Some(ch)
} else if key.modifiers == KeyModifiers::SHIFT {
Some(shift_printable_char(ch))
} else {
None
}
}
pub(crate) fn text_char_for_key_burst(key: KeyEvent) -> Option<char> {
if key.kind == KeyEventKind::Release {
return None;
}
if key.modifiers == KeyModifiers::NONE {
return match key.code {
KeyCode::Enter => Some('\n'),
KeyCode::Tab => Some('\t'),
_ => printable_char_for_text_input(key),
};
}
printable_char_for_text_input(key)
}
#[cfg(test)]
pub(crate) fn text_for_key_burst(keys: &[KeyEvent]) -> Option<String> {
keys.iter().copied().map(text_char_for_key_burst).collect()
}
fn shift_printable_char(ch: char) -> char {
if ch.is_ascii_lowercase() {
return ch.to_ascii_uppercase();
}
match ch {
'`' => '~',
'1' => '!',
'2' => '@',
'3' => '#',
'4' => '$',
'5' => '%',
'6' => '^',
'7' => '&',
'8' => '*',
'9' => '(',
'0' => ')',
'-' => '_',
'=' => '+',
'[' => '{',
']' => '}',
'\\' => '|',
';' => ':',
'\'' => '"',
',' => '<',
'.' => '>',
'/' => '?',
_ => ch,
}
}
fn classify_keybinding_action(
action: KeyCommand,
state: &MissionControlState,
prompt_viewport: PromptViewport,
transcript_viewport: TranscriptViewport,
activity_tree_viewport: ActivityTreeViewport,
detail_viewport: DetailViewport,
summary_viewport: Rect,
) -> InputCommand {
let display = |command: DisplayCommand, refresh_autocomplete: bool| {
if refresh_autocomplete {
DisplayCommand::batch([command, DisplayCommand::RefreshAutocomplete])
} else {
command
}
};
let has_activity = state.visible_node_count() != 0;
match action {
KeyCommand::ToggleSummary => InputCommand::display(DisplayCommand::ToggleSummary),
KeyCommand::ScrollSummaryUp
| KeyCommand::ScrollSummaryDown
| KeyCommand::PageSummaryUp
| KeyCommand::PageSummaryDown => InputCommand::display(DisplayCommand::ScrollSummary {
files: state.session_files.focused,
down: matches!(
action,
KeyCommand::ScrollSummaryDown | KeyCommand::PageSummaryDown
),
rows: if matches!(
action,
KeyCommand::PageSummaryUp | KeyCommand::PageSummaryDown
) {
summary_viewport.height.max(1)
} else {
1
},
width: summary_viewport.width,
height: summary_viewport.height,
}),
KeyCommand::SubmitPrompt => {
let prompt = state.prompt_plain_text();
let action = if is_quit_prompt(&prompt) {
InputAction::Exit
} else {
InputAction::Submit(prompt)
};
InputCommand::action_with_display(
action,
display(DisplayCommand::Prompt(PromptCommand::TakeForSubmit), true),
)
}
KeyCommand::InsertPromptNewline => {
InputCommand::display(DisplayCommand::Prompt(PromptCommand::Edit {
operation: PromptEditCommand::InsertNewline,
viewport: prompt_viewport,
refresh_autocomplete: true,
}))
}
KeyCommand::Exit => InputCommand::action(InputAction::Exit),
KeyCommand::ClearInputOrExit => {
if state.prompt_is_empty() {
InputCommand::action(InputAction::Exit)
} else {
InputCommand::display(display(
DisplayCommand::Prompt(PromptCommand::Edit {
operation: PromptEditCommand::Clear,
viewport: prompt_viewport,
refresh_autocomplete: true,
}),
true,
))
}
}
KeyCommand::FocusPrompt => InputCommand::display(display(
DisplayCommand::Focus(FocusCommand::Prompt { close_help: true }),
true,
)),
KeyCommand::FocusActivityTree => InputCommand::display(display(
DisplayCommand::Focus(FocusCommand::ActivityTree),
true,
)),
KeyCommand::SelectRightColumnTab(tab) => {
InputCommand::display(DisplayCommand::Focus(FocusCommand::RightColumnTab(tab)))
}
KeyCommand::CyclePrimaryAgent => {
let before = state.selected_primary_agent_id().map(str::to_owned);
let after = next_primary_agent_id(state);
let command = DisplayCommand::batch([
DisplayCommand::SetPrimaryAgent(after.clone()),
DisplayCommand::RefreshAutocomplete,
]);
if after != before {
InputCommand::action_with_display(
InputAction::PrimaryAgentSelectionChanged(after),
command,
)
} else {
InputCommand::display(command)
}
}
KeyCommand::CycleThinkingLevel => {
if let Some(next) = state.next_thinking_level() {
InputCommand::action_with_display(
InputAction::ThinkingLevelSelectionChanged(next),
DisplayCommand::batch([
DisplayCommand::SetThinkingLevel(next),
DisplayCommand::RefreshAutocomplete,
]),
)
} else {
InputCommand::display(display(
DisplayCommand::SetStatus(
"thinking level is not configurable for this model".to_string(),
),
true,
))
}
}
KeyCommand::ToggleHelp => InputCommand::display(display(
DisplayCommand::Focus(FocusCommand::ToggleHelp),
true,
)),
KeyCommand::ToggleActivityColumn => InputCommand::display(display(
DisplayCommand::Focus(FocusCommand::ToggleActivityColumn),
true,
)),
KeyCommand::OpenSetModel => InputCommand::action(InputAction::OpenSetModel),
KeyCommand::FocusTranscriptLayout => {
InputCommand::display(DisplayCommand::Focus(FocusCommand::TranscriptLayout))
}
KeyCommand::FocusActivityLayout => {
InputCommand::display(DisplayCommand::Focus(FocusCommand::ActivityLayout))
}
KeyCommand::MoveSelectionUp if has_activity => InputCommand::display(display(
DisplayCommand::Activity(ActivityCommand::MoveUp {
visible_rows: activity_tree_viewport.visible_rows,
}),
true,
)),
KeyCommand::MoveSelectionDown if has_activity => InputCommand::display(display(
DisplayCommand::Activity(ActivityCommand::MoveDown {
visible_rows: activity_tree_viewport.visible_rows,
}),
true,
)),
KeyCommand::CollapseSelected if has_activity => InputCommand::display(display(
DisplayCommand::Activity(ActivityCommand::CollapseSelected {
visible_rows: activity_tree_viewport.visible_rows,
}),
true,
)),
KeyCommand::ExpandSelected if has_activity => InputCommand::display(display(
DisplayCommand::Activity(ActivityCommand::ExpandSelected {
visible_rows: activity_tree_viewport.visible_rows,
}),
true,
)),
KeyCommand::ExpandAll if has_activity => InputCommand::display(display(
DisplayCommand::Activity(ActivityCommand::ExpandAll {
visible_rows: activity_tree_viewport.visible_rows,
}),
true,
)),
KeyCommand::CollapseAll if has_activity => InputCommand::display(display(
DisplayCommand::Activity(ActivityCommand::CollapseAll {
visible_rows: activity_tree_viewport.visible_rows,
}),
true,
)),
KeyCommand::SelectFailed if has_activity => InputCommand::display(display(
DisplayCommand::Activity(ActivityCommand::SelectStatus {
status: crate::output::ActivityStatus::Failed,
visible_rows: activity_tree_viewport.visible_rows,
}),
true,
)),
KeyCommand::SelectRunning if has_activity => InputCommand::display(display(
DisplayCommand::Activity(ActivityCommand::SelectStatus {
status: crate::output::ActivityStatus::Running,
visible_rows: activity_tree_viewport.visible_rows,
}),
true,
)),
KeyCommand::ScrollDetailUp
| KeyCommand::ScrollDetailDown
| KeyCommand::PageDetailUp
| KeyCommand::PageDetailDown => InputCommand::display(display(
DisplayCommand::Scroll(ScrollCommand {
target: ScrollTarget::Detail,
down: matches!(
action,
KeyCommand::ScrollDetailDown | KeyCommand::PageDetailDown
),
rows: if matches!(
action,
KeyCommand::PageDetailUp | KeyCommand::PageDetailDown
) {
detail_viewport.visible_rows
} else {
1
},
visible_rows: detail_viewport.visible_rows,
wrap_width: detail_viewport.wrap_width,
}),
true,
)),
KeyCommand::ScrollTranscriptUp => {
InputCommand::display(DisplayCommand::Scroll(ScrollCommand {
target: ScrollTarget::Transcript,
down: false,
rows: 1,
visible_rows: transcript_viewport.visible_rows,
wrap_width: transcript_viewport.wrap_width,
}))
}
KeyCommand::ScrollTranscriptDown => {
InputCommand::display(DisplayCommand::Scroll(ScrollCommand {
target: ScrollTarget::Transcript,
down: true,
rows: 1,
visible_rows: transcript_viewport.visible_rows,
wrap_width: transcript_viewport.wrap_width,
}))
}
KeyCommand::MovePromptCursorLeft => InputCommand::display(display(
DisplayCommand::Prompt(PromptCommand::Edit {
operation: PromptEditCommand::MoveCursorLeft,
viewport: prompt_viewport,
refresh_autocomplete: true,
}),
true,
)),
KeyCommand::MovePromptCursorRight => InputCommand::display(display(
DisplayCommand::Prompt(PromptCommand::Edit {
operation: PromptEditCommand::MoveCursorRight,
viewport: prompt_viewport,
refresh_autocomplete: true,
}),
true,
)),
KeyCommand::MovePromptCursorUp => InputCommand::display(display(
DisplayCommand::Prompt(PromptCommand::Edit {
operation: PromptEditCommand::MoveCursorUp,
viewport: prompt_viewport,
refresh_autocomplete: true,
}),
true,
)),
KeyCommand::MovePromptCursorDown => InputCommand::display(display(
DisplayCommand::Prompt(PromptCommand::Edit {
operation: PromptEditCommand::MoveCursorDown,
viewport: prompt_viewport,
refresh_autocomplete: true,
}),
true,
)),
KeyCommand::UndoPrompt => prompt_edit_command(PromptEditCommand::Undo, prompt_viewport),
KeyCommand::RedoPrompt => prompt_edit_command(PromptEditCommand::Redo, prompt_viewport),
KeyCommand::MovePromptWordLeft => {
prompt_edit_command(PromptEditCommand::MoveWordLeft, prompt_viewport)
}
KeyCommand::MovePromptWordRight => {
prompt_edit_command(PromptEditCommand::MoveWordRight, prompt_viewport)
}
KeyCommand::DeletePromptWord => {
prompt_edit_command(PromptEditCommand::DeleteWord, prompt_viewport)
}
KeyCommand::DeletePromptNextWord => {
prompt_edit_command(PromptEditCommand::DeleteNextWord, prompt_viewport)
}
KeyCommand::ExtendPromptSelectionLeft => {
prompt_edit_command(PromptEditCommand::ExtendSelectionLeft, prompt_viewport)
}
KeyCommand::ExtendPromptSelectionRight => {
prompt_edit_command(PromptEditCommand::ExtendSelectionRight, prompt_viewport)
}
KeyCommand::ExtendPromptSelectionUp => {
prompt_edit_command(PromptEditCommand::ExtendSelectionUp, prompt_viewport)
}
KeyCommand::ExtendPromptSelectionDown => {
prompt_edit_command(PromptEditCommand::ExtendSelectionDown, prompt_viewport)
}
KeyCommand::SelectAllPrompt => {
prompt_edit_command(PromptEditCommand::SelectAll, prompt_viewport)
}
KeyCommand::MoveSelectionUp
| KeyCommand::MoveSelectionDown
| KeyCommand::CollapseSelected
| KeyCommand::ExpandSelected
| KeyCommand::ExpandAll
| KeyCommand::CollapseAll
| KeyCommand::SelectFailed
| KeyCommand::SelectRunning => InputCommand::display(DisplayCommand::RefreshAutocomplete),
}
}
fn prompt_edit_command(operation: PromptEditCommand, viewport: PromptViewport) -> InputCommand {
InputCommand::display(DisplayCommand::Prompt(PromptCommand::Edit {
operation,
viewport,
refresh_autocomplete: true,
}))
}
fn next_primary_agent_id(state: &MissionControlState) -> Option<String> {
let next = match (
state.selected_primary_agent,
state.primary_agents.is_empty(),
) {
(_, true) => None,
(None, false) => Some(0),
(Some(index), false) if index + 1 < state.primary_agents.len() => Some(index + 1),
(Some(_), false) => None,
};
next.and_then(|index| {
state
.primary_agents
.get(index)
.map(|entry| entry.id.clone())
})
}
fn is_quit_prompt(prompt: &str) -> bool {
prompt
.trim()
.split_once(char::is_whitespace)
.map(|(command, _)| command)
.unwrap_or_else(|| prompt.trim())
== "/quit"
}
fn prepend_non_standalone_clear(key: KeyEvent, command: InputCommand) -> InputCommand {
let prefix = DisplayCommand::ClearStandaloneCtrl {
suppress_release: key.modifiers.contains(KeyModifiers::CONTROL),
};
let (action, display) = command.into_parts();
let display = match display {
Some(display) => DisplayCommand::batch([prefix, display]),
None => prefix,
};
InputCommand::action_with_display(action, display)
}
pub(crate) fn classify_paste_with_all_viewports(
text: &str,
state: &MissionControlState,
viewports: KeyViewports,
_autocomplete_candidates: &[AutocompleteCandidate],
) -> InputCommand {
if text.is_empty() {
return InputCommand::None;
}
let viewport = viewports.prompt.unwrap_or(DEFAULT_PROMPT_VIEWPORT).safe();
if modal_field_accepts_paste(state) {
return InputCommand::action(InputAction::InsertConnectProviderText(
RedactedInputText::new(text.to_string()),
));
}
if prompt_accepts_paste(state) {
return InputCommand::display(DisplayCommand::Prompt(PromptCommand::InsertText {
text: text.to_string(),
viewport,
refresh_autocomplete: true,
}));
}
InputCommand::None
}
pub(crate) fn classify_oversized_paste(state: &MissionControlState) -> InputCommand {
if modal_field_accepts_paste(state) {
InputCommand::display(DisplayCommand::SetStatus(
"pasted modal input is too large".to_string(),
))
} else if prompt_accepts_paste(state) {
InputCommand::display(DisplayCommand::SetStatus(
super::state::PROMPT_TOO_LARGE_STATUS.to_string(),
))
} else {
InputCommand::None
}
}
pub(crate) fn modal_field_accepts_paste(state: &MissionControlState) -> bool {
state
.modals
.connect_provider
.as_ref()
.is_some_and(|connect| {
matches!(
connect.stage,
crate::tui::state::ConnectProviderStage::ConfigureCustom
| crate::tui::state::ConnectProviderStage::OAuth
)
})
}
pub(crate) fn input_accepts_paste(state: &MissionControlState) -> bool {
modal_field_accepts_paste(state) || prompt_accepts_paste(state)
}
pub(crate) fn prompt_accepts_paste(state: &MissionControlState) -> bool {
state.is_prompt_focused()
&& !state.session_picker_visible()
&& !state.skills_modal_visible()
&& !state.mcp_modal_visible()
&& !state.tools_modal_visible()
&& !state.subagents_modal_visible()
&& !state.models_modal_visible()
&& !state.theme_picker_visible()
&& !state.system_prompt_modal_visible()
&& !state.usage_modal_visible()
&& !state.rewind_modal_visible()
&& !state.subagent_viewer_visible()
&& state.modals.logout_confirmation.is_none()
&& state.modals.connect_provider.is_none()
&& state.modals.logout_picker.is_none()
&& state.modals.model_picker.is_none()
}
#[cfg(test)]
fn apply_test_command(
command: InputCommand,
state: &mut MissionControlState,
autocomplete_candidates: &[AutocompleteCandidate],
) -> InputAction {
let (action, display) = command.into_parts();
if let Some(display) = display {
let _ = crate::tui::controller::apply_display_command_for_test(
display,
state,
autocomplete_candidates,
);
}
action
}
#[cfg(test)]
pub(crate) fn handle_key_with_all_viewports(
key: KeyEvent,
state: &mut MissionControlState,
viewports: KeyViewports,
autocomplete_candidates: &[AutocompleteCandidate],
) -> InputAction {
apply_test_command(
classify_key_with_all_viewports(key, state, viewports, autocomplete_candidates),
state,
autocomplete_candidates,
)
}
#[cfg(test)]
pub(crate) fn handle_key_at(
key: KeyEvent,
state: &mut MissionControlState,
viewports: KeyViewports,
autocomplete_candidates: &[AutocompleteCandidate],
now: Instant,
) -> InputAction {
apply_test_command(
classify_key_at(key, state, viewports, autocomplete_candidates, now),
state,
autocomplete_candidates,
)
}
#[cfg(test)]
pub(crate) fn handle_key_with_viewports(
key: KeyEvent,
state: &mut MissionControlState,
prompt_viewport: Option<PromptViewport>,
transcript_viewport: Option<TranscriptViewport>,
autocomplete_candidates: &[AutocompleteCandidate],
) -> InputAction {
let mut viewports = test_default_key_viewports();
viewports.prompt = prompt_viewport;
viewports.transcript = transcript_viewport;
apply_test_command(
classify_key_with_all_viewports(key, state, viewports, autocomplete_candidates),
state,
autocomplete_candidates,
)
}
#[cfg(test)]
pub(crate) fn handle_paste_with_viewport(
text: &str,
state: &mut MissionControlState,
prompt_viewport: Option<PromptViewport>,
autocomplete_candidates: &[AutocompleteCandidate],
) -> InputAction {
apply_test_command(
classify_paste_with_all_viewports(
text,
state,
KeyViewports {
prompt: prompt_viewport,
..KeyViewports::default()
},
autocomplete_candidates,
),
state,
autocomplete_candidates,
)
}
#[cfg(test)]
pub(crate) fn take_prompt_action(state: &mut MissionControlState) -> InputAction {
handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), state)
}
#[cfg(test)]
#[path = "tests/mod.rs"]
mod tests;
#[cfg(test)]
#[path = "primary_agent_input_tests.rs"]
mod primary_agent_input_tests;