diff --git a/Cargo.lock b/Cargo.lock index 53db245..fc5b773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -701,6 +701,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.5" @@ -735,7 +744,7 @@ dependencies = [ "bitflags 2.6.0", "crossterm_winapi", "futures-core", - "mio", + "mio 1.0.2", "parking_lot", "rustix", "serde", @@ -1234,6 +1243,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "flate2" version = "1.0.31" @@ -2007,6 +2028,26 @@ version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "insta" version = "1.41.1" @@ -2110,8 +2151,7 @@ dependencies = [ [[package]] name = "keymap" version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "060033dfc0287371e543c2bf48db38cbca4e6bfe370e4429f1ffef93eb17c86c" +source = "git+https://github.com/covercash2/keymap-rs#e78a20b2cd1e66cdf0f10b5ecbf5f266aed57644" dependencies = [ "crossterm", "pom", @@ -2120,6 +2160,26 @@ dependencies = [ "strum_macros", ] +[[package]] +name = "kqueue" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2162,6 +2222,19 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.6.0", "libc", + "redox_syscall", +] + +[[package]] +name = "linemux" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feb035c7806bd7982a317d8d66815021e91f7ab14a5fbedee22b06f608f11b43" +dependencies = [ + "futures-util", + "notify", + "pin-project-lite", + "tokio", ] [[package]] @@ -2325,6 +2398,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.2" @@ -2414,6 +2499,23 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "729f63e1ca555a43fe3efa4f3efdf4801c479da85b432242a7b726f353c88486" +dependencies = [ + "bitflags 1.3.2", + "crossbeam-channel", + "filetime", + "inotify", + "kqueue", + "libc", + "mio 0.8.11", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -2588,6 +2690,7 @@ dependencies = [ "insta", "itertools 0.13.0", "keymap", + "linemux", "modelfile", "nom", "ollama-rs", @@ -2609,6 +2712,7 @@ dependencies = [ "tracing-subscriber", "unicode-width 0.2.0", "url", + "xdg", ] [[package]] @@ -3631,7 +3735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 1.0.2", "signal-hook", ] @@ -4053,7 +4157,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 1.0.2", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -4687,6 +4791,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -4714,6 +4827,21 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -4745,6 +4873,12 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4757,6 +4891,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -4769,6 +4909,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -4787,6 +4933,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -4799,6 +4951,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4811,6 +4969,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -4823,6 +4987,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -4854,6 +5024,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "xdg" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" + [[package]] name = "yansi" version = "1.0.1" diff --git a/ollama-cli/Cargo.toml b/ollama-cli/Cargo.toml index 89ee40b..07b42b6 100644 --- a/ollama-cli/Cargo.toml +++ b/ollama-cli/Cargo.toml @@ -16,7 +16,8 @@ edit = "0.1.5" extend = "1.2.0" futures = "0.3.30" itertools = "0.13.0" -keymap = { version = "0.4.1", features = ["crossterm"] } +keymap = { git = "https://github.com/covercash2/keymap-rs", features = ["crossterm"] } +linemux = "0.3" modelfile = "0.2.0" nom = "7.1.3" ollama-rs = { version = "0.2.1", features = ["stream"] } @@ -36,6 +37,7 @@ tracing-log = "0.2.0" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] } unicode-width = "0.2.0" url = { version = "2.5.2", features = ["serde"] } +xdg = "2.5.2" [dev-dependencies] insta = { version = "1.41.1", features = ["json", "toml"] } diff --git a/ollama-cli/default_keymap.toml b/ollama-cli/default_keymap.toml index 3308532..609229c 100644 --- a/ollama-cli/default_keymap.toml +++ b/ollama-cli/default_keymap.toml @@ -16,6 +16,8 @@ w = "right_word" b = "left_word" enter = "enter" backspace = "left" +space = "popup" +"?" = "help" [edit] esc = "escape" diff --git a/ollama-cli/src/config.rs b/ollama-cli/src/config.rs new file mode 100644 index 0000000..7f6b67c --- /dev/null +++ b/ollama-cli/src/config.rs @@ -0,0 +1,62 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::{fs_ext::read_file_to_string, ollama::ModelHost, tui::event::EventDefinitions}; + +const APP_NAME: &str = "ollama_tui"; +const CONFIG_PATH_VAR: &str = "OLLAMA_TUI_CONFIG_PATH"; +const CONFIG_FILE_NAME: &str = "config.toml"; +const LOG_FILE_NAME: &str = "tui.log"; + +#[derive(Debug, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub log_file: LogFile, + #[serde(default)] + pub host: ModelHost, + #[serde(default)] + pub keymap: EventDefinitions, +} + +impl Config { + pub fn load() -> anyhow::Result { + get_config() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogFile(PathBuf); + +impl Default for LogFile { + fn default() -> Self { + let path = base_dirs() + .expect("unable to load base dirs") + .place_state_file(LOG_FILE_NAME) + .expect("unable to create default log file"); + LogFile(path) + } +} + +impl AsRef for LogFile { + fn as_ref(&self) -> &Path { + self.0.as_ref() + } +} + +fn get_config() -> anyhow::Result { + let path = if let Ok(path) = std::env::var(CONFIG_PATH_VAR) { + path.into() + } else { + base_dirs()?.place_config_file(CONFIG_FILE_NAME)? + }; + + let contents = read_file_to_string(path)?; + let config = toml::from_str(&contents)?; + + Ok(config) +} + +fn base_dirs() -> anyhow::Result { + Ok(xdg::BaseDirectories::with_prefix(APP_NAME)?) +} diff --git a/ollama-cli/src/error.rs b/ollama-cli/src/error.rs index 4900378..6d6443f 100644 --- a/ollama-cli/src/error.rs +++ b/ollama-cli/src/error.rs @@ -1,14 +1,25 @@ +use std::path::PathBuf; + use modelfile::modelfile::error::ModelfileError; use ollama_rs::error::OllamaError; use thiserror::Error; use tokio::sync::mpsc::error::SendError; -use crate::lm::Response; +use crate::{lm::Response, tui::event::InputMode}; pub type Result = std::result::Result; #[derive(Error, Debug)] pub enum Error { + #[error("error reading file {path}: {source}")] + ReadFile { + source: std::io::Error, + path: PathBuf, + }, + + #[error("keymap should have all modes defined by default. missing {0}")] + MissingKeymap(InputMode), + #[error("error indexing collection at index {index}: {msg}")] BadIndex { index: usize, msg: &'static str }, diff --git a/ollama-cli/src/fs_ext.rs b/ollama-cli/src/fs_ext.rs new file mode 100644 index 0000000..b02fb8c --- /dev/null +++ b/ollama-cli/src/fs_ext.rs @@ -0,0 +1,10 @@ +use std::path::Path; + +use crate::error::Result; + +pub fn read_file_to_string(path: impl AsRef) -> Result { + std::fs::read_to_string(&path).map_err(|source| crate::error::Error::ReadFile { + source, + path: path.as_ref().into(), + }) +} diff --git a/ollama-cli/src/main.rs b/ollama-cli/src/main.rs index d5a1c90..5f4f51c 100644 --- a/ollama-cli/src/main.rs +++ b/ollama-cli/src/main.rs @@ -1,20 +1,23 @@ -use std::fs::File; +use std::{fs::File, path::Path}; use clap::{Parser, Subcommand}; +use config::Config; use ollama::ModelHost; use tracing_subscriber::fmt::format::FmtSpan; use tui::AppContext; pub mod bytes_size; +mod config; mod error; +mod fs_ext; mod lm; mod ollama; mod tui; #[derive(Parser)] pub struct Cli { - #[arg(default_value_t)] - address: ModelHost, + #[arg(long)] + host: Option, #[command(subcommand)] mode: Mode, } @@ -34,20 +37,25 @@ enum Command { Embed(ollama::generate::Request), } -fn setup_tracing() { +fn setup_tracing(log_file: impl AsRef) -> anyhow::Result<()> { tracing_subscriber::fmt() .json() .with_span_events(FmtSpan::FULL) - .with_writer(File::create("logs.log").unwrap()) + .with_writer(File::create(log_file)?) .init(); + + Ok(()) } #[tokio::main] async fn main() -> anyhow::Result<()> { - setup_tracing(); let args = Cli::parse(); + let config = Config::load()?; + setup_tracing(&config.log_file)?; + + let host = args.host.as_ref().unwrap_or(&config.host); - let client = ollama::Client::new(args.address.url()).await?; + let client = ollama::Client::new(host.url()).await?; match args.mode { Mode::OneShot { command } => match command { @@ -62,7 +70,7 @@ async fn main() -> anyhow::Result<()> { Mode::Tui => { color_eyre::install().expect("unable to install color_eyre"); tracing::info!("starting TUI"); - let app_context = AppContext::new(client); + let app_context = AppContext::new(client, config); let terminal = ratatui::init(); app_context.run(terminal).await?; ratatui::restore(); diff --git a/ollama-cli/src/ollama/mod.rs b/ollama-cli/src/ollama/mod.rs index 4034ad1..097281d 100644 --- a/ollama-cli/src/ollama/mod.rs +++ b/ollama-cli/src/ollama/mod.rs @@ -5,6 +5,7 @@ use ollama_rs::{ models::{LocalModel, ModelInfo}, Ollama, }; +use serde::{Deserialize, Serialize}; use url::Url; use crate::error::Result; @@ -70,7 +71,7 @@ impl Display for ModelName { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct ModelHost(Url); impl ModelHost { diff --git a/ollama-cli/src/tui/event/mod.rs b/ollama-cli/src/tui/event/mod.rs index 7678133..85ca476 100644 --- a/ollama-cli/src/tui/event/mod.rs +++ b/ollama-cli/src/tui/event/mod.rs @@ -3,26 +3,24 @@ use std::collections::HashMap; use crossterm::event::{Event, KeyCode, KeyEvent}; use keymap::KeyMap; use serde::{Deserialize, Serialize}; +use strum::EnumIter; const DEFAULTS: &str = include_str!("../../../default_keymap.toml"); #[derive(Debug, PartialEq, Deserialize)] pub struct EventProcessor { - input_mode: InputMode, - definitions: EventDefinitions, + pub input_mode: InputMode, + pub definitions: EventDefinitions, } -impl Default for EventProcessor { - fn default() -> Self { - let definitions = toml::from_str(DEFAULTS).expect("should be able to load default keymaps"); - Self { +impl EventProcessor { + pub fn new(definitions: EventDefinitions) -> Self { + EventProcessor { input_mode: Default::default(), definitions, } } -} -impl EventProcessor { pub fn input_mode(&mut self, input_mode: InputMode) { self.input_mode = input_mode; } @@ -51,7 +49,19 @@ impl EventProcessor { } } -#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive( + Default, + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + EnumIter, + strum::Display, +)] #[serde(rename_all = "snake_case")] pub enum InputMode { #[default] @@ -59,8 +69,14 @@ pub enum InputMode { Edit, } -#[derive(Debug, PartialEq, Deserialize)] -pub struct EventDefinitions(HashMap); +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct EventDefinitions(pub HashMap); + +impl Default for EventDefinitions { + fn default() -> Self { + toml::from_str(DEFAULTS).expect("should be able to load default keymaps") + } +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ActionDefinition { @@ -77,10 +93,10 @@ impl From for ActionDefinition { } } -#[derive(Debug, PartialEq, Deserialize)] -pub struct ActionMap(HashMap); +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct ActionMap(pub HashMap); -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, strum::Display)] #[serde(rename_all = "snake_case")] pub enum Action { Beginning, @@ -93,6 +109,8 @@ pub enum Action { LeftWord, RightWord, Refresh, + Popup, + Help, Enter, Escape, Backspace, diff --git a/ollama-cli/src/tui/input/mod.rs b/ollama-cli/src/tui/input/mod.rs index 93eeea6..897bfdb 100644 --- a/ollama-cli/src/tui/input/mod.rs +++ b/ollama-cli/src/tui/input/mod.rs @@ -31,9 +31,7 @@ pub enum TextInputEvent { impl TextInputViewModel { pub fn handle_action(&mut self, action: Action) -> Result> { match action { - Action::Edit => { - return Ok(Some(TextInputEvent::InputMode(InputMode::Edit))) - } + Action::Edit => return Ok(Some(TextInputEvent::InputMode(InputMode::Edit))), Action::Quit => return Ok(Some(TextInputEvent::Quit)), Action::Right => self.move_cursor_right(), Action::Left => self.move_cursor_left(), diff --git a/ollama-cli/src/tui/mod.rs b/ollama-cli/src/tui/mod.rs index baab7c6..eba7ea6 100644 --- a/ollama-cli/src/tui/mod.rs +++ b/ollama-cli/src/tui/mod.rs @@ -9,6 +9,7 @@ use model_context::ModelContext; use models::{ModelsView, ModelsViewModel}; use nav::{NavView, NavViewModel}; use ollama_rs::models::ModelInfo; +use popup::{PopupView, PopupViewModel}; use ratatui::{ crossterm::event::Event, style::{Color, Style}, @@ -17,6 +18,7 @@ use ratatui::{ use strum::VariantNames; use crate::{ + config::Config, error::Result, lm::{Prompt, Response}, ollama, @@ -31,12 +33,15 @@ pub mod messages; mod model_context; pub mod models; mod nav; +mod popup; mod widgets_ext; pub struct AppContext { model_context: ModelContext, event_processor: EventProcessor, + popup: Option, view: View, + config: Config, } #[derive(Clone, Debug, strum::EnumString, strum::EnumDiscriminants)] @@ -98,11 +103,13 @@ impl Style { } impl AppContext { - pub fn new(client: ollama::Client) -> Self { + pub fn new(client: ollama::Client, config: Config) -> Self { Self { model_context: ModelContext::spawn(client), - event_processor: Default::default(), + event_processor: EventProcessor::new(config.keymap.clone()), + popup: None, view: Default::default(), + config, } } @@ -121,6 +128,9 @@ impl AppContext { frame.generate_view(frame.area(), Style::default(), generate_view_model) } } + if let Some(ref mut popup) = self.popup { + frame.popup(frame.area(), Style::active(), popup); + } } pub async fn run(mut self, mut terminal: DefaultTerminal) -> anyhow::Result<()> { @@ -172,7 +182,11 @@ impl AppContext { } } AppEvent::Deactivate => { - self.view = View::Nav(Default::default()); + if self.popup.is_some() { + self.popup = None; + } else { + self.view = View::Nav(Default::default()); + } Ok(true) } AppEvent::InputMode(input_mode) => { @@ -184,13 +198,28 @@ impl AppContext { async fn handle_input(&mut self, event: Event) -> anyhow::Result> { let action = self.event_processor.process(event); - let app_event = match &mut self.view { - View::Chat(ref mut chat_view_model) => chat_view_model.handle_action(action).await?, - View::Models(models_view_model) => models_view_model.handle_event(action).await?, - View::Nav(nav_view_model) => nav_view_model.handle_action(action)?, - View::Generate(generate_view_model) => generate_view_model.handle_action(action)?, - }; - Ok(app_event) + + if let Some(ref mut popup) = self.popup { + return Ok(popup.handle_action(action)?); + } + + if action == Action::Popup { + self.popup = Some(PopupViewModel::log_popup(&self.config.log_file)?); + Ok(None) + } else if action == Action::Help { + self.popup = Some(PopupViewModel::keymap_popup(&self.event_processor)); + Ok(None) + } else { + let app_event = match &mut self.view { + View::Chat(ref mut chat_view_model) => { + chat_view_model.handle_action(action).await? + } + View::Models(models_view_model) => models_view_model.handle_event(action).await?, + View::Nav(nav_view_model) => nav_view_model.handle_action(action)?, + View::Generate(generate_view_model) => generate_view_model.handle_action(action)?, + }; + Ok(app_event) + } } // TODO: use this function with [`modelfile`] diff --git a/ollama-cli/src/tui/popup.rs b/ollama-cli/src/tui/popup.rs new file mode 100644 index 0000000..062ce03 --- /dev/null +++ b/ollama-cli/src/tui/popup.rs @@ -0,0 +1,173 @@ +use std::{path::Path, sync::Arc}; + +use itertools::Itertools; +use ratatui::{ + layout::{Constraint, Flex, Layout, Rect}, + style::Style, + widgets::{Block, Clear, Padding, Paragraph, Wrap}, + Frame, +}; +use strum::IntoEnumIterator as _; + +use crate::{ + error::{Error, Result}, + fs_ext::read_file_to_string, + tui::event::InputMode, +}; + +use super::{ + event::{Action, EventProcessor}, + AppEvent, +}; + +#[derive(Debug, Clone)] +pub struct PopupViewModel { + title: String, + content: PopupContent, + scroll_offset: u16, +} + +#[derive(Debug, Clone)] +pub struct PopupContent { + columns: Arc<[String]>, +} + +impl> From for PopupContent { + fn from(value: S) -> Self { + PopupContent { + columns: [value.as_ref().to_string()].into(), + } + } +} + +impl> FromIterator for PopupContent { + fn from_iter>(iter: T) -> Self { + let columns: Arc<[String]> = iter.into_iter().map(|s| s.as_ref().into()).collect(); + + Self { columns } + } +} + +impl PopupContent { + pub fn line_count(&self) -> usize { + self.columns + .iter() + .map(|column| column.lines().count()) + .max() + .expect("should have a non-zero number of columns") + } +} + +impl PopupViewModel { + pub fn new(title: impl ToString, content: impl Into) -> Self { + PopupViewModel { + title: title.to_string(), + content: content.into(), + scroll_offset: 0, + } + } + + pub fn log_popup(log_file: impl AsRef) -> Result { + let logs = read_file_to_string(log_file)?; + + let content = logs.lines().rev().take(10).join("\n"); + + Ok(PopupViewModel::new("logs".to_string(), content)) + } + + pub fn keymap_popup(event_processor: &EventProcessor) -> Self { + let keymaps = &event_processor.definitions.0; + let keymap_help: PopupContent = InputMode::iter() + .map(|mode| { + let keymap = keymaps + .get(&mode) + .ok_or(Error::MissingKeymap(mode)) + .expect("should be able to get the keymap"); + + std::iter::once(mode.to_string()) + .chain( + keymap + .0 + .iter() + .map(|(key, action)| format!("{key}: {action}")), + ) + .join("\n") + }) + .collect(); + + PopupViewModel::new("help", keymap_help) + } + + fn max_scroll(&self) -> u16 { + (self.content.line_count()) + .try_into() + .expect("should be able to fit popup content into u16") + } + + pub fn handle_action(&mut self, action: Action) -> Result> { + match action { + Action::Up => { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + Ok(None) + } + Action::Down => { + self.scroll_offset = self.scroll_offset.saturating_add(1); + if self.scroll_offset > self.max_scroll() { + self.scroll_offset = self.max_scroll(); + } + Ok(None) + } + Action::Beginning => { + self.scroll_offset = 0; + Ok(None) + } + Action::End => { + self.scroll_offset = self.max_scroll(); + Ok(None) + } + Action::Popup | Action::Help | Action::Quit | Action::Enter | Action::Escape => { + Ok(Some(AppEvent::Deactivate)) + } + _ => Ok(None), + } + } +} + +fn popup_area(parent: Rect, percent_x: u16, percent_y: u16) -> Rect { + let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center); + let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center); + + let [area] = vertical.areas(parent); + let [area] = horizontal.areas(area); + + area +} + +#[extend::ext(name = PopupView)] +pub impl<'a> Frame<'a> { + fn popup(&mut self, parent: Rect, style: Style, view_model: &mut PopupViewModel) { + let area = popup_area(parent, 60, 60); + self.render_widget(Clear, area); + + let block = Block::bordered().title(view_model.title.as_str()); + + let layout = Layout::horizontal( + view_model + .content + .columns + .iter() + .map(|_| Constraint::Fill(1)), + ); + + for (i, column_area) in layout.split(area).iter().enumerate() { + let content = Paragraph::new(view_model.content.columns[i].as_str()) + .wrap(Wrap { trim: true }) + .scroll((view_model.scroll_offset, 0)) + .block(Block::new().padding(Padding::proportional(2))) + .style(style); + self.render_widget(content, *column_area); + } + + self.render_widget(block, area); + } +}