bcode_tui 0.0.1-alpha.0

Terminal user interface for bcode
//! TUI input handling.

use bmux_keyboard::KeyStroke;
use bmux_tui_components::text_input::{TextInputControl, TextInputOutcome};

use super::app::{BmuxApp, KeyActivationOutcome, composer_policy};
use super::keymap::{BmuxAction, BmuxKeyMap, BmuxScope};
const TRANSCRIPT_SCROLL_ROWS: usize = 3;
const TRANSCRIPT_PAGE_ROWS: usize = 10;

/// Follow-up request produced by handling one key stroke.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum KeyRequest {
    /// No follow-up request.
    #[default]
    None,
    /// Submit staged composer text.
    Submit {
        /// Placement behavior for the submitted prompt.
        placement: bcode_ipc::PromptPlacement,
    },
    /// Interrupt the active turn.
    Interrupt,
    /// Cycle to the next available agent.
    CycleAgent,
    /// Cycle to the next supported reasoning effort.
    CycleThinkingEffort,
}

/// Result of handling one key stroke.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct KeyOutcome {
    /// Whether the caller should redraw the UI.
    pub redraw: bool,
    /// Follow-up request for the caller to perform.
    pub request: KeyRequest,
}

/// Handle a key stroke.
pub fn handle_key(app: &mut BmuxApp, keymap: &BmuxKeyMap, stroke: KeyStroke) -> KeyOutcome {
    if let Some(binding) = keymap.binding_for_key(BmuxScope::Chat, stroke) {
        match app.activate_key_binding(BmuxScope::Chat, &binding) {
            KeyActivationOutcome::Activated(action) => {
                if let Some(outcome) = handle_chat_action(app, Some(action)) {
                    return outcome;
                }
            }
            KeyActivationOutcome::Pending => {
                return KeyOutcome {
                    redraw: true,
                    request: KeyRequest::None,
                };
            }
        }
    }
    if let Some(motion) = keymap.editor_selection_motion_for_key(stroke) {
        app.clear_pending_key_activation();
        app.extend_composer_selection(motion);
        return KeyOutcome {
            redraw: true,
            request: KeyRequest::None,
        };
    }
    if let Some(command) = keymap.editor_command_for_key(stroke) {
        app.clear_pending_key_activation();
        app.reset_input_history_navigation();
        app.composer_mut().apply_command(command);
        app.wake_cursor();
        return KeyOutcome {
            redraw: true,
            request: KeyRequest::None,
        };
    }

    let Some(text) = bcode_plugin_sdk::tui::keymap::controls::LiteralText::from_stroke(stroke)
        .filter(|_| {
            keymap.action("chat", stroke).is_none()
                && !keymap.registry().is_overridden_key("chat", stroke)
        })
    else {
        return KeyOutcome::default();
    };
    let outcome = TextInputControl::new(&composer_policy()).handle_key_with_history_action(
        app.composer_state_mut(),
        text.native_stroke(),
        None,
    );
    match outcome {
        TextInputOutcome::Submitted => {
            app.clear_pending_key_activation();
            submit(app, bcode_ipc::PromptPlacement::Steering)
        }
        TextInputOutcome::Edited => {
            app.clear_pending_key_activation();
            app.reset_input_history_navigation();
            app.wake_cursor();
            KeyOutcome {
                redraw: true,
                request: KeyRequest::None,
            }
        }
        TextInputOutcome::Ignored
        | TextInputOutcome::Redraw
        | TextInputOutcome::EdgeUp
        | TextInputOutcome::EdgeDown => KeyOutcome::default(),
    }
}

fn handle_composer_history(
    app: &mut BmuxApp,
    action: bmux_tui_components::text_input::TextInputHistoryAction,
) -> KeyOutcome {
    let result = TextInputControl::new(&composer_policy()).handle_key_with_history_action(
        app.composer_state_mut(),
        KeyStroke::simple(bmux_keyboard::KeyCode::Escape),
        Some(action),
    );
    if result == TextInputOutcome::Edited {
        app.reset_input_history_navigation();
        app.wake_cursor();
    }
    KeyOutcome {
        redraw: result != TextInputOutcome::Ignored,
        request: KeyRequest::None,
    }
}

fn exit_or_clear_composer(app: &mut BmuxApp) -> KeyOutcome {
    if app.composer().is_empty() {
        app.request_exit();
    } else {
        app.reset_input_history_navigation();
        app.composer_mut().clear();
        app.set_status("input cleared; press exit again to quit".to_owned());
        app.wake_cursor();
    }
    KeyOutcome {
        redraw: true,
        request: KeyRequest::None,
    }
}

pub fn handle_chat_action(app: &mut BmuxApp, action: Option<BmuxAction>) -> Option<KeyOutcome> {
    use bmux_tui_components::text_input::TextInputHistoryAction;
    let outcome = match action? {
        BmuxAction::EditorUndo => handle_composer_history(app, TextInputHistoryAction::Undo),
        BmuxAction::EditorRedo => handle_composer_history(app, TextInputHistoryAction::Redo),
        BmuxAction::AppExit => exit_or_clear_composer(app),
        BmuxAction::AppInterrupt => KeyOutcome {
            redraw: true,
            request: KeyRequest::Interrupt,
        },
        BmuxAction::InputSubmitSteering => submit(app, bcode_ipc::PromptPlacement::Steering),
        BmuxAction::InputSubmitFollowUp => submit(app, bcode_ipc::PromptPlacement::FollowUp),
        BmuxAction::AgentCycle => KeyOutcome {
            redraw: true,
            request: KeyRequest::CycleAgent,
        },
        BmuxAction::ThinkingEffortCycle => KeyOutcome {
            redraw: true,
            request: KeyRequest::CycleThinkingEffort,
        },
        BmuxAction::DiffViewerLayoutCycle => diff_viewer_layout_action(app),
        BmuxAction::MarkdownFocusNext
        | BmuxAction::MarkdownFocusPrevious
        | BmuxAction::MarkdownActivate
        | BmuxAction::MarkdownCopyDestination => markdown_action(app, action?),
        BmuxAction::InputHistoryPrevious => history_previous(app),
        BmuxAction::InputHistoryNext => history_next(app),
        BmuxAction::TranscriptPageUp => KeyOutcome {
            redraw: app.scroll_transcript_up(TRANSCRIPT_PAGE_ROWS),
            request: KeyRequest::None,
        },
        BmuxAction::TranscriptPageDown => KeyOutcome {
            redraw: app.scroll_transcript_down(TRANSCRIPT_PAGE_ROWS),
            request: KeyRequest::None,
        },
        BmuxAction::TranscriptTop => KeyOutcome {
            redraw: app.scroll_transcript_up(usize::MAX / 2),
            request: KeyRequest::None,
        },
        BmuxAction::TranscriptBottom => KeyOutcome {
            redraw: app.scroll_transcript_to_bottom(),
            request: KeyRequest::None,
        },
        BmuxAction::TranscriptLineUp => KeyOutcome {
            redraw: app.scroll_transcript_up(TRANSCRIPT_SCROLL_ROWS),
            request: KeyRequest::None,
        },
        BmuxAction::TranscriptLineDown => KeyOutcome {
            redraw: app.scroll_transcript_down(TRANSCRIPT_SCROLL_ROWS),
            request: KeyRequest::None,
        },
        BmuxAction::ClipboardPasteImage
        | BmuxAction::TranscriptCopySelection
        | BmuxAction::TranscriptClearSelection
        | BmuxAction::CommandPaletteOpen
        | BmuxAction::SessionSearchOpen
        | BmuxAction::PermissionApprove
        | BmuxAction::PermissionDeny
        | BmuxAction::SelectUp
        | BmuxAction::SelectDown
        | BmuxAction::SelectConfirm
        | BmuxAction::SelectCancel
        | BmuxAction::SessionNew
        | BmuxAction::SessionRename
        | BmuxAction::SessionDelete
        | BmuxAction::InputNewLine
        | BmuxAction::EditorMoveLeft
        | BmuxAction::EditorMoveRight
        | BmuxAction::EditorMoveWordLeft
        | BmuxAction::EditorMoveWordRight
        | BmuxAction::EditorMoveStart
        | BmuxAction::EditorMoveEnd
        | BmuxAction::EditorSelectLeft
        | BmuxAction::EditorSelectRight
        | BmuxAction::EditorSelectWordLeft
        | BmuxAction::EditorSelectWordRight
        | BmuxAction::EditorSelectUp
        | BmuxAction::EditorSelectDown
        | BmuxAction::EditorDeleteBackward
        | BmuxAction::EditorDeleteForward
        | BmuxAction::EditorDeleteWordBackward
        | BmuxAction::EditorDeleteWordForward
        | BmuxAction::EditorDeleteToStart
        | BmuxAction::EditorDeleteToEnd
        | BmuxAction::InteractionFocusActive
        | BmuxAction::SkillInvoke
        | BmuxAction::SkillActivate
        | BmuxAction::SkillDeactivate
        | BmuxAction::SkillHelp => return None,
    };
    Some(outcome)
}

fn diff_viewer_layout_action(app: &mut BmuxApp) -> KeyOutcome {
    app.cycle_diff_viewer_layout();
    KeyOutcome {
        redraw: true,
        request: KeyRequest::None,
    }
}

fn markdown_action(app: &mut BmuxApp, action: BmuxAction) -> KeyOutcome {
    let redraw = match action {
        BmuxAction::MarkdownFocusNext => app.move_markdown_focus(false),
        BmuxAction::MarkdownFocusPrevious => app.move_markdown_focus(true),
        BmuxAction::MarkdownActivate => app.activate_focused_markdown_contribution(),
        BmuxAction::MarkdownCopyDestination => app.copy_focused_markdown_destination(),
        _ => false,
    };
    KeyOutcome {
        redraw,
        request: KeyRequest::None,
    }
}

fn history_previous(app: &mut BmuxApp) -> KeyOutcome {
    KeyOutcome {
        redraw: if app.input_history_navigation_active() {
            app.move_composer_visual_up_preserving_history() || app.previous_input_history()
        } else {
            app.move_composer_visual_up() || app.previous_input_history()
        },
        request: KeyRequest::None,
    }
}

fn history_next(app: &mut BmuxApp) -> KeyOutcome {
    KeyOutcome {
        redraw: if app.input_history_navigation_active() {
            app.move_composer_visual_down_preserving_history() || app.next_input_history()
        } else {
            app.move_composer_visual_down() || app.next_input_history()
        },
        request: KeyRequest::None,
    }
}

fn submit(app: &mut BmuxApp, placement: bcode_ipc::PromptPlacement) -> KeyOutcome {
    app.stage_submission();
    app.wake_cursor();
    KeyOutcome {
        redraw: true,
        request: KeyRequest::Submit { placement },
    }
}