bcode_tui 0.0.1-alpha.0

Terminal user interface for bcode
//! Root-owned skill picker and action semantics.

use bmux_keyboard::KeyStroke;

use bcode_skill_models::SkillId;

use super::effects::{SkillActionKind, SkillActionRequest, TuiEffect};
use super::keymap::{BmuxAction, BmuxKeyMap, BmuxScope};
use super::session_flow::ActiveChat;
use super::{skill_picker, text_input_flow};

pub fn handle_skill_picker_key(
    picker: &mut skill_picker::SkillPickerApp,
    keymap: &BmuxKeyMap,
    stroke: KeyStroke,
) -> skill_picker::SkillPickerAction {
    match picker.mode() {
        skill_picker::SkillPickerMode::Filter => handle_skill_filter_key(picker, keymap, stroke),
        skill_picker::SkillPickerMode::Argument => {
            handle_skill_argument_key(picker, keymap, stroke)
        }
    }
}

fn handle_skill_filter_key(
    picker: &mut skill_picker::SkillPickerApp,
    keymap: &BmuxKeyMap,
    stroke: KeyStroke,
) -> skill_picker::SkillPickerAction {
    if let Some(action) = keymap.action_for_key(BmuxScope::SkillPicker, stroke) {
        return handle_skill_picker_action(picker, action);
    }
    match keymap.action("skill_picker", stroke) {
        Some("tui.skill.activate") => picker.selected_skill_id().map_or(
            skill_picker::SkillPickerAction::Continue,
            skill_picker::SkillPickerAction::Activate,
        ),
        Some("tui.skill.deactivate") => picker.selected_skill_id().map_or(
            skill_picker::SkillPickerAction::Continue,
            skill_picker::SkillPickerAction::Deactivate,
        ),
        Some("tui.skill.help") => picker.selected_skill_id().map_or(
            skill_picker::SkillPickerAction::Continue,
            skill_picker::SkillPickerAction::Help,
        ),
        _ => {
            if text_input_flow::handle_key_in_scope(
                picker.filter_mut(),
                keymap,
                "skill_picker",
                stroke,
            ) != bmux_tui_components::text_input::TextInputOutcome::Ignored
            {
                picker.refresh_filter();
            }
            skill_picker::SkillPickerAction::Continue
        }
    }
}

fn handle_skill_picker_action(
    picker: &mut skill_picker::SkillPickerApp,
    action: BmuxAction,
) -> skill_picker::SkillPickerAction {
    match action {
        BmuxAction::SelectCancel => skill_picker::SkillPickerAction::Cancel,
        BmuxAction::SelectUp => {
            picker.select_previous();
            skill_picker::SkillPickerAction::Continue
        }
        BmuxAction::SelectDown => {
            picker.select_next();
            skill_picker::SkillPickerAction::Continue
        }
        BmuxAction::SelectConfirm | BmuxAction::SkillInvoke => {
            if picker.selected_skill_id().is_some() {
                picker.start_argument();
            }
            skill_picker::SkillPickerAction::Continue
        }
        BmuxAction::SkillActivate => picker.selected_skill_id().map_or(
            skill_picker::SkillPickerAction::Continue,
            skill_picker::SkillPickerAction::Activate,
        ),
        BmuxAction::SkillDeactivate => picker.selected_skill_id().map_or(
            skill_picker::SkillPickerAction::Continue,
            skill_picker::SkillPickerAction::Deactivate,
        ),
        BmuxAction::SkillHelp => picker.selected_skill_id().map_or(
            skill_picker::SkillPickerAction::Continue,
            skill_picker::SkillPickerAction::Help,
        ),
        BmuxAction::InputSubmitSteering
        | BmuxAction::InputSubmitFollowUp
        | BmuxAction::InputHistoryPrevious
        | BmuxAction::InputHistoryNext
        | BmuxAction::AppExit
        | BmuxAction::AppInterrupt
        | BmuxAction::ClipboardPasteImage
        | BmuxAction::TranscriptCopySelection
        | BmuxAction::TranscriptClearSelection
        | BmuxAction::CommandPaletteOpen
        | BmuxAction::SessionSearchOpen
        | BmuxAction::AgentCycle
        | BmuxAction::DiffViewerLayoutCycle
        | BmuxAction::MarkdownFocusNext
        | BmuxAction::MarkdownFocusPrevious
        | BmuxAction::MarkdownActivate
        | BmuxAction::MarkdownCopyDestination
        | BmuxAction::ThinkingEffortCycle
        | BmuxAction::TranscriptPageUp
        | BmuxAction::TranscriptPageDown
        | BmuxAction::TranscriptTop
        | BmuxAction::TranscriptBottom
        | BmuxAction::TranscriptLineUp
        | BmuxAction::TranscriptLineDown
        | BmuxAction::PermissionApprove
        | BmuxAction::PermissionDeny
        | 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::InteractionFocusActive
        | BmuxAction::EditorDeleteToEnd
        | BmuxAction::EditorUndo
        | BmuxAction::EditorRedo => skill_picker::SkillPickerAction::Continue,
    }
}

fn handle_skill_argument_key(
    picker: &mut skill_picker::SkillPickerApp,
    keymap: &BmuxKeyMap,
    stroke: KeyStroke,
) -> skill_picker::SkillPickerAction {
    match keymap.action("skill_picker", stroke) {
        Some("tui.select.cancel") => skill_picker::SkillPickerAction::Cancel,
        Some("tui.skill.invoke") => picker.selected_skill_id().map_or(
            skill_picker::SkillPickerAction::Continue,
            |skill_id| skill_picker::SkillPickerAction::Invoke {
                skill_id,
                arguments: picker.argument().buffer().text().to_owned(),
            },
        ),
        _ => {
            let _outcome = text_input_flow::handle_key_in_scope(
                picker.argument_mut(),
                keymap,
                "skill_picker",
                stroke,
            );
            skill_picker::SkillPickerAction::Continue
        }
    }
}

pub fn format_skill_manifest_markdown(manifest: &bcode_skill_models::SkillManifest) -> String {
    let description = manifest
        .summary
        .description
        .as_deref()
        .unwrap_or("no description");
    super::slash_commands::format_skill_details_markdown(
        &manifest.summary.name,
        manifest.summary.id.as_str(),
        &manifest.summary.source.label,
        Some(description),
        &truncate_markdown_for_display(&manifest.instructions, 2_000),
    )
}

pub fn start_skill_action(
    launch_working_directory: &std::path::Path,
    launch_options: super::TuiLaunchOptions,
    chat: &mut ActiveChat,
    action: SkillActionKind,
    skill_id: SkillId,
    arguments: String,
) {
    let session_id = chat.app.session_id();
    let agent_id =
        (action == SkillActionKind::Invoke).then(|| chat.app.display_agent_id().to_owned());
    let provider_plugin_id = (action == SkillActionKind::Invoke && session_id.is_none())
        .then(|| {
            chat.app
                .selected_provider_plugin_id()
                .map(ToOwned::to_owned)
        })
        .flatten();
    let model_id = (action == SkillActionKind::Invoke && session_id.is_none())
        .then(|| chat.app.selected_model_id().map(ToOwned::to_owned))
        .flatten();
    chat.start_effect(TuiEffect::SkillAction {
        request: Box::new(SkillActionRequest {
            session_id,
            launch_working_directory: launch_working_directory.to_path_buf(),
            skill_id,
            action,
            arguments,
            provider_plugin_id,
            model_id,
            agent_id,
            reasoning_effort: (action == SkillActionKind::Invoke)
                .then(|| chat.app.reasoning_effort().map(ToOwned::to_owned))
                .flatten(),
            reasoning_summary: (action == SkillActionKind::Invoke)
                .then(|| chat.app.reasoning_summary().map(ToOwned::to_owned))
                .flatten(),
            reasoning_effort_generation: (action == SkillActionKind::Invoke)
                .then(|| chat.app.pending_reasoning_effort_generation())
                .flatten(),
            execution: launch_options.turn_execution_options(),
            event_sender: chat.event_sender.clone(),
        }),
    });
    let label = match action {
        SkillActionKind::Activate => "activating skill…",
        SkillActionKind::Deactivate => "deactivating skill…",
        SkillActionKind::Invoke => "invoking skill…",
    };
    chat.app.set_status(label.to_owned());
}

fn truncate_markdown_for_display(value: &str, max_chars: usize) -> String {
    if value.chars().count() <= max_chars {
        return value.to_owned();
    }

    let mut output = String::new();
    let mut used = 0_usize;
    let mut open_fence: Option<String> = None;
    for line in value.split_inclusive('\n') {
        let line_chars = line.chars().count();
        if used.saturating_add(line_chars) > max_chars {
            break;
        }
        output.push_str(line);
        used = used.saturating_add(line_chars);
        let trimmed = line.trim_start();
        if let Some(marker) = fence_marker(trimmed) {
            if open_fence.as_deref() == Some(marker) {
                open_fence = None;
            } else if open_fence.is_none() {
                open_fence = Some(marker.to_owned());
            }
        }
    }
    if output.is_empty() {
        output = value.chars().take(max_chars).collect();
    }
    if !output.ends_with('\n') {
        output.push('\n');
    }
    if let Some(marker) = open_fence {
        output.push_str(&marker);
        output.push('\n');
    }
    output.push_str("\n_… instructions truncated for display …_");
    output
}

fn fence_marker(line: &str) -> Option<&'static str> {
    if line.starts_with("```") {
        Some("```")
    } else if line.starts_with("~~~") {
        Some("~~~")
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::truncate_markdown_for_display;

    #[test]
    fn markdown_truncation_closes_open_fence_and_adds_notice() {
        let input =
            "# Instructions\n\n```rust\nfn main() {\n    println!(\"long\");\n}\n```\n\nAfter.";
        let output = truncate_markdown_for_display(input, 40);

        assert!(output.contains("```rust"));
        assert_eq!(output.matches("```").count(), 2);
        assert!(output.ends_with("_… instructions truncated for display …_"));
    }

    #[test]
    fn short_markdown_is_unchanged() {
        let input = "## Steps\n\n1. Read\n2. Test";
        assert_eq!(truncate_markdown_for_display(input, 100), input);
    }
}