diff --git a/CHANGELOG.md b/CHANGELOG.md index da6dcbd..042a8e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +## [0.10] TBA + +### Added + +- More vim like key bindings for the prompt +- Add Visual mode for the prompt +- Copy and paste from/to the clipboard in the prompt + +### Fixed + +- Use model from the config file if defined + ## [0.9] 01/11/2023 ### Features diff --git a/Cargo.lock b/Cargo.lock index f074a62..8665d68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,25 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "arboard" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafb29b107435aa276664c1db8954ac27a6e105cdad3c88287a199eb0e313c08" +dependencies = [ + "clipboard-win", + "core-graphics", + "image", + "log", + "objc", + "objc-foundation", + "objc_id", + "parking_lot", + "thiserror", + "winapi", + "x11rb", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -197,6 +216,12 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "bstr" version = "1.9.0" @@ -231,6 +256,12 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.5.0" @@ -306,6 +337,17 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" +[[package]] +name = "clipboard-win" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" +dependencies = [ + "error-code", + "str-buf", + "winapi", +] + [[package]] name = "clircle" version = "0.4.0" @@ -318,6 +360,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.0" @@ -372,6 +420,30 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "crc32fast" version = "1.3.2" @@ -489,6 +561,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "error-code" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" +dependencies = [ + "libc", + "str-buf", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -500,6 +582,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "fdeflate" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f9bfee30e4dedf0ab8b422f03af778d9612b63f502710fc500a334ebe2de645" +dependencies = [ + "simd-adler32", +] + [[package]] name = "flate2" version = "1.0.28" @@ -516,6 +607,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -573,6 +679,16 @@ dependencies = [ "slab", ] +[[package]] +name = "gethostname" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb65d4ba3173c56a500b555b532f72c42e8d1fe64962b518897f8959fae2c177" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "getrandom" version = "0.2.12" @@ -788,6 +904,20 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034bbe799d1909622a74d1193aa50147769440040ff36cb2baa947609b0a4e23" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indexmap" version = "2.1.0" @@ -834,6 +964,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + [[package]] name = "js-sys" version = "0.3.67" @@ -936,12 +1072,30 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -961,6 +1115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" dependencies = [ "adler", + "simd-adler32", ] [[package]] @@ -975,6 +1130,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -994,6 +1161,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "num-traits" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -1013,6 +1189,35 @@ dependencies = [ "libc", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "object" version = "0.32.2" @@ -1132,6 +1337,19 @@ dependencies = [ "time", ] +[[package]] +name = "png" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f6c3c3e617595665b8ea2ff95a86066be38fb121ff920a9c0eb282abcd1da5a" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1509,6 +1727,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "slab" version = "0.4.9" @@ -1556,6 +1780,12 @@ version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8207e78455ffdf55661170876f88daf85356e4edd54e0a3dbc79586ca1e50cbe" +[[package]] +name = "str-buf" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" + [[package]] name = "strsim" version = "0.10.0" @@ -1663,6 +1893,7 @@ name = "tenere" version = "0.9.0" dependencies = [ "ansi-to-tui", + "arboard", "bat", "clap", "colored", @@ -1674,6 +1905,7 @@ dependencies = [ "serde", "serde_json", "toml", + "tui-textarea", "unicode-width", ] @@ -1716,6 +1948,17 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.31" @@ -1867,6 +2110,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-textarea" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e" +dependencies = [ + "crossterm 0.27.0", + "ratatui", + "unicode-width", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -2048,6 +2302,12 @@ version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1778a42e8b3b90bff8d0f5032bf22250792889a5cdc752aa0020c84abe3aaf10" +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + [[package]] name = "wild" version = "2.2.0" @@ -2082,6 +2342,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "winapi-wsapoll" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c17110f57155602a80dca10be03852116403c9ff3cd25b079d666f2aa3df6e" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2239,6 +2508,28 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "x11rb" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1641b26d4dec61337c35a1b1aaf9e3cba8f46f0b43636c609ab0291a648040a" +dependencies = [ + "gethostname", + "nix", + "winapi", + "winapi-wsapoll", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d6c3f9a0fb6701fab8f6cea9b0c0bd5d6876f1f89f7fada07e558077c344bc" +dependencies = [ + "nix", +] + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/Cargo.toml b/Cargo.toml index 6a363df..a72197e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,8 @@ repository = "https://github.com/pythops/tenere" [dependencies] crossterm = "0.26" -tui = { package = "ratatui", version = "0.25", features = ["all-widgets"] } +ratatui = { version = "0.25", features = ["all-widgets"] } +tui-textarea = { version = "0.4" } unicode-width = "0.1" reqwest = { version = "0.11", default-features = false, features = [ "blocking", @@ -28,3 +29,4 @@ dirs = "5" regex = "1" bat = "0.24" colored = "2" +arboard = "3" diff --git a/README.md b/README.md index 20f7121..931d26a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ - Syntax highlights - Chat history - Save chats to files -- Vim keybinding (partial support for now) +- Vim keybinding (most common ops) +- Copy text from/to clipboard
@@ -25,13 +26,13 @@ Only **ChatGPT** is supported for the moment. But I'm planning to support more m
-## πŸ”Œ Installation +## πŸš€ Installation -### Binary releases +### πŸ“₯ Binary releases -You can download the prebuilt binaries from the [release page](https://github.com/pythops/tenere/releases) +You can download the pre-built binaries from the [release page](https://github.com/pythops/tenere/releases) -### crates.io +### πŸ“¦ crates.io `tenere` can be installed from [crates.io](https://crates.io/crates/tenere) @@ -39,7 +40,7 @@ You can download the prebuilt binaries from the [release page](https://github.co cargo install tenere ``` -### Build from source +### βš’οΈ Build from source To build from the source, you need [Rust](https://www.rust-lang.org/) compiler and [Cargo package manager](https://doc.rust-lang.org/cargo/). @@ -52,14 +53,13 @@ cargo build --release This will produce an executable file at `target/release/tenere` that you can copy to a directory in your `$PATH`. -### Brew +### 🍺Brew On macOS, you can use brew: ```bash brew tap pythops/tenere brew install tenere - ```
@@ -81,7 +81,6 @@ Here are the available general settings: ```toml archive_file_name = "tenere.archive" model = "chatgpt" - ``` ### Key bindings @@ -98,6 +97,8 @@ new_chat = 'n' save_chat = 's' ``` +⚠️ To avoid overlapping with vim key bindings, you need to use `ctrl` + `key` except for help `?`. + ## Chatgpt To use Tenere's chat functionality, you'll need to provide an API key for OpenAI. There are two ways to do this: @@ -117,60 +118,125 @@ model = "gpt-3.5-turbo" url = "https://api.openai.com/v1/chat/completions" ``` -The default model is set to `gpt-3.5-turbo`. check out the [OpenAI documentation](https://platform.openai.com/docs/models/gpt-3-5) for more info. +The default model is set to `gpt-3.5-turbo`. Check out the [OpenAI documentation](https://platform.openai.com/docs/models/gpt-3-5) for more info. -## πŸš€ Usage +
-There are two modes like vim: `Normal` and `Insert`. +## ⌨️ Key bindings -#### Insert mode +### Global + +These are the default key bindings regardless of the focused block. + +`ctrl + n`: Start a new chat and save the previous one in history. + +`ctrl + s`: Save the current chat or chat history (history pop-up should be visible first) to `tenere.archive` file in the current directory. + +`Tab`: Switch the focus. + +`j` or `Down arrow key`: Scroll down + +`k` or `Up arrow key`: Scroll up + +`ctrl + h` : Show chat history. Press `Esc` to dismiss it. -To enter `Insert` mode, You press `i`. Once you're in, you can use: +`ctrl + t` : Stop the stream response + +`q` or `ctrl + c`: Quit the app + +`?`: Show the help pop-up. Press `Esc` to dismiss it + +### Prompt + +There are 3 modes like vim: `Normal`, `Visual` and `Insert`. + +#### Insert mode `Esc`: to switch back to Normal mode. -`Enter`: to create a new line +`Enter`: to create a new line. -`Backspace`: to remove the previous character +`Backspace`: to remove the previous character. #### Normal mode -When you launch [tenere](), it's in `Normal` mode by default. In this mode, you can use: - `Enter`: to submit the prompt -`dd`: to clear the prompt. +
+ +`h or Left`: Move the cursor backward by one char. + +`j or Down`: Move the cursor down. + +`k or Up`: Move the cursor up. + +`l or Right`: Move the cursor forward by one char. + +`w`: Move the cursor right by one word. + +`b`: Move the cursor backward by one word. + +`0`: Move the cursor to the start of the line. + +`$`: Move the cursor to the end of the line. `G`: Go to th end. `gg`: Go to the top. -`n`: Start a new chat and save the previous one in history. +
+ +`a`: Insert after the cursor. + +`A`: Insert at the end of the line. + +`i`: Insert before the cursor. -`s`: Save the current chat or chat history (history popup should be visible first) to `tenere.archive` file in the current directory. +`I`: Insert at the beginning of the line. -`Tab`: to switch the focus. +`o`: Append a new line below the current line. + +`O`: Append a new line above the current line. + +
-`j` or `Down arrow key`: to scroll down +`x`: Delete one char under to the cursor. -`k` or `Up arrow key`: to scroll up +`dd`: Cut the current line -`h` : Show chat history +`D`: Delete the current line and -`t` : Stop the stream response +`dw`: Delete the word next to the cursor. -`q`: to quit the app +`db`: Delete the word on the left of the cursor. -`?`: to show the help pop-up. You can dismiss it with `Esc` +`d0`: Delete from the cursor to the beginning of the line. + +`d$`: Delete from the cursor to the end of the line.
-## πŸ› οΈ Built with +`C`: Change to the end of the line. + +`cc`: Change the current line. + +`c0`: Change from the cursor to the beginning of the line. + +`c$`: Change from the cursor to the end of the line. + +`cw`: Change the next word. + +`cb`: Change the word on the left of the cursor. + +
+ +`u`: Undo + +`p`: Paste + +#### Visual mode -- [ratatui](https://github.com/tui-rs-revival/ratatui) -- [crossterm](https://github.com/crossterm-rs/crossterm) -- [reqwest](https://github.com/seanmonstar/reqwest) -- [clap](https://github.com/clap-rs/clap) +`y`: Yank the selected text
diff --git a/src/app.rs b/src/app.rs index 2dcfd8b..35e15c0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,5 @@ +use crate::help::Help; +use crate::prompt::Prompt; use std; use std::collections::HashMap; use std::sync::atomic::AtomicBool; @@ -5,20 +7,14 @@ use std::sync::atomic::AtomicBool; use crate::notification::Notification; use crate::spinner::Spinner; use crate::{config::Config, formatter::Formatter}; -use crossterm::event::KeyCode; -use tui::text::{Line, Text}; +use arboard::Clipboard; +use ratatui::text::{Line, Text}; use std::sync::Arc; pub type AppResult = std::result::Result>; -#[derive(Debug)] -pub enum Mode { - Normal, - Insert, -} - -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum FocusedBlock { Prompt, Chat, @@ -45,25 +41,6 @@ pub struct Chat<'a> { pub length: u16, } -#[derive(Debug)] -pub struct Prompt<'a> { - pub message: String, - pub formatted_prompt: Text<'a>, - pub scroll: u16, - pub length: u16, -} - -impl Default for Prompt<'_> { - fn default() -> Self { - Self { - message: String::from(">_ "), - formatted_prompt: Text::raw(">_ "), - scroll: 0, - length: 0, - } - } -} - #[derive(Debug, Default)] pub struct Answer<'a> { pub answer: String, @@ -73,17 +50,17 @@ pub struct Answer<'a> { pub struct App<'a> { pub running: bool, pub prompt: Prompt<'a>, - pub mode: Mode, pub chat: Chat<'a>, - pub previous_key: KeyCode, pub focused_block: FocusedBlock, pub llm_messages: Vec>, pub answer: Answer<'a>, pub history: History<'a>, - pub config: Arc, pub notifications: Vec, pub spinner: Spinner, pub terminate_response_signal: Arc, + pub clipboard: Option, + pub help: Help, + pub config: Arc, pub formatter: &'a Formatter<'a>, } @@ -92,17 +69,17 @@ impl<'a> App<'a> { Self { running: true, prompt: Prompt::default(), - mode: Mode::Normal, chat: Chat::default(), - previous_key: KeyCode::Null, focused_block: FocusedBlock::Prompt, llm_messages: Vec::new(), answer: Answer::default(), history: History::default(), - config, notifications: Vec::new(), spinner: Spinner::default(), terminate_response_signal: Arc::new(AtomicBool::new(false)), + clipboard: Clipboard::new().ok(), + help: Help::new(), + config, formatter, } } diff --git a/src/formatter.rs b/src/formatter.rs index 79cbeb8..3e3545c 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -1,7 +1,7 @@ use ansi_to_tui::IntoText; use bat::{assets::HighlightingAssets, config::Config, controller::Controller, Input}; -use tui::text::Text; +use ratatui::text::Text; pub struct Formatter<'a> { controller: Controller<'a>, @@ -12,6 +12,7 @@ impl<'a> Formatter<'a> { let controller = Controller::new(config, assets); Self { controller } } + pub fn format(&self, input: &str) -> Text<'static> { let mut buffer = String::new(); let input = Input::from_bytes(input.as_bytes()).name("text.md"); diff --git a/src/handler.rs b/src/handler.rs index 449e3a0..502cbb1 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,8 +1,8 @@ -use crate::app::{Chat, Prompt}; use crate::llm::LLMAnswer; +use crate::{app::Chat, prompt::Mode}; use crate::{ - app::{App, AppResult, FocusedBlock, Mode}, + app::{App, AppResult, FocusedBlock}, event::Event, }; use colored::*; @@ -12,7 +12,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::sync::mpsc::Sender; use std::{collections::HashMap, thread}; -use tui::text::Line; +use ratatui::text::Line; use crate::notification::{Notification, NotificationLevel}; use std::sync::Arc; @@ -23,130 +23,87 @@ pub fn handle_key_events( llm: Arc, sender: Sender, ) -> AppResult<()> { - match app.mode { - Mode::Normal => match key_event.code { - // Change mode to Insert - KeyCode::Char('i') => { - app.mode = Mode::Insert; - app.focused_block = FocusedBlock::Prompt; + match key_event.code { + // Quit the app + KeyCode::Char('q') if app.prompt.mode != Mode::Insert => { + app.running = false; + } + + KeyCode::Char('c') if key_event.modifiers == KeyModifiers::CONTROL => { + app.running = false; + } + + // Terminate the stream response + KeyCode::Char('t') if key_event.modifiers == KeyModifiers::CONTROL => { + app.terminate_response_signal + .store(true, std::sync::atomic::Ordering::Relaxed); + } + + // scroll down + KeyCode::Char('j') | KeyCode::Down => match app.focused_block { + FocusedBlock::History => { + if !app.history.formatted_chat.is_empty() + && app.history.index < app.history.chat.len() - 1 + { + app.history.index += 1; + } } - // Quit the app - KeyCode::Char('q') => { - app.running = false; + FocusedBlock::Chat => { + app.chat.scroll = app.chat.scroll.saturating_add(1); } - // Terminate the stream response - KeyCode::Char('t') => { - app.terminate_response_signal - .store(true, std::sync::atomic::Ordering::Relaxed); + FocusedBlock::Preview => { + app.history.scroll = app.history.scroll.saturating_add(1); } - - // Submit the prompt - KeyCode::Enter => { - let user_input: String = app.prompt.message.drain(3..).collect(); - let user_input = user_input.trim(); - - if user_input.is_empty() { - return Ok(()); - } - - app.chat.messages.push(format!(" : {}\n", user_input)); - - app.chat.formatted_chat.extend( - app.formatter - .format(format!(" : {}\n", user_input).as_str()), - ); - - let conv = HashMap::from([ - ("role".into(), "user".into()), - ("content".into(), user_input.into()), - ]); - app.llm_messages.push(conv); - - let llm_messages = app.llm_messages.clone(); - - app.spinner.active = true; - - app.chat - .formatted_chat - .lines - .push(Line::raw("πŸ€–: ".to_string())); - - let terminate_response_signal = app.terminate_response_signal.clone(); - - thread::spawn(move || { - let res = llm.ask(llm_messages.to_vec(), &sender, terminate_response_signal); - if let Err(e) = res { - sender - .send(Event::LLMEvent(LLMAnswer::StartAnswer)) - .unwrap(); - sender - .send(Event::LLMEvent(LLMAnswer::Answer( - e.to_string().red().to_string(), - ))) - .unwrap(); - } - }); + FocusedBlock::Help => { + app.help.scroll_down(); } + _ => (), + }, - // scroll down - KeyCode::Char('j') | KeyCode::Down => match app.focused_block { - FocusedBlock::History => { - if !app.history.formatted_chat.is_empty() - && app.history.index < app.history.chat.len() - 1 - { - app.history.index += 1; - } - } - - FocusedBlock::Chat => { - app.chat.scroll = app.chat.scroll.saturating_add(1); - } - - FocusedBlock::Prompt => { - app.prompt.scroll = app.prompt.scroll.saturating_add(1); - } - - FocusedBlock::Preview => { - app.history.scroll = app.history.scroll.saturating_add(1); - } - _ => (), - }, - - // scroll up - KeyCode::Char('k') | KeyCode::Up => match app.focused_block { - FocusedBlock::History => app.history.index = app.history.index.saturating_sub(1), - - FocusedBlock::Preview => { - app.history.scroll = app.history.scroll.saturating_sub(1); - } + // scroll up + KeyCode::Char('k') | KeyCode::Up => match app.focused_block { + FocusedBlock::History => app.history.index = app.history.index.saturating_sub(1), - FocusedBlock::Chat => { - app.chat.scroll = app.chat.scroll.saturating_sub(1); - } - FocusedBlock::Prompt => { - app.prompt.scroll = app.prompt.scroll.saturating_sub(1); - } - _ => (), - }, + FocusedBlock::Preview => { + app.history.scroll = app.history.scroll.saturating_sub(1); + } - // New chat - KeyCode::Char(c) if c == app.config.key_bindings.new_chat => { - app.prompt = Prompt::default(); - app.history - .formatted_chat - .push(app.chat.formatted_chat.clone()); - app.history.chat.push(app.chat.messages.clone()); - app.chat = Chat::default(); - app.llm_messages = Vec::new(); + FocusedBlock::Chat => { + app.chat.scroll = app.chat.scroll.saturating_sub(1); + } - app.chat.scroll = 0; - app.prompt.scroll = 0; + FocusedBlock::Help => { + app.help.scroll_up(); } - // Save chat - KeyCode::Char(c) if c == app.config.key_bindings.save_chat => match app.focused_block { + _ => (), + }, + + // New chat + KeyCode::Char(c) + if c == app.config.key_bindings.new_chat + && key_event.modifiers == KeyModifiers::CONTROL => + { + app.prompt.clear(); + + app.history + .formatted_chat + .push(app.chat.formatted_chat.clone()); + app.history.chat.push(app.chat.messages.clone()); + app.chat = Chat::default(); + app.llm_messages = Vec::new(); + + app.chat.scroll = 0; + } + + // Save chat + KeyCode::Char(c) + if c == app.config.key_bindings.save_chat + && key_event.modifiers == KeyModifiers::CONTROL => + { + match app.focused_block { FocusedBlock::History | FocusedBlock::Preview => { if !app.history.chat.is_empty() { match std::fs::write( @@ -156,7 +113,7 @@ pub fn handle_key_events( Ok(_) => { let notif = Notification::new( format!( - "**Info**\nChat saved to `{}` file", + "Chat saved to `{}` file", app.config.archive_file_name ), NotificationLevel::Info, @@ -165,10 +122,8 @@ pub fn handle_key_events( sender.send(Event::Notification(notif)).unwrap(); } Err(e) => { - let notif = Notification::new( - format!("**Error**\n{}", e), - NotificationLevel::Error, - ); + let notif = + Notification::new(e.to_string(), NotificationLevel::Error); sender.send(Event::Notification(notif)).unwrap(); } @@ -182,131 +137,142 @@ pub fn handle_key_events( ) { Ok(_) => { let notif = Notification::new( - format!( - "**Info**\nChat saved to `{}` file", - app.config.archive_file_name - ), + format!("Chat saved to `{}` file", app.config.archive_file_name), NotificationLevel::Info, ); sender.send(Event::Notification(notif)).unwrap(); } Err(e) => { - let notif = Notification::new( - format!("**Error**\n{}", e), - NotificationLevel::Error, - ); + let notif = Notification::new(e.to_string(), NotificationLevel::Error); sender.send(Event::Notification(notif)).unwrap(); } } } _ => (), - }, - - // Switch the focus - KeyCode::Tab => match app.focused_block { - FocusedBlock::Chat => { - app.prompt.scroll = 0; - app.focused_block = FocusedBlock::Prompt; - } - FocusedBlock::Prompt => { - app.chat.scroll = (app.chat.formatted_chat.height() - + app.answer.formatted_answer.height()) - as u16; - app.focused_block = FocusedBlock::Chat; - } - - FocusedBlock::History => { - app.focused_block = FocusedBlock::Preview; - app.history.scroll = 0; - } - FocusedBlock::Preview => { - app.focused_block = FocusedBlock::History; - app.history.scroll = 0; - } - FocusedBlock::Help => (), - }, - - // kill the app - KeyCode::Char('c') | KeyCode::Char('C') => { - if key_event.modifiers == KeyModifiers::CONTROL { - app.running = false; - } } + } - // Show help - KeyCode::Char(c) if c == app.config.key_bindings.show_help => { - app.focused_block = FocusedBlock::Help; + // Switch the focus + KeyCode::Tab => match app.focused_block { + FocusedBlock::Chat => { + app.focused_block = FocusedBlock::Prompt; + app.prompt.update(&app.focused_block); } - - // Show history - KeyCode::Char(c) if c == app.config.key_bindings.show_history => { + FocusedBlock::Prompt => { + app.chat.scroll = (app.chat.formatted_chat.height() + + app.answer.formatted_answer.height()) + as u16; + app.focused_block = FocusedBlock::Chat; + app.prompt.mode = Mode::Normal; + app.prompt.update(&app.focused_block); + } + FocusedBlock::History => { + app.focused_block = FocusedBlock::Preview; + app.history.scroll = 0; + } + FocusedBlock::Preview => { app.focused_block = FocusedBlock::History; + app.history.scroll = 0; } + _ => (), + }, - // Discard help & history popups - KeyCode::Esc => { - app.focused_block = FocusedBlock::Prompt; + // Show help + KeyCode::Char(c) + if c == app.config.key_bindings.show_help && app.prompt.mode != Mode::Insert => + { + app.focused_block = FocusedBlock::Help; + } + + // Show history + KeyCode::Char(c) + if c == app.config.key_bindings.show_history + && app.prompt.mode != Mode::Insert + && key_event.modifiers == KeyModifiers::CONTROL => + { + app.focused_block = FocusedBlock::History; + } + + // Discard help & history popups + KeyCode::Esc => match app.focused_block { + FocusedBlock::History | FocusedBlock::Preview | FocusedBlock::Help => { + app.focused_block = FocusedBlock::Prompt } + _ => {} + }, - // Vim keybindings - // Clear the prompt: dd - KeyCode::Char('d') => { - if app.previous_key == KeyCode::Char('d') { - app.prompt = Prompt::default(); + // Go to the end: G + KeyCode::Char('G') => match app.focused_block { + FocusedBlock::Chat => app.chat.scroll = app.chat.length, + FocusedBlock::History => { + if !app.history.formatted_chat.is_empty() { + app.history.index = app.history.formatted_chat.len() - 1; } } - // Go to the end: G - KeyCode::Char('G') => match app.focused_block { - FocusedBlock::Chat => app.chat.scroll = app.chat.length, - FocusedBlock::Prompt => app.prompt.scroll = app.prompt.length, - FocusedBlock::History => { - if !app.history.formatted_chat.is_empty() { - app.history.index = app.history.formatted_chat.len() - 1; - } - } - FocusedBlock::Preview => app.history.scroll = app.history.length, - _ => (), - }, - - // Go to the top: gg - KeyCode::Char('g') => { - if app.previous_key == KeyCode::Char('g') { - match app.focused_block { - FocusedBlock::Chat => app.chat.scroll = 0, - FocusedBlock::Prompt => app.prompt.scroll = 0, - FocusedBlock::History => app.history.index = 0, - FocusedBlock::Preview => app.history.scroll = 0, - _ => (), - } + FocusedBlock::Preview => app.history.scroll = app.history.length, + _ => (), + }, + + _ => {} + } + + if let FocusedBlock::Prompt = app.focused_block { + if let Mode::Normal = app.prompt.mode { + if key_event.code == KeyCode::Enter { + let user_input = app.prompt.editor.lines().join("\n"); + let user_input = user_input.trim(); + if user_input.is_empty() { + return Ok(()); } - } - _ => {} - }, + app.prompt.clear(); - Mode::Insert => match key_event.code { - KeyCode::Enter => app.prompt.message.push('\n'), + app.chat.messages.push(format!(" : {}\n", user_input)); - KeyCode::Char(c) => { - app.prompt.message.push(c); - } + app.chat.formatted_chat.extend( + app.formatter + .format(format!(" : {}\n", user_input).as_str()), + ); - KeyCode::Backspace => { - if app.prompt.message.len() > 3 { - app.prompt.message.pop(); - } - } + let conv = HashMap::from([ + ("role".into(), "user".into()), + ("content".into(), user_input.into()), + ]); + app.llm_messages.push(conv); + + let llm_messages = app.llm_messages.clone(); + + app.spinner.active = true; + + app.chat + .formatted_chat + .lines + .push(Line::raw("πŸ€–: ".to_string())); + + let terminate_response_signal = app.terminate_response_signal.clone(); - //Switch to Normal mode - KeyCode::Esc => { - app.mode = Mode::Normal; + let sender = sender.clone(); + + thread::spawn(move || { + let res = llm.ask(llm_messages.to_vec(), &sender, terminate_response_signal); + if let Err(e) = res { + sender + .send(Event::LLMEvent(LLMAnswer::StartAnswer)) + .unwrap(); + sender + .send(Event::LLMEvent(LLMAnswer::Answer( + e.to_string().red().to_string(), + ))) + .unwrap(); + } + }); } - _ => {} - }, + } + + app.prompt.handler(key_event, app.clipboard.as_mut()); } - app.previous_key = key_event.code; Ok(()) } diff --git a/src/help.rs b/src/help.rs new file mode 100644 index 0000000..76a34df --- /dev/null +++ b/src/help.rs @@ -0,0 +1,102 @@ +use ratatui::{ + layout::{Alignment, Constraint, Rect}, + style::Style, + widgets::{Block, BorderType, Borders, Clear, Padding, Row, Table, TableState}, + Frame, +}; + +pub struct Help { + block_height: usize, + state: TableState, + keys: &'static [(&'static str, &'static str)], +} + +impl Default for Help { + fn default() -> Self { + let mut state = TableState::new().with_offset(0); + state.select(Some(0)); + + Self { + block_height: 0, + state, + keys: &[ + ("Esc", "Switch to Normal mode / Dismiss pop-up"), + ("Tab", "Switch the focus"), + ( + "ctrl + n", + "Start new chat and save the previous one to the history", + ), + ( + "ctrl + s", + "Save the chat to file in the current directory", + ), + ("ctrl + h", "Show history"), + ("ctrl + t", "Stop the stream response"), + ("j or Down", "Scroll down"), + ("k or Up", "Scroll up"), + ("G", "Go to the end"), + ("gg", "Go to the top"), + ("?", "show help"), + ], + } + } +} + +impl Help { + pub fn new() -> Self { + Self::default() + } + + pub fn scroll_down(&mut self) { + let i = match self.state.selected() { + Some(i) => { + if i >= self.keys.len().saturating_sub(self.block_height - 6) { + i + } else { + i + 1 + } + } + None => 1, + }; + *self.state.offset_mut() = i; + self.state.select(Some(i)); + } + pub fn scroll_up(&mut self) { + let i = match self.state.selected() { + Some(i) => { + if i > 1 { + i - 1 + } else { + 0 + } + } + None => 1, + }; + *self.state.offset_mut() = i; + self.state.select(Some(i)); + } + + pub fn render(&mut self, frame: &mut Frame, block: Rect) { + self.block_height = block.height as usize; + let widths = [Constraint::Length(15), Constraint::Min(60)]; + let rows: Vec = self + .keys + .iter() + .map(|key| Row::new(vec![key.0, key.1])) + .collect(); + + let table = Table::new(rows, widths).block( + Block::default() + .padding(Padding::uniform(2)) + .title(" Help ") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .style(Style::default()) + .border_type(BorderType::Thick) + .border_style(Style::default()), + ); + + frame.render_widget(Clear, block); + frame.render_stateful_widget(table, block, &mut self.state); + } +} diff --git a/src/lib.rs b/src/lib.rs index a38a4ee..ee2284f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,3 +21,7 @@ pub mod llm; pub mod spinner; pub mod formatter; + +pub mod prompt; + +pub mod help; diff --git a/src/main.rs b/src/main.rs index 5657d8e..69d42cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; use std::collections::HashMap; use std::{env, io}; use tenere::app::{Answer, App, AppResult}; @@ -8,11 +10,9 @@ use tenere::formatter::Formatter; use tenere::handler::handle_key_events; use tenere::llm::LLMAnswer; use tenere::tui::Tui; -use tui::backend::CrosstermBackend; -use tui::Terminal; +use ratatui::text::Text; use tenere::llm::{LLMBackend, LLMModel}; -use tui::text::Text; use std::sync::Arc; @@ -23,6 +23,7 @@ fn main() -> AppResult<()> { let config = Arc::new(Config::load()); + // TODO: move this to init app // Text formatter let formatter_config = bat::config::Config { colored_output: true, diff --git a/src/notification.rs b/src/notification.rs index b4075d8..ec4671d 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -1,3 +1,11 @@ +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Text}, + widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + #[derive(Debug, Clone)] pub struct Notification { pub message: String, @@ -20,4 +28,35 @@ impl Notification { ttl: 8, } } + + pub fn render(&mut self, frame: &mut Frame, block: Rect) { + let (color, title) = match self.level { + NotificationLevel::Info => (Color::Green, "Info"), + NotificationLevel::Warning => (Color::Yellow, "Warning"), + NotificationLevel::Error => (Color::Red, "Error"), + }; + + let text = Text::from(vec![ + Line::styled( + title, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center), + Line::raw(self.message.as_str()), + ]); + + let para = Paragraph::new(text) + .wrap(Wrap { trim: false }) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .style(Style::default()) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(color)), + ); + + frame.render_widget(Clear, block); + frame.render_widget(para, block); + } } diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 0000000..447ab16 --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,308 @@ +use arboard::Clipboard; +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::Text, + widgets::{Block, BorderType, Borders}, + Frame, +}; +use tui_textarea::{CursorMove, TextArea}; +use unicode_width::UnicodeWidthStr; + +use crate::app::FocusedBlock; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Debug, PartialEq)] +pub enum Mode { + Normal, + Insert, + Visual, +} + +pub struct Prompt<'a> { + pub mode: Mode, + pub previous_key: KeyCode, + pub formatted_prompt: Text<'a>, + pub editor: TextArea<'a>, + pub block: Block<'a>, +} + +impl Default for Prompt<'_> { + fn default() -> Self { + let mut editor = TextArea::default(); + editor.remove_line_number(); + editor.set_cursor_line_style(Style::default()); + editor.set_selection_style(Style::default().bg(Color::DarkGray)); + + let block = Block::default() + .border_type(BorderType::Thick) + .borders(Borders::ALL) + .style(Style::default()); + + Self { + mode: Mode::Normal, + previous_key: KeyCode::Null, + formatted_prompt: Text::raw(""), + editor, + block, + } + } +} + +impl Prompt<'_> { + pub fn new() -> Self { + Self::default() + } + + pub fn clear(&mut self) { + self.formatted_prompt = Text::raw(""); + self.editor.select_all(); + self.editor.cut(); + } + + pub fn height(&self, frame_size: &Rect) -> u16 { + let prompt_block_max_height = (0.4 * frame_size.height as f32) as u16; + + let height: u16 = 1 + self + .editor + .lines() + .iter() + .map(|line| 1 + line.width() as u16 / frame_size.width) + .sum::(); + + std::cmp::min(height, prompt_block_max_height) + } + + pub fn update(&mut self, focused_block: &FocusedBlock) { + self.block = Block::default() + .borders(Borders::ALL) + .style(Style::default()) + .border_type(match focused_block { + FocusedBlock::Prompt => BorderType::Thick, + _ => BorderType::Rounded, + }) + .border_style(match focused_block { + FocusedBlock::Prompt => match self.mode { + Mode::Insert => Style::default().fg(Color::Green), + Mode::Normal => Style::default(), + Mode::Visual => Style::default().fg(Color::Yellow), + }, + _ => Style::default(), + }); + } + + pub fn handler(&mut self, key_event: KeyEvent, clipboard: Option<&mut Clipboard>) { + match self.mode { + Mode::Insert => match key_event.code { + KeyCode::Enter => { + self.editor.insert_newline(); + } + + KeyCode::Char(c) => { + self.editor.insert_char(c); + } + + KeyCode::Backspace => { + self.editor.delete_char(); + } + + KeyCode::Esc => { + self.mode = Mode::Normal; + self.update(&FocusedBlock::Prompt); + } + _ => {} + }, + Mode::Normal | Mode::Visual => match key_event.code { + KeyCode::Char('i') => { + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + + KeyCode::Esc => { + self.mode = Mode::Normal; + self.update(&FocusedBlock::Prompt); + self.editor.cancel_selection(); + } + + KeyCode::Char('v') => { + self.mode = Mode::Visual; + self.update(&FocusedBlock::Prompt); + self.update(&FocusedBlock::Prompt); + self.editor.start_selection(); + } + + KeyCode::Char('h') | KeyCode::Left if key_event.modifiers == KeyModifiers::NONE => { + self.editor.move_cursor(CursorMove::Back); + } + + KeyCode::Char('j') | KeyCode::Down if key_event.modifiers == KeyModifiers::NONE => { + self.editor.move_cursor(CursorMove::Down); + } + + KeyCode::Char('k') | KeyCode::Up if key_event.modifiers == KeyModifiers::NONE => { + self.editor.move_cursor(CursorMove::Up); + } + + KeyCode::Char('l') | KeyCode::Right + if key_event.modifiers == KeyModifiers::NONE => + { + self.editor.move_cursor(CursorMove::Forward); + } + + KeyCode::Char('w') => match self.previous_key { + KeyCode::Char('d') => { + self.editor.delete_next_word(); + } + KeyCode::Char('c') => { + self.editor.delete_next_word(); + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + + _ => self.editor.move_cursor(CursorMove::WordForward), + }, + + KeyCode::Char('b') => match self.previous_key { + KeyCode::Char('d') => { + self.editor.delete_word(); + } + KeyCode::Char('c') => { + self.editor.delete_word(); + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + + _ => self.editor.move_cursor(CursorMove::WordBack), + }, + + KeyCode::Char('$') => match self.previous_key { + KeyCode::Char('d') => { + self.editor.delete_line_by_end(); + } + KeyCode::Char('c') => { + self.editor.delete_line_by_end(); + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + _ => self.editor.move_cursor(CursorMove::End), + }, + + KeyCode::Char('0') => match self.previous_key { + KeyCode::Char('d') => { + self.editor.delete_line_by_head(); + } + KeyCode::Char('c') => { + self.editor.delete_line_by_head(); + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + _ => self.editor.move_cursor(CursorMove::Head), + }, + + KeyCode::Char('G') => self.editor.move_cursor(CursorMove::Bottom), + + KeyCode::Char('g') => { + if self.previous_key == KeyCode::Char('g') { + self.editor.move_cursor(CursorMove::Jump(0, 0)) + } + } + + KeyCode::Char('D') => { + self.editor.move_cursor(CursorMove::Head); + self.editor.delete_line_by_end(); + self.editor.delete_line_by_head(); + } + + KeyCode::Char('d') => { + if self.previous_key == KeyCode::Char('d') { + self.editor.move_cursor(CursorMove::Head); + self.editor.delete_line_by_end(); + } + } + + KeyCode::Char('c') => { + if self.previous_key == KeyCode::Char('c') { + self.editor.move_cursor(CursorMove::Head); + self.editor.delete_line_by_end(); + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + } + + KeyCode::Char('C') => { + self.editor.delete_line_by_end(); + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + + KeyCode::Char('x') => { + self.editor.delete_next_char(); + } + + KeyCode::Char('a') => { + self.editor.move_cursor(CursorMove::Forward); + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + + KeyCode::Char('A') => { + self.editor.move_cursor(CursorMove::End); + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + + KeyCode::Char('o') => { + self.editor.move_cursor(CursorMove::End); + self.editor.insert_newline(); + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + + KeyCode::Char('O') => { + self.editor.move_cursor(CursorMove::Head); + self.editor.insert_newline(); + self.editor.move_cursor(CursorMove::Up); + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + + KeyCode::Char('I') => { + self.editor.move_cursor(CursorMove::Head); + self.mode = Mode::Insert; + self.update(&FocusedBlock::Prompt); + } + + KeyCode::Char('y') => { + self.editor.copy(); + if let Some(clipboard) = clipboard { + let text = self.editor.yank_text(); + let _ = clipboard.set_text(text); + } + } + + KeyCode::Char('p') => { + if !self.editor.paste() { + if let Some(clipboard) = clipboard { + if let Ok(text) = clipboard.get_text() { + self.editor.insert_str(text); + } + } + } + } + + KeyCode::Char('u') => { + self.editor.undo(); + } + + _ => {} + }, + } + + self.previous_key = key_event.code; + } + + pub fn render(&mut self, frame: &mut Frame, block: Rect) { + self.editor.set_block(self.block.clone()); + frame.render_widget(self.editor.widget(), block); + } +} diff --git a/src/tui.rs b/src/tui.rs index 9d77f38..66d9fa8 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -4,9 +4,9 @@ use crate::ui; use crossterm::cursor::EnableBlinking; use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use ratatui::backend::Backend; +use ratatui::Terminal; use std::io; -use tui::backend::Backend; -use tui::Terminal; #[derive(Debug)] pub struct Tui { diff --git a/src/ui.rs b/src/ui.rs index 8a0ad7d..5270ea9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,17 +1,14 @@ -use crate::notification::NotificationLevel; use std; -use crate::app::{App, FocusedBlock, Mode}; -use tui::{ - layout::{Constraint, Direction, Layout, Rect}, +use crate::app::{App, FocusedBlock}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Style}, text::{Line, Text}, widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap}, Frame, }; -use unicode_width::UnicodeWidthStr; - pub type AppResult = std::result::Result>; pub fn notification_rect(offset: u16, r: Rect) -> Rect { @@ -93,102 +90,19 @@ pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { } pub fn render(app: &mut App, frame: &mut Frame) { - // Layout let frame_size = frame.size(); - // prompt height can grow till 40% of the frame height - let prompt_block_max_height = (0.4 * frame_size.height as f32) as u16; - - let prompt_content_height = { - let mut height: u16 = 1; - for line in app.prompt.message.lines() { - height += 1; - height += line.width() as u16 / frame_size.width; - } - height - }; - - let prompt_block_height = std::cmp::min(prompt_content_height, prompt_block_max_height); - - // chat height is the frame height minus the prompt height - let chat_block_height = std::cmp::max( - frame_size.height - prompt_block_height - 3, - frame_size.height - prompt_block_max_height - 3, - ); + let prompt_block_height = app.prompt.height(&frame_size) + 3; + let chat_block_height = frame_size.height - prompt_block_height; let (chat_block, prompt_block) = { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints( - [ - Constraint::Length(chat_block_height), - Constraint::Length(prompt_block_height), - ] - .as_ref(), - ) + .constraints([Constraint::Min(1), Constraint::Length(prompt_block_height)].as_ref()) .split(frame.size()); (chunks[0], chunks[1]) }; - // prompt block - let prompt_paragraph = { - if let FocusedBlock::Prompt = app.focused_block { - let diff: isize = prompt_content_height as isize - prompt_block_max_height as isize - 1; - - // case where the prompt content height is shorter than the prompt block height - if diff < 0 { - app.prompt.scroll = 0; - } else { - let diff = diff as u16; - app.prompt.length = diff; - if app.prompt.scroll > diff { - app.prompt.scroll = diff - } - } - } - - Paragraph::new(app.prompt.message.as_str()) - .wrap(Wrap { trim: false }) - .scroll((app.prompt.scroll, 0)) - .style(Style::default()) - .block( - Block::default() - .borders(Borders::ALL) - .style(Style::default()) - .border_type(match app.focused_block { - FocusedBlock::Prompt => BorderType::Thick, - _ => BorderType::Rounded, - }) - .border_style(match app.focused_block { - FocusedBlock::Prompt => match app.mode { - Mode::Insert => Style::default().fg(Color::Green), - Mode::Normal => Style::default().fg(Color::Yellow), - }, - _ => Style::default(), - }), - ) - }; - - match app.mode { - Mode::Normal => {} - - Mode::Insert => frame.set_cursor( - prompt_block.x - + { - let last_line = app.prompt.message.lines().last().unwrap_or(""); - let mut width = last_line.len() as u16; - if last_line.len() as u16 > frame_size.width { - let last_word = last_line.rsplit(' ').last().unwrap_or(""); - width = - last_line.width() as u16 % frame_size.width + last_word.len() as u16; - } - width - } - + 1, - prompt_block.y + std::cmp::min(prompt_content_height, prompt_block_max_height) - 1, - ), - } - // Chat block let chat_text = { let mut c = app.chat.formatted_chat.clone(); @@ -233,20 +147,17 @@ pub fn render(app: &mut App, frame: &mut Frame) { _ => BorderType::Rounded, }) .border_style(match app.focused_block { - FocusedBlock::Chat => match app.mode { - Mode::Insert => Style::default().fg(Color::Green), - Mode::Normal => Style::default().fg(Color::Yellow), - }, + FocusedBlock::Chat => Style::default(), _ => Style::default(), }), ) }; - // Draw + // Render frame.render_widget(chat_paragraph, chat_block); - frame.render_widget(prompt_paragraph, prompt_block); + app.prompt.render(frame, prompt_block); if let FocusedBlock::History | FocusedBlock::Preview = app.focused_block { let area = centered_rect(80, 80, frame_size); @@ -284,7 +195,7 @@ pub fn render(app: &mut App, frame: &mut Frame) { Block::default() .borders(Borders::ALL) .title(" History ") - .title_alignment(tui::layout::Alignment::Center) + .title_alignment(Alignment::Center) .style(Style::default()) .border_type(BorderType::Rounded) .border_style(match app.focused_block { @@ -324,7 +235,7 @@ pub fn render(app: &mut App, frame: &mut Frame) { .block( Block::default() .title(" Preview ") - .title_alignment(tui::layout::Alignment::Center) + .title_alignment(Alignment::Center) .borders(Borders::ALL) .style(Style::default()) .border_type(BorderType::Rounded) @@ -339,66 +250,16 @@ pub fn render(app: &mut App, frame: &mut Frame) { frame.render_widget(preview, preview_block); } + // Show Help if let FocusedBlock::Help = app.focused_block { - let help = format!( - " -`i` : Switch to Insert mode -`Esc` : Switch to Normal mode -`dd` : Clear the prompt -`G` : Go to the end -`gg` : Go to the top -`n` : Start new chat and save the previous one to the history -`s` : Save the chat to `{}` file in the current directory -`Tab` : Switch the focus -`h` : Show history -`t` : Stop the stream response -`j` or `Down` : Scroll down -`k` or `Up` : Scroll up -`?` : show help -`q` : Quit -", - app.config.archive_file_name - ); - - let block = Paragraph::new(help.as_str()) - .wrap(Wrap { trim: false }) - .block( - Block::default() - .title(" Help ") - .title_alignment(tui::layout::Alignment::Center) - .borders(Borders::ALL) - .style(Style::default()) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(Color::Yellow)), - ); + app.prompt.update(&FocusedBlock::Help); let area = help_rect(frame_size); - frame.render_widget(Clear, area); - frame.render_widget(block, area); + app.help.render(frame, area); } - for (i, n) in app.notifications.iter().enumerate() { - let border_color = match n.level { - NotificationLevel::Info => Color::Green, - NotificationLevel::Warning => Color::Yellow, - NotificationLevel::Error => Color::Red, - }; - - let block = Paragraph::new(if !n.message.is_empty() { - Text::from(n.message.as_str()) - } else { - Text::from("") - }) - .wrap(Wrap { trim: false }) - .alignment(tui::layout::Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .style(Style::default()) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(border_color)), - ); + // Show notifications + for (i, notif) in app.notifications.iter_mut().enumerate() { let area = notification_rect(i as u16, frame_size); - frame.render_widget(Clear, area); - frame.render_widget(block, area); + notif.render(frame, area); } }