aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-02-24 11:48:20 -0800
committerGitHub <noreply@github.com>2026-02-24 11:48:20 -0800
commit6ea760bb6b36da241961e8ecd60cb2c5e15c0a78 (patch)
tree18ebbb710cea24e30bc69b5d6bc807518a950746
parentfix: forward $PATH to tmux popup in zsh (#3198) (diff)
downloadatuin-6ea760bb6b36da241961e8ecd60cb2c5e15c0a78.zip
feat: Generate commands or ask questions with `atuin ai` (#3199)
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 <noreply@anthropic.com>
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock749
-rw-r--r--Cargo.toml2
-rw-r--r--crates/atuin-ai/Cargo.toml13
-rwxr-xr-xcrates/atuin-ai/render-tests.sh34
-rwxr-xr-xcrates/atuin-ai/replay-states.sh101
-rw-r--r--crates/atuin-ai/src/commands.rs71
-rw-r--r--crates/atuin-ai/src/commands/debug_render.rs460
-rw-r--r--crates/atuin-ai/src/commands/init.rs155
-rw-r--r--crates/atuin-ai/src/commands/inline.rs924
-rw-r--r--crates/atuin-ai/src/main.rs1
-rw-r--r--crates/atuin-ai/src/tui/app.rs157
-rw-r--r--crates/atuin-ai/src/tui/event.rs303
-rw-r--r--crates/atuin-ai/src/tui/mod.rs14
-rw-r--r--crates/atuin-ai/src/tui/render.rs674
-rw-r--r--crates/atuin-ai/src/tui/spinner.rs99
-rw-r--r--crates/atuin-ai/src/tui/state.rs530
-rw-r--r--crates/atuin-ai/src/tui/terminal.rs203
-rw-r--r--crates/atuin-ai/src/tui/view_model.rs400
-rw-r--r--crates/atuin-ai/test-renders.json295
-rw-r--r--crates/atuin-client/src/settings.rs17
-rw-r--r--docs/docs/ai/introduction.md157
-rw-r--r--docs/docs/ai/settings.md16
-rw-r--r--docs/docs/configuration/config.md4
-rw-r--r--docs/mkdocs.yml3
-rw-r--r--docs/pyproject.toml5
-rw-r--r--docs/uv.lock9
27 files changed, 4674 insertions, 723 deletions
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"
@@ -153,6 +153,28 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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]]
@@ -1495,6 +1525,17 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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,11 +1830,20 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1818,6 +1867,19 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
@@ -2162,6 +2223,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
@@ -2409,16 +2476,22 @@ dependencies = [
]
[[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,12 +3005,21 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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,12 +3521,20 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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]]
@@ -5284,6 +5377,20 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
@@ -5513,6 +5626,15 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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,27 +5688,74 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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 <state-file.jsonl> [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 <state-file.jsonl> [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<String>,
+ /// Custom API token
+ #[arg(long, global = true, env = "ATUIN_AI_API_TOKEN")]
+ api_token: Option<String>,
+
#[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<String>,
+ 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<String>,
},
- /// 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<String>,
+
+ /// 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<String> {
+ 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<EventInput>,
+ /// 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<String>,
+ /// Session ID from server
+ #[serde(default)]
+ pub session_id: Option<String>,
+ /// Streaming status
+ #[serde(default)]
+ pub streaming_status: Option<String>,
+ /// 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<EventInput> 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<Self> {
+ 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<ConversationEvent> = 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<String>, 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::<Vec<_>>()
+ })
+ }).collect::<Vec<_>>()
+ })
+}
+
+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<String> {
+ 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<String>,
-}
-
-#[derive(Debug, Deserialize)]
-struct GenerateResponse {
- command: String,
- #[serde(default)]
- explanation: Option<String>,
-}
+use std::io::Write;
pub async fn run(
initial_command: Option<String>,
natural_language: bool,
api_endpoint: Option<String>,
+ api_token: Option<String>,
+ keep_output: bool,
+ debug_state_file: Option<String>,
) -> 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<GenerateResponse> {
- 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<String>,
+ messages: Vec<serde_json::Value>,
+ settings: &atuin_client::settings::Settings,
+) -> std::pin::Pin<Box<dyn futures::Stream<Item = Result<ChatStreamEvent>> + 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;
+ }
+ };
- 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")?;
+ // 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);
+ }
- if response.status().is_success() {
- let generated = response
- .json::<GenerateResponse>()
+
+ 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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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<Url> {
@@ -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<serde_json::Value> = 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<Self> {
+ 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<String>,
+ keep_output: bool,
+ debug_state_file: Option<String>,
) -> 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
- });
+ // 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);
- let generated = loop {
- if task.is_finished() {
- break task.await.context("generate task join failed")?;
- }
+ // Initialize event loop
+ let mut event_loop = EventLoop::new();
- ui.render_generating(&prompt, spinner_idx)?;
- spinner_idx = (spinner_idx + 1) % SPINNER_FRAMES.len();
+ // Track chat stream
+ let mut chat_stream: Option<
+ std::pin::Pin<Box<dyn futures::Stream<Item = Result<ChatStreamEvent>> + Send>>,
+ > = None;
- 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()));
- }
- }
- };
+ 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();
- 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()));
+ // 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");
}
+ 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");
+ }
+ std::task::Poll::Ready(None) => {
+ chat_stream = None;
+ app.state.finalize_streaming();
+ log_state!("stream_end");
}
+ std::task::Poll::Pending => {}
}
- };
+ }
+ }
+ _ => {}
+ }
- loop {
- ui.render_review(&prompt, &response)?;
- if !event::poll(Duration::from_millis(250))
- .context("failed to poll in review")?
- {
- continue;
- }
+ // 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
+ }
- let ev = event::read().context("failed to read review event")?;
- let Event::Key(key) = ev else {
- continue;
- };
+ // Check exit condition
+ if app.state.should_exit {
+ break;
+ }
- 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,
- _ => {}
- }
+ // 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,
+ ));
}
- KeyCode::Char(c) => {
- prompt.push(c);
- }
- _ => {}
}
}
+
+ // 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<bool> {
}
}
}
-
-fn wait_for_retry_or_cancel() -> Result<bool> {
- 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<CrosstermBackend<std::io::Stdout>>,
- anchor_col: u16,
-}
-
-impl InlineUi {
- fn new() -> Result<Self> {
- 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::<usize>()
- .max(1)
-}
-
-fn build_screen_content(
- screen: &Screen<'_>,
- content_width: usize,
-) -> (String, bool, Option<String>) {
- 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<time::Interval>,
+
+ /// 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<AppEvent> {
+ // 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<bool> {
+ 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<u16> = 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<Line<'a>> {
+ let parser = Parser::new(text);
+ let mut lines: Vec<Vec<Span<'a>>> = 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<Style> = vec![base_style];
+ let mut in_code_block = false;
+
+ for event in parser {
+ match event {
+ Event::Start(Tag::Strong) => {
+ let bold_style = style_stack
+ .last()
+ .copied()
+ .unwrap_or(base_style)
+ .add_modifier(Modifier::BOLD);
+ style_stack.push(bold_style);
+ }
+ Event::End(TagEnd::Strong) => {
+ style_stack.pop();
+ }
+ Event::Start(Tag::Emphasis) => {
+ let underline_style = style_stack
+ .last()
+ .copied()
+ .unwrap_or(base_style)
+ .add_modifier(Modifier::UNDERLINED);
+ style_stack.push(underline_style);
+ }
+ Event::End(TagEnd::Emphasis) => {
+ style_stack.pop();
+ }
+ Event::Start(Tag::CodeBlock(_)) => {
+ in_code_block = true;
+ // Start new line for code block
+ if !lines[current_line].is_empty() {
+ current_line += 1;
+ lines.push(Vec::new());
+ }
+ }
+ Event::End(TagEnd::CodeBlock) => {
+ in_code_block = false;
+ // Ensure blank line after code block
+ if !lines[current_line].is_empty() {
+ current_line += 1;
+ lines.push(Vec::new());
+ }
+ }
+ Event::Code(code) => {
+ lines[current_line].push(Span::styled(format!("`{}`", code), code_style));
+ }
+ Event::Text(text) => {
+ let current_style = if in_code_block {
+ // Use Important style for code block content
+ code_style
+ } else {
+ style_stack.last().copied().unwrap_or(base_style)
+ };
+ let parts: Vec<&str> = text.split('\n').collect();
+ for (i, part) in parts.iter().enumerate() {
+ if i > 0 {
+ current_line += 1;
+ lines.push(Vec::new());
+ }
+ if !part.is_empty() {
+ lines[current_line].push(Span::styled(part.to_string(), current_style));
+ }
+ }
+ }
+ Event::SoftBreak => {
+ let current_style = style_stack.last().copied().unwrap_or(base_style);
+ lines[current_line].push(Span::styled(" ", current_style));
+ }
+ Event::HardBreak => {
+ current_line += 1;
+ lines.push(Vec::new());
+ }
+ Event::Start(Tag::Paragraph) => {
+ if current_line > 0 || !lines[0].is_empty() {
+ current_line += 1;
+ lines.push(Vec::new());
+ }
+ }
+ Event::End(TagEnd::Paragraph) => {}
+ _ => {}
+ }
+ }
+
+ lines.into_iter().map(Line::from).collect()
+}
diff --git a/crates/atuin-ai/src/tui/spinner.rs b/crates/atuin-ai/src/tui/spinner.rs
new file mode 100644
index 00000000..138e0269
--- /dev/null
+++ b/crates/atuin-ai/src/tui/spinner.rs
@@ -0,0 +1,99 @@
+//! Spinner styles and configuration for TUI animations
+//!
+//! To experiment with different spinners, change `ACTIVE_SPINNER` below.
+
+use std::time::Duration;
+
+/// Active spinner style - change this to experiment with different styles
+pub const ACTIVE_SPINNER: SpinnerStyle = SpinnerStyle::Dots;
+
+/// Spinner style definitions
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SpinnerStyle {
+ /// Classic ASCII line spinner: / - \ |
+ Line,
+ /// Braille dots pattern
+ Dots,
+ /// Growing/shrinking dots
+ Pulse,
+ /// Simple arrow rotation
+ Arrow,
+ /// Block building
+ Block,
+}
+
+impl SpinnerStyle {
+ /// Get the frames for this spinner style
+ pub const fn frames(&self) -> &'static [&'static str] {
+ match self {
+ SpinnerStyle::Line => &["/", "-", "\\", "|"],
+ SpinnerStyle::Dots => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
+ SpinnerStyle::Pulse => &["·", "•", "●", "•"],
+ SpinnerStyle::Arrow => &["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
+ SpinnerStyle::Block => &[
+ "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█", "▉", "▊", "▋", "▌", "▍", "▎", "▏",
+ ],
+ }
+ }
+
+ /// Get the recommended tick interval for this spinner style
+ /// Faster spinners need shorter intervals to look smooth
+ pub const fn tick_interval(&self) -> Duration {
+ match self {
+ SpinnerStyle::Line => Duration::from_millis(150),
+ SpinnerStyle::Dots => Duration::from_millis(80),
+ SpinnerStyle::Pulse => Duration::from_millis(200),
+ SpinnerStyle::Arrow => Duration::from_millis(100),
+ SpinnerStyle::Block => Duration::from_millis(80),
+ }
+ }
+
+ /// Get the frame at the given index (wraps around)
+ pub fn frame_at(&self, index: usize) -> &'static str {
+ let frames = self.frames();
+ frames[index % frames.len()]
+ }
+
+ /// Get the number of frames in this spinner
+ pub fn frame_count(&self) -> usize {
+ self.frames().len()
+ }
+}
+
+/// Get the active spinner's frame at the given index
+pub fn active_frame(index: usize) -> &'static str {
+ ACTIVE_SPINNER.frame_at(index)
+}
+
+/// Get the active spinner's tick interval
+pub fn active_tick_interval() -> Duration {
+ ACTIVE_SPINNER.tick_interval()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_frame_wrapping() {
+ let style = SpinnerStyle::Line;
+ assert_eq!(style.frame_at(0), "/");
+ assert_eq!(style.frame_at(4), "/"); // wraps
+ assert_eq!(style.frame_at(5), "-");
+ }
+
+ #[test]
+ fn test_all_styles_have_frames() {
+ let styles = [
+ SpinnerStyle::Line,
+ SpinnerStyle::Dots,
+ SpinnerStyle::Pulse,
+ SpinnerStyle::Arrow,
+ SpinnerStyle::Block,
+ ];
+ for style in styles {
+ assert!(!style.frames().is_empty());
+ assert!(style.tick_interval().as_millis() > 0);
+ }
+ }
+}
diff --git a/crates/atuin-ai/src/tui/state.rs b/crates/atuin-ai/src/tui/state.rs
new file mode 100644
index 00000000..ba9c8ac6
--- /dev/null
+++ b/crates/atuin-ai/src/tui/state.rs
@@ -0,0 +1,530 @@
+//! Domain state types for the TUI application
+//!
+//! This module contains the core state types that represent the application's
+//! domain model. Conversation events match the API protocol format.
+
+use std::time::Instant;
+use tui_textarea::TextArea;
+
+use super::spinner::{ACTIVE_SPINNER, active_tick_interval};
+
+/// Streaming status indicators from server
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum StreamingStatus {
+ Processing,
+ Searching,
+ Thinking,
+ WaitingForTools,
+}
+
+impl StreamingStatus {
+ pub fn from_status_str(s: &str) -> Self {
+ match s {
+ "processing" => Self::Processing,
+ "searching" => Self::Searching,
+ "waiting_for_tools" => Self::WaitingForTools,
+ _ => Self::Thinking, // Default to thinking for "thinking" and unknown
+ }
+ }
+
+ pub fn display_text(&self) -> &'static str {
+ match self {
+ Self::Processing => "Processing...",
+ Self::Searching => "Searching...",
+ Self::Thinking => "Thinking...",
+ Self::WaitingForTools => "Waiting for tools...",
+ }
+ }
+}
+
+/// Conversation event types matching the API protocol
+#[derive(Debug, Clone)]
+pub enum ConversationEvent {
+ /// User message (what the user typed)
+ UserMessage { content: String },
+ /// Text content from assistant (streamed or complete)
+ Text { content: String },
+ /// Tool call from assistant
+ ToolCall {
+ id: String,
+ name: String,
+ input: serde_json::Value,
+ },
+ /// Tool result (usually from server-side execution)
+ ToolResult {
+ tool_use_id: String,
+ content: String,
+ is_error: bool,
+ },
+}
+
+impl ConversationEvent {
+ /// Convert to JSON for API calls
+ pub fn to_json(&self) -> serde_json::Value {
+ match self {
+ ConversationEvent::UserMessage { content } => serde_json::json!({
+ "type": "user_message",
+ "content": content
+ }),
+ ConversationEvent::Text { content } => serde_json::json!({
+ "type": "text",
+ "content": content
+ }),
+ ConversationEvent::ToolCall { id, name, input } => serde_json::json!({
+ "type": "tool_call",
+ "id": id,
+ "name": name,
+ "input": input
+ }),
+ ConversationEvent::ToolResult {
+ tool_use_id,
+ content,
+ is_error,
+ } => serde_json::json!({
+ "type": "tool_result",
+ "tool_use_id": tool_use_id,
+ "content": content,
+ "is_error": is_error
+ }),
+ }
+ }
+
+ /// Extract command from a suggest_command tool call
+ pub fn as_command(&self) -> Option<&str> {
+ if let ConversationEvent::ToolCall { name, input, .. } = self
+ && name == "suggest_command"
+ {
+ // command can be null for pure conversational turns
+ return input.get("command").and_then(|v| v.as_str());
+ }
+ None
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum AppMode {
+ /// User is typing input
+ Input,
+ /// Waiting for generation (showing spinner)
+ Generating,
+ /// Streaming SSE response
+ Streaming,
+ /// Reviewing generated command
+ Review,
+ /// Error state, can retry
+ Error,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ExitAction {
+ /// Run the command
+ Execute(String),
+ /// Insert command without running
+ Insert(String),
+ /// User canceled
+ Cancel,
+}
+
+/// Application state - the domain model
+///
+/// Conversation is stored as a sequence of events matching the API protocol.
+/// The view model is derived from this state via `Blocks::from_state()`.
+pub struct AppState {
+ /// Current application mode
+ pub mode: AppMode,
+ /// Conversation events (source of truth, matches API protocol)
+ pub events: Vec<ConversationEvent>,
+ /// Text being streamed (accumulated, flushed to Text event on completion)
+ pub streaming_text: String,
+ /// Active text input (uses tui-textarea for proper cursor handling)
+ pub textarea: TextArea<'static>,
+ /// Current error message (renders at end of blocks)
+ pub error: Option<String>,
+ /// Whether app should exit
+ pub should_exit: bool,
+ /// Exit action (set when exiting)
+ pub exit_action: Option<ExitAction>,
+ /// Session ID from server (store after first response, send on subsequent)
+ pub session_id: Option<String>,
+ /// Current streaming status (for spinner text)
+ pub streaming_status: Option<StreamingStatus>,
+ /// Whether current turn was interrupted by user
+ pub was_interrupted: bool,
+ /// Spinner animation state
+ pub spinner_frame: usize,
+ /// When spinner frame last advanced (for timing control)
+ pub last_spinner_tick: Instant,
+ /// When streaming started (for spinner delay)
+ pub streaming_started: Option<Instant>,
+ /// True when user has pressed Enter once on a dangerous command
+ pub confirmation_pending: bool,
+}
+
+/// Create a TextArea with our preferred configuration
+fn create_textarea() -> TextArea<'static> {
+ let mut textarea = TextArea::default();
+ // Disable underline on cursor line - it's distracting
+ textarea.set_cursor_line_style(ratatui::style::Style::default());
+ // Enable word wrapping
+ textarea.set_wrap_mode(tui_textarea::WrapMode::Word);
+ textarea
+}
+
+impl AppState {
+ pub fn new() -> Self {
+ Self {
+ mode: AppMode::Input,
+ events: Vec::new(),
+ streaming_text: String::new(),
+ textarea: create_textarea(),
+ error: None,
+ should_exit: false,
+ exit_action: None,
+ session_id: None,
+ streaming_status: None,
+ was_interrupted: false,
+ spinner_frame: 0,
+ last_spinner_tick: Instant::now(),
+ streaming_started: None,
+ confirmation_pending: false,
+ }
+ }
+
+ /// Get the current input text
+ pub fn input(&self) -> String {
+ self.textarea.lines().join("\n")
+ }
+
+ /// Check if input is empty
+ pub fn input_is_empty(&self) -> bool {
+ self.textarea.is_empty()
+ }
+
+ /// Clear the input
+ pub fn clear_input(&mut self) {
+ self.textarea = create_textarea();
+ }
+
+ /// Convert conversation events to Claude API message format
+ /// Groups consecutive tool calls, handles role alternation
+ pub fn events_to_messages(&self) -> Vec<serde_json::Value> {
+ let mut messages = Vec::new();
+ let mut i = 0;
+ let events = &self.events;
+
+ while i < events.len() {
+ match &events[i] {
+ ConversationEvent::UserMessage { content } => {
+ messages.push(serde_json::json!({
+ "role": "user",
+ "content": content
+ }));
+ i += 1;
+ }
+ ConversationEvent::Text { content } => {
+ messages.push(serde_json::json!({
+ "role": "assistant",
+ "content": content
+ }));
+ i += 1;
+ }
+ ConversationEvent::ToolCall { .. } => {
+ // Group consecutive tool calls into single assistant message
+ let mut tool_uses = Vec::new();
+ while i < events.len() {
+ if let ConversationEvent::ToolCall { id, name, input } = &events[i] {
+ tool_uses.push(serde_json::json!({
+ "type": "tool_use",
+ "id": id,
+ "name": name,
+ "input": input
+ }));
+ i += 1;
+ } else {
+ break;
+ }
+ }
+ messages.push(serde_json::json!({
+ "role": "assistant",
+ "content": tool_uses
+ }));
+ }
+ ConversationEvent::ToolResult {
+ tool_use_id,
+ content,
+ is_error,
+ } => {
+ messages.push(serde_json::json!({
+ "role": "user",
+ "content": [{
+ "type": "tool_result",
+ "tool_use_id": tool_use_id,
+ "content": content,
+ "is_error": is_error
+ }]
+ }));
+ i += 1;
+ }
+ }
+ }
+
+ messages
+ }
+
+ // ===== Generation lifecycle methods =====
+
+ /// Start generating from current input
+ pub fn start_generating(&mut self) {
+ // Add user message event
+ self.events.push(ConversationEvent::UserMessage {
+ content: self.input(),
+ });
+
+ // Clear input, switch mode
+ self.clear_input();
+ self.mode = AppMode::Generating;
+ }
+
+ /// Generation complete with command (legacy method, kept for compatibility)
+ pub fn generation_complete(
+ &mut self,
+ command: String,
+ explanation: Option<String>,
+ dangerous: bool,
+ warnings: Vec<String>,
+ ) {
+ // Add explanation as text event if present
+ if let Some(ref exp) = explanation {
+ self.events.push(ConversationEvent::Text {
+ content: exp.clone(),
+ });
+ }
+
+ // Add tool_call event for suggest_command
+ let tool_id = format!("gen_{}", uuid::Uuid::new_v4().simple());
+ let mut tool_input = serde_json::json!({
+ "command": command,
+ "conversation_only": false,
+ "confidence": "high"
+ });
+ if let Some(ref exp) = explanation {
+ tool_input["message"] = serde_json::json!(exp);
+ }
+ if dangerous {
+ tool_input["danger"] = serde_json::json!("high");
+ }
+ if !warnings.is_empty() {
+ tool_input["warning"] = serde_json::json!(warnings.join("; "));
+ }
+
+ self.events.push(ConversationEvent::ToolCall {
+ id: tool_id,
+ name: "suggest_command".to_string(),
+ input: tool_input,
+ });
+
+ self.mode = AppMode::Review;
+ }
+
+ /// Generation error occurred
+ pub fn generation_error(&mut self, error: String) {
+ self.error = Some(error);
+ self.mode = AppMode::Error;
+ }
+
+ /// Cancel during generation
+ pub fn cancel_generation(&mut self) {
+ // Remove the last user message since generation was cancelled
+ if let Some(ConversationEvent::UserMessage { .. }) = self.events.last() {
+ self.events.pop();
+ }
+ self.mode = AppMode::Input;
+ self.clear_input();
+ }
+
+ // ===== Streaming lifecycle methods =====
+
+ /// Start streaming response
+ pub fn start_streaming(&mut self) {
+ self.streaming_text.clear();
+ self.streaming_status = None;
+ self.was_interrupted = false;
+ self.streaming_started = Some(Instant::now());
+ self.mode = AppMode::Streaming;
+ }
+
+ /// Store session ID from server response
+ pub fn store_session_id(&mut self, session_id: String) {
+ self.session_id = Some(session_id);
+ }
+
+ /// Update streaming status from SSE event
+ pub fn update_streaming_status(&mut self, status: &str) {
+ self.streaming_status = Some(StreamingStatus::from_status_str(status));
+ }
+
+ /// Cancel streaming with context preservation
+ pub fn cancel_streaming(&mut self) {
+ // Mark as interrupted
+ self.was_interrupted = true;
+
+ // Flush partial text with interruption marker if any
+ // Trim leading whitespace since LLM responses often start with \n\n
+ let content = std::mem::take(&mut self.streaming_text);
+ let trimmed = content.trim_start();
+ if !trimmed.is_empty() {
+ let interrupted_text = format!("{trimmed}\n\n[User cancelled this generation]");
+ self.events.push(ConversationEvent::Text {
+ content: interrupted_text,
+ });
+ }
+
+ // Clear status and return to input
+ self.streaming_status = None;
+ self.confirmation_pending = false;
+ self.mode = AppMode::Input;
+ }
+
+ /// Append text chunk during streaming
+ /// Trims leading whitespace from the first chunk(s) since LLM responses often start with \n\n
+ pub fn append_streaming_text(&mut self, chunk: &str) {
+ if self.streaming_text.is_empty() {
+ // First chunk(s): trim leading whitespace
+ let trimmed = chunk.trim_start();
+ if !trimmed.is_empty() {
+ self.streaming_text.push_str(trimmed);
+ }
+ } else {
+ // Subsequent chunks: append as-is
+ self.streaming_text.push_str(chunk);
+ }
+ }
+
+ /// Add a tool call event during streaming
+ /// Flushes any pending streaming text first to maintain correct event order
+ /// For suggest_command, also transitions to Review mode since that ends the LLM turn
+ pub fn add_tool_call(&mut self, id: String, name: String, input: serde_json::Value) {
+ // Flush streaming text before adding tool call to maintain correct order
+ let content = std::mem::take(&mut self.streaming_text);
+ let trimmed = content.trim_start();
+ if !trimmed.is_empty() {
+ self.events.push(ConversationEvent::Text {
+ content: trimmed.to_string(),
+ });
+ }
+
+ // suggest_command marks the end of the LLM turn - transition to Review
+ let is_suggest_command = name == "suggest_command";
+
+ self.events
+ .push(ConversationEvent::ToolCall { id, name, input });
+
+ if is_suggest_command {
+ self.streaming_status = None;
+ self.streaming_started = None;
+ self.mode = AppMode::Review;
+ }
+ }
+
+ /// Add a tool result event during streaming
+ pub fn add_tool_result(&mut self, tool_use_id: String, content: String, is_error: bool) {
+ self.events.push(ConversationEvent::ToolResult {
+ tool_use_id,
+ content,
+ is_error,
+ });
+ }
+
+ /// Finalize streaming - flush accumulated text to event
+ pub fn finalize_streaming(&mut self) {
+ // Flush streaming text to a Text event if non-empty
+ // Trim leading whitespace since LLM responses often start with \n\n
+ let content = std::mem::take(&mut self.streaming_text);
+ let trimmed = content.trim_start();
+ if !trimmed.is_empty() {
+ self.events.push(ConversationEvent::Text {
+ content: trimmed.to_string(),
+ });
+ }
+ self.streaming_status = None;
+ self.streaming_started = None;
+ self.mode = AppMode::Review;
+ }
+
+ /// Streaming error
+ pub fn streaming_error(&mut self, error: String) {
+ // Discard any partial streaming text
+ self.streaming_text.clear();
+ self.streaming_started = None;
+ self.error = Some(error);
+ self.mode = AppMode::Error;
+ }
+
+ // ===== Edit mode and exit methods =====
+
+ /// Start edit mode for refinement
+ pub fn start_edit_mode(&mut self) {
+ self.confirmation_pending = false;
+ self.clear_input();
+ self.mode = AppMode::Input;
+ }
+
+ /// Exit with action
+ pub fn exit(&mut self, action: ExitAction) {
+ self.exit_action = Some(action);
+ self.should_exit = true;
+ }
+
+ /// Retry after error
+ pub fn retry(&mut self) {
+ self.error = None;
+ self.mode = AppMode::Generating;
+ }
+
+ // ===== Utility methods =====
+
+ /// Advance spinner frame if enough time has passed
+ /// Called on every event loop tick (50ms), but only advances spinner
+ /// when the active spinner's interval has elapsed
+ pub fn tick(&mut self) {
+ let interval = active_tick_interval();
+ if self.last_spinner_tick.elapsed() >= interval {
+ self.spinner_frame = (self.spinner_frame + 1) % ACTIVE_SPINNER.frame_count();
+ self.last_spinner_tick = Instant::now();
+ }
+ }
+
+ /// Get the most recent command from events
+ pub fn current_command(&self) -> Option<&str> {
+ self.events.iter().rev().find_map(|e| e.as_command())
+ }
+
+ /// Check if the most recent command suggestion is marked dangerous
+ /// Checks the `danger` field for "high", "medium", or "med" values
+ pub fn is_current_command_dangerous(&self) -> bool {
+ self.events
+ .iter()
+ .rev()
+ .find_map(|e| {
+ if let ConversationEvent::ToolCall { name, input, .. } = e
+ && name == "suggest_command"
+ {
+ let danger_level = input
+ .get("danger")
+ .and_then(|v| v.as_str())
+ .unwrap_or("low");
+ return Some(
+ danger_level == "high" || danger_level == "medium" || danger_level == "med",
+ );
+ }
+ None
+ })
+ .unwrap_or(false)
+ }
+}
+
+impl Default for AppState {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/crates/atuin-ai/src/tui/terminal.rs b/crates/atuin-ai/src/tui/terminal.rs
new file mode 100644
index 00000000..2e0bcbaa
--- /dev/null
+++ b/crates/atuin-ai/src/tui/terminal.rs
@@ -0,0 +1,203 @@
+use crossterm::{
+ cursor,
+ terminal::{disable_raw_mode, enable_raw_mode},
+};
+use eyre::{Context, Result, bail};
+use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend};
+use std::io::{IsTerminal, Stdout, stdout};
+
+/// Install a panic hook that ensures the terminal is restored to a usable state
+/// even if the application panics.
+///
+/// This must be called before creating the TerminalGuard to ensure proper cleanup
+/// during panics. The hook will:
+/// 1. Disable raw mode (restoring normal terminal behavior)
+/// 2. Call the original panic hook to display panic information
+///
+/// # Implementation Note
+/// This satisfies TUI-07: Terminal remains usable after panic by ensuring
+/// disable_raw_mode() is called before the panic message is displayed.
+pub fn install_panic_hook() {
+ let original_hook = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |panic_info| {
+ // Attempt to restore terminal - ignore errors since we're already panicking
+ let _ = disable_raw_mode();
+ // Call original hook to display panic with backtrace
+ original_hook(panic_info);
+ }));
+}
+
+/// Minimum viewport height
+const MIN_VIEWPORT_HEIGHT: u16 = 10;
+
+/// Margin to leave below viewport for shell prompt
+const VIEWPORT_BOTTOM_MARGIN: u16 = 2;
+
+/// Guards terminal lifecycle, ensuring proper setup and cleanup.
+///
+/// # Lifecycle
+/// - **Setup** (`new()`): Captures cursor position, enables raw mode, creates inline viewport
+/// - **Cleanup** (`Drop`): Clears terminal, disables raw mode
+///
+/// # Dynamic Viewport Sizing
+/// The viewport starts at 15 lines (enough for simple commands) and grows
+/// dynamically when content requires more space. Use `ensure_height()` before
+/// rendering to grow the viewport if needed.
+///
+/// # Safety Features
+/// - Non-TTY detection: Returns error early if stdout is not a terminal
+/// - Panic recovery: Works with `install_panic_hook()` to restore terminal after panic
+/// - Drop-based cleanup: Ensures terminal is restored on normal exit
+///
+/// # Example
+/// ```no_run
+/// use atuin_ai::tui::{install_panic_hook, TerminalGuard};
+///
+/// install_panic_hook(); // Once at program start
+/// let mut guard = TerminalGuard::new()?;
+/// let terminal = guard.terminal();
+/// // ... use terminal ...
+/// // Drop automatically cleans up
+/// # Ok::<(), eyre::Report>(())
+/// ```
+pub struct TerminalGuard {
+ terminal: Terminal<CrosstermBackend<Stdout>>,
+ anchor_col: u16,
+ keep_output: bool,
+ viewport_height: u16,
+}
+
+impl TerminalGuard {
+ /// Create a new TerminalGuard, initializing the terminal for inline TUI mode.
+ ///
+ /// # Arguments
+ /// * `keep_output` - If true, preserve TUI output on exit; if false, clear it
+ ///
+ /// # Process
+ /// 1. Check if stdout is a terminal (non-TTY detection)
+ /// 2. Capture cursor position for inline rendering anchor
+ /// 3. Enable raw mode for keyboard input
+ /// 4. Create terminal with inline viewport
+ ///
+ /// # Errors
+ /// - Returns error if stdout is not a terminal (e.g., piped or redirected)
+ /// - Returns error if terminal initialization fails
+ ///
+ /// # Implementation Note
+ /// Cursor position is captured BEFORE enabling raw mode because some terminals
+ /// may report position differently after raw mode is enabled.
+ pub fn new(keep_output: bool) -> Result<Self> {
+ // Non-TTY check: fail early if stdout is not a terminal
+ if !stdout().is_terminal() {
+ bail!(
+ "atuin-ai requires a terminal (TTY) but stdout is not a terminal. \
+ This typically happens when output is piped or redirected."
+ );
+ }
+
+ // Get terminal size and calculate viewport height
+ let (_, term_height) = crossterm::terminal::size().unwrap_or((80, 24));
+ let viewport_height = term_height
+ .saturating_sub(VIEWPORT_BOTTOM_MARGIN)
+ .max(MIN_VIEWPORT_HEIGHT);
+
+ // Capture cursor position BEFORE raw mode for accurate anchor
+ let anchor_col = cursor::position().map(|(x, _)| x).unwrap_or(0);
+
+ // Enable raw mode for keyboard input
+ enable_raw_mode().context("failed to enable raw mode")?;
+
+ // Create terminal with fixed viewport based on terminal size
+ let backend = CrosstermBackend::new(stdout());
+ let terminal = Terminal::with_options(
+ backend,
+ TerminalOptions {
+ viewport: Viewport::Inline(viewport_height),
+ },
+ )
+ .context("failed to create terminal with inline viewport")?;
+
+ Ok(Self {
+ terminal,
+ anchor_col,
+ keep_output,
+ viewport_height,
+ })
+ }
+
+ /// Returns the current viewport height.
+ ///
+ /// The viewport is fixed at creation time based on terminal size.
+ /// Content that exceeds this height will be scrolled automatically.
+ ///
+ /// The `_needed` parameter is kept for API compatibility but ignored -
+ /// we no longer attempt to resize the viewport dynamically since that
+ /// operation can fail unpredictably with inline viewports.
+ pub fn ensure_height(&mut self, _needed: u16) -> Result<u16> {
+ Ok(self.viewport_height)
+ }
+
+ /// Get the current viewport height.
+ pub fn viewport_height(&self) -> u16 {
+ self.viewport_height
+ }
+
+ /// Get mutable reference to the underlying terminal.
+ ///
+ /// Use this to perform rendering operations.
+ pub fn terminal(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {
+ &mut self.terminal
+ }
+
+ /// Get the anchor column where the inline UI should be positioned.
+ ///
+ /// This is the column position where the cursor was located when
+ /// the terminal was initialized.
+ pub fn anchor_col(&self) -> u16 {
+ self.anchor_col
+ }
+}
+
+/// Cleanup terminal state when TerminalGuard is dropped.
+///
+/// This implements TUI-08: Terminal restores correctly after normal exit.
+///
+/// # Cleanup Process
+/// 1. Conditionally clear terminal content (based on keep_output flag)
+/// 2. Disable raw mode (restore normal terminal behavior)
+///
+/// # Error Handling
+/// Errors are intentionally ignored during cleanup since:
+/// - We're already exiting and can't meaningfully handle errors
+/// - Best-effort restoration is better than panicking during Drop
+/// - The panic hook provides a second layer of safety for abnormal exits
+impl Drop for TerminalGuard {
+ fn drop(&mut self) {
+ // Clear terminal content only if keep_output is false - ignore errors (best-effort)
+ if !self.keep_output {
+ let _ = self.terminal.clear();
+ }
+
+ // Disable raw mode to restore normal terminal behavior - ignore errors
+ let _ = disable_raw_mode();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_panic_hook_installation() {
+ // Test that panic hook can be installed without error
+ install_panic_hook();
+ // Installing again should work (replaces previous hook)
+ install_panic_hook();
+ }
+
+ // Note: Cannot easily test TerminalGuard::new() in CI since it requires a TTY.
+ // Manual testing required for:
+ // 1. Non-TTY detection: echo "" | cargo run -p atuin-ai -- inline
+ // 2. Drop cleanup: Run inline command, press Esc, verify terminal is normal
+ // 3. Panic recovery: Add panic!("test") after TerminalGuard::new(), verify terminal is usable
+}
diff --git a/crates/atuin-ai/src/tui/view_model.rs b/crates/atuin-ai/src/tui/view_model.rs
new file mode 100644
index 00000000..e89932d9
--- /dev/null
+++ b/crates/atuin-ai/src/tui/view_model.rs
@@ -0,0 +1,400 @@
+//! View model types for the TUI application
+//!
+//! This module contains the view model types that represent the rendering
+//! specification. These types are derived from the domain state (conversation
+//! events) via the `Blocks::from_state()` function.
+
+use super::state::{AppMode, AppState, ConversationEvent};
+
+/// Warning classification for command suggestions
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WarningKind {
+ /// Dangerous command (! indicator, AlertError color)
+ Danger,
+ /// Low confidence answer (? indicator, AlertWarn color)
+ LowConfidence,
+}
+
+/// Content variants for blocks - each variant is fully self-describing
+#[derive(Debug, Clone)]
+pub enum Content {
+ Input {
+ text: String,
+ active: bool,
+ cursor_pos: usize,
+ },
+ /// Command suggestion (from suggest_command tool call)
+ Command {
+ text: String,
+ faded: bool, // Phase 5 feature
+ },
+ Text {
+ markdown: String,
+ },
+ Error {
+ message: String,
+ },
+ /// Warning for dangerous or low-confidence commands
+ Warning {
+ kind: WarningKind,
+ text: String,
+ pending_confirm: bool, // true when awaiting second Enter
+ },
+ Spinner {
+ frame: usize, // 0-3 for animation
+ status_text: String, // Status-based text (Processing..., Thinking..., etc.)
+ },
+ /// Tool call status display (in-flight or completed summary)
+ ToolStatus {
+ /// Number of non-suggest_command tools completed
+ completed_count: usize,
+ /// Current in-flight tool description (None if all done)
+ current_label: Option<String>,
+ /// Spinner frame for in-flight display
+ frame: usize,
+ },
+}
+
+impl Content {
+ /// Get the prefix symbol for this content type
+ pub fn prefix_symbol(&self) -> &'static str {
+ match self {
+ Content::Input { .. } => ">",
+ Content::Command { .. } => "$",
+ Content::Text { .. } => " ",
+ Content::Error { .. } => "!",
+ Content::Warning { kind, .. } => match kind {
+ WarningKind::Danger => "!",
+ WarningKind::LowConfidence => "?",
+ },
+ Content::Spinner { .. } => "/",
+ Content::ToolStatus { current_label, .. } => {
+ if current_label.is_some() {
+ "/"
+ } else {
+ "\u{2713}"
+ } // spinner or checkmark
+ }
+ }
+ }
+}
+
+/// A visual block in the UI
+#[derive(Debug, Clone)]
+pub struct Block {
+ pub content: Vec<Content>,
+ pub separator_above: bool,
+ pub title: Option<String>,
+}
+
+/// Complete view model - the rendering specification
+#[derive(Debug, Clone)]
+pub struct Blocks {
+ pub items: Vec<Block>,
+ pub footer: &'static str,
+}
+
+/// Count non-suggest_command tool calls since the last user message
+fn count_tool_calls_since_last_user(events: &[ConversationEvent]) -> (usize, Option<String>) {
+ let last_user_idx = events
+ .iter()
+ .rposition(|e| matches!(e, ConversationEvent::UserMessage { .. }))
+ .unwrap_or(0);
+
+ let mut completed = 0;
+ let mut in_flight: Option<String> = None;
+
+ for event in &events[last_user_idx..] {
+ match event {
+ ConversationEvent::ToolCall { name, .. } if name != "suggest_command" => {
+ // New tool call starts as in-flight
+ if in_flight.is_some() {
+ // Previous tool is now completed
+ completed += 1;
+ }
+ in_flight = Some(name.clone());
+ }
+ ConversationEvent::ToolResult { .. } => {
+ // Tool completed
+ if in_flight.is_some() {
+ completed += 1;
+ in_flight = None;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ (completed, in_flight)
+}
+
+/// Check if any turn in the conversation has a command
+fn has_any_command(events: &[ConversationEvent]) -> bool {
+ events.iter().any(|e| {
+ if let ConversationEvent::ToolCall { name, input, .. } = e {
+ name == "suggest_command" && input.get("command").and_then(|v| v.as_str()).is_some()
+ } else {
+ false
+ }
+ })
+}
+
+impl Blocks {
+ /// Pure function: derive the complete view model from state
+ ///
+ /// Iterates through conversation events and builds visual blocks.
+ /// Also handles streaming text and mode-dependent UI.
+ pub fn from_state(state: &AppState) -> Self {
+ let mut items = Vec::new();
+
+ // 1. Build blocks from conversation events
+ for event in &state.events {
+ match event {
+ ConversationEvent::UserMessage { content } => {
+ items.push(Block {
+ content: vec![Content::Input {
+ text: content.clone(),
+ active: false,
+ cursor_pos: 0,
+ }],
+ separator_above: false,
+ title: None,
+ });
+ }
+ ConversationEvent::Text { content } => {
+ // In Review mode with completed tool calls, prepend ToolStatus to this Text block
+ let (completed, _) = count_tool_calls_since_last_user(&state.events);
+ let mut block_content = Vec::new();
+
+ if state.mode == AppMode::Review && completed > 0 {
+ block_content.push(Content::ToolStatus {
+ completed_count: completed,
+ current_label: None,
+ frame: 0,
+ });
+ }
+
+ block_content.push(Content::Text {
+ markdown: content.clone(),
+ });
+
+ items.push(Block {
+ content: block_content,
+ separator_above: false,
+ title: None,
+ });
+ }
+ ConversationEvent::ToolCall { name, input, .. } => {
+ // Only render suggest_command tool calls with a command
+ if name == "suggest_command" {
+ let command = input.get("command").and_then(|v| v.as_str());
+
+ // Build block content - only render if command is present
+ // When command is null, this is a conversation-only turn and the
+ // response text comes via a separate Text event
+ let mut block_content = Vec::new();
+
+ if let Some(cmd) = command {
+ block_content.push(Content::Command {
+ text: cmd.to_string(),
+ faded: false,
+ });
+ }
+
+ // Extract warning data from tool call input
+ // danger: "high" | "medium" | "med" | "low" - high/medium/med trigger warning
+ let danger_level = input
+ .get("danger")
+ .and_then(|v| v.as_str())
+ .unwrap_or("low");
+ let is_dangerous = danger_level == "high"
+ || danger_level == "medium"
+ || danger_level == "med";
+ let danger_notes = input.get("danger_notes").and_then(|v| v.as_str());
+
+ // confidence: "high" | "medium" | "low" - low triggers warning
+ let confidence_level = input
+ .get("confidence")
+ .and_then(|v| v.as_str())
+ .unwrap_or("high");
+ let is_low_confidence = confidence_level == "low";
+ let confidence_notes =
+ input.get("confidence_notes").and_then(|v| v.as_str());
+
+ // Add warning content if applicable (danger takes precedence)
+ if is_dangerous {
+ if let Some(notes) = danger_notes {
+ block_content.push(Content::Warning {
+ kind: WarningKind::Danger,
+ text: notes.to_string(),
+ pending_confirm: state.confirmation_pending,
+ });
+ }
+ } else if is_low_confidence && let Some(notes) = confidence_notes {
+ block_content.push(Content::Warning {
+ kind: WarningKind::LowConfidence,
+ text: notes.to_string(),
+ pending_confirm: false, // low confidence doesn't require confirm
+ });
+ }
+
+ // Only add block if there's content
+ if !block_content.is_empty() {
+ items.push(Block {
+ content: block_content,
+ separator_above: false,
+ title: None,
+ });
+ }
+ }
+ // Other tool calls are not rendered (internal protocol)
+ }
+ ConversationEvent::ToolResult { .. } => {
+ // Tool results are not rendered (internal protocol)
+ }
+ }
+ }
+
+ // 2. AI response block (tool status + streaming text) - shown during Streaming only
+ // In Review mode, ToolStatus is handled inline with ConversationEvent::Text above
+ if state.mode == AppMode::Streaming {
+ let (completed, in_flight) = count_tool_calls_since_last_user(&state.events);
+ let mut response_content = Vec::new();
+
+ // Add tool status if there are any non-suggest_command tools
+ if completed > 0 || in_flight.is_some() {
+ response_content.push(Content::ToolStatus {
+ completed_count: completed,
+ current_label: in_flight.clone(),
+ frame: state.spinner_frame,
+ });
+ }
+
+ // Add streaming text or spinner
+ if state.streaming_text.is_empty() {
+ // Check if enough time has passed to show spinner (200ms delay)
+ // Show spinner immediately if status event has arrived
+ let should_show_spinner = state.streaming_status.is_some()
+ || state
+ .streaming_started
+ .map(|start| start.elapsed() >= std::time::Duration::from_millis(200))
+ .unwrap_or(true);
+
+ if should_show_spinner && in_flight.is_none() {
+ // Only show generating spinner if no tool is in-flight
+ let status_text = state
+ .streaming_status
+ .as_ref()
+ .map(|s| s.display_text().to_string())
+ .unwrap_or_else(|| "Generating...".to_string());
+
+ response_content.push(Content::Spinner {
+ frame: state.spinner_frame,
+ status_text,
+ });
+ }
+ } else {
+ // Show streaming text
+ response_content.push(Content::Text {
+ markdown: state.streaming_text.clone(),
+ });
+ }
+
+ // Add the response block if there's any content
+ if !response_content.is_empty() {
+ items.push(Block {
+ content: response_content,
+ separator_above: false,
+ title: None,
+ });
+ }
+ }
+
+ // 3. Mode-dependent UI
+ match state.mode {
+ AppMode::Input => {
+ // Active input uses TextArea widget, rendered directly
+ // We add a placeholder block that will be replaced by textarea rendering
+ items.push(Block {
+ content: vec![Content::Input {
+ text: state.input(),
+ active: true,
+ cursor_pos: 0, // Not used for active input - textarea handles cursor
+ }],
+ separator_above: false,
+ title: None,
+ });
+ }
+ AppMode::Generating => {
+ let status_text = state
+ .streaming_status
+ .as_ref()
+ .map(|s| s.display_text().to_string())
+ .unwrap_or_else(|| "Generating...".to_string());
+
+ items.push(Block {
+ content: vec![Content::Spinner {
+ frame: state.spinner_frame,
+ status_text,
+ }],
+ separator_above: false,
+ title: None,
+ });
+ }
+ AppMode::Streaming => {
+ // Handled above in streaming text section
+ }
+ AppMode::Review | AppMode::Error => {
+ // No additional UI elements
+ }
+ }
+
+ // 4. Error if present (renders at end)
+ if let Some(ref err) = state.error {
+ items.push(Block {
+ content: vec![Content::Error {
+ message: err.clone(),
+ }],
+ separator_above: false,
+ title: None,
+ });
+ }
+
+ // 5. Set separator flags (first has no separator)
+ for (idx, block) in items.iter_mut().enumerate() {
+ block.separator_above = idx > 0;
+ }
+
+ // 6. Set title on first block only
+ if let Some(first) = items.first_mut() {
+ first.title = Some("Ask questions or generate a command:".to_string());
+ }
+
+ // 7. Derive footer from mode and events
+ let footer = Self::footer_for_mode(&state.mode, &state.events, state.confirmation_pending);
+
+ Self { items, footer }
+ }
+
+ /// Derive footer text from current mode and conversation state
+ fn footer_for_mode(
+ mode: &AppMode,
+ events: &[ConversationEvent],
+ confirmation_pending: bool,
+ ) -> &'static str {
+ match mode {
+ AppMode::Input => "[Enter]: Accept [Esc]: Cancel",
+ AppMode::Generating | AppMode::Streaming => "[Esc]: Cancel",
+ AppMode::Review => {
+ if confirmation_pending {
+ "[Enter]: Confirm dangerous command [Esc]: Cancel"
+ } else if has_any_command(events) {
+ "[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel"
+ } else {
+ "[f]: Follow-up [Esc]: Cancel"
+ }
+ }
+ AppMode::Error => "[Enter]/[r]: Retry [Esc]: Cancel",
+ }
+ }
+}
diff --git a/crates/atuin-ai/test-renders.json b/crates/atuin-ai/test-renders.json
new file mode 100644
index 00000000..31c180fa
--- /dev/null
+++ b/crates/atuin-ai/test-renders.json
@@ -0,0 +1,295 @@
+[
+ {
+ "name": "01_empty_input",
+ "description": "Initial state with empty input prompt",
+ "state": {
+ "events": [],
+ "mode": "Input",
+ "input": "",
+ "cursor_pos": 0
+ }
+ },
+ {
+ "name": "02_typing_input",
+ "description": "User typing in input field",
+ "state": {
+ "events": [],
+ "mode": "Input",
+ "input": "list all files",
+ "cursor_pos": 14
+ }
+ },
+ {
+ "name": "03_generating_spinner",
+ "description": "Waiting for API response (spinner)",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "list all files"}
+ ],
+ "mode": "Generating",
+ "spinner_frame": 0
+ }
+ },
+ {
+ "name": "04_streaming_text",
+ "description": "Text streaming in from API",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "what is rust?"}
+ ],
+ "mode": "Streaming",
+ "streaming_text": "Rust is a systems programming language focused on safety, speed, and",
+ "spinner_frame": 2
+ }
+ },
+ {
+ "name": "05_simple_command",
+ "description": "Simple command suggestion",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "list all files"},
+ {"type": "tool_call", "id": "1", "name": "suggest_command", "input": {"command": "ls -la"}}
+ ],
+ "mode": "Review"
+ }
+ },
+ {
+ "name": "06_command_with_long_text",
+ "description": "Command that wraps to multiple lines",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "find large files"},
+ {"type": "tool_call", "id": "1", "name": "suggest_command", "input": {"command": "find /home -type f -size +100M -exec ls -lh {} \\; 2>/dev/null | sort -k5 -h"}}
+ ],
+ "mode": "Review"
+ }
+ },
+ {
+ "name": "07_conversation_only_response",
+ "description": "Response without command (conversation mode)",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "what does the -la flag do?"},
+ {"type": "text", "content": "The `-la` flags combine two options:\n\n- `-l` shows long format with permissions, owner, size, and date\n- `-a` shows all files including hidden ones (starting with .)"}
+ ],
+ "mode": "Review"
+ }
+ },
+ {
+ "name": "08_multi_turn_conversation",
+ "description": "Multiple turns of conversation",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "list all files"},
+ {"type": "tool_call", "id": "1", "name": "suggest_command", "input": {"command": "ls -la"}},
+ {"type": "user_message", "content": "can you explain those flags?"},
+ {"type": "text", "content": "The -l flag shows long format with permissions, -a shows hidden files."}
+ ],
+ "mode": "Review"
+ }
+ },
+ {
+ "name": "09_tool_call_in_progress",
+ "description": "Tool being executed (spinner)",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "what is the latest version of node?"},
+ {"type": "tool_call", "id": "1", "name": "web_search", "input": {"query": "nodejs latest version"}}
+ ],
+ "mode": "Streaming",
+ "streaming_text": "",
+ "spinner_frame": 1
+ }
+ },
+ {
+ "name": "10_tool_calls_completed_with_text",
+ "description": "Tools finished, text streaming",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "what is the latest version of node?"},
+ {"type": "tool_call", "id": "1", "name": "web_search", "input": {"query": "nodejs latest version"}},
+ {"type": "tool_result", "tool_use_id": "1", "content": "Node.js v22.0.0"}
+ ],
+ "mode": "Streaming",
+ "streaming_text": "The latest version of Node.js is v22.0.0, released in April 2024.",
+ "spinner_frame": 0
+ }
+ },
+ {
+ "name": "11_tool_calls_in_review",
+ "description": "Completed tools shown in review mode",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "what is the latest version of node?"},
+ {"type": "tool_call", "id": "1", "name": "web_search", "input": {"query": "nodejs latest version"}},
+ {"type": "tool_result", "tool_use_id": "1", "content": "Node.js v22.0.0"},
+ {"type": "tool_call", "id": "2", "name": "web_fetch", "input": {"url": "https://nodejs.org"}},
+ {"type": "tool_result", "tool_use_id": "2", "content": "..."},
+ {"type": "text", "content": "The latest version of Node.js is **v22.0.0**, released in April 2024. Key features include:\n\n- Native WebSocket client\n- Improved ES modules support\n- Better performance"}
+ ],
+ "mode": "Review"
+ }
+ },
+ {
+ "name": "12_error_state",
+ "description": "Error message displayed",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "do something"}
+ ],
+ "mode": "Error",
+ "error": "Failed to connect to API: connection timeout"
+ }
+ },
+ {
+ "name": "13_dangerous_command",
+ "description": "Dangerous command with warning",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "delete all files in home"},
+ {"type": "tool_call", "id": "1", "name": "suggest_command", "input": {
+ "command": "rm -rf ~/*",
+ "dangerous": true,
+ "warning": "This will permanently delete all files in your home directory including documents, configurations, and SSH keys."
+ }}
+ ],
+ "mode": "Review",
+ "confirmation_pending": false
+ }
+ },
+ {
+ "name": "14_dangerous_command_confirming",
+ "description": "Dangerous command awaiting second Enter",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "delete all files in home"},
+ {"type": "tool_call", "id": "1", "name": "suggest_command", "input": {
+ "command": "rm -rf ~/*",
+ "dangerous": true,
+ "warning": "This will permanently delete all files in your home directory."
+ }}
+ ],
+ "mode": "Review",
+ "confirmation_pending": true
+ }
+ },
+ {
+ "name": "15_low_confidence",
+ "description": "Low confidence command with warning",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "do that thing with the files"},
+ {"type": "tool_call", "id": "1", "name": "suggest_command", "input": {
+ "command": "ls -la",
+ "confidence": "low",
+ "warning": "I'm not entirely sure what you mean by 'that thing'. This lists files - is that what you wanted?"
+ }}
+ ],
+ "mode": "Review"
+ }
+ },
+ {
+ "name": "16_long_user_input",
+ "description": "User input that wraps",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "I need a command that will find all JavaScript files in my project, excluding node_modules, and count the total lines of code"}
+ ],
+ "mode": "Generating",
+ "spinner_frame": 0
+ }
+ },
+ {
+ "name": "17_long_text_response",
+ "description": "Long text response that wraps multiple times",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "explain git"},
+ {"type": "text", "content": "Git is a distributed version control system created by Linus Torvalds in 2005. It tracks changes to files and enables collaboration between developers. Key concepts include:\n\n- **Repository**: A directory containing your project and its history\n- **Commit**: A snapshot of your changes with a message\n- **Branch**: An independent line of development\n- **Merge**: Combining changes from different branches\n- **Remote**: A version of your repository hosted elsewhere (like GitHub)"}
+ ],
+ "mode": "Review"
+ }
+ },
+ {
+ "name": "18_streaming_with_tool_in_progress",
+ "description": "Tool in progress while streaming",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "search for rust async patterns"},
+ {"type": "text", "content": "Let me search for that..."},
+ {"type": "tool_call", "id": "1", "name": "web_search", "input": {"query": "rust async patterns"}}
+ ],
+ "mode": "Streaming",
+ "streaming_text": "",
+ "spinner_frame": 2
+ }
+ },
+ {
+ "name": "19_multiple_commands_in_conversation",
+ "description": "Multiple command suggestions across turns",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "create a new directory called test"},
+ {"type": "tool_call", "id": "1", "name": "suggest_command", "input": {"command": "mkdir test"}},
+ {"type": "user_message", "content": "now cd into it"},
+ {"type": "tool_call", "id": "2", "name": "suggest_command", "input": {"command": "cd test"}},
+ {"type": "user_message", "content": "create a file"},
+ {"type": "tool_call", "id": "3", "name": "suggest_command", "input": {"command": "touch file.txt"}}
+ ],
+ "mode": "Review"
+ }
+ },
+ {
+ "name": "20_empty_command_with_description",
+ "description": "Tool call with null command (conversation only)",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "what's the weather like?"},
+ {"type": "tool_call", "id": "1", "name": "suggest_command", "input": {
+ "command": null,
+ "description": "I can't check the weather directly, but you could use: curl wttr.in"
+ }}
+ ],
+ "mode": "Review"
+ }
+ },
+ {
+ "name": "21_status_processing",
+ "description": "Streaming with Processing status",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "analyze this code"}
+ ],
+ "mode": "Streaming",
+ "streaming_text": "",
+ "streaming_status": "Processing",
+ "spinner_frame": 0
+ }
+ },
+ {
+ "name": "22_status_thinking",
+ "description": "Streaming with Thinking status",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "how do I optimize this query?"}
+ ],
+ "mode": "Streaming",
+ "streaming_text": "",
+ "streaming_status": "Thinking",
+ "spinner_frame": 1
+ }
+ },
+ {
+ "name": "23_follow_up_input",
+ "description": "Follow-up input after command",
+ "state": {
+ "events": [
+ {"type": "user_message", "content": "list files"},
+ {"type": "tool_call", "id": "1", "name": "suggest_command", "input": {"command": "ls -la"}}
+ ],
+ "mode": "Input",
+ "input": "but only show directories",
+ "cursor_pos": 24
+ }
+ }
+]
diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs
index 1c35e6eb..a15ce461 100644
--- a/crates/atuin-client/src/settings.rs
+++ b/crates/atuin-client/src/settings.rs
@@ -477,6 +477,20 @@ pub struct Tmux {
pub height: String,
}
+#[derive(Default, Clone, Debug, Deserialize, Serialize)]
+pub struct Ai {
+ /// The address of the Atuin AI endpoint. Used for AI features like command generation.
+ /// Only necessary for custom AI endpoints.
+ pub ai_endpoint: Option<String>,
+
+ /// The API token for the Atuin AI endpoint. Used for AI features like command generation.
+ /// Only necessary for custom AI endpoints.
+ pub ai_api_token: Option<String>,
+
+ /// Whether or not to send the current working directory to the AI endpoint.
+ pub send_cwd: bool,
+}
+
impl Default for Preview {
fn default() -> Self {
Self {
@@ -836,6 +850,9 @@ pub struct Settings {
#[serde(default)]
pub meta: meta::Settings,
+
+ #[serde(default)]
+ pub ai: Ai,
}
impl Settings {
diff --git a/docs/docs/ai/introduction.md b/docs/docs/ai/introduction.md
new file mode 100644
index 00000000..39c45eec
--- /dev/null
+++ b/docs/docs/ai/introduction.md
@@ -0,0 +1,157 @@
+# Atuin AI
+
+Atuin AI is a separate binary that enables command generation and other information lookup via an LLM directly from your terminal. It is completely opt-in, and will not change the behavior of Atuin at all if you choose not to use it.
+
+Atuin AI requires an account on [Atuin Hub](https://hub.atuin.sh/), and you'll be prompted to login upon first use of the binary.
+
+## Getting Started
+
+Atuin AI currently supports zsh, bash, and fish shells. To get started, add the following to your shell's initialization file:
+
+```bash
+eval "$(atuin ai init)"
+```
+
+Once you've set it up and restarted your shell, you can invoke Atuin AI by pressing question mark (`?`) on an empty terminal line.
+
+## Settings
+
+For a list of settings that control the behavior of Atuin AI, see [its dedicated settings documentation](./settings.md).
+
+## 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┘
+```
diff --git a/docs/docs/ai/settings.md b/docs/docs/ai/settings.md
new file mode 100644
index 00000000..eb868f91
--- /dev/null
+++ b/docs/docs/ai/settings.md
@@ -0,0 +1,16 @@
+# AI Settings
+
+All the settings that control the behavior of [Atuin AI](./introduction.md) are specified in an `[ai]` section in your `config.toml`. See [the configuration documentation](../../configuration/config/) for more detailed information about Atuin's configuration system.
+
+### send_cwd
+
+Default: `false`
+
+Whether or not to include your current working directory in the context sent to the LLM. By default, only your OS and current shell are sent.
+
+**Example config**
+
+```toml
+[ai]
+send_cwd = true
+```
diff --git a/docs/docs/configuration/config.md b/docs/docs/configuration/config.md
index 8f0c6024..2bc1a682 100644
--- a/docs/docs/configuration/config.md
+++ b/docs/docs/configuration/config.md
@@ -873,3 +873,7 @@ columns = ["exit", "duration", "command"]
# Make directory expand instead of command
columns = ["duration", "time", { type = "directory", expand = true }, { type = "command", expand = false }]
```
+
+## ai
+
+The settings for Atuin AI are listed in [a separate section](../../ai/settings/).
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 26bf0bee..32df120c 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -89,6 +89,9 @@ nav:
- Docker: self-hosting/docker.md
- Kubernetes: self-hosting/kubernetes.md
- Systemd: self-hosting/systemd.md
+ - AI:
+ - Introduction: ai/introduction.md
+ - Settings: ai/settings.md
- Known Issues: known-issues.md
- Integrations: integrations.md
- FAQ: faq.md
diff --git a/docs/pyproject.toml b/docs/pyproject.toml
index e3bef982..2d58e8c7 100644
--- a/docs/pyproject.toml
+++ b/docs/pyproject.toml
@@ -11,5 +11,10 @@ dependencies = [
"mkdocs-redirects>=1.2.2",
]
+[tool.uv]
+override-dependencies = [
+ "click==8.2.1",
+]
+
[dependency-groups]
dev = []
diff --git a/docs/uv.lock b/docs/uv.lock
index 9575e6ea..e9f9b420 100644
--- a/docs/uv.lock
+++ b/docs/uv.lock
@@ -2,6 +2,9 @@ version = 1
revision = 3
requires-python = ">=3.11"
+[manifest]
+overrides = [{ name = "click", specifier = "==8.2.1" }]
+
[[package]]
name = "atuin-cli-docs"
version = "1.0.0"
@@ -133,14 +136,14 @@ wheels = [
[[package]]
name = "click"
-version = "8.3.1"
+version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
]
[[package]]