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);
}
}