From 6ea760bb6b36da241961e8ecd60cb2c5e15c0a78 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Tue, 24 Feb 2026 11:48:20 -0800 Subject: feat: Generate commands or ask questions with `atuin ai` (#3199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR refines the system created in #3178 to be suitable for a v1 release. --- ## Overview `atuin-ai` is a separate binary that allows for generating commands and asking questions from the command line. It is fully opt-in. ## Usage `atuin ai init` will output bindings for your shell. Currently, bash, zsh, and fish are supported. ```bash eval "$(atuin ai init)" ``` Once the hooks are installed, just press `?` on an empty prompt line to call up the TUI. `atuin ai` requires an account on [Atuin Hub](https://hub.atuin.sh/); you will be prompted to log in on first use. ## Features ### Command generation Prompt the LLM to create a command, and get one back, no fuss. Press `enter` to run, or `tab` to insert. ``` ┌Ask questions or generate a command:──────────────────────────┐ │ │ │ > Get a list of running docker containers │ │ │ ├──────────────────────────────────────────────────────────────┤ │ │ │ $ docker ps │ │ │ └────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘ ``` ### Follow-up You can follow-up with `f` to specify a refinement prompt to update the command that will be inserted. ``` ┌Ask questions or generate a command:──────────────────────────┐ │ │ │ > Get a list of running docker containers │ │ │ ├──────────────────────────────────────────────────────────────┤ │ │ │ $ docker ps │ │ │ ├──────────────────────────────────────────────────────────────┤ │ │ │ > Actually I want to get all docker containers │ │ │ ├──────────────────────────────────────────────────────────────┤ │ │ │ $ docker ps -a │ │ │ └────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘ ``` You can also follow-up with questions to get responses in natural language. ``` ┌Ask questions or generate a command:──────────────────────────┐ │ │ │ > Get a list of running docker containers │ │ │ ├──────────────────────────────────────────────────────────────┤ │ │ │ $ docker ps │ │ │ ├──────────────────────────────────────────────────────────────┤ │ │ │ > Actually I want to get all docker containers │ │ │ ├──────────────────────────────────────────────────────────────┤ │ │ │ $ docker ps -a │ │ │ ├──────────────────────────────────────────────────────────────┤ │ │ │ > What other useful flags to `docker ps` should I know? │ │ │ ├──────────────────────────────────────────────────────────────┤ │ │ │ Here are some handy `docker ps` flags: │ │ │ │ - `-q` — Only show container IDs (great for piping to │ │ other commands) │ │ - `-s` — Show container sizes │ │ - `-n 5` — Show the last 5 created containers │ │ - `-l` — Show only the latest created container │ │ - `--no-trunc` — Don't truncate output (shows full IDs and │ │ commands) │ │ - `-f` or `--filter` — Filter by condition, e.g.: │ │ - `-f status=exited` — only exited containers │ │ - `-f name=myapp` — filter by name │ │ - `-f ancestor=nginx` — filter by image │ │ - `--format` — Custom output using Go templates, e.g.: │ │ `--format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"` │ │ │ │ A common combo is `docker ps -aq` to get all container │ │ IDs, useful for bulk operations like `docker rm $(docker │ │ ps -aq)`. │ │ │ └────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘ ``` You can use `enter` or `tab` at any time to run or insert the last suggested command, even if it was suggested in a previous turn. ### Conversational and search usage If you prompt the LLM with a question that doesn't imply you want to generate a command, it can respond in natural language, and use web search if necessary to fetch the data it needs. ``` ┌Ask questions or generate a command:──────────────────────────┐ │ │ │ > What is the latest version of atuin? │ │ │ ├──────────────────────────────────────────────────────────────┤ │ │ │ ✓ Used 2 tools │ │ │ │ The latest version of Atuin is **v18.12.0**, available on │ │ the [GitHub releases │ │ page](https://github.com/atuinsh/atuin/releases). │ │ │ └─────────────────────────────────[f]: Follow-up [Esc]: Cancel┘ ``` ### Dangerous or low-confidence command detection The LLM scores its confidence in the command, as well as how dangerous the command is. This information is shown if a threshold is exceeded, and requires an extra confirmation step before running automatically with `enter`. The Atuin Hub server also monitors suggested commands for dangerous patterns the LLM didn't catch, and appends its own assessment at the end of the LLM's own assessment. ``` ┌Ask questions or generate a command:──────────────────────────┐ │ │ │ > Delete all files from $HOME │ │ │ ├──────────────────────────────────────────────────────────────┤ │ │ │ $ rm -rf $HOME/* │ │ │ │ ! ⚠️ This will PERMANENTLY delete ALL files and directories │ │ in your home directory, including documents, downloads, │ │ configurations, SSH keys, and everything else. This is │ │ irreversible and will likely break your system. Also note │ │ this won't delete hidden (dot) files — if you want those │ │ too, that's even more destructive.; [Server] Recursive │ │ delete of critical directory │ │ │ └────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘ ``` --------- Co-authored-by: Claude Opus 4.5 --- .gitignore | 1 + Cargo.lock | 749 ++++++++++++++------- Cargo.toml | 2 +- crates/atuin-ai/Cargo.toml | 13 +- crates/atuin-ai/render-tests.sh | 34 + crates/atuin-ai/replay-states.sh | 101 +++ crates/atuin-ai/src/commands.rs | 71 +- crates/atuin-ai/src/commands/debug_render.rs | 460 +++++++++++++ crates/atuin-ai/src/commands/init.rs | 155 ++++- crates/atuin-ai/src/commands/inline.rs | 932 ++++++++++++++------------- crates/atuin-ai/src/main.rs | 1 + crates/atuin-ai/src/tui/app.rs | 157 +++++ crates/atuin-ai/src/tui/event.rs | 303 +++++++++ crates/atuin-ai/src/tui/mod.rs | 14 + crates/atuin-ai/src/tui/render.rs | 674 +++++++++++++++++++ crates/atuin-ai/src/tui/spinner.rs | 99 +++ crates/atuin-ai/src/tui/state.rs | 530 +++++++++++++++ crates/atuin-ai/src/tui/terminal.rs | 203 ++++++ crates/atuin-ai/src/tui/view_model.rs | 400 ++++++++++++ crates/atuin-ai/test-renders.json | 295 +++++++++ crates/atuin-client/src/settings.rs | 17 + docs/docs/ai/introduction.md | 157 +++++ docs/docs/ai/settings.md | 16 + docs/docs/configuration/config.md | 4 + docs/mkdocs.yml | 3 + docs/pyproject.toml | 5 + docs/uv.lock | 9 +- 27 files changed, 4678 insertions(+), 727 deletions(-) create mode 100755 crates/atuin-ai/render-tests.sh create mode 100755 crates/atuin-ai/replay-states.sh create mode 100644 crates/atuin-ai/src/commands/debug_render.rs create mode 100644 crates/atuin-ai/src/tui/app.rs create mode 100644 crates/atuin-ai/src/tui/event.rs create mode 100644 crates/atuin-ai/src/tui/mod.rs create mode 100644 crates/atuin-ai/src/tui/render.rs create mode 100644 crates/atuin-ai/src/tui/spinner.rs create mode 100644 crates/atuin-ai/src/tui/state.rs create mode 100644 crates/atuin-ai/src/tui/terminal.rs create mode 100644 crates/atuin-ai/src/tui/view_model.rs create mode 100644 crates/atuin-ai/test-renders.json create mode 100644 docs/docs/ai/introduction.md create mode 100644 docs/docs/ai/settings.md diff --git a/.gitignore b/.gitignore index e4e42b21..2b1d63f9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ result publish.sh .envrc +.planning/ ui/backend/target ui/backend/gen diff --git a/Cargo.lock b/Cargo.lock index 1d61905c..329df7a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,9 +106,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "approx" @@ -152,6 +152,28 @@ dependencies = [ "password-hash", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -160,7 +182,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -237,7 +259,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-tree", - "unicode-width 0.2.0", + "unicode-width 0.2.2", "uuid", ] @@ -245,13 +267,17 @@ dependencies = [ name = "atuin-ai" version = "18.13.0-beta.1" dependencies = [ + "async-stream", "atuin-client", "atuin-common", "clap", "crossterm", "directories", + "eventsource-stream", "eyre", + "futures", "pretty_assertions", + "pulldown-cmark", "ratatui", "reqwest", "serde", @@ -260,6 +286,9 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "tui-textarea-2", + "unicode-width 0.2.2", + "uuid", ] [[package]] @@ -313,7 +342,7 @@ dependencies = [ "typed-builder", "urlencoding", "uuid", - "whoami 2.1.0", + "whoami 2.1.1", ] [[package]] @@ -655,9 +684,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ "serde_core", ] @@ -682,9 +711,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "by_address" @@ -694,9 +723,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -727,9 +756,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.53" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "shlex", @@ -766,9 +795,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", @@ -789,9 +818,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -799,9 +828,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -812,9 +841,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.65" +version = "4.5.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" +checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031" dependencies = [ "clap", ] @@ -831,21 +860,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "clipboard-win" @@ -932,7 +961,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width 0.2.0", + "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -1049,14 +1078,15 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm_winapi", "derive_more", "document-features", "filedescriptor", + "futures-core", "mio", "parking_lot", - "rustix 1.1.3", + "rustix 1.1.4", "serde", "signal-hook", "signal-hook-mio", @@ -1138,7 +1168,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1181,7 +1211,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1194,7 +1224,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1205,7 +1235,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1216,7 +1246,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1251,9 +1281,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -1278,7 +1308,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1335,7 +1365,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", ] @@ -1347,7 +1377,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1372,7 +1402,7 @@ checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1494,6 +1524,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom 7.1.3", + "pin-project-lite", +] + [[package]] name = "eyre" version = "0.6.12" @@ -1543,7 +1584,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1574,9 +1615,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "finl_unicode" @@ -1598,9 +1639,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1646,9 +1687,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.2.2" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" dependencies = [ "autocfg", ] @@ -1659,15 +1700,15 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.59.0", ] [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1680,9 +1721,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1690,15 +1731,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1718,38 +1759,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1759,7 +1800,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1790,10 +1830,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-link", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.2", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1817,6 +1866,19 @@ dependencies = [ "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "h2" version = "0.4.13" @@ -2034,14 +2096,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -2058,9 +2119,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2161,6 +2222,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2233,13 +2300,13 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.3" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" dependencies = [ "console", "portable-atomic", - "unicode-width 0.2.0", + "unicode-width 0.2.2", "unit-prefix", "web-time", ] @@ -2272,7 +2339,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2374,9 +2441,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "f4eacb0641a310445a4c513f2a5e23e19952e269c6a38887254d5f837a305506" dependencies = [ "once_cell", "wasm-bindgen", @@ -2408,17 +2475,23 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.180" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -2426,9 +2499,9 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", - "redox_syscall 0.7.0", + "redox_syscall 0.7.2", ] [[package]] @@ -2448,7 +2521,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -2459,9 +2532,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "listenfd" @@ -2523,7 +2596,7 @@ dependencies = [ "quote", "regex-syntax", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2587,9 +2660,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmem" @@ -2665,7 +2738,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2676,9 +2749,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minijinja" -version = "2.14.0" +version = "2.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12ea9ac0a51fb5112607099560fdf0f90366ab088a2a9e6e8ae176794e9806aa" +checksum = "5c54f3bcc034dd74496b5ca929fd0b710186672d5ff0b0f255a9ceb259042ece" dependencies = [ "serde", ] @@ -2739,7 +2812,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -2776,9 +2849,9 @@ dependencies = [ [[package]] name = "ntapi" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ "winapi", ] @@ -2822,7 +2895,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2879,7 +2952,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -2891,7 +2964,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", "objc2", ] @@ -2902,7 +2975,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -2921,7 +2994,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", ] @@ -2932,11 +3005,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3019,7 +3101,7 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3095,9 +3177,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -3105,9 +3187,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -3115,22 +3197,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "pest_meta" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", "sha2", @@ -3187,7 +3269,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3216,7 +3298,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3260,11 +3342,11 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -3284,9 +3366,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" @@ -3329,7 +3411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3368,7 +3450,7 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.114", + "syn 2.0.117", "tempfile", ] @@ -3382,7 +3464,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3439,11 +3521,19 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", + "getopts", "memchr", + "pulldown-cmark-escape", "unicase", ] +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pulldown-cmark-to-cmark" version = "22.0.0" @@ -3494,9 +3584,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -3595,7 +3685,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "compact_str", "hashbrown 0.16.1", "indoc", @@ -3606,7 +3696,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -3647,7 +3737,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "hashbrown 0.16.1", "indoc", "instability", @@ -3657,7 +3747,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -3666,7 +3756,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -3695,16 +3785,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "6d94dd2f7cd932d4dc02cc8b2b50dfd38bd079a4e5d79198b99743d7fcf9a4b4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -3735,14 +3825,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -3752,9 +3842,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3763,25 +3853,26 @@ dependencies = [ [[package]] name = "regex-lite" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -3800,12 +3891,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] @@ -3903,7 +3996,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3912,22 +4005,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "once_cell", "ring", @@ -4048,9 +4141,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "salsa20" @@ -4093,9 +4186,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -4111,11 +4204,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -4124,9 +4217,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -4165,7 +4258,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4235,7 +4328,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.13.0", "schemars 0.9.0", - "schemars 1.2.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -4251,7 +4344,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4287,9 +4380,9 @@ dependencies = [ [[package]] name = "shellexpand" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ "dirs", ] @@ -4349,9 +4442,9 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "sketches-ddsketch" @@ -4361,9 +4454,9 @@ checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -4376,9 +4469,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -4474,7 +4567,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4497,7 +4590,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.114", + "syn 2.0.117", "tokio", "url", ] @@ -4510,7 +4603,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags 2.10.0", + "bitflags 2.11.0", "byteorder", "bytes", "crc", @@ -4554,7 +4647,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags 2.10.0", + "bitflags 2.11.0", "byteorder", "crc", "dotenvy", @@ -4659,7 +4752,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4681,9 +4774,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -4707,7 +4800,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4727,14 +4820,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4744,7 +4837,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.60.2", ] @@ -4777,7 +4870,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.10.0", + "bitflags 2.11.0", "fancy-regex", "filedescriptor", "finl_unicode", @@ -4846,7 +4939,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4857,7 +4950,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4983,7 +5076,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5022,9 +5115,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "serde_core", "serde_spanned", @@ -5044,9 +5137,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -5089,7 +5182,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5114,7 +5207,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn 2.0.114", + "syn 2.0.117", "tempfile", "tonic-build", ] @@ -5155,7 +5248,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -5212,7 +5305,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5283,6 +5376,20 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-textarea-2" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75da0b78f788c39bb3292763c40cc22585806fc601b28a5978036a1f6a4c9b6c" +dependencies = [ + "crossterm", + "portable-atomic", + "ratatui-core", + "ratatui-widgets", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "typed-builder" version = "0.18.2" @@ -5300,7 +5407,7 @@ checksum = "1f718dfaf347dcb5b983bfc87608144b0bad87970aebcbea5ce44d2a30c08e63" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5329,9 +5436,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -5362,7 +5469,7 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -5373,9 +5480,15 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "unit-prefix" @@ -5431,12 +5544,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ "atomic", - "getrandom 0.3.4", + "getrandom 0.4.1", "js-sys", "serde_core", "wasm-bindgen", @@ -5512,6 +5625,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -5529,9 +5651,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "05d7d0fce354c88b7982aec4400b3e7fcf723c32737cef571bd165f7613557ee" dependencies = [ "cfg-if", "once_cell", @@ -5542,9 +5664,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "ee85afca410ac4abba5b584b12e77ea225db6ee5471d0aebaae0861166f9378a" dependencies = [ "cfg-if", "futures-util", @@ -5556,9 +5678,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "55839b71ba921e4f75b674cb16f843f4b1f3b26ddfcb3454de1cf65cc021ec0f" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5566,26 +5688,73 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "caf2e969c2d60ff52e7e98b7392ff1588bffdd1ccd4769eba27222fd3d621571" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "0861f0dcdf46ea819407495634953cdcc8a8c7215ab799a7a7ce366be71c7b30" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.12" @@ -5594,7 +5763,7 @@ checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.3", + "rustix 1.1.4", "smallvec", "wayland-sys", ] @@ -5605,8 +5774,8 @@ version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ - "bitflags 2.10.0", - "rustix 1.1.3", + "bitflags 2.11.0", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -5617,7 +5786,7 @@ version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -5629,7 +5798,7 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5658,9 +5827,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "10053fbf9a374174094915bbce141e87a6bf32ecd9a002980db4b638405e8962" dependencies = [ "js-sys", "wasm-bindgen", @@ -5678,9 +5847,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -5691,14 +5860,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", ] [[package]] name = "webpki-roots" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -5793,11 +5962,13 @@ dependencies = [ [[package]] name = "whoami" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fae98cf96deed1b7572272dfc777713c249ae40aa1cf8862e091e8b745f5361" +checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" dependencies = [ + "libc", "libredox", + "objc2-system-configuration", "wasite 1.0.2", "web-sys", ] @@ -5873,7 +6044,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5884,7 +6055,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6222,6 +6393,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "wl-clipboard-rs" @@ -6232,7 +6485,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.3", + "rustix 1.1.4", "thiserror 2.0.18", "tree_magic_mini", "wayland-backend", @@ -6254,7 +6507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.3", + "rustix 1.1.4", "x11rb-protocol", ] @@ -6289,28 +6542,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6330,7 +6583,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -6351,7 +6604,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6384,14 +6637,14 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.16" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index b2be9743..4acb21ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ features = ["ansi", "fmt", "registry", "env-filter"] [workspace.dependencies.reqwest] version = "0.13" -features = ["json", "rustls-no-provider"] +features = ["json", "rustls-no-provider", "stream"] default-features = false [workspace.dependencies.sqlx] diff --git a/crates/atuin-ai/Cargo.toml b/crates/atuin-ai/Cargo.toml index d42b6a67..692ff9c2 100644 --- a/crates/atuin-ai/Cargo.toml +++ b/crates/atuin-ai/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "atuin-ai" edition = "2024" -description = "ai library for atuin" +description = "AI integration for Atuin CLI" rust-version = { workspace = true } version = { workspace = true } @@ -30,8 +30,15 @@ tracing-appender = "0.2.4" reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -crossterm = { workspace = true, features = ["use-dev-tty"] } -ratatui = { workspace = true } +crossterm = { workspace = true, features = ["use-dev-tty", "event-stream"] } +ratatui = { workspace = true, features = ["unstable-rendered-line-info"] } +futures = "0.3" +eventsource-stream = "0.2" +pulldown-cmark = "0.13.0" +async-stream = "0.3" +uuid = { workspace = true } +tui-textarea-2 = "0.9.1" +unicode-width = "0.2" [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/crates/atuin-ai/render-tests.sh b/crates/atuin-ai/render-tests.sh new file mode 100755 index 00000000..8dedc76e --- /dev/null +++ b/crates/atuin-ai/render-tests.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Render all test cases from test-renders.json +# Usage: ./render-tests.sh [test_name] +# With no args: renders all tests +# With arg: renders only matching test (e.g., ./render-tests.sh 05) + +set -e +cd "$(dirname "$0")" + +JSON_FILE="test-renders.json" +FILTER="${1:-}" + +# Build once +cargo build -p atuin-ai --quiet + +# Count tests +TOTAL=$(jq length "$JSON_FILE") + +for i in $(seq 0 $((TOTAL - 1))); do + NAME=$(jq -r ".[$i].name" "$JSON_FILE") + DESC=$(jq -r ".[$i].description" "$JSON_FILE") + STATE=$(jq -c ".[$i].state" "$JSON_FILE") + + # Skip if filter provided and doesn't match + if [[ -n "$FILTER" && ! "$NAME" =~ $FILTER ]]; then + continue + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "[$NAME] $DESC" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "$STATE" | cargo run -p atuin-ai --quiet -- debug-render -f plain + echo "" +done diff --git a/crates/atuin-ai/replay-states.sh b/crates/atuin-ai/replay-states.sh new file mode 100755 index 00000000..4f586709 --- /dev/null +++ b/crates/atuin-ai/replay-states.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Replay state snapshots from a debug state JSONL file +# Usage: ./replay-states.sh [entry-number] +# With no entry: renders all frames in sequence (press Enter to advance) +# With entry number: renders just that frame + +set -e +# cd "$(dirname "$0")" + +STATE_FILE="${1:-}" +ENTRY_FILTER="${2:-}" + +if [[ -z "$STATE_FILE" ]]; then + echo "Usage: $0 [entry-number]" + echo "" + echo "Examples:" + echo " $0 /tmp/state.jsonl # Interactive replay of all frames" + echo " $0 /tmp/state.jsonl 15 # Show just entry 15" + exit 1 +fi + +if [[ ! -f "$STATE_FILE" ]]; then + echo "Error: File not found: $STATE_FILE" + exit 1 +fi + +# Build once +cargo build -p atuin-ai --quiet + +# Count entries +TOTAL=$(wc -l < "$STATE_FILE" | tr -d ' ') + +if [[ -n "$ENTRY_FILTER" ]]; then + # Show single entry + LINE=$(sed -n "${ENTRY_FILTER}p" "$STATE_FILE") + if [[ -z "$LINE" ]]; then + echo "Error: Entry $ENTRY_FILTER not found (file has $TOTAL entries)" + exit 1 + fi + + ENTRY=$(echo "$LINE" | jq -r '.entry') + LABEL=$(echo "$LINE" | jq -r '.label') + STATE=$(echo "$LINE" | jq -c '.state') + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "[$ENTRY/$TOTAL] $LABEL" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "$STATE" | cargo run -p atuin-ai --quiet -- debug-render -f plain +else + # Interactive replay + echo "Replaying $TOTAL frames from $STATE_FILE" + echo "Press Enter to advance, 'q' to quit, or number+Enter to jump" + echo "" + + CURRENT=1 + while [[ $CURRENT -le $TOTAL ]]; do + LINE=$(sed -n "${CURRENT}p" "$STATE_FILE") + ENTRY=$(echo "$LINE" | jq -r '.entry') + LABEL=$(echo "$LINE" | jq -r '.label') + STATE=$(echo "$LINE" | jq -c '.state') + + clear + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "[$CURRENT/$TOTAL] $LABEL" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "$STATE" | cargo run -p atuin-ai --quiet -- debug-render -f plain + echo "" + echo "[Enter: next] [p: prev] [number: jump] [s: show state JSON] [q: quit]" + + read -r INPUT + case "$INPUT" in + q|Q) + break + ;; + p|P) + if [[ $CURRENT -gt 1 ]]; then + CURRENT=$((CURRENT - 1)) + fi + ;; + s|S) + echo "" + echo "State JSON:" + echo "$STATE" | jq . + echo "" + echo "Press Enter to continue..." + read -r + ;; + ''|' ') + CURRENT=$((CURRENT + 1)) + ;; + *[0-9]*) + if [[ "$INPUT" =~ ^[0-9]+$ ]] && [[ "$INPUT" -ge 1 ]] && [[ "$INPUT" -le $TOTAL ]]; then + CURRENT=$INPUT + else + echo "Invalid entry number (1-$TOTAL)" + sleep 1 + fi + ;; + esac + done +fi diff --git a/crates/atuin-ai/src/commands.rs b/crates/atuin-ai/src/commands.rs index 56741544..7d5ca16b 100644 --- a/crates/atuin-ai/src/commands.rs +++ b/crates/atuin-ai/src/commands.rs @@ -1,7 +1,11 @@ +use atuin_common::shell::Shell; use clap::{Parser, Subcommand}; use tracing::Level; use tracing_subscriber::{EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt}; +#[cfg(debug_assertions)] +pub mod debug_render; + pub mod init; pub mod inline; @@ -16,6 +20,10 @@ struct Cli { #[arg(long, global = true, env = "ATUIN_AI_API_ENDPOINT")] api_endpoint: Option, + /// Custom API token + #[arg(long, global = true, env = "ATUIN_AI_API_TOKEN")] + api_token: Option, + #[command(subcommand)] command: Commands, } @@ -23,13 +31,10 @@ struct Cli { #[derive(Subcommand, Debug)] enum Commands { /// Initialize shell integration - Init, - - /// Complete current command line - Complete { - /// Current command line to complete - #[arg(value_name = "COMMAND")] - command: Option, + Init { + /// Shell to generate integration for; defaults to "auto" + #[arg(value_name = "SHELL", default_value = "auto")] + shell: String, }, /// Inline completion mode with small TUI overlay @@ -41,10 +46,27 @@ enum Commands { /// Start in natural language mode #[arg(long)] natural_language: bool, + + /// Keep TUI output visible after exit (default: erase) + #[arg(long)] + keep: bool, + + /// Log state changes to file for debugging (dev tool) + #[arg(long, value_name = "FILE")] + debug_state: Option, }, - /// Interactive mode with TUI - Interactive, + /// Debug render: output a single frame from JSON state (dev tool) + #[cfg(debug_assertions)] + DebugRender { + /// Input file (reads from stdin if not provided) + #[arg(short, long)] + input: Option, + + /// Output format: ansi (default), plain, json + #[arg(short, long, default_value = "ansi")] + format: String, + }, } pub async fn run() -> eyre::Result<()> { @@ -53,13 +75,32 @@ pub async fn run() -> eyre::Result<()> { init_tracing(cli.verbose); match cli.command { - Commands::Init => init::run().await, + Commands::Init { shell } => init::run(shell).await, Commands::Inline { command, natural_language, - } => inline::run(command, natural_language, cli.api_endpoint).await, - Commands::Complete { command } => inline::run(command, false, cli.api_endpoint).await, - Commands::Interactive => Err(eyre::eyre!("interactive mode not implemented yet")), + keep, + debug_state, + } => { + inline::run( + command, + natural_language, + cli.api_endpoint, + cli.api_token, + keep, + debug_state, + ) + .await + } + #[cfg(debug_assertions)] + Commands::DebugRender { input, format } => { + let output_format = match format.as_str() { + "plain" => debug_render::OutputFormat::Plain, + "json" => debug_render::OutputFormat::Json, + _ => debug_render::OutputFormat::Ansi, + }; + debug_render::run(input, output_format).await + } } } @@ -95,3 +136,7 @@ fn init_tracing(verbose: bool) { subscriber.init(); } } + +pub fn detect_shell() -> Option { + Some(Shell::current().to_string()) +} diff --git a/crates/atuin-ai/src/commands/debug_render.rs b/crates/atuin-ai/src/commands/debug_render.rs new file mode 100644 index 00000000..e78a418a --- /dev/null +++ b/crates/atuin-ai/src/commands/debug_render.rs @@ -0,0 +1,460 @@ +//! Debug render command for TUI development +//! +//! Takes JSON state as input and outputs a single rendered frame as text. +//! Useful for debugging view model derivation and rendering without running the full TUI. + +use eyre::{Context, Result}; +use ratatui::{Terminal, backend::TestBackend}; +use serde::Deserialize; +use std::io::{self, Read}; +use std::time::Instant; + +use crate::tui::{ + render::{RenderContext, render}, + state::{AppMode, AppState, ConversationEvent, StreamingStatus}, + view_model::Blocks, +}; + +/// JSON input format for debug rendering +#[derive(Debug, Deserialize)] +pub struct DebugInput { + /// Conversation events in API format + pub events: Vec, + /// Current mode: "Input", "Generating", "Streaming", "Review", "Error" + #[serde(default = "default_mode")] + pub mode: String, + /// Text being streamed (for Streaming mode) + #[serde(default)] + pub streaming_text: String, + /// Current input buffer + #[serde(default)] + pub input: String, + /// Cursor position + #[serde(default)] + pub cursor_pos: usize, + /// Spinner frame (0-3) + #[serde(default)] + pub spinner_frame: usize, + /// Error message + #[serde(default)] + pub error: Option, + /// Session ID from server + #[serde(default)] + pub session_id: Option, + /// Streaming status + #[serde(default)] + pub streaming_status: Option, + /// Whether current turn was interrupted + #[serde(default)] + pub was_interrupted: bool, + /// Terminal width for rendering + #[serde(default = "default_width")] + pub width: u16, + /// Terminal height for rendering + #[serde(default = "default_height")] + pub height: u16, +} + +fn default_mode() -> String { + "Review".to_string() +} + +fn default_width() -> u16 { + 80 +} + +fn default_height() -> u16 { + // Default to a reasonable height; state files include calculated height + 50 +} + +/// Event input matching the API protocol format +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum EventInput { + UserMessage { + content: String, + }, + Text { + content: String, + }, + ToolCall { + id: String, + name: String, + input: serde_json::Value, + }, + ToolResult { + tool_use_id: String, + content: String, + #[serde(default)] + is_error: bool, + }, +} + +impl From for ConversationEvent { + fn from(input: EventInput) -> Self { + match input { + EventInput::UserMessage { content } => ConversationEvent::UserMessage { content }, + EventInput::Text { content } => ConversationEvent::Text { content }, + EventInput::ToolCall { id, name, input } => { + ConversationEvent::ToolCall { id, name, input } + } + EventInput::ToolResult { + tool_use_id, + content, + is_error, + } => ConversationEvent::ToolResult { + tool_use_id, + content, + is_error, + }, + } + } +} + +impl DebugInput { + /// Parse JSON from string + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json).context("Failed to parse debug input JSON") + } + + /// Convert to AppState + pub fn to_state(&self) -> AppState { + let mode = match self.mode.as_str() { + "Input" => AppMode::Input, + "Generating" => AppMode::Generating, + "Streaming" => AppMode::Streaming, + "Review" => AppMode::Review, + "Error" => AppMode::Error, + _ => AppMode::Review, + }; + + let events: Vec = self.events.iter().cloned().map(Into::into).collect(); + + let streaming_status = self + .streaming_status + .as_ref() + .map(|s| StreamingStatus::from_status_str(s)); + + // Create textarea from input and set cursor position + let mut textarea = tui_textarea::TextArea::from(self.input.lines()); + // Disable underline on cursor line + textarea.set_cursor_line_style(ratatui::style::Style::default()); + // Enable word wrapping + textarea.set_wrap_mode(tui_textarea::WrapMode::Word); + // Note: cursor_pos from old format is character-based; new format has row/col + // For compatibility, just move to end if we have text + if !self.input.is_empty() { + textarea.move_cursor(tui_textarea::CursorMove::End); + } + + AppState { + mode, + events, + streaming_text: self.streaming_text.clone(), + textarea, + error: self.error.clone(), + should_exit: false, + exit_action: None, + session_id: self.session_id.clone(), + streaming_status, + was_interrupted: self.was_interrupted, + spinner_frame: self.spinner_frame, + last_spinner_tick: Instant::now(), + streaming_started: None, + confirmation_pending: false, + } + } +} + +/// Output format options +#[derive(Debug, Clone, Copy, Default)] +pub enum OutputFormat { + /// Raw terminal output (ANSI) + #[default] + Ansi, + /// Plain text (strips ANSI codes) + Plain, + /// JSON with blocks structure + Json, +} + +/// Run the debug render command +pub async fn run(input_file: Option, format: OutputFormat) -> Result<()> { + // Read input JSON + let json = if let Some(path) = input_file { + std::fs::read_to_string(&path).context(format!("Failed to read input file: {}", path))? + } else { + let mut buffer = String::new(); + io::stdin() + .read_to_string(&mut buffer) + .context("Failed to read from stdin")?; + buffer + }; + + let debug_input = DebugInput::from_json(&json)?; + let state = debug_input.to_state(); + + match format { + OutputFormat::Json => { + // Output the derived blocks as JSON + let blocks = Blocks::from_state(&state); + println!( + "{}", + serde_json::to_string_pretty(&blocks_to_json(&blocks))? + ); + } + OutputFormat::Plain | OutputFormat::Ansi => { + // Render to a test backend + let backend = TestBackend::new(debug_input.width, debug_input.height); + let mut terminal = Terminal::new(backend)?; + + // Load default theme + let settings = atuin_client::settings::Settings::new()?; + let mut theme_manager = atuin_client::theme::ThemeManager::new(None, None); + let theme = theme_manager.load_theme(&settings.theme.name, None); + + let ctx = RenderContext { + theme, + anchor_col: 0, + textarea: Some(&state.textarea), + max_height: debug_input.height, + }; + + terminal.draw(|frame| { + render(frame, &state, &ctx); + })?; + + // Get buffer content + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer, matches!(format, OutputFormat::Plain)); + print!("{}", output); + } + } + + Ok(()) +} + +/// Convert blocks to JSON for debugging +fn blocks_to_json(blocks: &Blocks) -> serde_json::Value { + serde_json::json!({ + "count": blocks.items.len(), + "blocks": blocks.items.iter().map(|block| { + serde_json::json!({ + "separator_above": block.separator_above, + "title": block.title, + "content": block.content.iter().map(content_to_json).collect::>() + }) + }).collect::>() + }) +} + +fn content_to_json(content: &crate::tui::view_model::Content) -> serde_json::Value { + use crate::tui::view_model::Content; + match content { + Content::Input { + text, + active, + cursor_pos, + } => serde_json::json!({ + "type": "Input", + "text": text, + "active": active, + "cursor_pos": cursor_pos + }), + Content::Command { text, faded } => serde_json::json!({ + "type": "Command", + "text": text, + "faded": faded + }), + Content::Text { markdown } => serde_json::json!({ + "type": "Text", + "markdown": markdown + }), + Content::Error { message } => serde_json::json!({ + "type": "Error", + "message": message + }), + Content::Warning { + kind, + text, + pending_confirm, + } => serde_json::json!({ + "type": "Warning", + "kind": format!("{:?}", kind), + "text": text, + "pending_confirm": pending_confirm + }), + Content::Spinner { frame, status_text } => serde_json::json!({ + "type": "Spinner", + "frame": frame, + "status_text": status_text + }), + Content::ToolStatus { + completed_count, + current_label, + frame, + } => serde_json::json!({ + "type": "ToolStatus", + "completed_count": completed_count, + "current_label": current_label, + "frame": frame + }), + } +} + +/// Convert ratatui buffer to string +fn buffer_to_string(buffer: &ratatui::buffer::Buffer, strip_ansi: bool) -> String { + let area = buffer.area; + let mut output = String::new(); + + for y in 0..area.height { + for x in 0..area.width { + let cell = &buffer[(x, y)]; + if strip_ansi { + output.push_str(cell.symbol()); + } else { + // Include ANSI styling + let fg = cell.fg; + let bg = cell.bg; + let mods = cell.modifier; + + // Simple ANSI encoding + if fg != ratatui::style::Color::Reset + || bg != ratatui::style::Color::Reset + || !mods.is_empty() + { + output.push_str("\x1b["); + let mut first = true; + + if mods.contains(ratatui::style::Modifier::BOLD) { + output.push('1'); + first = false; + } + if mods.contains(ratatui::style::Modifier::DIM) { + if !first { + output.push(';'); + } + output.push('2'); + first = false; + } + if mods.contains(ratatui::style::Modifier::REVERSED) { + if !first { + output.push(';'); + } + output.push('7'); + first = false; + } + if mods.contains(ratatui::style::Modifier::UNDERLINED) { + if !first { + output.push(';'); + } + output.push('4'); + first = false; + } + + if let Some(code) = color_to_ansi(fg, true) { + if !first { + output.push(';'); + } + output.push_str(&code); + first = false; + } + + if let Some(code) = color_to_ansi(bg, false) { + if !first { + output.push(';'); + } + output.push_str(&code); + } + + output.push('m'); + } + + output.push_str(cell.symbol()); + + if fg != ratatui::style::Color::Reset + || bg != ratatui::style::Color::Reset + || !mods.is_empty() + { + output.push_str("\x1b[0m"); + } + } + } + output.push('\n'); + } + + output +} + +fn color_to_ansi(color: ratatui::style::Color, foreground: bool) -> Option { + use ratatui::style::Color; + let base = if foreground { 30 } else { 40 }; + + match color { + Color::Reset => None, + Color::Black => Some((base).to_string()), + Color::Red => Some((base + 1).to_string()), + Color::Green => Some((base + 2).to_string()), + Color::Yellow => Some((base + 3).to_string()), + Color::Blue => Some((base + 4).to_string()), + Color::Magenta => Some((base + 5).to_string()), + Color::Cyan => Some((base + 6).to_string()), + Color::Gray | Color::White => Some((base + 7).to_string()), + Color::DarkGray => Some((base + 60).to_string()), + Color::LightRed => Some((base + 61).to_string()), + Color::LightGreen => Some((base + 62).to_string()), + Color::LightYellow => Some((base + 63).to_string()), + Color::LightBlue => Some((base + 64).to_string()), + Color::LightMagenta => Some((base + 65).to_string()), + Color::LightCyan => Some((base + 66).to_string()), + Color::Indexed(i) => Some(format!("{}8;5;{}", if foreground { 3 } else { 4 }, i)), + Color::Rgb(r, g, b) => Some(format!( + "{}8;2;{};{};{}", + if foreground { 3 } else { 4 }, + r, + g, + b + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_input() { + let json = r#"{ + "events": [ + {"type": "user_message", "content": "list files"}, + {"type": "tool_call", "id": "123", "name": "suggest_command", "input": {"command": "ls -la"}} + ], + "mode": "Review" + }"#; + + let input = DebugInput::from_json(json).unwrap(); + assert_eq!(input.events.len(), 2); + assert_eq!(input.mode, "Review"); + + let state = input.to_state(); + assert_eq!(state.events.len(), 2); + assert_eq!(state.mode, AppMode::Review); + } + + #[test] + fn test_parse_streaming_state() { + let json = r#"{ + "events": [ + {"type": "user_message", "content": "explain flags"} + ], + "mode": "Streaming", + "streaming_text": "The -l flag means..." + }"#; + + let input = DebugInput::from_json(json).unwrap(); + let state = input.to_state(); + assert_eq!(state.mode, AppMode::Streaming); + assert_eq!(state.streaming_text, "The -l flag means..."); + } +} diff --git a/crates/atuin-ai/src/commands/init.rs b/crates/atuin-ai/src/commands/init.rs index bf5c6256..8174b583 100644 --- a/crates/atuin-ai/src/commands/init.rs +++ b/crates/atuin-ai/src/commands/init.rs @@ -1,9 +1,29 @@ -pub async fn run() -> eyre::Result<()> { - let zsh_function = generate_zsh_integration(); - println!("{}", zsh_function); +use crate::commands::detect_shell; + +pub async fn run(shell: String) -> eyre::Result<()> { + let integration = match shell.as_str() { + "zsh" => generate_zsh_integration(), + "bash" => generate_bash_integration(), + "fish" => generate_fish_integration(), + "auto" => generate_auto_integration()?, + _ => eyre::bail!("Unsupported shell: {}", shell), + }; + + println!("{}", integration); Ok(()) } +fn generate_auto_integration() -> eyre::Result<&'static str> { + let shell = detect_shell(); + match shell.as_deref() { + Some("zsh") => Ok(generate_zsh_integration()), + Some("bash") => Ok(generate_bash_integration()), + Some("fish") => Ok(generate_fish_integration()), + Some(s) => eyre::bail!("Unsupported shell: {}", s), + None => eyre::bail!("Could not detect shell"), + } +} + /// Generate the zsh integration function - pure function for easy testing pub fn generate_zsh_integration() -> &'static str { r#" @@ -53,6 +73,111 @@ bindkey '?' _atuin_ai_question_mark # Question mark .trim() } +/// Generate the bash integration function - pure function for easy testing +pub fn generate_bash_integration() -> &'static str { + r#" +# Question mark at start of line - natural language mode +_atuin_ai_question_mark() { + # If buffer is empty or just contains '?', trigger natural language mode + if [[ -z "$READLINE_LINE" || "$READLINE_LINE" == "?" ]]; then + READLINE_LINE="" + READLINE_POINT=0 + + local output + output=$(atuin-ai inline --natural-language 3>&1 1>&2 2>&3) + + if [[ $output == __atuin_ai_cancel__ ]]; then + # User cancelled, do nothing + READLINE_LINE="" + READLINE_POINT=0 + elif [[ $output == __atuin_ai_execute__:* ]]; then + # Execute the command immediately + READLINE_LINE=${output#__atuin_ai_execute__:} + READLINE_POINT=${#READLINE_LINE} + # Note: We can't directly execute in bash bind -x, but we can + # use a workaround by binding to a macro that accepts the line + bind '"\C-x\C-a": accept-line' + bind -x '"\C-x\C-e": _atuin_ai_question_mark' + elif [[ $output == __atuin_ai_insert__:* ]]; then + # Insert the command for editing + READLINE_LINE=${output#__atuin_ai_insert__:} + READLINE_POINT=${#READLINE_LINE} + elif [[ -n $output ]]; then + # Default: insert for editing + READLINE_LINE=$output + READLINE_POINT=${#READLINE_LINE} + fi + else + # Not at empty prompt, just insert the question mark + READLINE_LINE="${READLINE_LINE:0:READLINE_POINT}?${READLINE_LINE:READLINE_POINT}" + ((READLINE_POINT++)) + fi +} + +# Set up keybindings +# Bash requires special handling: we use bind -x for the function, +# but need a two-step approach for execute mode +__atuin_ai_accept_line="" + +_atuin_ai_question_mark_wrapper() { + _atuin_ai_question_mark + if [[ -n "$__atuin_ai_accept_line" ]]; then + __atuin_ai_accept_line="" + fi +} + +bind -x '"?": _atuin_ai_question_mark' +"# + .trim() +} + +/// Generate the fish integration function - pure function for easy testing +pub fn generate_fish_integration() -> &'static str { + r#" +# Question mark at start of line - natural language mode +function _atuin_ai_question_mark + set -l buf (commandline -b) + + # If buffer is empty or just contains '?', trigger natural language mode + if test -z "$buf" -o "$buf" = "?" + commandline -r "" + + # Run atuin-ai inline, swapping stdout and stderr + set -l output (atuin-ai inline --natural-language 3>&1 1>&2 2>&3 | string collect) + + if test "$output" = "__atuin_ai_cancel__" + # User cancelled, do nothing + commandline -f repaint + else if string match --quiet '__atuin_ai_execute__:*' "$output" + # Execute the command immediately + set -l cmd (string replace "__atuin_ai_execute__:" "" -- "$output" | string collect) + commandline -r "$cmd" + commandline -f repaint + commandline -f execute + else if string match --quiet '__atuin_ai_insert__:*' "$output" + # Insert the command for editing + set -l cmd (string replace "__atuin_ai_insert__:" "" -- "$output" | string collect) + commandline -r "$cmd" + commandline -f repaint + else if test -n "$output" + # Default: insert for editing + commandline -r "$output" + commandline -f repaint + else + commandline -f repaint + end + else + # Not at empty prompt, just insert the question mark + commandline -i "?" + end +end + +# Set up keybindings +bind "?" _atuin_ai_question_mark +"# + .trim() +} + #[cfg(test)] mod tests { use super::*; @@ -67,4 +192,28 @@ mod tests { assert!(result.contains("__atuin_ai_execute__")); assert!(result.contains("__atuin_ai_insert__")); } + + #[test] + fn test_generate_bash_integration() { + let result = generate_bash_integration(); + assert!(result.contains("_atuin_ai_question_mark")); + assert!(result.contains("bind")); + assert!(result.contains("READLINE_LINE")); + assert!(result.contains("atuin-ai inline")); + assert!(result.contains("__atuin_ai_cancel__")); + assert!(result.contains("__atuin_ai_execute__")); + assert!(result.contains("__atuin_ai_insert__")); + } + + #[test] + fn test_generate_fish_integration() { + let result = generate_fish_integration(); + assert!(result.contains("_atuin_ai_question_mark")); + assert!(result.contains("bind")); + assert!(result.contains("commandline")); + assert!(result.contains("atuin-ai inline")); + assert!(result.contains("__atuin_ai_cancel__")); + assert!(result.contains("__atuin_ai_execute__")); + assert!(result.contains("__atuin_ai_insert__")); + } } diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs index cfa27db4..3f9278a2 100644 --- a/crates/atuin-ai/src/commands/inline.rs +++ b/crates/atuin-ai/src/commands/inline.rs @@ -1,52 +1,52 @@ +use crate::commands::detect_shell; +use crate::tui::render::render; +use crate::tui::{ + App, AppEvent, AppMode, ConversationEvent, EventLoop, ExitAction, RenderContext, TerminalGuard, + calculate_needed_height, install_panic_hook, +}; +use atuin_client::theme::ThemeManager; use atuin_common::tls::ensure_crypto_provider; use crossterm::{ - cursor, event::{self, Event, KeyCode}, terminal::{disable_raw_mode, enable_raw_mode}, }; +use eventsource_stream::Eventsource; use eyre::{Context as _, Result, bail}; -use ratatui::{ - Frame, Terminal, TerminalOptions, Viewport, - backend::CrosstermBackend, - layout::{Alignment, Rect}, - text::Line, - widgets::{Block, Borders, Paragraph, Wrap}, -}; +use futures::StreamExt; use reqwest::Url; -use serde::{Deserialize, Serialize}; -use std::time::Duration; - -#[derive(Debug, Serialize)] -struct GenerateRequest { - query: String, - description: String, - context: GenerateContext, -} - -#[derive(Debug, Serialize)] -struct GenerateContext { - os: String, - shell: String, - pwd: Option, -} - -#[derive(Debug, Deserialize)] -struct GenerateResponse { - command: String, - #[serde(default)] - explanation: Option, -} +use std::io::Write; pub async fn run( initial_command: Option, natural_language: bool, api_endpoint: Option, + api_token: Option, + keep_output: bool, + debug_state_file: Option, ) -> Result<()> { + // Install panic hook once at entry point to ensure terminal restoration + install_panic_hook(); + + // Token and endpoint priority: + // 1. Command line arguments/environment variables + // 2. Settings file + // 3. Default let settings = atuin_client::settings::Settings::new()?; - let endpoint = api_endpoint - .as_deref() - .unwrap_or(settings.hub_address.as_str()); - let token = ensure_hub_session(&settings, endpoint).await?; + let endpoint = api_endpoint.as_deref().unwrap_or( + settings + .ai + .ai_endpoint + .as_deref() + .unwrap_or("https://hub.atuin.sh"), + ); + let api_token = api_token.as_deref().or(settings.ai.ai_api_token.as_deref()); + + let token = if let Some(token) = &api_token { + token.to_string() + } else { + ensure_hub_session(&settings, endpoint).await? + }; + let action = run_inline_tui( endpoint.to_string(), token, @@ -55,6 +55,8 @@ pub async fn run( } else { initial_command }, + keep_output, + debug_state_file, ) .await?; emit_shell_result(action.0, &action.1); @@ -95,55 +97,172 @@ async fn ensure_hub_session( Ok(token) } -async fn generate_command( - hub_address: &str, - token: &str, - description: &str, -) -> Result { - ensure_crypto_provider(); - let endpoint = hub_url(hub_address, "/api/cli/generate")?; - let request = GenerateRequest { - query: description.to_string(), - description: description.to_string(), - context: GenerateContext { - os: detect_os(), - shell: detect_shell(), - pwd: std::env::current_dir() - .ok() - .map(|path| path.to_string_lossy().into_owned()), - }, - }; +/// SSE event received from chat endpoint +#[derive(Debug, Clone)] +enum ChatStreamEvent { + /// Text chunk to display + TextChunk(String), + /// Tool call event (need to echo back, may contain suggest_command) + ToolCall { + id: String, + name: String, + input: serde_json::Value, + }, + /// Tool result from server-side execution + ToolResult { + tool_use_id: String, + content: String, + is_error: bool, + }, + /// Status update from server + Status(String), + /// Stream complete + Done { session_id: String }, + /// Error from server + Error(String), +} + +fn create_chat_stream( + hub_address: String, + token: String, + session_id: Option, + messages: Vec, + settings: &atuin_client::settings::Settings, +) -> std::pin::Pin> + Send>> { + let send_cwd = settings.ai.send_cwd; + + Box::pin(async_stream::stream! { + ensure_crypto_provider(); + let endpoint = match hub_url(&hub_address, "/api/cli/chat") { + Ok(url) => url, + Err(e) => { + yield Err(e); + return; + } + }; + + // Build request body + let mut request_body = serde_json::json!({ + "messages": messages, + "context": { + "os": detect_os(), + "shell": detect_shell(), + "pwd": if send_cwd { std::env::current_dir() + .ok() + .map(|path| path.to_string_lossy().into_owned()) } else { None }, + } + }); + + // Include session_id only if present (not on first request) + if let Some(ref sid) = session_id { + request_body["session_id"] = serde_json::json!(sid); + } - let client = reqwest::Client::new(); - let response = client - .post(endpoint) - .bearer_auth(token) - .json(&request) - .send() - .await - .context("failed to call Atuin Hub generate endpoint")?; - - if response.status().is_success() { - let generated = response - .json::() + + let client = reqwest::Client::new(); + let response = match client + .post(endpoint.clone()) + .header("Accept", "text/event-stream") + .bearer_auth(&token) + .json(&request_body) + .send() .await - .context("failed to decode generate response")?; + { + Ok(resp) => resp, + Err(e) => { + yield Err(eyre::eyre!("Failed to send SSE request: {}", e)); + return; + } + }; - if generated.command.trim().is_empty() { - bail!("Hub returned an empty command. Please try again with a more specific request."); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + // Clear saved session on auth error + let _ = atuin_client::hub::delete_session().await; + yield Err(eyre::eyre!("Hub session expired. Re-run to authenticate again.")); + return; + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + yield Err(eyre::eyre!("SSE request failed ({}): {}", status, body)); + return; } - return Ok(generated); - } + let byte_stream = response.bytes_stream(); + let mut stream = byte_stream.eventsource(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - atuin_client::hub::delete_session().await?; - bail!("Hub session expired. Re-run to authenticate again."); - } + while let Some(event) = stream.next().await { + match event { + Ok(sse_event) => { + let event_type = sse_event.event.as_str(); + let data = sse_event.data.clone(); + + tracing::debug!(event_type = %event_type, data = %data, "SSE event received"); - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - bail!("Hub request failed ({status}): {body}"); + match event_type { + "text" => { + if let Ok(json) = serde_json::from_str::(&data) + && let Some(content) = json.get("content").and_then(|v| v.as_str()) + { + yield Ok(ChatStreamEvent::TextChunk(content.to_string())); + } + } + "tool_call" => { + if let Ok(json) = serde_json::from_str::(&data) { + let id = json.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let input = json.get("input").cloned().unwrap_or(serde_json::json!({})); + yield Ok(ChatStreamEvent::ToolCall { id, name, input }); + } + } + "tool_result" => { + if let Ok(json) = serde_json::from_str::(&data) { + let tool_use_id = json.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let content = json.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let is_error = json.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false); + yield Ok(ChatStreamEvent::ToolResult { tool_use_id, content, is_error }); + } + } + "status" => { + if let Ok(json) = serde_json::from_str::(&data) + && let Some(state) = json.get("state").and_then(|v| v.as_str()) + { + yield Ok(ChatStreamEvent::Status(state.to_string())); + } + } + "done" => { + if let Ok(json) = serde_json::from_str::(&data) { + let session_id = json.get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + yield Ok(ChatStreamEvent::Done { session_id }); + } else { + yield Ok(ChatStreamEvent::Done { session_id: String::new() }); + } + break; + } + "error" => { + if let Ok(json) = serde_json::from_str::(&data) { + let message = json.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error").to_string(); + yield Ok(ChatStreamEvent::Error(message)); + } else { + yield Ok(ChatStreamEvent::Error(data)); + } + break; + } + _ => { + // Unknown event type, ignore + } + } + } + Err(e) => { + yield Err(eyre::eyre!("SSE error: {}", e)); + break; + } + } + } + }) } fn hub_url(base: &str, path: &str) -> Result { @@ -162,35 +281,11 @@ fn detect_os() -> String { match std::env::consts::OS { "macos" => "macos".to_string(), "linux" => "linux".to_string(), + "windows" => "windows".to_string(), _ => "linux".to_string(), } } -fn detect_shell() -> String { - if let Ok(shell) = std::env::var("ATUIN_SHELL") - && !shell.trim().is_empty() - { - return shell; - } - - let shell = std::env::var("SHELL") - .ok() - .and_then(|value| { - std::path::Path::new(&value) - .file_name() - .map(std::ffi::OsStr::to_string_lossy) - .map(std::borrow::Cow::into_owned) - }) - .filter(|value| !value.trim().is_empty()); - - match shell.as_deref() { - Some("zsh") => "zsh".to_string(), - Some("fish") => "fish".to_string(), - Some("bash") => "bash".to_string(), - _ => "bash".to_string(), - } -} - #[derive(Clone, Copy)] enum Action { Execute, @@ -198,105 +293,306 @@ enum Action { Cancel, } +/// Serialize AppState to JSON for debug logging +fn state_to_json(state: &crate::tui::AppState) -> serde_json::Value { + let events: Vec = state.events.iter().map(|e| e.to_json()).collect(); + + let mode = match state.mode { + AppMode::Input => "Input", + AppMode::Generating => "Generating", + AppMode::Streaming => "Streaming", + AppMode::Review => "Review", + AppMode::Error => "Error", + }; + + // Get input and cursor from textarea + let input = state.input(); + let cursor = state.textarea.cursor(); + + let mut json = serde_json::json!({ + "events": events, + "mode": mode, + "input": input, + "cursor_row": cursor.0, + "cursor_col": cursor.1, + "spinner_frame": state.spinner_frame, + "confirmation_pending": state.confirmation_pending, + }); + + // Add streaming fields if in streaming mode + if !state.streaming_text.is_empty() { + json["streaming_text"] = serde_json::json!(state.streaming_text); + } + if let Some(ref status) = state.streaming_status { + json["streaming_status"] = serde_json::json!(status.display_text()); + } + if let Some(ref err) = state.error { + json["error"] = serde_json::json!(err); + } + + json +} + +/// Debug logger that writes state changes to a file +struct DebugStateLogger { + file: std::fs::File, + entry_count: usize, + width: u16, +} + +impl DebugStateLogger { + fn new(path: &str) -> Result { + let file = std::fs::File::create(path) + .with_context(|| format!("Failed to create debug state file: {}", path))?; + // Get terminal width, default to 80 + let (width, _) = crossterm::terminal::size().unwrap_or((80, 24)); + Ok(Self { + file, + entry_count: 0, + width, + }) + } + + fn log(&mut self, label: &str, state: &crate::tui::AppState) { + use crate::tui::calculate_needed_height; + + self.entry_count += 1; + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + + // Calculate the actual content height needed for this state + let content_height = calculate_needed_height(state); + + let mut state_json = state_to_json(state); + // Add dimensions for accurate replay + state_json["width"] = serde_json::json!(self.width); + state_json["height"] = serde_json::json!(content_height); + + let entry = serde_json::json!({ + "entry": self.entry_count, + "label": label, + "timestamp_ms": timestamp_ms, + "state": state_json, + }); + + // Write as JSONL (one JSON object per line) + if let Err(e) = writeln!(self.file, "{}", entry) { + tracing::warn!("Failed to write debug state: {}", e); + } + let _ = self.file.flush(); + } +} + async fn run_inline_tui( endpoint: String, token: String, initial_prompt: Option, + keep_output: bool, + debug_state_file: Option, ) -> Result<(Action, String)> { - let mut ui = InlineUi::new()?; - let mut prompt = initial_prompt.unwrap_or_default(); - let mut spinner_idx = 0usize; + // Initialize terminal guard and app state + let mut guard = TerminalGuard::new(keep_output)?; + let mut app = App::new(); + if let Some(prompt) = initial_prompt { + // Set initial text in textarea + let mut textarea = tui_textarea::TextArea::from(prompt.lines()); + // Disable underline on cursor line + textarea.set_cursor_line_style(ratatui::style::Style::default()); + // Enable word wrapping + textarea.set_wrap_mode(tui_textarea::WrapMode::Word); + // Move cursor to end + textarea.move_cursor(tui_textarea::CursorMove::End); + app.state.textarea = textarea; + } - loop { - ui.render_prompt(&prompt)?; - if !event::poll(Duration::from_millis(250)).context("failed to poll for input")? { - continue; - } + // Initialize debug state logger if requested + let mut debug_logger = debug_state_file + .map(|path| DebugStateLogger::new(&path)) + .transpose()?; - let ev = event::read().context("failed to read terminal event")?; - let Event::Key(key) = ev else { - continue; + // Helper macro to log state changes + macro_rules! log_state { + ($label:expr) => { + if let Some(ref mut logger) = debug_logger { + logger.log($label, &app.state); + } }; + } - match key.code { - KeyCode::Esc => return Ok((Action::Cancel, String::new())), - KeyCode::Backspace => { - prompt.pop(); - } - KeyCode::Enter => { - let query = prompt.trim().to_string(); - if query.is_empty() { - return Ok((Action::Cancel, String::new())); - } + // Log initial state + log_state!("init"); - let response = loop { - let endpoint_clone = endpoint.clone(); - let token_clone = token.clone(); - let query_clone = query.clone(); - let task = tokio::spawn(async move { - generate_command(&endpoint_clone, &token_clone, &query_clone).await - }); - - let generated = loop { - if task.is_finished() { - break task.await.context("generate task join failed")?; - } + // Load theme + let settings = atuin_client::settings::Settings::new()?; + let mut theme_manager = ThemeManager::new(None, None); + let theme = theme_manager.load_theme(&settings.theme.name, None); - ui.render_generating(&prompt, spinner_idx)?; - spinner_idx = (spinner_idx + 1) % SPINNER_FRAMES.len(); + // Initialize event loop + let mut event_loop = EventLoop::new(); - if event::poll(Duration::from_millis(100)) - .context("failed to poll while generating")? - { - let ev = event::read().context("failed reading generate event")?; - if let Event::Key(key) = ev - && key.code == KeyCode::Esc - { - task.abort(); - return Ok((Action::Cancel, String::new())); + // Track chat stream + let mut chat_stream: Option< + std::pin::Pin> + Send>>, + > = None; + + loop { + // Ensure viewport is large enough for current content (capped at terminal height) + let needed_height = calculate_needed_height(&app.state); + let actual_height = guard.ensure_height(needed_height)?; + + // Render current state + let anchor_col = guard.anchor_col(); + let ctx = RenderContext { + theme, + anchor_col, + textarea: Some(&app.state.textarea), + max_height: actual_height, + }; + // Handle draw errors gracefully - cursor position reads can fail during resize + if let Err(e) = guard.terminal().draw(|frame| { + render(frame, &app.state, &ctx); + }) { + let err_msg = e.to_string(); + if err_msg.contains("cursor position") { + // Cursor position read failed (common during terminal resize) + // Skip this frame and continue - next frame will likely succeed + tracing::debug!( + "Skipping frame due to cursor position read error: {}", + err_msg + ); + continue; + } + return Err(e.into()); + } + + // Get next event + let event = event_loop.run().await?; + + // Handle event based on app mode + match event { + AppEvent::Key(key) => { + app.handle_key(key); + log_state!("key"); + } + AppEvent::Tick => { + app.state.tick(); + + // Poll chat stream if active - keep polling until done regardless of mode + // (mode may change to Review before we receive the done event with session_id) + if let Some(stream) = &mut chat_stream { + let mut cx = std::task::Context::from_waker(futures::task::noop_waker_ref()); + match stream.as_mut().poll_next(&mut cx) { + std::task::Poll::Ready(Some(Ok(event))) => match event { + ChatStreamEvent::TextChunk(text) => { + tracing::debug!(text = %text, "Processing TextChunk"); + app.state.append_streaming_text(&text); + log_state!("text_chunk"); } - } - }; - - match generated { - Ok(value) => break value, - Err(err) => { - ui.render_error(&prompt, &err.to_string())?; - if !wait_for_retry_or_cancel()? { - return Ok((Action::Cancel, String::new())); + ChatStreamEvent::ToolCall { id, name, input } => { + tracing::debug!(id = %id, name = %name, "Processing ToolCall"); + app.state.add_tool_call(id, name, input); + log_state!("tool_call"); + } + ChatStreamEvent::ToolResult { + tool_use_id, + content, + is_error, + } => { + tracing::debug!(tool_use_id = %tool_use_id, "Processing ToolResult"); + app.state.add_tool_result(tool_use_id, content, is_error); + log_state!("tool_result"); + } + ChatStreamEvent::Status(status) => { + tracing::debug!(status = %status, "Processing Status"); + app.state.update_streaming_status(&status); + log_state!("status"); + } + ChatStreamEvent::Done { session_id } => { + tracing::debug!(session_id = %session_id, "Processing Done"); + chat_stream = None; + if !session_id.is_empty() { + app.state.store_session_id(session_id); + } + app.state.finalize_streaming(); + log_state!("done"); } + ChatStreamEvent::Error(msg) => { + tracing::debug!(error = %msg, "Processing Error"); + chat_stream = None; + app.state.streaming_error(msg); + log_state!("error"); + } + }, + std::task::Poll::Ready(Some(Err(e))) => { + chat_stream = None; + app.state.streaming_error(e.to_string()); + log_state!("stream_error"); } - } - }; - - loop { - ui.render_review(&prompt, &response)?; - if !event::poll(Duration::from_millis(250)) - .context("failed to poll in review")? - { - continue; - } - - let ev = event::read().context("failed to read review event")?; - let Event::Key(key) = ev else { - continue; - }; - - match key.code { - KeyCode::Enter => return Ok((Action::Execute, response.command)), - KeyCode::Tab => return Ok((Action::Insert, response.command)), - KeyCode::Esc => return Ok((Action::Cancel, String::new())), - KeyCode::Char('e') => break, - _ => {} + std::task::Poll::Ready(None) => { + chat_stream = None; + app.state.finalize_streaming(); + log_state!("stream_end"); + } + std::task::Poll::Pending => {} } } } - KeyCode::Char(c) => { - prompt.push(c); - } _ => {} } + + // Handle user cancellation (Esc during streaming) - drop the stream + if app.state.was_interrupted && chat_stream.is_some() { + tracing::debug!("User cancelled streaming, dropping chat stream"); + chat_stream = None; + app.state.was_interrupted = false; // Reset the flag + } + + // Check exit condition + if app.state.should_exit { + break; + } + + // Handle generation trigger - unified path for all turns + if app.state.mode == AppMode::Generating && chat_stream.is_none() { + // Get the last user message from events + let last_user_content = app.state.events.iter().rev().find_map(|e| { + if let ConversationEvent::UserMessage { content } = e { + Some(content.clone()) + } else { + None + } + }); + + if last_user_content.is_some() { + // Build messages in Claude API format + let messages = app.state.events_to_messages(); + + // Transition to streaming mode + app.state.start_streaming(); + log_state!("start_streaming"); + + // Start the chat stream + chat_stream = Some(create_chat_stream( + endpoint.clone(), + token.clone(), + app.state.session_id.clone(), + messages, + &settings, + )); + } + } } + + // Map exit action to return value + let result = match app.state.exit_action { + Some(ExitAction::Execute(cmd)) => (Action::Execute, cmd), + Some(ExitAction::Insert(cmd)) => (Action::Insert, cmd), + _ => (Action::Cancel, String::new()), + }; + + Ok(result) } struct RawModeGuard; @@ -330,279 +626,3 @@ fn wait_for_login_confirmation() -> Result { } } } - -fn wait_for_retry_or_cancel() -> Result { - loop { - let ev = event::read().context("failed to read retry/cancel key")?; - if let Event::Key(key) = ev { - match key.code { - KeyCode::Enter | KeyCode::Char('r') => return Ok(true), - KeyCode::Esc => return Ok(false), - _ => {} - } - } - } -} - -const SPINNER_FRAMES: [&str; 4] = ["/", "-", "\\", "|"]; - -struct InlineUi { - terminal: Terminal>, - anchor_col: u16, -} - -impl InlineUi { - fn new() -> Result { - let anchor_col = cursor::position().map(|(x, _)| x).unwrap_or(0); - enable_raw_mode().context("failed to enable raw mode for inline UI")?; - let backend = CrosstermBackend::new(std::io::stdout()); - let terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(16), - }, - ) - .context("failed to initialize inline UI")?; - Ok(Self { - terminal, - anchor_col, - }) - } - - fn render_prompt(&mut self, prompt: &str) -> Result<()> { - self.render(Screen::Prompt { - prompt, - footer: "[Enter]: Accept [Esc]: Cancel", - }) - } - - fn render_generating(&mut self, prompt: &str, spinner_idx: usize) -> Result<()> { - self.render(Screen::Generating { - prompt, - footer: "[Esc]: Cancel", - spinner_idx, - }) - } - - fn render_review(&mut self, prompt: &str, response: &GenerateResponse) -> Result<()> { - self.render(Screen::Review { - prompt, - response, - footer: "[Enter]: Run [Tab]: Insert [e]: Edit [Esc]: Cancel", - }) - } - - fn render_error(&mut self, prompt: &str, err: &str) -> Result<()> { - self.render(Screen::Error { - prompt, - err, - footer: "[Enter]/[r]: Retry [Esc]: Cancel", - }) - } - - fn render(&mut self, screen: Screen<'_>) -> Result<()> { - self.terminal - .draw(|f| draw_screen(f, screen, self.anchor_col)) - .context("failed rendering inline UI")?; - Ok(()) - } -} - -impl Drop for InlineUi { - fn drop(&mut self) { - let _ = self.terminal.clear(); - let _ = disable_raw_mode(); - } -} - -enum Screen<'a> { - Prompt { - prompt: &'a str, - footer: &'a str, - }, - Generating { - prompt: &'a str, - footer: &'a str, - spinner_idx: usize, - }, - Review { - prompt: &'a str, - response: &'a GenerateResponse, - footer: &'a str, - }, - Error { - prompt: &'a str, - err: &'a str, - footer: &'a str, - }, -} - -fn draw_screen(frame: &mut Frame, screen: Screen<'_>, anchor_col: u16) { - let area = frame.area(); - let desired_width = 64u16.min(area.width.saturating_sub(2)).max(32); - let content_width = usize::from(desired_width.saturating_sub(2)).max(1); - let (content_preview, _, _) = build_screen_content(&screen, content_width); - let desired_height = (wrapped_line_count(&content_preview, content_width) as u16) - .saturating_add(2) - .min(area.height.max(1)) - .max(3); - - let max_x = area.x + area.width.saturating_sub(desired_width); - let preferred_x = area.x + anchor_col.saturating_sub(2); - let card = Rect { - x: preferred_x.min(max_x), - y: area.y, - width: desired_width, - height: desired_height, - }; - - let footer = match &screen { - Screen::Prompt { footer, .. } - | Screen::Generating { footer, .. } - | Screen::Review { footer, .. } - | Screen::Error { footer, .. } => *footer, - }; - - let block = Block::default() - .borders(Borders::ALL) - .title("Describe the command you'd like to generate:") - .title_bottom(Line::from(footer).alignment(Alignment::Right)); - - let content_area = block.inner(card); - frame.render_widget(block, card); - - let (content, show_cursor, cursor_prompt) = - build_screen_content(&screen, usize::from(content_area.width).max(1)); - - let paragraph = Paragraph::new(content).wrap(Wrap { trim: false }); - frame.render_widget(paragraph, content_area); - - if show_cursor { - let width = usize::from(content_area.width).max(1); - let (cursor_row, cursor_col) = - prompt_cursor_position(cursor_prompt.as_deref().unwrap_or_default(), width); - let cursor_x = content_area.x.saturating_add(cursor_col); - let cursor_y = content_area.y.saturating_add(cursor_row); - frame.set_cursor_position((cursor_x, cursor_y)); - } -} - -fn format_prompt(prompt: &str) -> String { - if prompt.is_empty() { - return "> ".to_string(); - } - format!("> {prompt}") -} - -fn wrapped_line_count(text: &str, width: usize) -> usize { - if width == 0 { - return 1; - } - - text.split('\n') - .map(|line| { - let len = line.chars().count(); - len.max(1).div_ceil(width) - }) - .sum::() - .max(1) -} - -fn build_screen_content( - screen: &Screen<'_>, - content_width: usize, -) -> (String, bool, Option) { - match screen { - Screen::Prompt { prompt, .. } => { - let formatted = format_prompt(prompt); - (formatted, true, Some((*prompt).to_string())) - } - Screen::Generating { - prompt, - spinner_idx, - .. - } => ( - format!( - "{}\n\n{} Generating...", - format_prompt(prompt), - SPINNER_FRAMES[*spinner_idx] - ), - false, - None, - ), - Screen::Review { - prompt, response, .. - } => { - let separator = "─".repeat(content_width.max(1)); - let mut text = format!( - "{}\n\n{}\n\n$ {}\n", - format_prompt(prompt), - separator, - response.command - ); - if let Some(explanation) = &response.explanation { - text.push('\n'); - text.push_str(explanation); - } - (text, false, None) - } - Screen::Error { prompt, err, .. } => ( - format!("{}\n\nRequest failed:\n{}", format_prompt(prompt), err), - false, - None, - ), - } -} - -fn prompt_cursor_position(prompt: &str, width: usize) -> (u16, u16) { - if width == 0 { - return (0, 0); - } - - // The visible prompt line is always `> {prompt}`. - // We mimic word-wrapping so cursor tracking matches visual layout. - let mut row = 0usize; - let mut col = 2usize; // "> " - - let mut saw_any_word = false; - for word in prompt.split_whitespace() { - let word_len = word.chars().count(); - if !saw_any_word { - saw_any_word = true; - if col + word_len <= width { - col += word_len; - } else if word_len >= width { - let used = width.saturating_sub(col); - let remaining = word_len.saturating_sub(used); - row += 1 + (remaining / width); - col = remaining % width; - } else { - row += 1; - col = word_len; - } - continue; - } - - if col + 1 + word_len <= width { - col += 1 + word_len; - } else if word_len >= width { - row += 1 + (word_len / width); - col = word_len % width; - } else { - row += 1; - col = word_len; - } - } - - // Keep trailing spaces user typed. - let trailing_spaces = prompt.chars().rev().take_while(|c| *c == ' ').count(); - for _ in 0..trailing_spaces { - if col >= width { - row += 1; - col = 0; - } - col += 1; - } - - (row as u16, col as u16) -} diff --git a/crates/atuin-ai/src/main.rs b/crates/atuin-ai/src/main.rs index 6302bbda..fb1e517e 100644 --- a/crates/atuin-ai/src/main.rs +++ b/crates/atuin-ai/src/main.rs @@ -1,4 +1,5 @@ pub mod commands; +pub mod tui; #[tokio::main] async fn main() -> eyre::Result<()> { diff --git a/crates/atuin-ai/src/tui/app.rs b/crates/atuin-ai/src/tui/app.rs new file mode 100644 index 00000000..ecb1eb81 --- /dev/null +++ b/crates/atuin-ai/src/tui/app.rs @@ -0,0 +1,157 @@ +use super::state::{AppMode, AppState, ExitAction}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use tui_textarea::{Input, Key}; + +/// Thin wrapper around AppState for compatibility +/// All state lives in AppState, this just provides the handle_key interface +pub struct App { + pub state: AppState, +} + +impl App { + pub fn new() -> Self { + Self { + state: AppState::new(), + } + } + + /// Handle a key event. Returns true if render is needed. + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + match self.state.mode { + AppMode::Input => self.handle_input_key(key), + AppMode::Generating => self.handle_generating_key(key), + AppMode::Streaming => self.handle_streaming_key(key), + AppMode::Review => self.handle_review_key(key), + AppMode::Error => self.handle_error_key(key), + } + } + + fn handle_input_key(&mut self, key: KeyEvent) -> bool { + // Handle special keys ourselves + match key.code { + KeyCode::Esc => { + self.state.exit(ExitAction::Cancel); + return true; + } + KeyCode::Enter => { + if self.state.input_is_empty() { + self.state.exit(ExitAction::Cancel); + } else { + self.state.start_generating(); + } + return true; + } + _ => {} + } + + // Delegate all other keys to textarea + // Manually convert crossterm KeyEvent to tui-textarea Input + // (needed due to crossterm version mismatch) + let tui_key = match key.code { + KeyCode::Char(c) => Key::Char(c), + KeyCode::Backspace => Key::Backspace, + KeyCode::Delete => Key::Delete, + KeyCode::Left => Key::Left, + KeyCode::Right => Key::Right, + KeyCode::Up => Key::Up, + KeyCode::Down => Key::Down, + KeyCode::Home => Key::Home, + KeyCode::End => Key::End, + KeyCode::PageUp => Key::PageUp, + KeyCode::PageDown => Key::PageDown, + KeyCode::Tab => Key::Tab, + _ => Key::Null, + }; + + if tui_key != Key::Null { + let input = Input { + key: tui_key, + ctrl: key.modifiers.contains(KeyModifiers::CONTROL), + alt: key.modifiers.contains(KeyModifiers::ALT), + shift: key.modifiers.contains(KeyModifiers::SHIFT), + }; + self.state.textarea.input(input); + } + true + } + + fn handle_generating_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Esc => { + self.state.cancel_generation(); + true + } + _ => false, // Discard other keys during generation + } + } + + fn handle_streaming_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Esc => { + self.state.cancel_streaming(); + true + } + _ => false, // Ignore other keys during streaming + } + } + + fn handle_review_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Esc => { + self.state.confirmation_pending = false; // Clear confirmation state + self.state.exit(ExitAction::Cancel); + true + } + KeyCode::Enter => { + let cmd = self.state.current_command().map(|c| c.to_string()); + if let Some(cmd) = cmd { + if self.state.is_current_command_dangerous() && !self.state.confirmation_pending + { + // First Enter on dangerous command: enter confirmation mode + self.state.confirmation_pending = true; + } else { + // Second Enter (confirmation), or non-dangerous command: execute + self.state.confirmation_pending = false; + self.state.exit(ExitAction::Execute(cmd)); + } + } + true + } + KeyCode::Tab => { + let cmd = self.state.current_command().map(|c| c.to_string()); + if let Some(cmd) = cmd { + self.state.confirmation_pending = false; // Clear on Tab too + self.state.exit(ExitAction::Insert(cmd)); + } + true + } + KeyCode::Char('f') => { + // Changed from 'e' to 'f' for follow-up mode + self.state.confirmation_pending = false; // Clear on follow-up + self.state.start_edit_mode(); + true + } + _ => false, + } + } + + fn handle_error_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Esc => { + self.state.exit(ExitAction::Cancel); + true + } + KeyCode::Enter | KeyCode::Char('r') => { + self.state.retry(); + true + } + _ => false, + } + } +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/atuin-ai/src/tui/event.rs b/crates/atuin-ai/src/tui/event.rs new file mode 100644 index 00000000..8efbf522 --- /dev/null +++ b/crates/atuin-ai/src/tui/event.rs @@ -0,0 +1,303 @@ +use crate::tui::App; +use crossterm::event::{Event, EventStream, KeyEvent, KeyEventKind}; +use eyre::{Result, eyre}; +use futures::StreamExt; +use std::time::Duration; +use tokio::time; + +/// Base tick interval for the event loop (fast for responsive streaming) +const BASE_TICK_INTERVAL: Duration = Duration::from_millis(50); + +/// Application events that drive the TUI state machine. +/// +/// # Event Types +/// - `Key`: Keyboard input (filtered to KeyEventKind::Press only) +/// - `Tick`: Periodic event for updates (50ms base interval) +/// - `Resize`: Terminal window resize +/// - `StreamChunk/StreamDone/StreamError`: Placeholders for Phase 3 streaming +/// +/// # Design Decisions +/// - Fast 50ms base tick for responsive streaming; spinner timing handled in AppState +/// - Stream events are placeholders - will be wired to channels in Phase 3 +/// - Resize handling enables responsive layout adjustments +#[derive(Debug, Clone)] +pub enum AppEvent { + /// Keyboard input event (filtered to Press events only) + Key(KeyEvent), + + /// Periodic tick for updates (50ms base interval; spinner timing in AppState) + Tick, + + /// Terminal resize event (width, height) + Resize(u16, u16), + + /// Stream chunk received (Phase 3 placeholder) + StreamChunk(String), + + /// Stream completed successfully (Phase 3 placeholder) + StreamDone, + + /// Stream error occurred (Phase 3 placeholder) + StreamError(String), +} + +/// Async event loop that drives the TUI with prioritized event handling. +/// +/// # Priority Model (Biased Select) +/// 1. **Stream data** - Highest priority (future Phase 3 streaming) +/// 2. **Keyboard input** - Medium priority (user responsiveness) +/// 3. **Tick events** - Lowest priority (spinner animation) +/// +/// This ensures stream data is processed immediately when available, +/// keyboard input is responsive, and spinner updates don't block higher priority events. +/// +/// # Graceful Shutdown +/// - SIGINT (Ctrl+C) sets shutdown flag and breaks the loop +/// - EventStream close (stdin EOF) triggers shutdown +/// - Shutdown flag can be checked/set externally for controlled termination +/// +/// # Example +/// ```no_run +/// use atuin_ai::tui::EventLoop; +/// +/// # async fn example() -> eyre::Result<()> { +/// let mut event_loop = EventLoop::new(); +/// loop { +/// let event = event_loop.run().await?; +/// // Handle event... +/// # break; +/// } +/// # Ok(()) +/// # } +/// ``` +pub struct EventLoop { + /// Tick interval timer (created lazily on first run) + tick_timer: Option, + + /// Flag indicating a render was requested (future use in Phase 2) + #[allow(dead_code)] + render_requested: bool, + + /// Shutdown flag - when true, event loop will terminate + shutdown: bool, +} + +impl EventLoop { + /// Create a new EventLoop with default settings. + /// + /// # Defaults + /// - Tick interval: 50ms base rate (spinner timing handled separately in AppState) + /// - Render requested: false + /// - Shutdown: false + pub fn new() -> Self { + Self { + tick_timer: None, + render_requested: false, + shutdown: false, + } + } + + /// Run the event loop, returning the next application event. + /// + /// # Priority Model + /// Uses `tokio::select!` with `biased;` mode to enforce priority: + /// 1. Stream data (placeholder for Phase 3) + /// 2. Keyboard input with rapid keypress batching + /// 3. Tick for spinner animation + /// + /// # Keyboard Handling + /// - Filters to KeyEventKind::Press on all platforms for safety + /// - Batching of rapid keypresses will be implemented in Phase 2 + /// - Currently returns individual key events + /// + /// # Graceful Shutdown + /// - SIGINT (Ctrl+C) triggers shutdown and returns last event + /// - EventStream close (stdin EOF) triggers shutdown + /// - Shutdown flag can be checked after this returns + /// + /// # Errors + /// - Returns error if terminal event stream encounters an error + /// - EventStream close is handled gracefully as shutdown signal + /// + /// # Example + /// ```no_run + /// # use atuin_ai::tui::EventLoop; + /// # async fn example() -> eyre::Result<()> { + /// let mut event_loop = EventLoop::new(); + /// while !event_loop.is_shutdown() { + /// match event_loop.run().await? { + /// // Handle events... + /// # _ => break, + /// } + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn run(&mut self) -> Result { + // Create async event stream for keyboard/terminal events + let mut reader = EventStream::new(); + + // Get or create the tick timer (reused across calls to maintain timing) + // Uses fast base tick for responsive streaming; spinner timing handled in AppState + let tick_timer = self.tick_timer.get_or_insert_with(|| { + let mut interval = time::interval(BASE_TICK_INTERVAL); + // Skip the first immediate tick + interval.reset(); + interval + }); + + loop { + if self.shutdown { + break; + } + + // Biased select: prioritize stream > keyboard > tick + let event = tokio::select! { + biased; + + // Priority 1: Stream data (placeholder for Phase 3) + // In Phase 3, this will be: + // Some(chunk) = stream_rx.recv() => { ... } + + // Priority 2: Keyboard input + maybe_event = reader.next() => { + match maybe_event { + Some(Ok(Event::Key(key))) => { + // Filter to Press events only for cross-platform safety + if key.kind == KeyEventKind::Press { + // Note: Rapid keypress batching will be implemented in Phase 2 + // when we integrate with the state machine. + // For now, just return individual key events. + Some(AppEvent::Key(key)) + } else { + None + } + } + Some(Ok(Event::Resize(w, h))) => { + Some(AppEvent::Resize(w, h)) + } + Some(Err(e)) => { + return Err(eyre!("terminal event error: {}", e)); + } + None => { + // EventStream closed (stdin EOF) - trigger shutdown + self.shutdown = true; + None + } + _ => { + // Ignore other event types (mouse, focus, etc.) + None + } + } + } + + // Priority 3: Tick for spinner animation + _ = tick_timer.tick() => { + Some(AppEvent::Tick) + } + + // SIGINT handling (Ctrl+C) - cross-platform + _ = tokio::signal::ctrl_c() => { + self.shutdown = true; + // Return one more event to allow graceful shutdown handling + Some(AppEvent::Tick) + } + }; + + if let Some(app_event) = event { + return Ok(app_event); + } + } + + // Loop exited due to shutdown - return final tick to allow cleanup + Ok(AppEvent::Tick) + } + + /// Check if the event loop has been signaled to shut down. + /// + /// This can be used to cleanly exit the main TUI loop after receiving + /// a shutdown signal (Ctrl+C, stdin close, etc.) + pub fn is_shutdown(&self) -> bool { + self.shutdown + } + + /// Signal the event loop to shut down. + /// + /// The shutdown will take effect on the next iteration of `run()`. + pub fn shutdown(&mut self) { + self.shutdown = true; + } + + /// Poll for next event and apply to app state. + /// + /// This is a convenience method that combines `run()` with `App` state updates. + /// Returns true if app should continue, false if should exit. + /// + /// # Example + /// ```no_run + /// # use atuin_ai::tui::{EventLoop, App}; + /// # async fn example() -> eyre::Result<()> { + /// let mut event_loop = EventLoop::new(); + /// let mut app = App::new(); + /// + /// while event_loop.poll_and_apply(&mut app).await? { + /// // Render app state... + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn poll_and_apply(&mut self, app: &mut App) -> Result { + let event = self.run().await?; + + match event { + AppEvent::Key(key) => { + app.handle_key(key); + } + AppEvent::Tick => { + app.state.tick(); + } + AppEvent::Resize(_, _) => { + // Render will be triggered anyway + } + AppEvent::StreamChunk(_) | AppEvent::StreamDone | AppEvent::StreamError(_) => { + // Placeholder for Phase 3 + } + } + + Ok(!app.state.should_exit) + } +} + +impl Default for EventLoop { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_event_loop_creation() { + let event_loop = EventLoop::new(); + assert!(!event_loop.shutdown); + } + + #[test] + fn test_shutdown_flag() { + let mut event_loop = EventLoop::new(); + assert!(!event_loop.is_shutdown()); + + event_loop.shutdown(); + assert!(event_loop.is_shutdown()); + } + + // Note: Cannot easily test run() in unit tests since it requires a TTY. + // Integration tests should verify: + // 1. Tick events are generated at 150ms intervals + // 2. Keyboard events are properly filtered to Press only + // 3. Rapid keypresses are batched + // 4. SIGINT triggers graceful shutdown + // 5. Resize events are propagated correctly +} diff --git a/crates/atuin-ai/src/tui/mod.rs b/crates/atuin-ai/src/tui/mod.rs new file mode 100644 index 00000000..dbf4457b --- /dev/null +++ b/crates/atuin-ai/src/tui/mod.rs @@ -0,0 +1,14 @@ +pub mod app; +pub mod event; +pub mod render; +pub mod spinner; +pub mod state; +pub mod terminal; +pub mod view_model; + +pub use app::App; +pub use event::{AppEvent, EventLoop}; +pub use render::{RenderContext, calculate_needed_height, markdown_to_spans}; +pub use state::{AppMode, AppState, ConversationEvent, ExitAction}; +pub use terminal::{TerminalGuard, install_panic_hook}; +pub use view_model::{Block, Blocks, Content}; diff --git a/crates/atuin-ai/src/tui/render.rs b/crates/atuin-ai/src/tui/render.rs new file mode 100644 index 00000000..0b6341e6 --- /dev/null +++ b/crates/atuin-ai/src/tui/render.rs @@ -0,0 +1,674 @@ +use atuin_client::theme::{Meaning, Theme}; +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; +use ratatui::{ + Frame, + backend::FromCrossterm, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block as RatatuiBlock, Borders, Padding, Paragraph, Wrap}, +}; +use tui_textarea::TextArea; + +use super::spinner::active_frame; +use super::state::AppState; +use super::view_model::{Blocks, Content, WarningKind}; + +/// Fixed card width for the TUI +const CARD_WIDTH: u16 = 64; + +pub struct RenderContext<'a> { + pub theme: &'a Theme, + pub anchor_col: u16, + pub textarea: Option<&'a TextArea<'static>>, + /// Maximum viewport height (for scroll calculations) + pub max_height: u16, +} + +/// Calculate the height needed to render the current state. +/// Used to dynamically resize the viewport before rendering. +pub fn calculate_needed_height(state: &AppState) -> u16 { + use super::state::AppMode; + + let view = Blocks::from_state(state); + let content_width = usize::from(CARD_WIDTH.saturating_sub(4)).max(1); + + let mut total_height = 0u16; + for (idx, block) in view.items.iter().enumerate() { + if idx > 0 { + total_height = total_height.saturating_add(1); // separator + total_height = total_height.saturating_add(1); // leading blank after separator + } + total_height = + total_height.saturating_add(calculate_block_height(&block.content, content_width)); + } + + // In Streaming/Generating mode, always reserve space for spinner block even during + // the 200ms delay when it's not yet shown. This prevents the UI from briefly + // shrinking and scrolling away the user message. + let has_spinner_block = view.items.iter().any(|b| { + b.content + .iter() + .any(|c| matches!(c, Content::Spinner { .. })) + }); + if matches!(state.mode, AppMode::Streaming | AppMode::Generating) && !has_spinner_block { + // Reserve space for separator (2 lines) + spinner block (1 line) + total_height = total_height.saturating_add(3); + } + + // Add borders (2) + top padding (1), minimum 5 + total_height.saturating_add(3).max(5) +} + +/// Main render function: derives view model from state, then renders it +pub fn render(frame: &mut Frame, state: &AppState, ctx: &RenderContext) { + // PURE DERIVATION: view model is always rebuilt from state + let view = Blocks::from_state(state); + + // Render the derived view model + render_view(frame, &view, ctx); +} + +fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { + let area = frame.area(); + + // Calculate frame dimensions (fixed width, min 32 if terminal is narrow) + let desired_width = CARD_WIDTH.min(area.width.saturating_sub(2)).max(32); + let content_width = usize::from(desired_width.saturating_sub(4)).max(1); + + // Position at anchor_col + let max_x = area.x + area.width.saturating_sub(desired_width); + let preferred_x = area.x + ctx.anchor_col.saturating_sub(2); + + // Calculate height from view model + let mut total_height = 0u16; + for (idx, block) in view.items.iter().enumerate() { + if idx > 0 { + total_height = total_height.saturating_add(1); // separator + total_height = total_height.saturating_add(1); // leading blank after separator + } + total_height = + total_height.saturating_add(calculate_block_height(&block.content, content_width)); + } + + let desired_height = total_height + .saturating_add(3) // borders (2) + top padding (1), no bottom padding + .max(5); + + // Cap card height at viewport height to prevent overflow + let actual_height = desired_height.min(area.height); + + // Calculate scroll offset (scroll to show bottom content when overflowing) + let scroll_offset = desired_height.saturating_sub(actual_height); + + let card = Rect { + x: preferred_x.min(max_x), + y: area.y, + width: desired_width, + height: actual_height, + }; + + // Get title from first block (if any) + let title = view + .items + .first() + .and_then(|b| b.title.as_deref()) + .unwrap_or("Describe the command you'd like to generate:"); + + // Create bordered frame + // Padding: left=1, right=1, top=1, bottom=0 (blocks have trailing blanks) + let outer_block = RatatuiBlock::default() + .borders(Borders::ALL) + .title(title) + .title_bottom(Line::from(view.footer).alignment(Alignment::Right)) + .padding(Padding::new(1, 1, 1, 0)); + + let inner_area = outer_block.inner(card); + frame.render_widget(outer_block, card); + + // Render blocks (with scroll offset for overflowing content) + render_blocks_content(frame, view, ctx, inner_area, card.width, scroll_offset); +} + +fn render_blocks_content( + frame: &mut Frame, + view: &Blocks, + ctx: &RenderContext, + area: Rect, + card_width: u16, + scroll_offset: u16, +) { + let content_width = usize::from(area.width).max(1); + + // Build layout constraints for full content + let mut constraints = Vec::new(); + let mut block_heights = Vec::new(); + for (idx, block) in view.items.iter().enumerate() { + if idx > 0 { + constraints.push(Constraint::Length(1)); // separator + constraints.push(Constraint::Length(1)); // leading blank after separator + block_heights.push(1); + block_heights.push(1); + } + let height = calculate_block_height(&block.content, content_width); + constraints.push(Constraint::Length(height)); + block_heights.push(height); + } + + if constraints.is_empty() { + return; + } + + // Calculate cumulative heights to find which blocks are visible after scrolling + let mut cumulative: Vec = Vec::with_capacity(block_heights.len() + 1); + cumulative.push(0); + for h in &block_heights { + cumulative.push(cumulative.last().unwrap() + h); + } + + // Render each chunk, offsetting by scroll_offset and clipping to visible area + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + let mut chunk_idx = 0; + for (idx, block) in view.items.iter().enumerate() { + if idx > 0 { + // Check if separator is visible (its position minus scroll_offset) + let sep_start = cumulative[chunk_idx]; + if sep_start >= scroll_offset && sep_start < scroll_offset + area.height { + let adjusted_chunk = Rect { + y: area.y + sep_start - scroll_offset, + ..chunks[chunk_idx] + }; + render_separator(frame, adjusted_chunk, ctx, card_width); + } + chunk_idx += 1; + chunk_idx += 1; // skip leading blank + } + + // Check if this block is at least partially visible + let block_start = cumulative[chunk_idx]; + let block_end = cumulative[chunk_idx + 1]; + + // Block is visible if it starts before viewport end and ends after viewport start + if block_start < scroll_offset + area.height && block_end > scroll_offset { + // Calculate visible portion + let visible_start = block_start.max(scroll_offset); + let visible_end = block_end.min(scroll_offset + area.height); + + let adjusted_chunk = Rect { + x: area.x, + y: area.y + visible_start - scroll_offset, + width: area.width, + height: visible_end - visible_start, + }; + + render_block_content(frame, &block.content, adjusted_chunk, ctx); + } + + chunk_idx += 1; + } +} + +/// Render all content items in a block +fn render_block_content(frame: &mut Frame, content: &[Content], area: Rect, ctx: &RenderContext) { + if content.is_empty() { + return; + } + + let content_width = usize::from(area.width).max(1); + + // Build layout constraints for each content item WITH spacing between items + let mut constraints = Vec::new(); + for (idx, c) in content.iter().enumerate() { + if idx > 0 { + constraints.push(Constraint::Length(1)); // blank line between items + } + constraints.push(Constraint::Length(calculate_single_content_height( + c, + content_width, + ))); + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + let mut chunk_idx = 0; + for (idx, item) in content.iter().enumerate() { + if idx > 0 { + chunk_idx += 1; // skip the blank line chunk + } + render_single_content(frame, item, chunks[chunk_idx], ctx); + chunk_idx += 1; + } +} + +/// Render a single content item using ratatui's native wrapping. +/// Symbol is rendered at column 0, text wraps in columns 2+ (offset area). +fn render_single_content(frame: &mut Frame, content: &Content, area: Rect, ctx: &RenderContext) { + // Helper to create offset text area (2 chars for symbol column) + let text_area = Rect { + x: area.x.saturating_add(2), + y: area.y, + width: area.width.saturating_sub(2), + height: area.height, + }; + + match content { + Content::Input { text, active, .. } => { + let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Guidance)); + let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); + + // Render ">" symbol at column 0 + render_symbol(frame, ">", symbol_style, area); + + if *active { + // Active input: render TextArea widget (handles cursor display) + if let Some(textarea) = ctx.textarea { + frame.render_widget(textarea, text_area); + } + } else { + // Inactive input: render as plain paragraph + let paragraph = Paragraph::new(text.as_str()) + .style(text_style) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, text_area); + } + } + + Content::Command { text, faded } => { + let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Important)); + let mut text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); + if *faded { + text_style = text_style.add_modifier(Modifier::DIM); + } + + render_symbol(frame, "$", symbol_style, area); + + let paragraph = Paragraph::new(text.as_str()) + .style(text_style) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, text_area); + } + + Content::Text { markdown } => { + // No symbol, just indent - render directly in offset area + let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); + + let paragraph = Paragraph::new(markdown.as_str()) + .style(text_style) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, text_area); + } + + Content::Error { message } => { + let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::AlertError)); + let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); + + render_symbol(frame, "!", symbol_style, area); + + let paragraph = Paragraph::new(message.as_str()) + .style(text_style) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, text_area); + } + + Content::Warning { + kind, + text, + pending_confirm, + } => { + let (symbol, meaning) = match kind { + WarningKind::Danger => ("!", Meaning::AlertError), + WarningKind::LowConfidence => ("?", Meaning::AlertWarn), + }; + let symbol_style = Style::from_crossterm(ctx.theme.as_style(meaning)); + let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); + + let display_text = if *pending_confirm { + "Press Enter again to run this dangerous command" + } else { + text.as_str() + }; + + render_symbol(frame, symbol, symbol_style, area); + + let paragraph = Paragraph::new(display_text) + .style(text_style) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, text_area); + } + + Content::Spinner { + frame: spinner_frame, + status_text, + } => { + let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); + let symbol = active_frame(*spinner_frame); + + render_symbol(frame, symbol, style, area); + + let paragraph = Paragraph::new(status_text.as_str()).style(style); + frame.render_widget(paragraph, text_area); + } + + Content::ToolStatus { + completed_count, + current_label, + frame: spinner_frame, + } => { + let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); + + let (symbol, text) = if let Some(label) = current_label { + let spinner = active_frame(*spinner_frame); + let text = if *completed_count > 0 { + format!( + "{} (used {} tool{})", + label, + completed_count, + if *completed_count == 1 { "" } else { "s" } + ) + } else { + label.clone() + }; + (spinner, text) + } else { + ( + "\u{2713}", + format!( + "Used {} tool{}", + completed_count, + if *completed_count == 1 { "" } else { "s" } + ), + ) + }; + + render_symbol(frame, symbol, style, area); + + let paragraph = Paragraph::new(text).style(style); + frame.render_widget(paragraph, text_area); + } + } +} + +/// Render a single-character symbol at the start of an area +fn render_symbol(frame: &mut Frame, symbol: &str, style: Style, area: Rect) { + let symbol_area = Rect { + x: area.x, + y: area.y, + width: 1, + height: 1, + }; + frame.render_widget(Paragraph::new(symbol).style(style), symbol_area); +} + +fn render_separator(frame: &mut Frame, area: Rect, ctx: &RenderContext, card_width: u16) { + let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Muted)); + + // Build separator: ├ + ─ repeated + ┤ spanning the full card width + // -2 for the ├ and ┤ characters themselves + let inner_width = card_width.saturating_sub(2) as usize; + let separator = format!( + "\u{251c}{}\u{2524}", // ├ ... ┤ + "\u{2500}".repeat(inner_width) // ─ + ); + + let paragraph = Paragraph::new(Span::styled(separator, style)); + + // Render at x offset to overlap the border (area is inside padding, border is 2 chars left) + let sep_area = Rect { + x: area.x.saturating_sub(2), // move left to overlap left border + y: area.y, + width: card_width, + height: 1, + }; + frame.render_widget(paragraph, sep_area); +} + +/// Calculate total height for all content items in a block +fn calculate_block_height(content: &[Content], width: usize) -> u16 { + let content_height: u16 = content + .iter() + .map(|c| calculate_single_content_height(c, width)) + .sum(); + + // Add spacing between items (n-1 blank lines for n items) + let spacing = if content.len() > 1 { + (content.len() - 1) as u16 + } else { + 0 + }; + + // Add 1 for trailing blank line (padding after content) + content_height.saturating_add(spacing).saturating_add(1) +} + +/// Calculate height for a single content item. +/// Uses ratatui's Paragraph::line_count for consistency with rendering. +fn calculate_single_content_height(content: &Content, width: usize) -> u16 { + // Text area is offset by 2 for symbol column + let text_width = width.saturating_sub(2); + + match content { + // Input uses word wrapping (WrapMode::Word) in TextArea, which can produce + // more lines than character wrapping since it won't break words mid-word + Content::Input { text, active, .. } => { + if *active { + // For active input, use word-wrap line counting to match TextArea behavior + let (lines, last_line_width) = + word_wrap_line_count_with_last_width(text, text_width); + // Only add extra line for cursor if the last line is full + if last_line_width >= text_width { + lines.saturating_add(1) + } else { + lines + } + } else { + line_count_wrapped(text, text_width) + } + } + Content::Command { text, .. } => line_count_wrapped(text, text_width), + Content::Text { markdown } => line_count_wrapped(markdown, text_width), + Content::Error { message } => line_count_wrapped(message, text_width), + Content::Warning { + text, + pending_confirm, + .. + } => { + let display_text = if *pending_confirm { + "Press Enter again to run this dangerous command" + } else { + text.as_str() + }; + line_count_wrapped(display_text, text_width) + } + Content::Spinner { .. } => 1, + Content::ToolStatus { .. } => 1, + } +} + +/// Count lines when text is wrapped at given width. +/// Uses ratatui's Paragraph::line_count for accurate wrapping calculation. +fn line_count_wrapped(text: &str, width: usize) -> u16 { + if width == 0 { + return 1; + } + + let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); + paragraph.line_count(width as u16).max(1) as u16 +} + +/// Count lines using word-wrap algorithm (matches TextArea's WrapMode::Word). +/// Words won't be broken mid-word, so this may produce more lines than character wrapping. +/// Returns (line_count, last_line_width) so caller can determine if cursor needs extra space. +fn word_wrap_line_count_with_last_width(text: &str, width: usize) -> (u16, usize) { + if width == 0 || text.is_empty() { + return (1, 0); + } + + let mut line_count = 0u16; + let mut current_line_width = 0usize; + + for line in text.lines() { + if line.is_empty() { + line_count += 1; + current_line_width = 0; + continue; + } + + let mut line_started = false; + + for word in line.split_whitespace() { + let word_width = unicode_width::UnicodeWidthStr::width(word); + + if !line_started { + // First word on line + if word_width > width { + // Word is longer than width, it will be split by character + // Count how many lines it takes + line_count += word_width.div_ceil(width) as u16; + current_line_width = word_width % width; + if current_line_width == 0 { + current_line_width = 0; + line_started = false; + } else { + line_started = true; + } + } else { + current_line_width = word_width; + line_started = true; + } + } else { + // Subsequent word - need space before it + let needed = current_line_width + 1 + word_width; + if needed > width { + // Word doesn't fit, start new line + line_count += 1; + if word_width > width { + // Word itself is too long, will be split + line_count += word_width.div_ceil(width) as u16; + current_line_width = word_width % width; + if current_line_width == 0 { + line_started = false; + } + } else { + current_line_width = word_width; + } + } else { + current_line_width = needed; + } + } + } + + // Count the last line of this logical line + if line_started { + line_count += 1; + } + } + + // Handle case where text has no lines() output (empty or just whitespace) + if line_count == 0 { + line_count = 1; + current_line_width = 0; + } + + (line_count, current_line_width) +} + +/// Convert markdown to styled spans (existing function, kept as-is) +pub fn markdown_to_spans<'a>(text: &'a str, theme: &'a Theme) -> Vec> { + let parser = Parser::new(text); + let mut lines: Vec>> = vec![Vec::new()]; + let mut current_line = 0; + + let base_style = Style::from_crossterm(theme.as_style(Meaning::Base)); + let code_style = Style::from_crossterm(theme.as_style(Meaning::Important)); + let mut style_stack: Vec