monothink 0.5.2

A totally non-agentic coding assistant
/*
 * This file is part of monothink.
 *
 * monothink is free software: you can redistribute it and/or modify it under the terms
 * of the GNU General Public License as published by the Free Software Foundation,
 * either version 3 of the License, or (at your option) any later version.
 *
 * monothink is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with monothink.
 * If not, see <https://www.gnu.org/licenses/>.
 */

use unicode_width::UnicodeWidthStr;

// as of now, this is just some helper for modal input.
// if I ever implement non-modal input, all modal input handling should be in this module
// but in a different structure. There should be a input module and inside this module
// an modal.rs and plain.rs.
use crate::{
    common::conversation::Inline, config::ViewMode, session::Command as SessionCommand,
    ui::suggestion_popup::Suggestion,
};
use std::collections::HashMap;

#[derive(Clone, Debug)]
struct ModalCommand {
    command: fn(&str) -> Option<SessionCommand>,
    hint: Vec<Inline>,
    name: Vec<Inline>,
}

#[derive(Clone, Debug)]
struct ViewModeParameter {
    mode: ViewMode,
    hint: Vec<Inline>,
    name: Vec<Inline>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum CommandId {
    Cancel,
    Export,
    Model,
    Quit,
    Reprompt,
    Session,
    Title,
    ViewMode,
}

#[derive(Clone, Debug)]
pub struct ModalCommands {
    commands: HashMap<CommandId, ModalCommand>,
    long_key: HashMap<String, CommandId>,
    models: Vec<Suggestion>,
    sessions: Vec<Suggestion>,
    short_key: HashMap<String, CommandId>,
}

impl ModalCommands {
    pub fn new() -> Self {
        let commands = vec![
            (
                CommandId::Cancel,
                "cancel".to_string(),
                "c".to_string(),
                ModalCommand {
                    command: |_| Some(SessionCommand::CancelRequest),
                    name: vec![
                        Inline::StrongEmphasis(vec![Inline::Text("c".to_string())]),
                        Inline::Text("ancel".to_string()),
                    ],
                    hint: vec![Inline::Text("Cancel running request".to_string())],
                },
            ),
            (
                CommandId::Export,
                "export".to_string(),
                "e".to_string(),
                ModalCommand {
                    command: |name| Some(SessionCommand::Export(name.to_string())),
                    name: vec![
                        Inline::StrongEmphasis(vec![Inline::Text("e".to_string())]),
                        Inline::Text("xport".to_string()),
                    ],
                    hint: vec![Inline::Text("Export to md file".to_string())],
                },
            ),
            (
                CommandId::Model,
                "model".to_string(),
                "m".to_string(),
                ModalCommand {
                    command: |model| Some(SessionCommand::SelectModel(model.to_string())),
                    name: vec![
                        Inline::StrongEmphasis(vec![Inline::Text("m".to_string())]),
                        Inline::Text("odel".to_string()),
                    ],
                    hint: vec![Inline::Text("Select model to use".to_string())],
                },
            ),
            (
                CommandId::Quit,
                "quit".to_string(),
                "q".to_string(),
                ModalCommand {
                    command: |_| Some(SessionCommand::Quit),
                    name: vec![
                        Inline::StrongEmphasis(vec![Inline::Text("q".to_string())]),
                        Inline::Text("uit".to_string()),
                    ],
                    hint: vec![Inline::Text("Quit monothink".to_string())],
                },
            ),
            (
                CommandId::Reprompt,
                "reprompt".to_string(),
                "r".to_string(),
                ModalCommand {
                    command: |_| Some(SessionCommand::ResendPrompt),
                    name: vec![
                        Inline::StrongEmphasis(vec![Inline::Text("r".to_string())]),
                        Inline::Text("eprompt".to_string()),
                    ],
                    hint: vec![Inline::Text("Resend the last prompt".to_string())],
                },
            ),
            (
                CommandId::Session,
                "session".to_string(),
                "s".to_string(),
                ModalCommand {
                    command: |id| Some(SessionCommand::LoadSession(id.to_string())),
                    name: vec![
                        Inline::StrongEmphasis(vec![Inline::Text("s".to_string())]),
                        Inline::Text("ession".to_string()),
                    ],
                    hint: vec![Inline::Text("Load a saved session".to_string())],
                },
            ),
            (
                CommandId::Title,
                "title".to_string(),
                "t".to_string(),
                ModalCommand {
                    command: |name| Some(SessionCommand::Title(name.to_string())),
                    name: vec![
                        Inline::StrongEmphasis(vec![Inline::Text("t".to_string())]),
                        Inline::Text("itle".to_string()),
                    ],
                    hint: vec![Inline::Text("Set conversation title".to_string())],
                },
            ),
            (
                CommandId::ViewMode,
                "view-mode".to_string(),
                "v".to_string(),
                ModalCommand {
                    command: |param| {
                        viewmode_parameters()
                            .iter()
                            .find(|(key, _)| *key == param)
                            .map(|(_, param)| param.mode)
                            .map(SessionCommand::ViewMode)
                    },
                    name: vec![
                        Inline::StrongEmphasis(vec![Inline::Text("v".to_string())]),
                        Inline::Text("iew-".to_string()),
                        Inline::StrongEmphasis(vec![Inline::Text("m".to_string())]),
                        Inline::Text("ode".to_string()),
                    ],
                    hint: vec![Inline::Text("Change conversation view mode".to_string())],
                },
            ),
        ];

        let mut mc = ModalCommands {
            commands: HashMap::new(),
            long_key: HashMap::new(),
            models: Vec::new(),
            sessions: Vec::new(),
            short_key: HashMap::new(),
        };
        for (id, long, short, command) in commands.into_iter() {
            mc.insert(command, long, short, id);
        }
        mc
    }

    pub fn session_command(&self, key: &str, params: &str) -> Option<SessionCommand> {
        self.long_key
            .get(key)
            .and_then(|id| self.commands.get(id))
            .or_else(|| self.short_key.get(key).and_then(|id| self.commands.get(id)))
            .and_then(|cmd| (cmd.command)(params))
    }

    pub fn command_suggestions(&self) -> Vec<Suggestion> {
        self.commands
            .keys()
            .map(|id| Suggestion {
                raw: self
                    .long_key
                    .iter()
                    .find(|(_, cand)| *cand == id)
                    .map(|(name, _)| name)
                    .expect("For every id there should be a long name")
                    .to_owned(),
                name: self.commands[id].name.clone(),
                hint: self.commands[id].hint.clone(),
            })
            .collect()
    }

    // select part of params to use a filter
    pub fn parameter_filter<'a>(&self, command: &str, params: &'a str) -> &'a str {
        match self.command_id(command) {
            Some(id) => match id {
                CommandId::Cancel => params,
                CommandId::Export => params, // @todo last part of path
                CommandId::Model => params,
                CommandId::Quit => params,
                CommandId::Reprompt => params,
                CommandId::Session => params,
                CommandId::Title => params,
                CommandId::ViewMode => params,
            },
            None => "",
        }
    }

    pub fn parameter_suggestions(&self, command: &str, _params: &str) -> Option<Vec<Suggestion>> {
        match self.command_id(command) {
            Some(id) => match id {
                CommandId::Cancel => None,
                CommandId::Export => None, // @todo path based on params
                CommandId::Model => {
                    if self.models.is_empty() {
                        None
                    } else {
                        Some(self.models.clone())
                    }
                }
                CommandId::Quit => None,
                CommandId::Reprompt => None,
                CommandId::Session => {
                    if self.sessions.is_empty() {
                        None
                    } else {
                        Some(self.sessions.clone())
                    }
                }
                CommandId::Title => None,
                CommandId::ViewMode => Some(
                    viewmode_parameters()
                        .iter()
                        .map(|(name, param)| Suggestion {
                            raw: name.clone(),
                            name: param.name.clone(),
                            hint: param.hint.clone(),
                        })
                        .collect(),
                ),
            },
            None => None,
        }
    }

    pub fn model_suggestions(&mut self, models: Vec<Suggestion>) {
        self.models = models.to_vec();
    }

    pub fn session_suggestions(&mut self, sessions: Vec<Suggestion>) {
        self.sessions = sessions.to_vec();
    }

    fn command_id(&self, command: &str) -> Option<CommandId> {
        self.long_key
            .get(command)
            .or_else(|| self.short_key.get(command))
            .copied()
    }

    fn insert(&mut self, cmd: ModalCommand, long: String, short: String, id: CommandId) {
        self.commands.insert(id, cmd);
        self.long_key.insert(long, id);
        self.short_key.insert(short, id);
    }
}

fn viewmode_parameters() -> HashMap<String, ViewModeParameter> {
    HashMap::from([
        (
            "compact".to_string(),
            ViewModeParameter {
                mode: ViewMode::Compact,
                hint: vec![Inline::Text(
                    "Show user prompt, agent response, tool call summaries".to_string(),
                )],
                name: vec![Inline::Text("compact".to_string())],
            },
        ),
        (
            "debug".to_string(),
            ViewModeParameter {
                mode: ViewMode::Debug,
                hint: vec![Inline::Text(
                    "Show everything incl. debug messages".to_string(),
                )],
                name: vec![Inline::Text("debug".to_string())],
            },
        ),
        (
            "detailed".to_string(),
            ViewModeParameter {
                mode: ViewMode::Detailed,
                hint: vec![Inline::Text(
                    "Show user message, full assistant output, tool call summaries".to_string(),
                )],
                name: vec![Inline::Text("detailed".to_string())],
            },
        ),
        (
            "full".to_string(),
            ViewModeParameter {
                mode: ViewMode::Full,
                hint: vec![Inline::Text("Show everything uncompacted".to_string())],
                name: vec![Inline::Text("full".to_string())],
            },
        ),
        (
            "minimal".to_string(),
            ViewModeParameter {
                mode: ViewMode::Minimal,
                hint: vec![Inline::Text(
                    "Only show user message and assistant responses".to_string(),
                )],
                name: vec![Inline::Text("minimal".to_string())],
            },
        ),
    ])
}

#[derive(Debug)]
pub struct ModalCommandParser {
    name: String,
    params: String,
}

impl ModalCommandParser {
    pub fn new(input: &[String]) -> Self {
        let command = input.first().map_or(String::new(), |line| line.to_owned());

        let (name, params) = command[0..]
            .split_once(char::is_whitespace)
            .unwrap_or((&command[0..], ""));
        // trim start because split includes whitespace in second part
        // trim end because ther sometimes is an invisible whitespace at the end that confuses the user
        let params = params.trim();

        Self {
            name: name.to_string(),
            params: params.to_string(),
        }
    }

    pub fn cmd_params(&self) -> (String, String) {
        (self.name.clone(), self.params.clone())
    }

    pub fn command(&self) -> String {
        self.name.clone()
    }

    pub fn params(&self) -> String {
        self.params.clone()
    }

    pub fn hovers_command(&self, pos: usize) -> bool {
        pos <= self.name.width()
    }
}

#[test]
fn test_whitespace() {
    let mcp = ModalCommandParser::new(&["view-mode co".to_string()]);
    assert_eq!(mcp.command(), "view-mode");
    assert_eq!(mcp.params(), "co");
}