pub mod command_registry;
mod completer;
mod highlighter;
mod prompt;
pub use completer::SelfwareCompleter;
pub use highlighter::SelfwareHighlighter;
pub use prompt::SelfwarePrompt;
use anyhow::Result;
use reedline::{
default_emacs_keybindings, ColumnarMenu, DefaultHinter, DefaultValidator, EditCommand, Emacs,
FileBackedHistory, KeyCode, KeyModifiers, Keybindings, MenuBuilder, Reedline, ReedlineEvent,
ReedlineMenu, Signal, Vi,
};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum InputMode {
#[default]
Emacs,
Vi,
}
#[derive(Debug, Clone)]
pub struct InputConfig {
pub mode: InputMode,
pub history_path: Option<PathBuf>,
pub max_history: usize,
pub syntax_highlight: bool,
pub show_hints: bool,
pub tool_names: Vec<String>,
pub commands: Vec<String>,
}
impl Default for InputConfig {
fn default() -> Self {
Self {
mode: InputMode::Emacs,
history_path: dirs_history_path(),
max_history: 10000,
syntax_highlight: true,
show_hints: true,
tool_names: vec![],
commands: command_registry::command_names(),
}
}
}
fn dirs_history_path() -> Option<PathBuf> {
dirs::data_local_dir().map(|p| p.join("selfware").join("history.txt"))
}
pub struct SelfwareEditor {
editor: Reedline,
prompt: SelfwarePrompt,
config: InputConfig,
}
impl SelfwareEditor {
pub fn new(config: InputConfig) -> Result<Self> {
let history = if let Some(path) = &config.history_path {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
Box::new(FileBackedHistory::with_file(
config.max_history,
path.clone(),
)?)
} else {
Box::new(FileBackedHistory::new(config.max_history)?)
};
let completer = Box::new(SelfwareCompleter::new(
config.tool_names.clone(),
config.commands.clone(),
));
let highlighter = Box::new(SelfwareHighlighter::new());
let hinter = Box::new(DefaultHinter::default());
let completion_menu = Box::new(
ColumnarMenu::default()
.with_name("completion_menu")
.with_columns(1) .with_column_padding(2)
.with_marker(" > "), );
let keybindings = Self::build_keybindings(config.mode);
let edit_mode: Box<dyn reedline::EditMode> = match config.mode {
InputMode::Emacs => Box::new(Emacs::new(keybindings)),
InputMode::Vi => Box::new(Vi::default()),
};
let validator = Box::new(DefaultValidator);
let editor_cmd = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let temp_file =
std::env::temp_dir().join(format!("selfware_edit_{}.tmp", std::process::id()));
let buffer_editor = std::process::Command::new(editor_cmd);
let mut editor = Reedline::create()
.with_history(history)
.with_completer(completer)
.with_quick_completions(true)
.with_partial_completions(true)
.with_hinter(hinter)
.with_highlighter(highlighter)
.with_validator(validator)
.with_menu(ReedlineMenu::EngineCompleter(completion_menu))
.with_edit_mode(edit_mode)
.with_buffer_editor(buffer_editor, temp_file);
editor = editor.with_history_exclusion_prefix(Some(" ".into()));
let prompt = SelfwarePrompt::new();
Ok(Self {
editor,
prompt,
config,
})
}
fn build_keybindings(mode: InputMode) -> Keybindings {
let mut keybindings = match mode {
InputMode::Emacs => default_emacs_keybindings(),
InputMode::Vi => Keybindings::default(),
};
keybindings.add_binding(
KeyModifiers::NONE,
KeyCode::Tab,
ReedlineEvent::UntilFound(vec![
ReedlineEvent::HistoryHintComplete, ReedlineEvent::Edit(vec![EditCommand::Complete]), ReedlineEvent::Menu("completion_menu".to_string()), ReedlineEvent::MenuNext, ]),
);
keybindings.add_binding(
KeyModifiers::NONE,
KeyCode::Char('/'),
ReedlineEvent::Multiple(vec![
ReedlineEvent::Edit(vec![EditCommand::InsertChar('/')]),
ReedlineEvent::Menu("completion_menu".to_string()),
]),
);
keybindings.add_binding(
KeyModifiers::SHIFT,
KeyCode::BackTab,
ReedlineEvent::ExecuteHostCommand("__cycle_mode__".to_string()),
);
keybindings.add_binding(KeyModifiers::NONE, KeyCode::Esc, ReedlineEvent::Esc);
keybindings.add_binding(
KeyModifiers::NONE,
KeyCode::Right,
ReedlineEvent::UntilFound(vec![
ReedlineEvent::HistoryHintComplete,
ReedlineEvent::Edit(vec![EditCommand::MoveRight { select: false }]),
]),
);
keybindings.add_binding(
KeyModifiers::CONTROL,
KeyCode::Char('j'),
ReedlineEvent::Edit(vec![EditCommand::InsertNewline]),
);
keybindings.add_binding(
KeyModifiers::CONTROL,
KeyCode::Char('y'),
ReedlineEvent::ExecuteHostCommand("__toggle_yolo__".to_string()),
);
keybindings.add_binding(
KeyModifiers::CONTROL,
KeyCode::Char('x'),
ReedlineEvent::OpenEditor,
);
keybindings.add_binding(
KeyModifiers::CONTROL,
KeyCode::Char(' '),
ReedlineEvent::Edit(vec![EditCommand::InsertString("".into())]),
);
keybindings
}
pub fn read_line(&mut self) -> Result<ReadlineResult> {
match self.editor.read_line(&self.prompt) {
Ok(Signal::Success(line)) => {
if line.starts_with("__") && line.ends_with("__") {
Ok(ReadlineResult::HostCommand(line))
} else {
Ok(ReadlineResult::Line(line))
}
}
Ok(Signal::CtrlC) => Ok(ReadlineResult::Interrupt),
Ok(Signal::CtrlD) => Ok(ReadlineResult::Eof),
Err(e) => Err(e.into()),
}
}
pub fn set_prompt_context(&mut self, model: &str, step: usize) {
self.prompt = SelfwarePrompt::with_context(model, step);
}
pub fn set_prompt_full_context(&mut self, model: &str, step: usize, context_pct: f64) {
self.prompt = SelfwarePrompt::with_full_context(model, step, context_pct);
}
pub fn add_tools(&mut self, tools: Vec<String>) {
let _ = tools;
}
pub fn toggle_vim_mode(&mut self) -> Result<InputMode> {
let new_mode = match self.config.mode {
InputMode::Emacs => InputMode::Vi,
InputMode::Vi => InputMode::Emacs,
};
self.config.mode = new_mode;
let new_editor = SelfwareEditor::new(self.config.clone())?;
self.editor = new_editor.editor;
Ok(new_mode)
}
}
#[derive(Debug)]
pub enum ReadlineResult {
Line(String),
Interrupt,
Eof,
HostCommand(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_input_config_default() {
let config = InputConfig::default();
assert_eq!(config.mode, InputMode::Emacs);
assert_eq!(config.max_history, 10000);
assert!(config.commands.contains(&"/help".into()));
}
#[test]
fn test_input_config_default_commands() {
let config = InputConfig::default();
assert!(config.commands.contains(&"/help".into()));
assert!(config.commands.contains(&"/status".into()));
assert!(config.commands.contains(&"/stats".into()));
assert!(config.commands.contains(&"/mode".into()));
assert!(config.commands.contains(&"/ctx".into()));
assert!(config.commands.contains(&"/compress".into()));
assert!(config.commands.contains(&"/context".into()));
assert!(config.commands.contains(&"/memory".into()));
assert!(config.commands.contains(&"/clear".into()));
assert!(config.commands.contains(&"/tools".into()));
assert!(config.commands.contains(&"/analyze".into()));
assert!(config.commands.contains(&"/review".into()));
assert!(config.commands.contains(&"/plan".into()));
assert!(config.commands.contains(&"/swarm".into()));
assert!(config.commands.contains(&"/queue".into()));
assert!(config.commands.contains(&"/diff".into()));
assert!(config.commands.contains(&"/git".into()));
assert!(config.commands.contains(&"/undo".into()));
assert!(config.commands.contains(&"/cost".into()));
assert!(config.commands.contains(&"/model".into()));
assert!(config.commands.contains(&"/compact".into()));
assert!(config.commands.contains(&"/verbose".into()));
assert!(config.commands.contains(&"/last".into()));
assert!(config.commands.contains(&"/debug".into()));
assert!(config.commands.contains(&"/debug-log".into()));
assert!(config.commands.contains(&"/config".into()));
assert!(config.commands.contains(&"/garden".into()));
assert!(config.commands.contains(&"/journal".into()));
assert!(config.commands.contains(&"/palette".into()));
assert!(config.commands.contains(&"exit".into()));
assert!(config.commands.contains(&"quit".into()));
}
#[test]
fn test_input_config_custom() {
let config = InputConfig {
mode: InputMode::Vi,
max_history: 500,
tool_names: vec!["my_tool".into()],
..Default::default()
};
assert_eq!(config.mode, InputMode::Vi);
assert_eq!(config.max_history, 500);
assert!(config.tool_names.contains(&"my_tool".into()));
}
#[test]
fn test_input_mode_default() {
assert_eq!(InputMode::default(), InputMode::Emacs);
}
#[test]
fn test_input_mode_equality() {
assert_eq!(InputMode::Emacs, InputMode::Emacs);
assert_eq!(InputMode::Vi, InputMode::Vi);
assert_ne!(InputMode::Emacs, InputMode::Vi);
}
#[test]
fn test_input_config_syntax_highlight() {
let config = InputConfig::default();
assert!(config.syntax_highlight);
}
#[test]
fn test_input_config_show_hints() {
let config = InputConfig::default();
assert!(config.show_hints);
}
#[test]
fn test_dirs_history_path() {
let path = dirs_history_path();
if let Some(p) = path {
assert!(p.to_string_lossy().contains("selfware"));
assert!(p.to_string_lossy().contains("history"));
}
}
#[test]
fn test_readline_result_variants() {
let _line = ReadlineResult::Line("test".into());
let _interrupt = ReadlineResult::Interrupt;
let _eof = ReadlineResult::Eof;
let _host_cmd = ReadlineResult::HostCommand("__toggle_yolo__".into());
}
#[test]
fn test_readline_result_debug() {
let result = ReadlineResult::Line("test".into());
let debug_str = format!("{:?}", result);
assert!(debug_str.contains("Line"));
assert!(debug_str.contains("test"));
}
#[test]
fn test_readline_result_host_command_debug() {
let result = ReadlineResult::HostCommand("__toggle_yolo__".into());
let debug_str = format!("{:?}", result);
assert!(debug_str.contains("HostCommand"));
assert!(debug_str.contains("__toggle_yolo__"));
}
#[test]
fn test_input_config_new_commands() {
let config = InputConfig::default();
assert!(config.commands.contains(&"/vim".into()));
assert!(config.commands.contains(&"/copy".into()));
assert!(config.commands.contains(&"/restore".into()));
assert!(config.commands.contains(&"/chat".into()));
assert!(config.commands.contains(&"/theme".into()));
}
}