aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2026-03-04 00:47:17 +0100
committerGitHub <noreply@github.com>2026-03-04 00:47:17 +0100
commit0685cb414d2d163ef84106b37029d24aa154694c (patch)
tree3b4f68ef5a6b75b647b8b5e44dd509bd99477edb
parentfix: clear script database before rebuild to prevent unique constraint violat... (diff)
downloadatuin-0685cb414d2d163ef84106b37029d24aa154694c.zip
feat: initial draft of atuin-shell (#3206)
<!-- Thank you for making a PR! Bug fixes are always welcome, but if you're adding a new feature or changing an existing one, we'd really appreciate if you open an issue, post on the forum, or drop in on Discord --> ## Checks - [ ] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle - [ ] I have checked that there are no existing pull requests for the same thing --------- Co-authored-by: Claude <noreply@anthropic.com>
-rw-r--r--Cargo.lock308
-rw-r--r--crates/atuin-daemon/Cargo.toml3
-rw-r--r--crates/atuin-shell/Cargo.toml24
-rw-r--r--crates/atuin-shell/src/main.rs342
-rw-r--r--crates/atuin-shell/src/osc133.rs657
-rw-r--r--crates/atuin/Cargo.toml2
6 files changed, 1255 insertions, 81 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 3ab7d99d..c9575892 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -90,7 +90,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -101,7 +101,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -545,6 +545,17 @@ dependencies = [
]
[[package]]
+name = "atuin-shell"
+version = "18.13.0-beta.3"
+dependencies = [
+ "clap",
+ "crossterm",
+ "eyre",
+ "portable-pty",
+ "signal-hook",
+]
+
+[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -903,7 +914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
- "windows-sys 0.48.0",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -1362,14 +1373,14 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
name = "dispatch2"
-version = "0.3.0"
+version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.11.0",
"objc2",
@@ -1490,7 +1501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1868,19 +1879,19 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
-version = "0.4.1"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 6.0.0",
"wasip2",
"wasip3",
]
@@ -2363,10 +2374,19 @@ dependencies = [
]
[[package]]
+name = "ioctl-rs"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "ipnet"
-version = "2.11.0"
+version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
@@ -2451,9 +2471,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "js-sys"
-version = "0.3.89"
+version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4eacb0641a310445a4c513f2a5e23e19952e269c6a38887254d5f837a305506"
+checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -2515,13 +2535,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
-version = "0.1.12"
+version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
+checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"bitflags 2.11.0",
"libc",
- "redox_syscall 0.7.2",
+ "plain",
+ "redox_syscall 0.7.3",
]
[[package]]
@@ -2637,7 +2658,7 @@ version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [
- "nix",
+ "nix 0.29.0",
"winapi",
]
@@ -2686,6 +2707,15 @@ checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15"
[[package]]
name = "memoffset"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
@@ -2763,9 +2793,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minijinja"
-version = "2.16.0"
+version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c54f3bcc034dd74496b5ca929fd0b710186672d5ff0b0f255a9ceb259042ece"
+checksum = "65ab6f50e4e8fb40bd21f527066bd019f5b029035b4e5ac9b9f9ba526c6bd87b"
dependencies = [
"serde",
]
@@ -2822,6 +2852,20 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "nix"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
+dependencies = [
+ "autocfg",
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+ "memoffset 0.6.5",
+ "pin-utils",
+]
+
+[[package]]
+name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
@@ -2830,7 +2874,7 @@ dependencies = [
"cfg-if",
"cfg_aliases",
"libc",
- "memoffset",
+ "memoffset 0.9.1",
]
[[package]]
@@ -2876,13 +2920,13 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
name = "nucleo"
version = "0.5.0"
-source = "git+https://github.com/atuinsh/nucleo-ext.git?branch=main#74bd786e98f7c88d68f967855d6f57b3ac2d09ef"
+source = "git+https://github.com/atuinsh/nucleo-ext.git?rev=74bd786#74bd786e98f7c88d68f967855d6f57b3ac2d09ef"
dependencies = [
"nucleo-matcher",
"parking_lot",
@@ -2892,7 +2936,7 @@ dependencies = [
[[package]]
name = "nucleo-matcher"
version = "0.3.1"
-source = "git+https://github.com/atuinsh/nucleo-ext.git?branch=main#74bd786e98f7c88d68f967855d6f57b3ac2d09ef"
+source = "git+https://github.com/atuinsh/nucleo-ext.git?rev=74bd786#74bd786e98f7c88d68f967855d6f57b3ac2d09ef"
dependencies = [
"memchr",
"unicode-segmentation",
@@ -2972,9 +3016,9 @@ dependencies = [
[[package]]
name = "objc2"
-version = "0.6.3"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
@@ -3109,7 +3153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.45.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3316,18 +3360,18 @@ dependencies = [
[[package]]
name = "pin-project"
-version = "1.1.10"
+version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
+checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
-version = "1.1.10"
+version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
+checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
dependencies = [
"proc-macro2",
"quote",
@@ -3336,9 +3380,9 @@ dependencies = [
[[package]]
name = "pin-project-lite"
-version = "0.2.16"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
@@ -3374,6 +3418,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3404,6 +3454,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
+name = "portable-pty"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be"
+dependencies = [
+ "anyhow",
+ "bitflags 1.3.2",
+ "downcast-rs",
+ "filedescriptor",
+ "lazy_static",
+ "libc",
+ "log",
+ "nix 0.25.1",
+ "serial",
+ "shared_library",
+ "shell-words",
+ "winapi",
+ "winreg",
+]
+
+[[package]]
name = "potential_utf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3578,12 +3649,9 @@ dependencies = [
[[package]]
name = "pxfm"
-version = "0.1.27"
+version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
-dependencies = [
- "num-traits",
-]
+checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "quanta"
@@ -3617,9 +3685,9 @@ dependencies = [
[[package]]
name = "quote"
-version = "1.0.44"
+version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -3631,6 +3699,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3823,9 +3897,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
-version = "0.7.2"
+version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d94dd2f7cd932d4dc02cc8b2b50dfd38bd079a4e5d79198b99743d7fcf9a4b4"
+checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags 2.11.0",
]
@@ -4033,7 +4107,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4089,7 +4163,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4338,9 +4412,9 @@ dependencies = [
[[package]]
name = "serde_with"
-version = "3.16.1"
+version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7"
+checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
dependencies = [
"base64",
"chrono",
@@ -4357,9 +4431,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
-version = "3.16.1"
+version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
+checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
dependencies = [
"darling 0.21.3",
"proc-macro2",
@@ -4368,6 +4442,48 @@ dependencies = [
]
[[package]]
+name = "serial"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86"
+dependencies = [
+ "serial-core",
+ "serial-unix",
+ "serial-windows",
+]
+
+[[package]]
+name = "serial-core"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "serial-unix"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7"
+dependencies = [
+ "ioctl-rs",
+ "libc",
+ "serial-core",
+ "termios 0.2.2",
+]
+
+[[package]]
+name = "serial-windows"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162"
+dependencies = [
+ "libc",
+ "serial-core",
+]
+
+[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4399,6 +4515,22 @@ dependencies = [
]
[[package]]
+name = "shared_library"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
+dependencies = [
+ "lazy_static",
+ "libc",
+]
+
+[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+
+[[package]]
name = "shellexpand"
version = "3.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4468,9 +4600,9 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "sketches-ddsketch"
-version = "0.3.0"
+version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a"
+checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
[[package]]
name = "slab"
@@ -4845,10 +4977,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
- "getrandom 0.4.1",
+ "getrandom 0.4.2",
"once_cell",
"rustix",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4875,6 +5007,15 @@ dependencies = [
[[package]]
name = "termios"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "termios"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b"
@@ -4900,7 +5041,7 @@ dependencies = [
"libc",
"log",
"memmem",
- "nix",
+ "nix 0.29.0",
"num-derive",
"num-traits",
"ordered-float",
@@ -4911,7 +5052,7 @@ dependencies = [
"signal-hook",
"siphasher",
"terminfo",
- "termios",
+ "termios 0.3.3",
"thiserror 1.0.69",
"ucd-trie",
"unicode-segmentation",
@@ -5073,9 +5214,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.49.0"
+version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
+checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
@@ -5090,9 +5231,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
-version = "2.6.0"
+version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
+checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
@@ -5411,9 +5552,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tui-textarea-2"
-version = "0.9.1"
+version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75da0b78f788c39bb3292763c40cc22585806fc601b28a5978036a1f6a4c9b6c"
+checksum = "bae981fdb654241cb325bf15b78adba3ce21ad85972ebe4820ad7dc7f2884e49"
dependencies = [
"crossterm",
"portable-atomic",
@@ -5582,7 +5723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
dependencies = [
"atomic",
- "getrandom 0.4.1",
+ "getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
@@ -5684,9 +5825,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.112"
+version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05d7d0fce354c88b7982aec4400b3e7fcf723c32737cef571bd165f7613557ee"
+checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
@@ -5697,9 +5838,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.62"
+version = "0.4.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee85afca410ac4abba5b584b12e77ea225db6ee5471d0aebaae0861166f9378a"
+checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
dependencies = [
"cfg-if",
"futures-util",
@@ -5711,9 +5852,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.112"
+version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55839b71ba921e4f75b674cb16f843f4b1f3b26ddfcb3454de1cf65cc021ec0f"
+checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5721,9 +5862,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.112"
+version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "caf2e969c2d60ff52e7e98b7392ff1588bffdd1ccd4769eba27222fd3d621571"
+checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -5734,9 +5875,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.112"
+version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0861f0dcdf46ea819407495634953cdcc8a8c7215ab799a7a7ce366be71c7b30"
+checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
"unicode-ident",
]
@@ -5860,9 +6001,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.89"
+version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10053fbf9a374174094915bbce141e87a6bf32ecd9a002980db4b638405e8962"
+checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -6028,7 +6169,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6422,6 +6563,15 @@ dependencies = [
]
[[package]]
+name = "winreg"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6581,18 +6731,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.39"
+version = "0.8.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
+checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.39"
+version = "0.8.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
+checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
dependencies = [
"proc-macro2",
"quote",
diff --git a/crates/atuin-daemon/Cargo.toml b/crates/atuin-daemon/Cargo.toml
index 40f013cf..5d0b4cf1 100644
--- a/crates/atuin-daemon/Cargo.toml
+++ b/crates/atuin-daemon/Cargo.toml
@@ -38,7 +38,8 @@ tokio-stream = { version = "0.1.14", features = ["net"] }
hyper-util = "0.1"
rand.workspace = true
-nucleo = { git = "https://github.com/atuinsh/nucleo-ext.git", branch = "main" }
+nucleo = { git = "https://github.com/atuinsh/nucleo-ext.git", rev="74bd786" }
+
[target.'cfg(target_os = "linux")'.dependencies]
listenfd = "1.0.1"
diff --git a/crates/atuin-shell/Cargo.toml b/crates/atuin-shell/Cargo.toml
new file mode 100644
index 00000000..c14072dd
--- /dev/null
+++ b/crates/atuin-shell/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "atuin-shell"
+edition = "2024"
+description = "a terminal emulator for atuin"
+
+version = { workspace = true }
+authors = { workspace = true }
+rust-version = { workspace = true }
+license = { workspace = true }
+homepage = { workspace = true }
+repository = { workspace = true }
+
+[[bin]]
+name = "atuin-shell"
+path = "src/main.rs"
+
+[dependencies]
+clap = { workspace = true }
+
+[target.'cfg(all(unix, not(target_os = "illumos")))'.dependencies]
+crossterm = { workspace = true }
+eyre = { workspace = true }
+portable-pty = "0.8"
+signal-hook = "0.3"
diff --git a/crates/atuin-shell/src/main.rs b/crates/atuin-shell/src/main.rs
new file mode 100644
index 00000000..337237de
--- /dev/null
+++ b/crates/atuin-shell/src/main.rs
@@ -0,0 +1,342 @@
+mod osc133;
+
+use clap::{Args, Parser, Subcommand, ValueEnum};
+
+#[derive(Parser, Debug)]
+#[command(infer_subcommands = true)]
+struct Cli {
+ #[command(subcommand)]
+ command: Option<Cmd>,
+}
+
+#[derive(Subcommand, Debug)]
+enum Cmd {
+ /// Print shell code to initialize atuin-shell on shell startup
+ Init(Init),
+}
+
+#[derive(Args, Debug)]
+struct Init {
+ /// Shell to generate init for. If omitted, attempt auto-detection
+ #[arg(value_enum)]
+ shell: Option<Shell>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
+#[value(rename_all = "lower")]
+#[allow(clippy::enum_variant_names, clippy::doc_markdown)]
+enum Shell {
+ /// Zsh setup
+ Zsh,
+ /// Bash setup
+ Bash,
+ /// Fish setup
+ Fish,
+}
+
+impl Shell {
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::Bash => "bash",
+ Self::Zsh => "zsh",
+ Self::Fish => "fish",
+ }
+ }
+}
+
+impl Init {
+ fn run(self) -> Result<(), String> {
+ let shell = detect_shell(self.shell)?;
+ let script = render_init(shell);
+ print!("{script}");
+ Ok(())
+ }
+}
+
+fn detect_shell(cli_shell: Option<Shell>) -> Result<Shell, String> {
+ if let Some(shell) = cli_shell {
+ return Ok(shell);
+ }
+
+ if let Ok(shell) = std::env::var("ATUIN_SHELL")
+ && let Some(shell) = shell_from_name(&shell)
+ {
+ return Ok(shell);
+ }
+
+ if let Ok(shell) = std::env::var("SHELL")
+ && let Some(shell) = shell_from_name(&shell)
+ {
+ return Ok(shell);
+ }
+
+ Err(
+ "could not detect a supported shell. Please specify one explicitly: bash, zsh, or fish"
+ .to_string(),
+ )
+}
+
+fn shell_from_name(name: &str) -> Option<Shell> {
+ let shell = name
+ .trim()
+ .rsplit('/')
+ .next()
+ .unwrap_or(name)
+ .trim_start_matches('-')
+ .to_ascii_lowercase();
+
+ match shell.as_str() {
+ "bash" => Some(Shell::Bash),
+ "zsh" => Some(Shell::Zsh),
+ "fish" => Some(Shell::Fish),
+ _ => None,
+ }
+}
+
+fn init_command(shell: Shell) -> String {
+ format!("atuin init {}", shell.as_str())
+}
+
+fn render_init(shell: Shell) -> String {
+ let init_command = init_command(shell);
+
+ match shell {
+ Shell::Bash | Shell::Zsh => format!(
+ r#"if [[ "$-" == *i* ]] && [[ -t 0 ]] && [[ -t 1 ]]; then
+ _atuin_shell_tmux_current="${{TMUX:-}}"
+ _atuin_shell_tmux_previous="${{ATUIN_SHELL_TMUX:-}}"
+
+ if [[ -z "${{ATUIN_SHELL_ACTIVE:-}}" ]] || [[ "$_atuin_shell_tmux_current" != "$_atuin_shell_tmux_previous" ]]; then
+ export ATUIN_SHELL_ACTIVE=1
+ export ATUIN_SHELL_TMUX="$_atuin_shell_tmux_current"
+ exec atuin-shell
+ fi
+
+ unset _atuin_shell_tmux_current _atuin_shell_tmux_previous
+fi
+
+eval "$({init_command})"
+"#
+ ),
+ Shell::Fish => format!(
+ r#"if status is-interactive; and test -t 0; and test -t 1
+ set -l _atuin_shell_tmux_current ""
+ if set -q TMUX
+ set _atuin_shell_tmux_current "$TMUX"
+ end
+
+ set -l _atuin_shell_tmux_previous ""
+ if set -q ATUIN_SHELL_TMUX
+ set _atuin_shell_tmux_previous "$ATUIN_SHELL_TMUX"
+ end
+
+ if not set -q ATUIN_SHELL_ACTIVE
+ set -gx ATUIN_SHELL_ACTIVE 1
+ set -gx ATUIN_SHELL_TMUX "$_atuin_shell_tmux_current"
+ exec atuin-shell
+ else if test "$_atuin_shell_tmux_current" != "$_atuin_shell_tmux_previous"
+ set -gx ATUIN_SHELL_ACTIVE 1
+ set -gx ATUIN_SHELL_TMUX "$_atuin_shell_tmux_current"
+ exec atuin-shell
+ end
+end
+
+{init_command} | source
+"#
+ ),
+ }
+}
+
+fn main() {
+ let cli = Cli::parse();
+
+ match cli.command {
+ Some(Cmd::Init(init)) => {
+ if let Err(err) = init.run() {
+ eprintln!("atuin-shell: {err}");
+ std::process::exit(1);
+ }
+ }
+ None => app::main(),
+ }
+}
+
+#[cfg(any(not(unix), target_os = "illumos"))]
+mod app {
+ pub(crate) fn main() {
+ eprintln!("atuin-shell currently supports unix platforms excluding illumos");
+ std::process::exit(1);
+ }
+}
+
+#[cfg(all(unix, not(target_os = "illumos")))]
+mod app {
+ use std::io::{Read, Write};
+
+ use crossterm::terminal;
+ use portable_pty::{CommandBuilder, PtySize, native_pty_system};
+
+ pub(crate) fn main() {
+ if let Err(e) = run() {
+ let _ = terminal::disable_raw_mode();
+ eprintln!("atuin-shell: {e:#}");
+ std::process::exit(1);
+ }
+ }
+
+ fn run() -> eyre::Result<()> {
+ let (cols, rows) = terminal::size()?;
+
+ let pty_system = native_pty_system();
+ let pair = pty_system
+ .openpty(PtySize {
+ rows,
+ cols,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .map_err(|e| eyre::eyre!("{e:#}"))?;
+
+ let mut cmd = CommandBuilder::new_default_prog();
+ cmd.cwd(std::env::current_dir()?);
+ let mut child = pair
+ .slave
+ .spawn_command(cmd)
+ .map_err(|e| eyre::eyre!("{e:#}"))?;
+
+ // Close slave side in parent process
+ drop(pair.slave);
+
+ let mut pty_reader = pair
+ .master
+ .try_clone_reader()
+ .map_err(|e| eyre::eyre!("{e:#}"))?;
+ let mut pty_writer = pair
+ .master
+ .take_writer()
+ .map_err(|e| eyre::eyre!("{e:#}"))?;
+
+ // Handle terminal resize via SIGWINCH
+ {
+ use signal_hook::consts::SIGWINCH;
+ use signal_hook::iterator::Signals;
+
+ let master = pair.master;
+ let mut signals = Signals::new([SIGWINCH])?;
+
+ std::thread::spawn(move || {
+ for _ in signals.forever() {
+ if let Ok((cols, rows)) = terminal::size() {
+ let _ = master.resize(PtySize {
+ rows,
+ cols,
+ pixel_width: 0,
+ pixel_height: 0,
+ });
+ }
+ }
+ });
+ }
+
+ terminal::enable_raw_mode()?;
+
+ // PTY -> stdout (with OSC 133 parsing)
+ let stdout_thread = std::thread::spawn(move || {
+ let mut stdout = std::io::stdout();
+ let mut parser = crate::osc133::Parser::new();
+ let mut buf = [0u8; 8192];
+ loop {
+ match pty_reader.read(&mut buf) {
+ Ok(0) | Err(_) => break,
+ Ok(n) => {
+ parser.push(&buf[..n], |_event| {
+ // Zone transitions are tracked inside the parser.
+ // Callers can query parser.zone() after push.
+ });
+ if stdout.write_all(&buf[..n]).is_err() {
+ break;
+ }
+ let _ = stdout.flush();
+ }
+ }
+ }
+ });
+
+ // stdin -> PTY
+ std::thread::spawn(move || {
+ let mut stdin = std::io::stdin();
+ let mut buf = [0u8; 8192];
+ loop {
+ match stdin.read(&mut buf) {
+ Ok(0) | Err(_) => break,
+ Ok(n) => {
+ if pty_writer.write_all(&buf[..n]).is_err() {
+ break;
+ }
+ }
+ }
+ }
+ });
+
+ let status = child.wait()?;
+ let _ = stdout_thread.join();
+
+ let _ = terminal::disable_raw_mode();
+
+ std::process::exit(process_exit_code(status.exit_code()));
+ }
+
+ fn process_exit_code(code: u32) -> i32 {
+ i32::try_from(code).unwrap_or(1)
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use super::process_exit_code;
+
+ #[test]
+ fn process_exit_code_preserves_valid_values() {
+ assert_eq!(process_exit_code(0), 0);
+ assert_eq!(process_exit_code(127), 127);
+ assert_eq!(process_exit_code(i32::MAX as u32), i32::MAX);
+ }
+
+ #[test]
+ fn process_exit_code_defaults_when_out_of_range() {
+ assert_eq!(process_exit_code(i32::MAX as u32 + 1), 1);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Shell, init_command, render_init, shell_from_name};
+
+ #[test]
+ fn shell_from_name_handles_paths() {
+ assert_eq!(shell_from_name("/bin/zsh"), Some(Shell::Zsh));
+ assert_eq!(shell_from_name("/usr/local/bin/bash"), Some(Shell::Bash));
+ assert_eq!(shell_from_name("fish"), Some(Shell::Fish));
+ }
+
+ #[test]
+ fn init_command_is_bootstrap_only() {
+ let command = init_command(Shell::Zsh);
+ assert_eq!(command, "atuin init zsh");
+ }
+
+ #[test]
+ fn posix_init_uses_exec_and_tmux_guard() {
+ let script = render_init(Shell::Bash);
+ assert!(script.contains("exec atuin-shell"));
+ assert!(script.contains("ATUIN_SHELL_TMUX"));
+ assert!(script.contains("eval \"$(atuin init bash)\""));
+ }
+
+ #[test]
+ fn fish_init_uses_source() {
+ let script = render_init(Shell::Fish);
+ assert!(script.contains("exec atuin-shell"));
+ assert!(script.contains("atuin init fish | source"));
+ }
+}
diff --git a/crates/atuin-shell/src/osc133.rs b/crates/atuin-shell/src/osc133.rs
new file mode 100644
index 00000000..d6ee1220
--- /dev/null
+++ b/crates/atuin-shell/src/osc133.rs
@@ -0,0 +1,657 @@
+//! Streaming parser for OSC 133 (FinalTerm semantic prompt) escape sequences.
+//!
+//! OSC 133 marks four regions of a shell interaction:
+//!
+//! | Marker | Meaning |
+//! |--------|--------------------------------------|
+//! | A | Prompt is about to be printed |
+//! | B | Prompt ended — command input begins |
+//! | C | Command submitted — output begins |
+//! | D[;n] | Command finished with exit code *n* |
+//!
+//! The wire format is `ESC ] 133 ; <cmd> [; <params>] ST` where ST is either
+//! BEL (0x07) or ESC \ (0x1B 0x5C).
+//!
+//! # Design goals
+//!
+//! * **Zero-copy** — the parser observes the byte stream without buffering or
+//! modifying it.
+//! * **Zero-alloc** — after construction no heap allocation occurs.
+//! * **Non-blocking** — [`Parser::push`] processes whatever bytes are available
+//! and returns immediately.
+//! * **Transparent** — the caller is responsible for forwarding bytes to their
+//! destination; the parser only emits [`Event`]s through a callback.
+
+/// Events emitted when an OSC 133 marker is detected.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Event {
+ /// `ESC ] 133 ; A ST` — the shell is about to display its prompt.
+ PromptStart,
+ /// `ESC ] 133 ; B ST` — the prompt has ended; the user may type a command.
+ CommandStart,
+ /// `ESC ] 133 ; C ST` — the command has been submitted for execution.
+ CommandExecuted,
+ /// `ESC ] 133 ; D [; <exit_code>] ST` — command output is complete.
+ CommandFinished {
+ /// The exit code reported after the `;`, if present and valid.
+ exit_code: Option<i32>,
+ },
+}
+
+/// The current semantic zone as determined by the most recent OSC 133 marker.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+#[allow(dead_code)]
+pub enum Zone {
+ /// No marker seen yet, or after a `D` marker (between commands).
+ #[default]
+ Unknown,
+ /// Between `A` and `B` — the shell is rendering its prompt.
+ Prompt,
+ /// Between `B` and `C` — the user is editing a command line.
+ Input,
+ /// Between `C` and `D` — command output is being produced.
+ Output,
+}
+
+// ---------------------------------------------------------------------------
+// Internal constants
+// ---------------------------------------------------------------------------
+
+const ESC: u8 = 0x1B;
+const BEL: u8 = 0x07;
+const BACKSLASH: u8 = b'\\';
+const RIGHT_BRACKET: u8 = b']';
+
+/// Maximum bytes we'll buffer for the OSC parameter string. 32 bytes is far
+/// more than any valid OSC 133 payload needs (e.g. `133;D;127` is 9 bytes).
+/// Longer (non-133) OSC sequences simply stop accumulating once the buffer is
+/// full — the dispatch logic will harmlessly ignore them.
+const PARAM_BUF_CAP: usize = 32;
+
+// ---------------------------------------------------------------------------
+// State machine
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum State {
+ /// Normal pass-through.
+ Ground,
+ /// Saw ESC (0x1B).
+ Esc,
+ /// Inside an OSC sequence (`ESC ]`), accumulating parameter bytes.
+ OscParam,
+ /// Inside an OSC sequence, saw ESC — next byte decides if this is `ESC \`
+ /// (string terminator) or something else.
+ OscEsc,
+}
+
+/// A streaming, zero-allocation parser for OSC 133 escape sequences.
+///
+/// Feed arbitrary byte slices into [`Parser::push`]. The parser detects
+/// OSC 133 markers and reports [`Event`]s through a caller-supplied callback
+/// without modifying the data. It can sit transparently between a PTY reader
+/// and stdout.
+pub struct Parser {
+ state: State,
+ zone: Zone,
+ param_buf: [u8; PARAM_BUF_CAP],
+ param_len: usize,
+}
+
+impl Default for Parser {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Parser {
+ /// Create a new parser in the initial (ground / unknown-zone) state.
+ #[inline]
+ pub fn new() -> Self {
+ Self {
+ state: State::Ground,
+ zone: Zone::Unknown,
+ param_buf: [0u8; PARAM_BUF_CAP],
+ param_len: 0,
+ }
+ }
+
+ /// The current semantic zone based on markers seen so far.
+ #[inline]
+ #[allow(dead_code)]
+ pub fn zone(&self) -> Zone {
+ self.zone
+ }
+
+ /// Process a chunk of bytes, calling `on_event` for every OSC 133 marker
+ /// found.
+ ///
+ /// All bytes in `data` should still be forwarded to the terminal by the
+ /// caller — this method only *observes* the stream.
+ #[inline]
+ pub fn push(&mut self, data: &[u8], mut on_event: impl FnMut(Event)) {
+ for &byte in data {
+ match self.state {
+ State::Ground => {
+ if byte == ESC {
+ self.state = State::Esc;
+ }
+ }
+ State::Esc => {
+ if byte == RIGHT_BRACKET {
+ self.state = State::OscParam;
+ self.param_len = 0;
+ } else {
+ self.state = State::Ground;
+ }
+ }
+ State::OscParam => {
+ if byte == BEL {
+ self.dispatch(&mut on_event);
+ self.state = State::Ground;
+ } else if byte == ESC {
+ self.state = State::OscEsc;
+ } else if self.param_len < PARAM_BUF_CAP {
+ self.param_buf[self.param_len] = byte;
+ self.param_len += 1;
+ }
+ // If param_len == PARAM_BUF_CAP we silently stop
+ // accumulating — dispatch will ignore non-133 sequences.
+ }
+ State::OscEsc => {
+ if byte == BACKSLASH {
+ self.dispatch(&mut on_event);
+ }
+ // Whether we got a valid ST or not, return to ground.
+ // (A new ESC ] would restart accumulation via the Ground
+ // -> Esc -> OscParam path on the *next* byte.)
+ self.state = State::Ground;
+ }
+ }
+ }
+ }
+
+ /// Inspect the accumulated parameter buffer. If it holds an OSC 133
+ /// payload, emit the corresponding [`Event`] and update the zone.
+ #[inline]
+ fn dispatch(&mut self, on_event: &mut impl FnMut(Event)) {
+ let params = &self.param_buf[..self.param_len];
+
+ // Must start with "133;"
+ if params.len() < 5 || &params[..4] != b"133;" {
+ return;
+ }
+
+ let cmd = params[4];
+ let event = match cmd {
+ b'A' => {
+ self.zone = Zone::Prompt;
+ Event::PromptStart
+ }
+ b'B' => {
+ self.zone = Zone::Input;
+ Event::CommandStart
+ }
+ b'C' => {
+ self.zone = Zone::Output;
+ Event::CommandExecuted
+ }
+ b'D' => {
+ let exit_code = if params.len() > 6 && params[5] == b';' {
+ std::str::from_utf8(&params[6..])
+ .ok()
+ .and_then(|s| s.parse::<i32>().ok())
+ } else {
+ None
+ };
+ self.zone = Zone::Unknown;
+ Event::CommandFinished { exit_code }
+ }
+ _ => return,
+ };
+
+ on_event(event);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Collect all events from a single `push` call.
+ fn parse_events(data: &[u8]) -> Vec<Event> {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+ parser.push(data, |e| events.push(e));
+ events
+ }
+
+ // -- Basic event detection ------------------------------------------------
+
+ #[test]
+ fn detect_prompt_start_bel() {
+ let data = b"\x1b]133;A\x07";
+ assert_eq!(parse_events(data), vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn detect_prompt_start_st() {
+ let data = b"\x1b]133;A\x1b\\";
+ assert_eq!(parse_events(data), vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn detect_command_start_bel() {
+ let data = b"\x1b]133;B\x07";
+ assert_eq!(parse_events(data), vec![Event::CommandStart]);
+ }
+
+ #[test]
+ fn detect_command_start_st() {
+ let data = b"\x1b]133;B\x1b\\";
+ assert_eq!(parse_events(data), vec![Event::CommandStart]);
+ }
+
+ #[test]
+ fn detect_command_executed_bel() {
+ let data = b"\x1b]133;C\x07";
+ assert_eq!(parse_events(data), vec![Event::CommandExecuted]);
+ }
+
+ #[test]
+ fn detect_command_executed_st() {
+ let data = b"\x1b]133;C\x1b\\";
+ assert_eq!(parse_events(data), vec![Event::CommandExecuted]);
+ }
+
+ #[test]
+ fn detect_command_finished_no_exit_code() {
+ let data = b"\x1b]133;D\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished { exit_code: None }]
+ );
+ }
+
+ #[test]
+ fn detect_command_finished_exit_zero() {
+ let data = b"\x1b]133;D;0\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished { exit_code: Some(0) }]
+ );
+ }
+
+ #[test]
+ fn detect_command_finished_exit_nonzero() {
+ let data = b"\x1b]133;D;127\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished {
+ exit_code: Some(127)
+ }]
+ );
+ }
+
+ #[test]
+ fn detect_command_finished_negative_exit_code() {
+ let data = b"\x1b]133;D;-1\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished {
+ exit_code: Some(-1)
+ }]
+ );
+ }
+
+ #[test]
+ fn detect_command_finished_exit_code_st() {
+ let data = b"\x1b]133;D;42\x1b\\";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished {
+ exit_code: Some(42)
+ }]
+ );
+ }
+
+ #[test]
+ fn invalid_exit_code_yields_none() {
+ let data = b"\x1b]133;D;abc\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished { exit_code: None }]
+ );
+ }
+
+ // -- Zone tracking --------------------------------------------------------
+
+ #[test]
+ fn zone_starts_unknown() {
+ let parser = Parser::new();
+ assert_eq!(parser.zone(), Zone::Unknown);
+ }
+
+ #[test]
+ fn full_zone_cycle() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b]133;A\x07", |e| events.push(e));
+ assert_eq!(parser.zone(), Zone::Prompt);
+
+ parser.push(b"\x1b]133;B\x07", |e| events.push(e));
+ assert_eq!(parser.zone(), Zone::Input);
+
+ parser.push(b"\x1b]133;C\x07", |e| events.push(e));
+ assert_eq!(parser.zone(), Zone::Output);
+
+ parser.push(b"\x1b]133;D;0\x07", |e| events.push(e));
+ assert_eq!(parser.zone(), Zone::Unknown);
+
+ assert_eq!(
+ events,
+ vec![
+ Event::PromptStart,
+ Event::CommandStart,
+ Event::CommandExecuted,
+ Event::CommandFinished { exit_code: Some(0) },
+ ]
+ );
+ }
+
+ // -- Multiple events in one push ------------------------------------------
+
+ #[test]
+ fn multiple_events_single_push() {
+ let data = b"\x1b]133;A\x07$ \x1b]133;B\x07ls\n\x1b]133;C\x07file.txt\n\x1b]133;D;0\x07";
+ let events = parse_events(data);
+ assert_eq!(
+ events,
+ vec![
+ Event::PromptStart,
+ Event::CommandStart,
+ Event::CommandExecuted,
+ Event::CommandFinished { exit_code: Some(0) },
+ ]
+ );
+ }
+
+ // -- Split across push boundaries -----------------------------------------
+
+ #[test]
+ fn split_esc_and_bracket() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b", |e| events.push(e));
+ assert!(events.is_empty());
+
+ parser.push(b"]133;A\x07", |e| events.push(e));
+ assert_eq!(events, vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn split_mid_param() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b]13", |e| events.push(e));
+ assert!(events.is_empty());
+
+ parser.push(b"3;D;42\x07", |e| events.push(e));
+ assert_eq!(
+ events,
+ vec![Event::CommandFinished {
+ exit_code: Some(42)
+ }]
+ );
+ }
+
+ #[test]
+ fn split_before_terminator() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b]133;B", |e| events.push(e));
+ assert!(events.is_empty());
+
+ parser.push(b"\x07", |e| events.push(e));
+ assert_eq!(events, vec![Event::CommandStart]);
+ }
+
+ #[test]
+ fn split_esc_backslash_terminator() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b]133;C\x1b", |e| events.push(e));
+ assert!(events.is_empty());
+
+ parser.push(b"\\", |e| events.push(e));
+ assert_eq!(events, vec![Event::CommandExecuted]);
+ }
+
+ // -- Interleaved normal text ----------------------------------------------
+
+ #[test]
+ fn normal_text_before_and_after() {
+ let data = b"hello world\x1b]133;A\x07prompt text\x1b]133;B\x07command";
+ let events = parse_events(data);
+ assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);
+ }
+
+ // -- Non-133 OSC sequences (should be ignored) ----------------------------
+
+ #[test]
+ fn non_133_osc_ignored() {
+ let data = b"\x1b]0;window title\x07\x1b]133;A\x07";
+ let events = parse_events(data);
+ assert_eq!(events, vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn osc_7_ignored() {
+ let data = b"\x1b]7;file:///home/user\x07";
+ assert!(parse_events(data).is_empty());
+ }
+
+ // -- Unknown command letter -----------------------------------------------
+
+ #[test]
+ fn unknown_command_ignored() {
+ let data = b"\x1b]133;Z\x07";
+ assert!(parse_events(data).is_empty());
+ }
+
+ // -- Malformed sequences --------------------------------------------------
+
+ #[test]
+ fn esc_followed_by_non_bracket() {
+ let data = b"\x1b[31m\x1b]133;A\x07";
+ let events = parse_events(data);
+ assert_eq!(events, vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn lone_esc_at_end_of_chunk() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b", |e| events.push(e));
+ assert!(events.is_empty());
+
+ // Feed non-bracket to abort the escape, then a real sequence.
+ parser.push(b"x\x1b]133;A\x07", |e| events.push(e));
+ assert_eq!(events, vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn truncated_133_prefix() {
+ // "13" followed by terminator — not "133;" so no event.
+ let data = b"\x1b]13\x07";
+ assert!(parse_events(data).is_empty());
+ }
+
+ #[test]
+ fn empty_osc() {
+ let data = b"\x1b]\x07";
+ assert!(parse_events(data).is_empty());
+ }
+
+ // -- Buffer overflow (very long non-133 OSC) ------------------------------
+
+ #[test]
+ fn very_long_osc_does_not_panic() {
+ let mut data = Vec::new();
+ data.extend_from_slice(b"\x1b]");
+ data.extend(std::iter::repeat(b'x').take(1000));
+ data.push(BEL);
+ // Should not panic and should produce no event.
+ assert!(parse_events(&data).is_empty());
+ }
+
+ // -- Empty input ----------------------------------------------------------
+
+ #[test]
+ fn empty_input() {
+ assert!(parse_events(b"").is_empty());
+ }
+
+ #[test]
+ fn only_normal_text() {
+ let data = b"just some regular terminal output\r\n";
+ assert!(parse_events(data).is_empty());
+ }
+
+ // -- Repeated prompts (empty command) ------------------------------------
+
+ #[test]
+ fn repeated_prompt_cycle() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ // User hits enter on an empty prompt twice.
+ let data = b"\x1b]133;A\x07$ \x1b]133;B\x07\x1b]133;D\x07\x1b]133;A\x07$ \x1b]133;B\x07";
+ parser.push(data, |e| events.push(e));
+
+ assert_eq!(
+ events,
+ vec![
+ Event::PromptStart,
+ Event::CommandStart,
+ Event::CommandFinished { exit_code: None },
+ Event::PromptStart,
+ Event::CommandStart,
+ ]
+ );
+ assert_eq!(parser.zone(), Zone::Input);
+ }
+
+ // -- Byte-at-a-time feeding -----------------------------------------------
+
+ #[test]
+ fn byte_at_a_time() {
+ let data = b"\x1b]133;D;99\x07";
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ for &byte in data {
+ parser.push(&[byte], |e| events.push(e));
+ }
+
+ assert_eq!(
+ events,
+ vec![Event::CommandFinished {
+ exit_code: Some(99)
+ }]
+ );
+ }
+
+ // -- Mixed terminators ----------------------------------------------------
+
+ #[test]
+ fn mixed_bel_and_st_terminators() {
+ let data = b"\x1b]133;A\x07\x1b]133;B\x1b\\\x1b]133;C\x07\x1b]133;D;1\x1b\\";
+ let events = parse_events(data);
+ assert_eq!(
+ events,
+ vec![
+ Event::PromptStart,
+ Event::CommandStart,
+ Event::CommandExecuted,
+ Event::CommandFinished { exit_code: Some(1) },
+ ]
+ );
+ }
+
+ // -- Default trait --------------------------------------------------------
+
+ #[test]
+ fn parser_default() {
+ let parser = Parser::default();
+ assert_eq!(parser.zone(), Zone::Unknown);
+ }
+
+ #[test]
+ fn zone_default() {
+ assert_eq!(Zone::default(), Zone::Unknown);
+ }
+
+ // -- D with empty exit code field -----------------------------------------
+
+ #[test]
+ fn d_with_semicolon_but_empty_code() {
+ // "133;D;" — semicolon present but no digits.
+ let data = b"\x1b]133;D;\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished { exit_code: None }]
+ );
+ }
+
+ // -- Consecutive OSC sequences without gap --------------------------------
+
+ #[test]
+ fn back_to_back_osc_no_gap() {
+ let data = b"\x1b]133;A\x07\x1b]133;B\x07";
+ let events = parse_events(data);
+ assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);
+ }
+
+ // -- CSI sequences interleaved (should not confuse parser) ----------------
+
+ #[test]
+ fn csi_sequences_ignored() {
+ // CSI (ESC [) color codes mixed with OSC 133.
+ let data = b"\x1b[32m\x1b]133;A\x07\x1b[0m$ \x1b]133;B\x07";
+ let events = parse_events(data);
+ assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);
+ }
+
+ // -- Large exit codes -----------------------------------------------------
+
+ #[test]
+ fn large_exit_code() {
+ let data = b"\x1b]133;D;2147483647\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished {
+ exit_code: Some(i32::MAX)
+ }]
+ );
+ }
+
+ #[test]
+ fn overflow_exit_code_yields_none() {
+ let data = b"\x1b]133;D;9999999999999\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished { exit_code: None }]
+ );
+ }
+}
diff --git a/crates/atuin/Cargo.toml b/crates/atuin/Cargo.toml
index 5f1fb189..c3f8c786 100644
--- a/crates/atuin/Cargo.toml
+++ b/crates/atuin/Cargo.toml
@@ -85,7 +85,7 @@ uuid = { workspace = true }
sysinfo = "0.30.7"
regex = "1.10.5"
norm = { version = "0.1.1", features = ["fzf-v2"] }
-nucleo-matcher = { git = "https://github.com/atuinsh/nucleo-ext.git", branch = "main" }
+nucleo-matcher = { git = "https://github.com/atuinsh/nucleo-ext.git", rev="74bd786" }
tempfile = { workspace = true }
shlex = "1.3.0"