aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar lancebord 2026-03-06 18:09:52 -0500
committerGravatar lancebord 2026-03-06 18:09:52 -0500
commitffb9c05de1c755dbddd8b67cca1d6023b213115f (patch)
treefa8c375fc6489871b3539e15f39310dad99b3618
initial commit
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock948
-rw-r--r--Cargo.toml26
-rw-r--r--LICENSE19
-rw-r--r--src/client/event.rs50
-rw-r--r--src/client/handler.rs250
-rw-r--r--src/client/mod.rs176
-rw-r--r--src/client/state.rs62
-rw-r--r--src/connection/mod.rs82
-rw-r--r--src/lib.rs3
-rw-r--r--src/main.rs268
-rw-r--r--src/proto/codec.rs98
-rw-r--r--src/proto/error.rs25
-rw-r--r--src/proto/message.rs163
-rw-r--r--src/proto/mod.rs5
-rw-r--r--src/proto/parser.rs170
-rw-r--r--src/proto/serializer.rs78
-rw-r--r--src/tui/app.rs130
-rw-r--r--src/tui/mod.rs2
-rw-r--r--src/tui/ui.rs252
20 files changed, 2808 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..0e13054
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,948 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "compact_str"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
+[[package]]
+name = "crossterm"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+dependencies = [
+ "bitflags",
+ "crossterm_winapi",
+ "mio",
+ "parking_lot",
+ "rustix",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "instability"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
+dependencies = [
+ "darling",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.182"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "mio"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "ratatui"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
+dependencies = [
+ "bitflags",
+ "cassowary",
+ "compact_str",
+ "crossterm",
+ "indoc",
+ "instability",
+ "itertools",
+ "lru",
+ "paste",
+ "strum",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width 0.2.0",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "speakez"
+version = "0.1.0"
+dependencies = [
+ "bytes",
+ "crossterm",
+ "futures",
+ "ratatui",
+ "thiserror",
+ "tokio",
+ "tokio-test",
+ "tokio-util",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tokio"
+version = "1.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-test"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545"
+dependencies = [
+ "futures-core",
+ "tokio",
+ "tokio-stream",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
+[[package]]
+name = "unicode-truncate"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
+dependencies = [
+ "itertools",
+ "unicode-segmentation",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..8f4f54d
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "speakez"
+version = "0.1.0"
+edition = "2024"
+
+[[bin]]
+name = "speakez"
+path = "src/main.rs"
+
+[lib]
+name = "irc_client"
+path = "src/lib.rs"
+
+[dependencies]
+tokio = { version = "1", features = ["full"] }
+tokio-util = { version = "0.7", features = ["codec"] }
+bytes = "1"
+futures = "0.3"
+thiserror = "1"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+ratatui = "0.29"
+crossterm = "0.28"
+
+[dev-dependencies]
+tokio-test = "0.4"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ac4bbee
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2026 Lance Borden
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/client/event.rs b/src/client/event.rs
new file mode 100644
index 0000000..d34908d
--- /dev/null
+++ b/src/client/event.rs
@@ -0,0 +1,50 @@
+/// Events produced by the IRC client and surfaced to your application.
+/// Match on these in your main loop to drive UI, bot logic, etc.
+#[derive(Debug, Clone)]
+pub enum Event {
+ /// Successfully registered with the server (001 received)
+ Connected { server: String, nick: String },
+
+ /// A PRIVMSG or NOTICE in a channel or as a PM
+ Message {
+ from: String,
+ target: String,
+ text: String,
+ is_notice: bool,
+ },
+
+ /// We joined a channel
+ Joined { channel: String },
+
+ /// We or someone else left a channel
+ Parted {
+ channel: String,
+ nick: String,
+ reason: Option<String>,
+ },
+
+ /// Someone quit the server
+ Quit {
+ nick: String,
+ reason: Option<String>,
+ },
+
+ /// A nick change (could be ours)
+ NickChanged { old_nick: String, new_nick: String },
+
+ /// Channel topic was set or changed
+ Topic { channel: String, topic: String },
+
+ /// NAMES list entry (members of a channel)
+ Names {
+ channel: String,
+ members: Vec<String>,
+ },
+
+ /// A raw message we didn't handle specifically
+ /// Useful for debugging or handling custom commands
+ Raw(crate::proto::message::IrcMessage),
+
+ /// The connection was closed
+ Disconnected,
+}
diff --git a/src/client/handler.rs b/src/client/handler.rs
new file mode 100644
index 0000000..91aa5ea
--- /dev/null
+++ b/src/client/handler.rs
@@ -0,0 +1,250 @@
+use tracing::{debug, warn};
+
+use crate::client::event::Event;
+use crate::client::state::{ClientState, RegistrationState};
+use crate::connection::Sender;
+use crate::proto::message::{Command, IrcMessage, Prefix};
+use crate::proto::serializer::serialize;
+
+/// Dispatch a single incoming `IrcMessage`, updating `state` and returning
+/// zero or more `Event`s for the application to handle.
+pub fn handle(msg: IrcMessage, state: &mut ClientState, sender: &Sender) -> Vec<Event> {
+ let mut events = Vec::new();
+
+ match &msg.command {
+ // --- PING: must reply immediately or the server drops us ---
+ Command::Ping => {
+ let token = msg.params.first().cloned().unwrap_or_default();
+ sender.send(IrcMessage::new(Command::Pong, vec![token]));
+ }
+
+ // --- CAP: capability negotiation ---
+ Command::Cap => {
+ handle_cap(&msg, state, sender);
+ }
+
+ // --- 001: welcome — registration complete ---
+ Command::Numeric(1) => {
+ let server = msg
+ .prefix
+ .as_ref()
+ .map(|p| match p {
+ Prefix::Server(s) => s.clone(),
+ Prefix::User { nick, .. } => nick.clone(),
+ })
+ .unwrap_or_default();
+
+ // Server may have assigned us a different nick
+ if let Some(nick) = msg.params.first() {
+ state.nick = nick.clone();
+ }
+
+ state.reg = RegistrationState::Registered;
+ state.server_name = Some(server.clone());
+
+ events.push(Event::Connected {
+ server,
+ nick: state.nick.clone(),
+ });
+ }
+
+ // --- 353: NAMES reply (list of members in a channel) ---
+ Command::Numeric(353) => {
+ // params: [our_nick, ("=" / "*" / "@"), channel, ":member1 member2 ..."]
+ if let (Some(channel), Some(members_str)) = (msg.params.get(2), msg.params.get(3)) {
+ let members: Vec<String> = members_str
+ .split_whitespace()
+ // Strip membership prefixes (@, +, etc.)
+ .map(|m| {
+ m.trim_start_matches(&['@', '+', '%', '~', '&'][..])
+ .to_string()
+ })
+ .collect();
+
+ let ch = state.channel_mut(channel);
+ ch.members.extend(members.clone());
+
+ events.push(Event::Names {
+ channel: channel.clone(),
+ members,
+ });
+ }
+ }
+
+ // --- 332: topic on join ---
+ Command::Numeric(332) => {
+ if let (Some(channel), Some(topic)) = (msg.params.get(1), msg.params.get(2)) {
+ state.channel_mut(channel).topic = Some(topic.clone());
+ events.push(Event::Topic {
+ channel: channel.clone(),
+ topic: topic.clone(),
+ });
+ }
+ }
+
+ // --- JOIN ---
+ Command::Join => {
+ let nick = nick_from_prefix(&msg.prefix);
+ if let Some(channel) = msg.params.first() {
+ if nick == state.nick {
+ // We joined
+ state.channel_mut(channel);
+ events.push(Event::Joined {
+ channel: channel.clone(),
+ });
+ } else {
+ // Someone else joined
+ state.channel_mut(channel).members.insert(nick);
+ }
+ }
+ }
+
+ // --- PART ---
+ Command::Part => {
+ let nick = nick_from_prefix(&msg.prefix);
+ let channel = msg.params.first().cloned().unwrap_or_default();
+ let reason = msg.params.get(1).cloned();
+
+ if nick == state.nick {
+ state.remove_channel(&channel);
+ } else {
+ state.channel_mut(&channel).members.remove(&nick);
+ }
+
+ events.push(Event::Parted {
+ channel,
+ nick,
+ reason,
+ });
+ }
+
+ // --- QUIT ---
+ Command::Quit => {
+ let nick = nick_from_prefix(&msg.prefix);
+ let reason = msg.params.first().cloned();
+
+ // Remove them from all channels
+ for ch in state.channels.values_mut() {
+ ch.members.remove(&nick);
+ }
+
+ events.push(Event::Quit { nick, reason });
+ }
+
+ // --- NICK ---
+ Command::Nick => {
+ let old_nick = nick_from_prefix(&msg.prefix);
+ let new_nick = msg.params.first().cloned().unwrap_or_default();
+
+ if old_nick == state.nick {
+ state.nick = new_nick.clone();
+ }
+
+ // Update in all channels
+ for ch in state.channels.values_mut() {
+ if ch.members.remove(&old_nick) {
+ ch.members.insert(new_nick.clone());
+ }
+ }
+
+ events.push(Event::NickChanged { old_nick, new_nick });
+ }
+
+ // --- PRIVMSG / NOTICE ---
+ Command::Privmsg | Command::Notice => {
+ let from = nick_from_prefix(&msg.prefix);
+ let target = msg.params.first().cloned().unwrap_or_default();
+ let text = msg.params.get(1).cloned().unwrap_or_default();
+ let is_notice = msg.command == Command::Notice;
+
+ events.push(Event::Message {
+ from,
+ target,
+ text,
+ is_notice,
+ });
+ }
+
+ // --- TOPIC (live change) ---
+ Command::Topic => {
+ if let (Some(channel), Some(topic)) = (msg.params.first(), msg.params.get(1)) {
+ state.channel_mut(channel).topic = Some(topic.clone());
+ events.push(Event::Topic {
+ channel: channel.clone(),
+ topic: topic.clone(),
+ });
+ }
+ }
+
+ // --- Everything else: surface as Raw ---
+ _ => {
+ debug!("Unhandled: {}", serialize(&msg));
+ events.push(Event::Raw(msg));
+ }
+ }
+
+ events
+}
+
+/// Handle CAP sub-commands during capability negotiation.
+fn handle_cap(msg: &IrcMessage, state: &mut ClientState, sender: &Sender) {
+ // params: [target, subcommand, (optional "*",) params]
+ let subcommand = msg.params.get(1).map(|s| s.as_str()).unwrap_or("");
+
+ match subcommand {
+ "LS" => {
+ // Server listed its capabilities.
+ // For now, request a small set of common useful caps.
+ let want = ["multi-prefix", "away-notify", "server-time", "message-tags"];
+ let server_caps = msg.params.last().map(|s| s.as_str()).unwrap_or("");
+
+ let to_request: Vec<&str> = want
+ .iter()
+ .copied()
+ .filter(|cap| server_caps.split_whitespace().any(|s| s == *cap))
+ .collect();
+
+ if to_request.is_empty() {
+ sender.send(IrcMessage::new(Command::Cap, vec!["END".into()]));
+ state.reg = RegistrationState::WaitingForWelcome;
+ } else {
+ sender.send(IrcMessage::new(
+ Command::Cap,
+ vec!["REQ".into(), to_request.join(" ")],
+ ));
+ state.reg = RegistrationState::CapPending;
+ }
+ }
+
+ "ACK" => {
+ // Server acknowledged our capability requests
+ if let Some(caps) = msg.params.last() {
+ for cap in caps.split_whitespace() {
+ state.caps.insert(cap.to_string());
+ }
+ }
+ sender.send(IrcMessage::new(Command::Cap, vec!["END".into()]));
+ state.reg = RegistrationState::WaitingForWelcome;
+ }
+
+ "NAK" => {
+ // Server rejected our request — just end negotiation
+ warn!("CAP NAK: {:?}", msg.params.last());
+ sender.send(IrcMessage::new(Command::Cap, vec!["END".into()]));
+ state.reg = RegistrationState::WaitingForWelcome;
+ }
+
+ other => {
+ debug!("Unhandled CAP subcommand: {}", other);
+ }
+ }
+}
+
+/// Extract the nick from a message prefix, returning empty string if absent.
+fn nick_from_prefix(prefix: &Option<Prefix>) -> String {
+ match prefix {
+ Some(Prefix::User { nick, .. }) => nick.clone(),
+ Some(Prefix::Server(s)) => s.clone(),
+ None => String::new(),
+ }
+}
diff --git a/src/client/mod.rs b/src/client/mod.rs
new file mode 100644
index 0000000..6d32017
--- /dev/null
+++ b/src/client/mod.rs
@@ -0,0 +1,176 @@
+use tokio::sync::mpsc;
+use tracing::info;
+
+use crate::client::event::Event;
+use crate::client::handler::handle;
+use crate::client::state::ClientState;
+use crate::connection::{self, Sender};
+use crate::proto::message::{Command, IrcMessage};
+
+pub mod event;
+pub mod handler;
+pub mod state;
+
+/// Configuration for the IRC client.
+pub struct Config {
+ /// Server address, e.g. "irc.libera.chat:6667"
+ pub server: String,
+ /// Desired nick
+ pub nick: String,
+ /// IRC username (shown in /whois)
+ pub user: String,
+ /// Real name (shown in /whois)
+ pub realname: String,
+ /// Optional server password
+ pub password: Option<String>,
+ /// Channels to auto-join after registration
+ pub autojoin: Vec<String>,
+}
+
+/// The main IRC client.
+///
+/// Call `Client::connect` to establish a connection, then drive the event
+/// loop with `client.next_event().await` in your application loop.
+pub struct Client {
+ state: ClientState,
+ sender: Sender,
+ inbox: mpsc::UnboundedReceiver<IrcMessage>,
+ config: Config,
+}
+
+impl Client {
+ /// Connect to the server and begin the registration handshake.
+ pub async fn connect(config: Config) -> Result<Self, std::io::Error> {
+ let (sender, inbox) = connection::connect(&config.server).await?;
+ let state = ClientState::new(&config.nick);
+
+ let client = Self {
+ state,
+ sender,
+ inbox,
+ config,
+ };
+ client.register();
+ Ok(client)
+ }
+
+ /// Send a raw `IrcMessage` to the server.
+ pub fn send(&self, msg: IrcMessage) {
+ self.sender.send(msg);
+ }
+
+ /// Send a PRIVMSG to a channel or user.
+ pub fn privmsg(&self, target: &str, text: &str) {
+ self.sender.send(IrcMessage::new(
+ Command::Privmsg,
+ vec![target.to_string(), text.to_string()],
+ ));
+ }
+
+ /// Join a channel.
+ pub fn join(&self, channel: &str) {
+ self.sender
+ .send(IrcMessage::new(Command::Join, vec![channel.to_string()]));
+ }
+
+ /// Part a channel.
+ pub fn part(&self, channel: &str, reason: Option<&str>) {
+ let mut params = vec![channel.to_string()];
+ if let Some(r) = reason {
+ params.push(r.to_string());
+ }
+ self.sender.send(IrcMessage::new(Command::Part, params));
+ }
+
+ /// Change nick.
+ pub fn nick(&self, new_nick: &str) {
+ self.sender
+ .send(IrcMessage::new(Command::Nick, vec![new_nick.to_string()]));
+ }
+
+ /// Read-only view of current client state.
+ pub fn state(&self) -> &ClientState {
+ &self.state
+ }
+
+ /// Wait for the next event from the server.
+ /// Returns `None` if the connection has closed.
+ pub async fn next_event(&mut self) -> Option<Event> {
+ loop {
+ let msg = self.inbox.recv().await?;
+ let events = handle(msg, &mut self.state, &self.sender);
+
+ // Handle auto-join after registration
+ for event in &events {
+ if let Event::Connected { .. } = event {
+ for channel in &self.config.autojoin.clone() {
+ info!("Auto-joining {}", channel);
+ self.join(channel);
+ }
+ }
+ }
+
+ // Return the first event; re-queue the rest
+ // (simple approach: process one at a time via recursive buffering)
+ if let Some(first) = events.into_iter().next() {
+ return Some(first);
+ }
+ // If no events were produced (e.g. a PING), loop and wait for next message
+ }
+ }
+
+ /// Send the registration sequence to the server.
+ fn register(&self) {
+ // Optional server password
+ if let Some(pass) = &self.config.password {
+ self.sender
+ .send(IrcMessage::new(Command::Pass, vec![pass.clone()]));
+ }
+
+ // Begin CAP negotiation first — lets us request IRCv3 caps
+ // before NICK/USER so the server doesn't rush past registration
+ self.sender.send(IrcMessage::new(
+ Command::Cap,
+ vec!["LS".into(), "302".into()],
+ ));
+
+ self.sender.send(IrcMessage::new(
+ Command::Nick,
+ vec![self.config.nick.clone()],
+ ));
+
+ self.sender.send(IrcMessage::new(
+ Command::User,
+ vec![
+ self.config.user.clone(),
+ "0".into(),
+ "*".into(),
+ self.config.realname.clone(),
+ ],
+ ));
+ }
+}
+
+impl Client {
+ /// Non-blocking version of `next_event`.
+ /// Returns `Some(event)` if one is immediately available, `None` otherwise.
+ /// Used by the TUI loop to drain events without blocking the render tick.
+ pub fn next_event_nowait(&mut self) -> Option<Event> {
+ loop {
+ let msg = self.inbox.try_recv().ok()?;
+ let mut events = handle(msg, &mut self.state, &self.sender);
+
+ for event in &events {
+ if let Event::Connected { .. } = event {
+ for channel in &self.config.autojoin.clone() {
+ self.join(channel);
+ }
+ }
+ }
+
+ if !events.is_empty() {
+ return Some(events.remove(0));
+ }
+ }
+ }
+}
diff --git a/src/client/state.rs b/src/client/state.rs
new file mode 100644
index 0000000..b987509
--- /dev/null
+++ b/src/client/state.rs
@@ -0,0 +1,62 @@
+use std::collections::{HashMap, HashSet};
+
+/// The full state of a connected IRC client.
+#[derive(Debug, Default)]
+pub struct ClientState {
+ pub nick: String,
+ pub channels: HashMap<String, Channel>,
+ pub caps: HashSet<String>,
+ pub server_name: Option<String>,
+ pub reg: RegistrationState,
+}
+
+impl ClientState {
+ pub fn new(nick: impl Into<String>) -> Self {
+ Self {
+ nick: nick.into(),
+ ..Default::default()
+ }
+ }
+
+ pub fn channel(&self, name: &str) -> Option<&Channel> {
+ self.channels.get(&name.to_lowercase())
+ }
+
+ pub fn channel_mut(&mut self, name: &str) -> &mut Channel {
+ self.channels
+ .entry(name.to_lowercase())
+ .or_insert_with(|| Channel::new(name))
+ }
+
+ pub fn remove_channel(&mut self, name: &str) {
+ self.channels.remove(&name.to_lowercase());
+ }
+}
+
+/// State of the registration handshake.
+#[derive(Debug, Default, PartialEq, Eq)]
+pub enum RegistrationState {
+ #[default]
+ CapNegotiation,
+ CapPending,
+ WaitingForWelcome,
+ Registered,
+}
+
+/// A joined channel and its current state.
+#[derive(Debug)]
+pub struct Channel {
+ pub name: String,
+ pub members: HashSet<String>,
+ pub topic: Option<String>,
+}
+
+impl Channel {
+ pub fn new(name: impl Into<String>) -> Self {
+ Self {
+ name: name.into(),
+ members: HashSet::new(),
+ topic: None,
+ }
+ }
+}
diff --git a/src/connection/mod.rs b/src/connection/mod.rs
new file mode 100644
index 0000000..bc837c0
--- /dev/null
+++ b/src/connection/mod.rs
@@ -0,0 +1,82 @@
+use futures::SinkExt;
+use tokio::net::TcpStream;
+use tokio::sync::mpsc;
+use tokio_util::codec::Framed;
+use tracing::{debug, error, info};
+
+use crate::proto::codec::IrcCodec;
+use crate::proto::message::IrcMessage;
+
+/// A handle to send messages to the server.
+/// Cheaply cloneable — pass it wherever you need to write.
+#[derive(Clone)]
+pub struct Sender {
+ tx: mpsc::UnboundedSender<IrcMessage>,
+}
+
+impl Sender {
+ pub fn send(&self, msg: IrcMessage) {
+ // Only fails if the connection task has shut down
+ let _ = self.tx.send(msg);
+ }
+}
+
+/// Establish a TCP connection and return:
+/// - A `Sender` handle for writing messages
+/// - An `mpsc::UnboundedReceiver<IrcMessage>` for reading incoming messages
+///
+/// Two background tasks are spawned:
+/// - A **writer task**: drains the sender channel and writes to the TCP stream
+/// - A **reader task**: reads from the TCP stream and forwards to the inbox
+///
+/// This split means the caller never has to hold a lock to send a message.
+pub async fn connect(
+ addr: &str,
+) -> Result<(Sender, mpsc::UnboundedReceiver<IrcMessage>), std::io::Error> {
+ info!("Connecting to {}", addr);
+ let stream = TcpStream::connect(addr).await?;
+ info!("TCP connected to {}", addr);
+
+ let framed = Framed::new(stream, IrcCodec::new());
+ let (mut sink, mut stream) = futures::StreamExt::split(framed);
+
+ // Channel for outbound messages (caller → writer task)
+ let (out_tx, mut out_rx) = mpsc::unbounded_channel::<IrcMessage>();
+
+ // Channel for inbound messages (reader task → caller)
+ let (in_tx, in_rx) = mpsc::unbounded_channel::<IrcMessage>();
+
+ // Writer task: takes messages from out_rx and sends them to the server
+ tokio::spawn(async move {
+ while let Some(msg) = out_rx.recv().await {
+ debug!("--> {}", crate::proto::serializer::serialize(&msg));
+ if let Err(e) = sink.send(msg).await {
+ error!("Write error: {}", e);
+ break;
+ }
+ }
+ debug!("Writer task shut down");
+ });
+
+ // Reader task: receives messages from the server and forwards to in_tx
+ tokio::spawn(async move {
+ use futures::StreamExt;
+ while let Some(result) = stream.next().await {
+ match result {
+ Ok(msg) => {
+ debug!("<-- {}", crate::proto::serializer::serialize(&msg));
+ if in_tx.send(msg).is_err() {
+ break; // Receiver dropped, shut down
+ }
+ }
+ Err(e) => {
+ error!("Read error: {}", e);
+ break;
+ }
+ }
+ }
+ debug!("Reader task shut down");
+ });
+
+ Ok((Sender { tx: out_tx }, in_rx))
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..f625d00
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,3 @@
+pub mod client;
+pub mod connection;
+pub mod proto;
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..b7d4365
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,268 @@
+use std::io;
+use std::time::Duration;
+
+use crossterm::{
+ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
+ execute,
+ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
+};
+use ratatui::{Terminal, backend::CrosstermBackend};
+
+use irc_client::client::event::Event as IrcEvent;
+use irc_client::client::{Client, Config};
+use irc_client::proto::message::{Command, IrcMessage};
+use tui::app::{AppState, CHANNEL};
+use tui::ui;
+mod tui;
+
+const NICK: &str = "";
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+ // ── Terminal setup ────────────────────────────────────────────────────
+ enable_raw_mode()?;
+ let mut stdout = io::stdout();
+ execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+ let backend = CrosstermBackend::new(stdout);
+ let mut terminal = Terminal::new(backend)?;
+ terminal.clear()?;
+
+ let result = run(&mut terminal).await;
+
+ // ── Restore terminal ──────────────────────────────────────────────────
+ disable_raw_mode()?;
+ execute!(
+ terminal.backend_mut(),
+ LeaveAlternateScreen,
+ DisableMouseCapture,
+ )?;
+ terminal.show_cursor()?;
+
+ result
+}
+
+async fn run(
+ terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
+) -> Result<(), Box<dyn std::error::Error>> {
+ let mut app = AppState::new(NICK, CHANNEL);
+
+ // ── Connect to IRC ────────────────────────────────────────────────────
+ let config = Config {
+ server: "irc.libera.chat:6667".to_string(),
+ nick: NICK.to_string(),
+ user: "speakez".to_string(),
+ realname: "speakez".to_string(),
+ password: None,
+ autojoin: vec![CHANNEL.to_string()],
+ };
+
+ let mut client = Client::connect(config).await?;
+
+ // ── Main event loop ───────────────────────────────────────────────────
+ // We poll both IRC events and keyboard events with short timeouts so
+ // neither blocks the other.
+ loop {
+ // Draw
+ terminal.draw(|f| ui::draw(f, &app))?;
+
+ // Poll crossterm for keyboard input (non-blocking, 20ms timeout)
+ if event::poll(Duration::from_millis(20))? {
+ if let Event::Key(key) = event::read()? {
+ match (key.modifiers, key.code) {
+ // Quit
+ (KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
+
+ // Send message / command
+ (_, KeyCode::Enter) => {
+ let text = app.take_input();
+ if !text.is_empty() {
+ handle_input(&text, &mut app, &mut client);
+ }
+ }
+
+ // Typing
+ (KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => {
+ app.input_insert(c);
+ }
+ (_, KeyCode::Backspace) => app.input_backspace(),
+ (_, KeyCode::Left) => app.cursor_left(),
+ (_, KeyCode::Right) => app.cursor_right(),
+ (_, KeyCode::Home) => app.cursor = 0,
+ (_, KeyCode::End) => app.cursor = app.input.len(),
+
+ // Scroll
+ (_, KeyCode::PageUp) => app.scroll = app.scroll.saturating_add(5),
+ (_, KeyCode::PageDown) => app.scroll = app.scroll.saturating_sub(5),
+
+ _ => {}
+ }
+ }
+ }
+
+ // Drain IRC events (non-blocking — try_recv drains without waiting)
+ // We use a small loop to process a burst without starving the UI
+ for _ in 0..16 {
+ match client.next_event_nowait() {
+ Some(irc_event) => handle_irc_event(irc_event, &mut app),
+ None => break,
+ }
+ }
+ }
+
+ Ok(())
+}
+
+/// Handle a line entered in the input box.
+fn handle_input(text: &str, app: &mut AppState, client: &mut Client) {
+ if let Some(cmd) = text.strip_prefix('/') {
+ // It's a command
+ let mut parts = cmd.splitn(2, ' ');
+ let verb = parts.next().unwrap_or("").to_uppercase();
+ let args = parts.next().unwrap_or("");
+
+ match verb.as_str() {
+ "JOIN" => {
+ client.join(args.trim());
+ }
+ "PART" => {
+ let channel = if args.is_empty() {
+ &app.channel
+ } else {
+ args.trim()
+ };
+ client.part(channel, None);
+ }
+ "NICK" => {
+ client.nick(args.trim());
+ }
+ "QUIT" => {
+ client.send(IrcMessage::new(Command::Quit, vec![args.to_string()]));
+ }
+ "ME" => {
+ // CTCP ACTION
+ let ctcp = format!("\x01ACTION {}\x01", args);
+ client.privmsg(&app.channel, &ctcp);
+ app.push_system(&format!("* {} {}", app.nick, args));
+ }
+ "MSG" => {
+ let mut p = args.splitn(2, ' ');
+ if let (Some(target), Some(msg)) = (p.next(), p.next()) {
+ client.privmsg(target, msg);
+ app.push_system(&format!("→ {}: {}", target, msg));
+ }
+ }
+ other => {
+ app.push_system(&format!("Unknown command: /{}", other));
+ }
+ }
+ } else {
+ // Regular chat message to active channel
+ client.privmsg(&app.channel, text);
+ app.push_message(&app.nick.clone(), text, true);
+ }
+}
+
+/// Apply an IRC event to the app state.
+fn handle_irc_event(event: IrcEvent, app: &mut AppState) {
+ match event {
+ IrcEvent::Connected { server, nick } => {
+ app.nick = nick.clone();
+ app.connected = true;
+ app.status = format!("connected to {}", server);
+ app.push_system(&format!("Connected to {} as {}", server, nick));
+ }
+
+ IrcEvent::Joined { channel } => {
+ app.push_system(&format!("You joined {}", channel));
+ }
+
+ IrcEvent::Message {
+ from,
+ target,
+ text,
+ is_notice: _,
+ } => {
+ // Only show messages for our active channel (or PMs to us)
+ let is_self = from == app.nick;
+ if !is_self {
+ // Don't re-echo our own messages (we already pushed them in handle_input)
+ if target == app.channel || target == app.nick {
+ app.push_message(&from, &text, false);
+ }
+ }
+ }
+
+ IrcEvent::Parted {
+ channel,
+ nick,
+ reason,
+ } => {
+ app.members.retain(|m| {
+ let bare = m.trim_start_matches(&['@', '+', '%'][..]);
+ bare != nick
+ });
+ app.push_system(&format!(
+ "{} left {}{}",
+ nick,
+ channel,
+ reason.map(|r| format!(" ({})", r)).unwrap_or_default()
+ ));
+ }
+
+ IrcEvent::Quit { nick, reason } => {
+ app.members.retain(|m| {
+ let bare = m.trim_start_matches(&['@', '+', '%'][..]);
+ bare != nick
+ });
+ app.push_system(&format!(
+ "{} quit{}",
+ nick,
+ reason.map(|r| format!(" ({})", r)).unwrap_or_default()
+ ));
+ }
+
+ IrcEvent::NickChanged { old_nick, new_nick } => {
+ for m in &mut app.members {
+ let bare = m.trim_start_matches(&['@', '+', '%'][..]).to_string();
+ if bare == old_nick {
+ let sigil: String = m.chars().take_while(|c| "@+%~&".contains(*c)).collect();
+ *m = format!("{}{}", sigil, new_nick);
+ break;
+ }
+ }
+ if old_nick == app.nick {
+ app.nick = new_nick.clone();
+ }
+ app.push_system(&format!("{} is now {}", old_nick, new_nick));
+ }
+
+ IrcEvent::Topic { channel, topic } => {
+ app.status = format!("{}: {}", channel, topic);
+ app.push_system(&format!("Topic: {}", topic));
+ }
+
+ IrcEvent::Names {
+ channel: _,
+ members,
+ } => {
+ for m in members {
+ if !app
+ .members
+ .iter()
+ .any(|existing| existing.trim_start_matches(&['@', '+', '%'][..]) == m)
+ {
+ app.members.push(m);
+ }
+ }
+ app.sort_members();
+ }
+
+ IrcEvent::Disconnected => {
+ app.connected = false;
+ app.status = "disconnected".to_string();
+ app.push_system("--- Disconnected ---");
+ }
+
+ IrcEvent::Raw(_) => {}
+ }
+}
diff --git a/src/proto/codec.rs b/src/proto/codec.rs
new file mode 100644
index 0000000..d8f9b10
--- /dev/null
+++ b/src/proto/codec.rs
@@ -0,0 +1,98 @@
+use bytes::{BufMut, BytesMut};
+use tokio_util::codec::{Decoder, Encoder};
+
+use crate::proto::error::CodecError;
+use crate::proto::message::IrcMessage;
+use crate::proto::parser::parse;
+use crate::proto::serializer::serialize;
+
+const MAX_LINE_LENGTH: usize = 512;
+
+pub struct IrcCodec {
+ max_line_length: usize,
+}
+
+impl IrcCodec {
+ pub fn new() -> Self {
+ Self {
+ max_line_length: MAX_LINE_LENGTH,
+ }
+ }
+
+ pub fn with_max_length(max_line_length: usize) -> Self {
+ Self { max_line_length }
+ }
+}
+
+impl Default for IrcCodec {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Decoder for IrcCodec {
+ type Item = IrcMessage;
+ type Error = CodecError;
+
+ fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+ loop {
+ let newline_pos = src.iter().position(|&b| b == b'\n');
+
+ match newline_pos {
+ None => {
+ if src.len() > self.max_line_length {
+ return Err(CodecError::Parse(
+ crate::proto::error::ParseError::MessageTooLong,
+ ));
+ }
+ return Ok(None);
+ }
+ Some(pos) => {
+ let line_bytes = src.split_to(pos + 1);
+
+ let line = &line_bytes[..line_bytes.len() - 1]; // strip \n
+ let line = if line.last() == Some(&b'\r') {
+ &line[..line.len() - 1] // strip \r
+ } else {
+ line
+ };
+
+ // Skip empty lines silently
+ if line.is_empty() {
+ continue;
+ }
+
+ let line_str = std::str::from_utf8(line).map_err(|_| {
+ CodecError::Io(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "IRC message is not valid UTF-8",
+ ))
+ })?;
+
+ let msg = parse(line_str)?;
+ return Ok(Some(msg));
+ }
+ }
+ }
+ }
+}
+
+impl Encoder<IrcMessage> for IrcCodec {
+ type Error = CodecError;
+
+ fn encode(&mut self, msg: IrcMessage, dst: &mut BytesMut) -> Result<(), Self::Error> {
+ let line = serialize(&msg);
+
+ // +2 for \r\n
+ if line.len() + 2 > self.max_line_length {
+ return Err(CodecError::Parse(
+ crate::proto::error::ParseError::MessageTooLong,
+ ));
+ }
+
+ dst.reserve(line.len() + 2);
+ dst.put_slice(line.as_bytes());
+ dst.put_slice(b"\r\n");
+ Ok(())
+ }
+}
diff --git a/src/proto/error.rs b/src/proto/error.rs
new file mode 100644
index 0000000..8a901ce
--- /dev/null
+++ b/src/proto/error.rs
@@ -0,0 +1,25 @@
+use thiserror::Error;
+
+#[derive(Debug, Error, PartialEq)]
+pub enum ParseError {
+ #[error("message is empty")]
+ EmptyMessage,
+
+ #[error("missing command")]
+ MissingCommand,
+
+ #[error("invalid tag format: {0}")]
+ InvalidTag(String),
+
+ #[error("line exceeds max message length")]
+ MessageTooLong,
+}
+
+#[derive(Debug, Error)]
+pub enum CodecError {
+ #[error("parse error: {0}")]
+ Parse(#[from] ParseError),
+
+ #[error("I/O error: {0}")]
+ Io(#[from] std::io::Error),
+}
diff --git a/src/proto/message.rs b/src/proto/message.rs
new file mode 100644
index 0000000..dbe4a69
--- /dev/null
+++ b/src/proto/message.rs
@@ -0,0 +1,163 @@
+use std::collections::HashMap;
+use std::fmt;
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct IrcMessage {
+ pub tags: HashMap<String, Option<String>>,
+ pub prefix: Option<Prefix>,
+ pub command: Command,
+ pub params: Vec<String>,
+}
+
+impl IrcMessage {
+ pub fn trailing(&self) -> Option<&str> {
+ self.params.last().map(|s| s.as_str())
+ }
+
+ pub fn new(command: Command, params: Vec<String>) -> Self {
+ Self {
+ tags: HashMap::new(),
+ prefix: None,
+ command,
+ params,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Prefix {
+ Server(String),
+ User {
+ nick: String,
+ user: Option<String>,
+ host: Option<String>,
+ },
+}
+
+impl fmt::Display for Prefix {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Prefix::Server(s) => write!(f, "{}", s),
+ Prefix::User { nick, user, host } => {
+ write!(f, "{}", nick)?;
+ if let Some(u) = user {
+ write!(f, "!{}", u)?;
+ }
+ if let Some(h) = host {
+ write!(f, "@{}", h)?;
+ }
+ Ok(())
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum Command {
+ // connection
+ Cap,
+ Nick,
+ User,
+ Pass,
+ Quit,
+ Ping,
+ Pong,
+
+ // channel operations
+ Join,
+ Part,
+ Kick,
+ Topic,
+ Names,
+ List,
+ Invite,
+
+ // messaging
+ Privmsg,
+ Notice,
+
+ // mode & status
+ Mode,
+ Who,
+ Whois,
+ Whowas,
+
+ // Server
+ Oper,
+ Kill,
+ Rehash,
+
+ // Numeric (001-999)
+ Numeric(u16),
+
+ Other(String),
+}
+
+impl Command {
+ pub fn from_str(s: &str) -> Self {
+ if s.len() == 3 && s.chars().all(|c| c.is_ascii_digit()) {
+ if let Ok(n) = s.parse::<u16>() {
+ return Command::Numeric(n);
+ }
+ }
+
+ match s.to_ascii_uppercase().as_str() {
+ "CAP" => Command::Cap,
+ "NICK" => Command::Nick,
+ "USER" => Command::User,
+ "PASS" => Command::Pass,
+ "QUIT" => Command::Quit,
+ "PING" => Command::Ping,
+ "PONG" => Command::Pong,
+ "JOIN" => Command::Join,
+ "PART" => Command::Part,
+ "KICK" => Command::Kick,
+ "TOPIC" => Command::Topic,
+ "NAMES" => Command::Names,
+ "LIST" => Command::List,
+ "INVITE" => Command::Invite,
+ "PRIVMSG" => Command::Privmsg,
+ "NOTICE" => Command::Notice,
+ "MODE" => Command::Mode,
+ "WHO" => Command::Who,
+ "WHOIS" => Command::Whois,
+ "WHOWAS" => Command::Whowas,
+ "OPER" => Command::Oper,
+ "KILL" => Command::Kill,
+ "REHASH" => Command::Rehash,
+ other => Command::Other(other.to_string()),
+ }
+ }
+}
+
+impl fmt::Display for Command {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Command::Cap => write!(f, "CAP"),
+ Command::Nick => write!(f, "NICK"),
+ Command::User => write!(f, "USER"),
+ Command::Pass => write!(f, "PASS"),
+ Command::Quit => write!(f, "QUIT"),
+ Command::Ping => write!(f, "PING"),
+ Command::Pong => write!(f, "PONG"),
+ Command::Join => write!(f, "JOIN"),
+ Command::Part => write!(f, "PART"),
+ Command::Kick => write!(f, "KICK"),
+ Command::Topic => write!(f, "TOPIC"),
+ Command::Names => write!(f, "NAMES"),
+ Command::List => write!(f, "LIST"),
+ Command::Invite => write!(f, "INVITE"),
+ Command::Privmsg => write!(f, "PRIVMSG"),
+ Command::Notice => write!(f, "NOTICE"),
+ Command::Mode => write!(f, "MODE"),
+ Command::Who => write!(f, "WHO"),
+ Command::Whois => write!(f, "WHOIS"),
+ Command::Whowas => write!(f, "WHOWAS"),
+ Command::Oper => write!(f, "OPER"),
+ Command::Kill => write!(f, "KILL"),
+ Command::Rehash => write!(f, "REHASH"),
+ Command::Numeric(n) => write!(f, "{:03}", n),
+ Command::Other(s) => write!(f, "{}", s),
+ }
+ }
+}
diff --git a/src/proto/mod.rs b/src/proto/mod.rs
new file mode 100644
index 0000000..e82784c
--- /dev/null
+++ b/src/proto/mod.rs
@@ -0,0 +1,5 @@
+pub mod codec;
+pub mod error;
+pub mod message;
+pub mod parser;
+pub mod serializer;
diff --git a/src/proto/parser.rs b/src/proto/parser.rs
new file mode 100644
index 0000000..42f516d
--- /dev/null
+++ b/src/proto/parser.rs
@@ -0,0 +1,170 @@
+use crate::proto::error::ParseError;
+use crate::proto::message::{Command, IrcMessage, Prefix};
+use std::collections::HashMap;
+
+pub fn parse(line: &str) -> Result<IrcMessage, ParseError> {
+ if line.is_empty() {
+ return Err(ParseError::EmptyMessage);
+ }
+
+ let mut rest = line;
+
+ // parse tags
+ let tags = if rest.starts_with('@') {
+ let (tag_str, remaining) = rest[1..]
+ .split_once(' ')
+ .ok_or(ParseError::MissingCommand)?;
+ rest = remaining;
+ parse_tags(tag_str)?
+ } else {
+ HashMap::new()
+ };
+
+ // parse prefix
+ let prefix = if rest.starts_with(':') {
+ let (prefix_str, remaining) = rest[1..]
+ .split_once(' ')
+ .ok_or(ParseError::MissingCommand)?;
+ rest = remaining;
+ Some(parse_prefix(prefix_str))
+ } else {
+ None
+ };
+
+ // parse command
+ let (command_str, rest) = match rest.split_once(' ') {
+ Some((cmd, params)) => (cmd, params),
+ None => (rest, ""),
+ };
+
+ if command_str.is_empty() {
+ return Err(ParseError::MissingCommand);
+ }
+
+ let command = Command::from_str(command_str);
+
+ // parse params
+ let params = parse_params(rest);
+
+ Ok(IrcMessage {
+ tags,
+ prefix,
+ command,
+ params,
+ })
+}
+
+fn parse_tags(tag_str: &str) -> Result<HashMap<String, Option<String>>, ParseError> {
+ let mut tags = HashMap::new();
+
+ for tag in tag_str.split(';') {
+ if tag.is_empty() {
+ continue;
+ }
+ match tag.split_once('=') {
+ Some((key, value)) => {
+ tags.insert(key.to_string(), Some(unescape_tag_value(value)));
+ }
+ None => {
+ // boolean tags
+ tags.insert(tag.to_string(), None);
+ }
+ }
+ }
+
+ Ok(tags)
+}
+
+fn unescape_tag_value(value: &str) -> String {
+ let mut result = String::with_capacity(value.len());
+ let mut chars = value.chars().peekable();
+
+ while let Some(ch) = chars.next() {
+ if ch == '\\' {
+ match chars.next() {
+ Some(':') => result.push(';'),
+ Some('s') => result.push(' '),
+ Some('\\') => result.push('\\'),
+ Some('r') => result.push('\r'),
+ Some('n') => result.push('\n'),
+ Some(c) => {
+ result.push('\\');
+ result.push(c);
+ }
+ None => result.push('\\'),
+ }
+ } else {
+ result.push(ch);
+ }
+ }
+
+ result
+}
+
+fn parse_prefix(prefix: &str) -> Prefix {
+ if prefix.contains('!') || prefix.contains('@') {
+ let (nick, rest) = prefix
+ .split_once('!')
+ .map(|(n, r)| (n, Some(r)))
+ .unwrap_or((prefix, None));
+
+ let (user, host) = match rest {
+ Some(r) => r
+ .split_once('@')
+ .map(|(u, h)| (Some(u.to_string()), Some(h.to_string())))
+ .unwrap_or((Some(r.to_string()), None)),
+ None => {
+ // Could be nick@host with no user
+ if let Some((n2, h)) = nick.split_once('@') {
+ return Prefix::User {
+ nick: n2.to_string(),
+ user: None,
+ host: Some(h.to_string()),
+ };
+ }
+ (None, None)
+ }
+ };
+
+ Prefix::User {
+ nick: nick.to_string(),
+ user,
+ host,
+ }
+ } else {
+ // Heuristic: if it contains a dot, it's likely a server name
+ // (nick-only prefixes are also possible but rare without user/host)
+ Prefix::Server(prefix.to_string())
+ }
+}
+
+fn parse_params(params_str: &str) -> Vec<String> {
+ let mut params = Vec::new();
+ let mut rest = params_str;
+
+ loop {
+ rest = rest.trim_start_matches(' ');
+
+ if rest.is_empty() {
+ break;
+ }
+
+ if rest.starts_with(':') {
+ params.push(rest[1..].to_string());
+ break;
+ }
+
+ match rest.split_once(' ') {
+ Some((param, remaining)) => {
+ params.push(param.to_string());
+ rest = remaining;
+ }
+ None => {
+ params.push(rest.to_string());
+ break;
+ }
+ }
+ }
+
+ params
+}
diff --git a/src/proto/serializer.rs b/src/proto/serializer.rs
new file mode 100644
index 0000000..603cc0b
--- /dev/null
+++ b/src/proto/serializer.rs
@@ -0,0 +1,78 @@
+use crate::proto::message::{IrcMessage, Prefix};
+use std::fmt::Write;
+
+pub fn serialize(msg: &IrcMessage) -> String {
+ let mut out = String::with_capacity(512);
+
+ // tags
+ if !msg.tags.is_empty() {
+ out.push('@');
+ let mut first = true;
+ for (key, value) in &msg.tags {
+ if !first {
+ out.push(';');
+ }
+ first = false;
+ out.push_str(key);
+ if let Some(v) = value {
+ out.push('=');
+ escape_tag_value(&mut out, v);
+ }
+ }
+ out.push(' ');
+ }
+
+ // prefix
+ if let Some(prefix) = &msg.prefix {
+ out.push(':');
+ match prefix {
+ Prefix::Server(s) => out.push_str(s),
+ Prefix::User { nick, user, host } => {
+ out.push_str(nick);
+ if let Some(u) = user {
+ out.push('!');
+ out.push_str(u);
+ }
+ if let Some(h) = host {
+ out.push('@');
+ out.push_str(h);
+ }
+ }
+ }
+ out.push(' ');
+ }
+
+ // command
+ let _ = write!(out, "{}", msg.command);
+
+ // params
+ let last_idx = msg.params.len().saturating_sub(1);
+ for (i, param) in msg.params.iter().enumerate() {
+ out.push(' ');
+ // The last param must be trailing if it contains spaces or starts with ':'
+ let needs_trailing = i == last_idx
+ && (param.contains(' ')
+ || param.starts_with(':')
+ || param.is_empty()
+ || msg.params.len() > 1);
+ if needs_trailing {
+ out.push(':');
+ }
+ out.push_str(param);
+ }
+
+ out
+}
+
+fn escape_tag_value(out: &mut String, value: &str) {
+ for ch in value.chars() {
+ match ch {
+ ';' => out.push_str(r"\:"),
+ ' ' => out.push_str(r"\s"),
+ '\\' => out.push_str(r"\\"),
+ '\r' => out.push_str(r"\r"),
+ '\n' => out.push_str(r"\n"),
+ c => out.push(c),
+ }
+ }
+}
diff --git a/src/tui/app.rs b/src/tui/app.rs
new file mode 100644
index 0000000..1e7d021
--- /dev/null
+++ b/src/tui/app.rs
@@ -0,0 +1,130 @@
+/// Compile-time channel to join on startup
+pub const CHANNEL: &str = "#speakez";
+
+/// A single chat message in the log
+#[derive(Clone)]
+pub struct ChatLine {
+ pub nick: String,
+ pub text: String,
+ /// true = server/system message (JOIN, PART, topic, etc.)
+ pub is_system: bool,
+ /// true = NOTICE
+ pub is_notice: bool,
+ /// true = this is our own message
+ pub is_self: bool,
+}
+
+/// All mutable state the TUI needs to render and respond to input
+pub struct AppState {
+ /// Our nick
+ pub nick: String,
+ /// The active channel name
+ pub channel: String,
+ /// Chat log for the active channel
+ pub messages: Vec<ChatLine>,
+ /// Member list for the active channel
+ pub members: Vec<String>,
+ /// Current contents of the input box
+ pub input: String,
+ /// Cursor position within `input` (byte index)
+ pub cursor: usize,
+ /// Scroll offset from the bottom (0 = pinned to latest)
+ pub scroll: usize,
+ /// Status line text (connection state, errors, etc.)
+ pub status: String,
+ /// Whether we've fully registered
+ pub connected: bool,
+}
+
+impl AppState {
+ pub fn new(nick: impl Into<String>, channel: impl Into<String>) -> Self {
+ Self {
+ nick: nick.into(),
+ channel: channel.into(),
+ messages: Vec::new(),
+ members: Vec::new(),
+ input: String::new(),
+ cursor: 0,
+ scroll: 0,
+ status: "Connecting...".into(),
+ connected: false,
+ }
+ }
+
+ /// Push a chat message
+ pub fn push_message(&mut self, nick: &str, text: &str, is_self: bool) {
+ self.messages.push(ChatLine {
+ nick: nick.to_string(),
+ text: text.to_string(),
+ is_system: false,
+ is_notice: false,
+ is_self,
+ });
+ }
+
+ /// Push a system/event line (joins, parts, topic changes)
+ pub fn push_system(&mut self, text: &str) {
+ self.messages.push(ChatLine {
+ nick: String::new(),
+ text: text.to_string(),
+ is_system: true,
+ is_notice: false,
+ is_self: false,
+ });
+ }
+
+ /// Insert a character at the cursor
+ pub fn input_insert(&mut self, ch: char) {
+ self.input.insert(self.cursor, ch);
+ self.cursor += ch.len_utf8();
+ }
+
+ /// Delete the character before the cursor
+ pub fn input_backspace(&mut self) {
+ if self.cursor == 0 {
+ return;
+ }
+ // Find the start of the previous character
+ let prev = self.input[..self.cursor]
+ .char_indices()
+ .last()
+ .map(|(i, _)| i)
+ .unwrap_or(0);
+ self.input.remove(prev);
+ self.cursor = prev;
+ }
+
+ /// Move cursor left one character
+ pub fn cursor_left(&mut self) {
+ self.cursor = self.input[..self.cursor]
+ .char_indices()
+ .last()
+ .map(|(i, _)| i)
+ .unwrap_or(0);
+ }
+
+ /// Move cursor right one character
+ pub fn cursor_right(&mut self) {
+ if self.cursor < self.input.len() {
+ let ch = self.input[self.cursor..].chars().next().unwrap();
+ self.cursor += ch.len_utf8();
+ }
+ }
+
+ /// Take the current input, clear the box, return the text
+ pub fn take_input(&mut self) -> String {
+ self.cursor = 0;
+ std::mem::take(&mut self.input)
+ }
+
+ /// Sort and deduplicate the member list
+ pub fn sort_members(&mut self) {
+ self.members.sort_by(|a, b| {
+ // Strip sigils for sorting (@, +, %)
+ let a = a.trim_start_matches(&['@', '+', '%', '~', '&'][..]);
+ let b = b.trim_start_matches(&['@', '+', '%', '~', '&'][..]);
+ a.to_lowercase().cmp(&b.to_lowercase())
+ });
+ self.members.dedup();
+ }
+}
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
new file mode 100644
index 0000000..3e2facd
--- /dev/null
+++ b/src/tui/mod.rs
@@ -0,0 +1,2 @@
+pub mod app;
+pub mod ui;
diff --git a/src/tui/ui.rs b/src/tui/ui.rs
new file mode 100644
index 0000000..a38834a
--- /dev/null
+++ b/src/tui/ui.rs
@@ -0,0 +1,252 @@
+use ratatui::{
+ Frame,
+ layout::{Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span, Text},
+ widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},
+};
+
+use super::app::AppState;
+
+// ── Colour palette ────────────────────────────────────────────────────────────
+// Dark terminal aesthetic: near-black background, cool grey chrome,
+// amber accent for our own nick, cyan for others, muted green for system.
+
+const BG: Color = Color::Rgb(16, 16, 16);
+const ORANGE: Color = Color::Rgb(251, 84, 43); // border / panel bg
+const FG: Color = Color::Rgb(204, 204, 204); // main foreground
+const GRAY: Color = Color::Rgb(74, 74, 74); // other nicks
+const LIGHT_ORANGE: Color = Color::Rgb(255, 122, 89); // system messages
+
+pub fn draw(f: &mut Frame, state: &AppState) {
+ let area = f.area();
+
+ // Fill background
+ f.render_widget(Block::default().style(Style::default().bg(BG)), area);
+
+ // ── Outer layout: title bar + body + status bar ───────────────────────
+ let outer = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(1), // title bar
+ Constraint::Min(0), // body
+ Constraint::Length(1), // status bar
+ ])
+ .split(area);
+
+ draw_titlebar(f, outer[0], state);
+ draw_body(f, outer[1], state);
+ draw_statusbar(f, outer[2], state);
+}
+
+fn draw_titlebar(f: &mut Frame, area: Rect, state: &AppState) {
+ let title = Line::from(vec![
+ Span::styled(
+ "  ",
+ Style::default().fg(ORANGE).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(
+ "speakez",
+ Style::default().fg(FG).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(" │ ", Style::default().fg(FG)),
+ Span::styled(
+ &state.channel,
+ Style::default().fg(FG).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(" │ ", Style::default().fg(FG)),
+ Span::styled(&state.nick, Style::default().fg(ORANGE)),
+ ]);
+
+ f.render_widget(Paragraph::new(title).style(Style::default()), area);
+}
+
+fn draw_body(f: &mut Frame, area: Rect, state: &AppState) {
+ // Body: [chat (fill)] | [members (18)]
+ let cols = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Min(0), // centre: chat log + input
+ Constraint::Length(18), // right: member list
+ ])
+ .split(area);
+
+ draw_center(f, cols[0], state);
+ draw_members_panel(f, cols[1], state);
+}
+
+fn draw_center(f: &mut Frame, area: Rect, state: &AppState) {
+ // Centre column: chat log on top, input box on bottom
+ let rows = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Min(0), // chat log
+ Constraint::Length(3), // input box
+ ])
+ .split(area);
+
+ draw_chat_log(f, rows[0], state);
+ draw_input(f, rows[1], state);
+}
+
+fn draw_chat_log(f: &mut Frame, area: Rect, state: &AppState) {
+ let inner_height = area.height.saturating_sub(2) as usize; // subtract borders
+
+ // Build all rendered lines first so we can scroll from the bottom
+ let lines: Vec<Line> = state
+ .messages
+ .iter()
+ .map(|msg| render_chat_line(msg))
+ .collect();
+
+ // Apply scroll offset
+ // scroll=0 means pinned to bottom (newest). scroll=N means N lines back from bottom.
+ let total = lines.len();
+ let max_scroll = total.saturating_sub(inner_height);
+ let scroll = state.scroll.min(max_scroll);
+ let visible_start = total.saturating_sub(inner_height + scroll);
+ let visible: Vec<Line> = lines
+ .into_iter()
+ .skip(visible_start)
+ .take(inner_height)
+ .collect();
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_type(BorderType::Plain)
+ .border_style(Style::default().fg(ORANGE))
+ .style(Style::default().bg(BG));
+
+ f.render_widget(
+ Paragraph::new(Text::from(visible))
+ .block(block)
+ .wrap(Wrap { trim: false }),
+ area,
+ );
+}
+
+fn render_chat_line(msg: &super::app::ChatLine) -> Line<'static> {
+ if msg.is_system {
+ return Line::from(Span::styled(
+ format!(" ∙ {}", msg.text),
+ Style::default()
+ .fg(LIGHT_ORANGE)
+ .add_modifier(Modifier::DIM),
+ ));
+ }
+
+ let nick_style = if msg.is_self {
+ Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)
+ } else if msg.is_notice {
+ Style::default().fg(ORANGE)
+ } else {
+ Style::default().fg(GRAY)
+ };
+
+ let nick = format!("{}", msg.nick);
+
+ Line::from(vec![
+ Span::styled(nick, nick_style),
+ Span::styled(" ", Style::default()),
+ Span::styled(msg.text.clone(), Style::default().fg(FG)),
+ ])
+}
+
+fn draw_input(f: &mut Frame, area: Rect, state: &AppState) {
+ // Show a blinking cursor indicator at the cursor position
+ let before = &state.input[..state.cursor];
+ let after = &state.input[state.cursor..];
+
+ let cursor_char = if after.is_empty() {
+ " "
+ } else {
+ &after[..after.chars().next().map(|c| c.len_utf8()).unwrap_or(1)]
+ };
+ let after_cursor = if after.is_empty() {
+ ""
+ } else {
+ &after[cursor_char.len()..]
+ };
+
+ let line = Line::from(vec![
+ Span::styled(before.to_string(), Style::default().fg(FG)),
+ Span::styled(
+ cursor_char.to_string(),
+ Style::default().bg(FG).fg(BG).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(after_cursor.to_string(), Style::default().fg(FG)),
+ ]);
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_type(BorderType::Plain)
+ .border_style(Style::default().fg(ORANGE))
+ .title(Span::styled(" send ", Style::default().fg(FG)));
+
+ f.render_widget(Paragraph::new(line).block(block), area);
+}
+
+fn draw_members_panel(f: &mut Frame, area: Rect, state: &AppState) {
+ let items: Vec<ListItem> = state
+ .members
+ .iter()
+ .map(|nick| {
+ // Highlight ops (@) differently
+ let (sigil, rest) = if nick.starts_with('@') {
+ ("@", &nick[1..])
+ } else if nick.starts_with('+') {
+ ("+", &nick[1..])
+ } else {
+ ("", nick.as_str())
+ };
+
+ let sigil_style = if sigil == "@" {
+ Style::default().fg(ORANGE)
+ } else {
+ Style::default().fg(FG)
+ };
+
+ ListItem::new(Line::from(vec![
+ Span::styled(sigil.to_string(), sigil_style),
+ Span::styled(rest.to_string(), Style::default().fg(FG)),
+ ]))
+ })
+ .collect();
+
+ let title = format!(" users ({}) ", state.members.len());
+ let block = panel_block(&title);
+
+ f.render_widget(List::new(items).block(block), area);
+}
+
+fn draw_statusbar(f: &mut Frame, area: Rect, state: &AppState) {
+ let (status_text, status_style) = if state.connected {
+ ("● connected", Style::default().fg(LIGHT_ORANGE))
+ } else {
+ ("○ connecting…", Style::default().fg(GRAY))
+ };
+
+ let line = Line::from(vec![
+ Span::styled(" ", Style::default()),
+ Span::styled(status_text, status_style),
+ Span::styled(" │ ", Style::default().fg(FG)),
+ Span::styled(&state.status, Style::default().fg(FG)),
+ Span::styled(" │ ", Style::default().fg(FG)),
+ Span::styled("PgUp/Dn scroll Ctrl-C quit", Style::default().fg(FG)),
+ ]);
+
+ f.render_widget(Paragraph::new(line).style(Style::default()), area);
+}
+
+/// Consistent panel block style
+fn panel_block(title: &str) -> Block<'static> {
+ Block::default()
+ .borders(Borders::ALL)
+ .border_type(BorderType::Plain)
+ .border_style(Style::default().fg(ORANGE))
+ .title(Span::styled(
+ format!(" {} ", title),
+ Style::default().fg(FG),
+ ))
+ .style(Style::default().bg(BG))
+}