diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 00000000..382eb355
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,66 @@
+name: Deploy T1 Chat demo
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - "apps/t1code-demo/**"
+ - ".github/workflows/pages.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ id-token: write
+ pages: write
+
+concurrency:
+ group: github-pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v5
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+
+ - name: Cache Rust builds
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: apps/t1code-demo -> target
+
+ - name: Install Trunk
+ run: cargo install trunk --version 0.21.14 --locked
+
+ - name: Build demo
+ working-directory: apps/t1code-demo
+ run: trunk build --release --public-url "/${{ github.event.repository.name }}/"
+
+ - name: Configure Pages
+ uses: actions/configure-pages@v5
+ with:
+ enablement: true
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v4
+ with:
+ path: apps/t1code-demo/dist
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 471855de..9e08078b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ node_modules
*.log
*.tsbuildinfo
apps/*/dist
+apps/*/target
.astro
packages/*/dist
.env
diff --git a/README.md b/README.md
index 1bb6c21c..d3559803 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](./LICENSE)
[](https://www.npmjs.com/package/@maria_rcks/t1code)
-[](https://github.com/maria-rcks/t1code)
+[](https://github.com/ahzs645/t1chat)
@@ -12,6 +12,8 @@ _T3Code, but in your terminal._
+## t1code (code mode)
+
Run instantly:
```bash
@@ -27,10 +29,51 @@ bun add -g @maria_rcks/t1code
Develop from source:
```bash
-git clone https://github.com/maria-rcks/t1code.git
+git clone https://github.com/ahzs645/t1chat.git
cd t1code
bun install
bun dev:tui
```
+## t1chat (chat mode)
+
+
+

+
+
+t1chat is a chat-focused mode that transforms the TUI into a conversational interface inspired by [T3 Chat](https://t3.chat). It features a pink/magenta/lavender theme, a flat thread list grouped by time, and a streamlined UI without code-specific tools.
+
+### What changes in chat mode
+
+- Sidebar shows a flat thread list grouped by time (Today, Yesterday, Last 7 Days, etc.) instead of nested projects
+- "New Chat" button and thread search in the sidebar
+- Title shows "T1 Chat" instead of "T1 Code"
+- Git tools, diff viewer, Chat/Plan toggle, and Full access button are hidden
+- Settings and temp chat toggle in the top-right corner
+- Composer placeholder says "Type your message here..."
+- Pink/magenta/lavender color scheme matching T3 Chat
+
+### Run chat mode
+
+If installed globally:
+
+```bash
+t1chat
+```
+
+Run instantly:
+
+```bash
+bunx @maria_rcks/t1code t1chat
+```
+
+Develop from source:
+
+```bash
+git clone https://github.com/ahzs645/t1chat.git
+cd t1code
+bun install
+T1CODE_CHAT_MODE=1 bun dev:tui
+```
+
Based on T3 Code by [@t3dotgg](https://github.com/t3dotgg) and [@juliusmarminge](https://github.com/juliusmarminge).
diff --git a/apps/t1code-demo/Cargo.lock b/apps/t1code-demo/Cargo.lock
new file mode 100644
index 00000000..55e87612
--- /dev/null
+++ b/apps/t1code-demo/Cargo.lock
@@ -0,0 +1,731 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "beamterm-core"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0375f7f3df163a52a06807f2150bac9a5da1933864ed40d52dda7dcd7cfa193b"
+dependencies = [
+ "beamterm-data",
+ "beamterm-unicode",
+ "bitflags",
+ "compact_str",
+ "glow",
+ "lru 0.16.4",
+ "rustc-hash",
+ "thiserror",
+ "unicode-width",
+]
+
+[[package]]
+name = "beamterm-data"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f21ff1fc630079352bae9b024f85519bf1f641cf7f326623f4c0b59f7ea834fd"
+dependencies = [
+ "compact_str",
+ "miniz_oxide",
+ "thiserror",
+]
+
+[[package]]
+name = "beamterm-renderer"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8db76d04d575a0534ad5dba63acc05ccbe69597f977f11c59a612cbea199112"
+dependencies = [
+ "beamterm-core",
+ "beamterm-data",
+ "bitflags",
+ "compact_str",
+ "console_error_panic_hook",
+ "glow",
+ "js-sys",
+ "thiserror",
+ "unicode-width",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "beamterm-unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f44155933cdd0625a699c554c504aab30a571e4df68f3532fb95bf9cf53b935"
+dependencies = [
+ "unicode-width",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "bitvec"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[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.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[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 = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[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-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "glow"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+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.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
+dependencies = [
+ "darling",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kasuari"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
+dependencies = [
+ "hashbrown 0.16.1",
+ "thiserror",
+]
+
+[[package]]
+name = "line-clipping"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "lru"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "lru"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
+dependencies = [
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[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.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
+[[package]]
+name = "ratatui"
+version = "0.30.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
+dependencies = [
+ "instability",
+ "ratatui-core",
+ "ratatui-widgets",
+]
+
+[[package]]
+name = "ratatui-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
+dependencies = [
+ "bitflags",
+ "compact_str",
+ "critical-section",
+ "hashbrown 0.17.1",
+ "itertools",
+ "kasuari",
+ "lru 0.18.0",
+ "strum",
+ "thiserror",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width",
+]
+
+[[package]]
+name = "ratatui-widgets"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.17.1",
+ "indoc",
+ "instability",
+ "itertools",
+ "line-clipping",
+ "ratatui-core",
+ "strum",
+ "time",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "ratzilla"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d53309a50b2cfb9828d1ea653b77fc6d2fe08137b05e65aa070ab87630e35b49"
+dependencies = [
+ "beamterm-renderer",
+ "bitvec",
+ "compact_str",
+ "console_error_panic_hook",
+ "ratatui",
+ "thiserror",
+ "unicode-width",
+ "web-sys",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc-std-workspace-core"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9c45b374136f52f2d6311062c7146bff20fec063c3f5d46a410bd937746955"
+
+[[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 = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[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.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "t1code-demo"
+version = "0.1.0"
+dependencies = [
+ "console_error_panic_hook",
+ "critical-section",
+ "ratzilla",
+]
+
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "time"
+version = "0.3.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "time-core",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[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.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-truncate"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
+dependencies = [
+ "itertools",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+dependencies = [
+ "rustc-std-workspace-core",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
diff --git a/apps/t1code-demo/Cargo.toml b/apps/t1code-demo/Cargo.toml
new file mode 100644
index 00000000..b454de70
--- /dev/null
+++ b/apps/t1code-demo/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "t1code-demo"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[dependencies]
+console_error_panic_hook = "0.1.7"
+critical-section = { version = "1.2", features = ["std"] }
+ratzilla = "0.3.1"
diff --git a/apps/t1code-demo/index.html b/apps/t1code-demo/index.html
new file mode 100644
index 00000000..0a0242a6
--- /dev/null
+++ b/apps/t1code-demo/index.html
@@ -0,0 +1,551 @@
+
+
+
+
+
+
+
+ T1Code Ratzilla Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✱
+ GPT-5.4
+ GPT-5m
+ Claude
+
+ │
+
+
+ Low
+ Fast
+
+
+
+
+
+
+
+
+
+
+
+
✕
+
+
+
+
+ ✱
+ ⌖
+ ⌘
+ ◇
+
+
+
✓
+
✓
+
✓
+
+
+
+ Theme
+
+
+
+
+
+
+ Boring Mode
+ ○
+ ●
+
+
+
+
+ Settings
+
+
+
+
+
diff --git a/apps/t1code-demo/src/main.rs b/apps/t1code-demo/src/main.rs
new file mode 100644
index 00000000..f2783a99
--- /dev/null
+++ b/apps/t1code-demo/src/main.rs
@@ -0,0 +1,1730 @@
+use std::{
+ cell::{Cell, RefCell},
+ io,
+ rc::Rc,
+};
+
+use ratzilla::{
+ backend::webgl2::{WebGl2Backend, WebGl2BackendOptions},
+ event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind},
+ ratatui::{
+ layout::{Alignment, Constraint, Layout, Rect},
+ prelude::{Color, Frame, Line, Modifier, Span, Style, Text, Widget},
+ widgets::{Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph, Wrap},
+ Terminal,
+ },
+ WebRenderer,
+};
+
+const SIDEBAR_WIDTH: u16 = 28;
+const COMPOSER_HEIGHT: u16 = 7;
+const FOOTER_HEIGHT: u16 = 0;
+const MAX_VISIBLE_PROMPTS: usize = 4;
+const WELCOME_CARD_ROW: u16 = 7;
+const WELCOME_CARD_GAP: u16 = 1;
+const WELCOME_CARDS: &[(&str, u16)] = &[
+ (" Create", 11),
+ (" Explore", 12),
+ (" Code", 10),
+ (" Learn", 11),
+];
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum FocusArea {
+ Threads,
+ Timeline,
+ Composer,
+ Controls,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum ThinkingMode {
+ Fast,
+ Think,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum MainView {
+ Thread,
+ Settings,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum ThemeChoice {
+ Light,
+ System,
+ Dark,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum ModelChoice {
+ Gpt54,
+ Gpt5Mini,
+ Claude,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Popover {
+ None,
+ Model,
+ Thinking,
+ Settings,
+ SidebarSettings,
+ QuickAction(usize),
+}
+
+#[derive(Clone, Copy)]
+struct DemoPalette {
+ accent: Color,
+ new_chat_bg: Color,
+ composer_border: Color,
+ send_active: Color,
+ control_active: Color,
+ text: Color,
+ muted: Color,
+ subtle: Color,
+ divider: Color,
+ page_bg: Color,
+ sidebar_bg: Color,
+ panel_bg: Color,
+ surface: Color,
+ surface_alt: Color,
+}
+
+const DARK_PALETTE: DemoPalette = DemoPalette {
+ accent: Color::Rgb(227, 63, 134),
+ new_chat_bg: Color::Rgb(162, 59, 103),
+ composer_border: Color::Rgb(163, 0, 76),
+ send_active: Color::Rgb(163, 0, 76),
+ control_active: Color::Rgb(57, 37, 56),
+ text: Color::Rgb(249, 248, 251),
+ muted: Color::Rgb(180, 159, 181),
+ subtle: Color::Rgb(134, 117, 135),
+ divider: Color::Rgb(57, 37, 56),
+ page_bg: Color::Rgb(31, 21, 29),
+ sidebar_bg: Color::Rgb(24, 15, 23),
+ panel_bg: Color::Rgb(31, 21, 29),
+ surface: Color::Rgb(39, 25, 37),
+ surface_alt: Color::Rgb(46, 31, 44),
+};
+
+const LIGHT_PALETTE: DemoPalette = DemoPalette {
+ accent: Color::Rgb(202, 2, 119),
+ new_chat_bg: Color::Rgb(162, 59, 103),
+ composer_border: Color::Rgb(227, 63, 134),
+ send_active: Color::Rgb(227, 63, 134),
+ control_active: Color::Rgb(230, 204, 233),
+ text: Color::Rgb(80, 24, 84),
+ muted: Color::Rgb(122, 63, 126),
+ subtle: Color::Rgb(154, 107, 158),
+ divider: Color::Rgb(224, 184, 220),
+ page_bg: Color::Rgb(242, 225, 244),
+ sidebar_bg: Color::Rgb(234, 208, 239),
+ panel_bg: Color::Rgb(253, 247, 253),
+ surface: Color::Rgb(255, 255, 255),
+ surface_alt: Color::Rgb(245, 234, 246),
+};
+
+const BORING_DARK_PALETTE: DemoPalette = DemoPalette {
+ accent: Color::Rgb(173, 82, 115),
+ new_chat_bg: Color::Rgb(162, 59, 103),
+ composer_border: Color::Rgb(118, 55, 80),
+ send_active: Color::Rgb(118, 55, 80),
+ control_active: Color::Rgb(42, 42, 42),
+ text: Color::Rgb(230, 230, 230),
+ muted: Color::Rgb(176, 176, 176),
+ subtle: Color::Rgb(112, 112, 112),
+ divider: Color::Rgb(40, 40, 40),
+ page_bg: Color::Rgb(21, 21, 21),
+ sidebar_bg: Color::Rgb(26, 26, 26),
+ panel_bg: Color::Rgb(21, 21, 21),
+ surface: Color::Rgb(30, 30, 30),
+ surface_alt: Color::Rgb(34, 34, 34),
+};
+
+const BORING_LIGHT_PALETTE: DemoPalette = DemoPalette {
+ accent: Color::Rgb(173, 82, 115),
+ new_chat_bg: Color::Rgb(162, 59, 103),
+ composer_border: Color::Rgb(173, 82, 115),
+ send_active: Color::Rgb(173, 82, 115),
+ control_active: Color::Rgb(212, 212, 212),
+ text: Color::Rgb(23, 23, 23),
+ muted: Color::Rgb(86, 86, 86),
+ subtle: Color::Rgb(128, 128, 128),
+ divider: Color::Rgb(208, 208, 208),
+ page_bg: Color::Rgb(235, 235, 235),
+ sidebar_bg: Color::Rgb(224, 224, 224),
+ panel_bg: Color::Rgb(240, 240, 240),
+ surface: Color::Rgb(255, 255, 255),
+ surface_alt: Color::Rgb(232, 232, 232),
+};
+
+thread_local! {
+ static ACTIVE_PALETTE: Cell = const { Cell::new(DARK_PALETTE) };
+}
+
+struct DemoState {
+ sidebar_open: bool,
+ active_thread: Option,
+ focus: FocusArea,
+ composer: String,
+ sent_prompt: Option,
+ turn_count: usize,
+ temporary_chat: bool,
+ thinking_mode: ThinkingMode,
+ model_choice: ModelChoice,
+ main_view: MainView,
+ theme_choice: ThemeChoice,
+ boring_mode: bool,
+ profile_create_open: bool,
+ last_button_down: Option<(u16, u16)>,
+ popover: Popover,
+ viewport: Rect,
+}
+
+impl Default for DemoState {
+ fn default() -> Self {
+ Self {
+ sidebar_open: true,
+ active_thread: None,
+ focus: FocusArea::Composer,
+ composer: String::new(),
+ sent_prompt: None,
+ turn_count: 0,
+ temporary_chat: false,
+ thinking_mode: ThinkingMode::Think,
+ model_choice: ModelChoice::Gpt54,
+ main_view: MainView::Thread,
+ theme_choice: ThemeChoice::System,
+ boring_mode: false,
+ profile_create_open: false,
+ last_button_down: None,
+ popover: Popover::None,
+ viewport: Rect::new(0, 0, 0, 0),
+ }
+ }
+}
+
+impl DemoState {
+ fn handle_key(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Char(c) if !key.ctrl && !key.alt && self.focus == FocusArea::Composer => {
+ self.composer.push(c);
+ }
+ KeyCode::Char('b') | KeyCode::Char('B') => self.sidebar_open = !self.sidebar_open,
+ KeyCode::Char('n') | KeyCode::Char('N') => self.start_new_chat(),
+ KeyCode::Char('t') | KeyCode::Char('T') => self.temporary_chat = !self.temporary_chat,
+ KeyCode::Char('m') | KeyCode::Char('M') => self.toggle_thinking_mode(),
+ KeyCode::Esc => {
+ self.profile_create_open = false;
+ self.popover = Popover::None;
+ if self.main_view == MainView::Settings {
+ self.main_view = MainView::Thread;
+ }
+ }
+ KeyCode::Tab => self.next_focus(),
+ KeyCode::Backspace => {
+ self.composer.pop();
+ }
+ KeyCode::Enter => self.send_demo_turn(),
+ KeyCode::Up => self.select_relative_thread(-1),
+ KeyCode::Down => self.select_relative_thread(1),
+ KeyCode::Char(c) => {
+ if !key.ctrl && !key.alt {
+ self.focus = FocusArea::Composer;
+ self.composer.push(c);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn handle_mouse(&mut self, event: MouseEvent) {
+ match event.kind {
+ MouseEventKind::ButtonDown(MouseButton::Left) => {
+ self.last_button_down = Some((event.col, event.row));
+ }
+ MouseEventKind::SingleClick(MouseButton::Left) => {
+ if self.last_button_down == Some((event.col, event.row)) {
+ self.last_button_down = None;
+ return;
+ }
+ }
+ _ => return,
+ }
+
+ let layout = AppLayout::from_area(self.viewport, self.sidebar_open);
+
+ if self.handle_popover_click(&layout, event.col, event.row) {
+ return;
+ }
+
+ if let Some(sidebar) = layout.sidebar {
+ if contains(sidebar, event.col, event.row) {
+ self.focus = FocusArea::Threads;
+ self.handle_sidebar_click(sidebar, event.col, event.row);
+ return;
+ }
+ }
+
+ if contains(layout.footer, event.col, event.row) {
+ self.handle_footer_click(event.col);
+ return;
+ }
+
+ if contains(layout.header, event.col, event.row) {
+ self.handle_header_click(layout.header, event.col);
+ return;
+ }
+
+ if self.main_view == MainView::Settings {
+ let settings = settings_view_area(&layout);
+ if contains(settings, event.col, event.row) {
+ self.handle_settings_view_click(settings, event.col, event.row);
+ }
+ return;
+ }
+
+ if contains(layout.composer, event.col, event.row) {
+ self.handle_composer_click(layout.composer, event.col, event.row);
+ return;
+ }
+
+ if contains(layout.content, event.col, event.row) {
+ self.focus = FocusArea::Timeline;
+ if self.active_thread.is_none() {
+ self.handle_welcome_click(timeline_inner(layout.content), event.col, event.row);
+ }
+ }
+ }
+
+ fn handle_sidebar_click(&mut self, sidebar: Rect, col: u16, row: u16) {
+ if row >= sidebar.y.saturating_add(sidebar.height.saturating_sub(4)) {
+ self.focus = FocusArea::Controls;
+ if col >= sidebar.x.saturating_add(10) {
+ self.popover = Popover::SidebarSettings;
+ }
+ return;
+ }
+
+ let inner_x = sidebar.x + 1;
+ if row == sidebar.y + 3
+ && col >= inner_x
+ && col < sidebar.x + sidebar.width.saturating_sub(1)
+ {
+ self.start_new_chat();
+ return;
+ }
+
+ if row == sidebar.y + 5 {
+ self.composer.clear();
+ self.focus = FocusArea::Composer;
+ return;
+ }
+
+ if let Some(index) = thread_index_for_sidebar_row(row) {
+ self.active_thread = Some(index);
+ self.sent_prompt = None;
+ self.focus = FocusArea::Timeline;
+ }
+ }
+
+ fn handle_footer_click(&mut self, col: u16) {
+ self.focus = FocusArea::Controls;
+ match col {
+ 0..=17 => self.sidebar_open = !self.sidebar_open,
+ 18..=35 => self.start_new_chat(),
+ 36..=54 => self.temporary_chat = !self.temporary_chat,
+ 55..=72 => self.toggle_thinking_mode(),
+ _ => {}
+ }
+ }
+
+ fn handle_composer_click(&mut self, composer: Rect, col: u16, row: u16) {
+ self.focus = FocusArea::Composer;
+ let toolbar_y = composer.y.saturating_add(composer.height.saturating_sub(2));
+
+ if row >= toolbar_y.saturating_sub(1) {
+ self.focus = FocusArea::Controls;
+ if col >= composer.x.saturating_add(composer.width.saturating_sub(8)) {
+ self.popover = Popover::None;
+ self.send_demo_turn();
+ } else if col < composer.x.saturating_add(16) {
+ self.popover = Popover::Model;
+ } else if col < composer.x.saturating_add(30) {
+ self.popover = Popover::Thinking;
+ } else {
+ self.popover = Popover::None;
+ }
+ return;
+ }
+
+ self.popover = Popover::None;
+ }
+
+ fn handle_header_click(&mut self, header: Rect, col: u16) {
+ self.focus = FocusArea::Controls;
+ if col >= header.x.saturating_add(header.width.saturating_sub(4)) {
+ self.popover = Popover::Settings;
+ } else if col >= header.x.saturating_add(header.width.saturating_sub(8)) {
+ self.temporary_chat = !self.temporary_chat;
+ self.popover = Popover::None;
+ } else {
+ self.popover = Popover::None;
+ }
+ }
+
+ fn handle_settings_view_click(&mut self, area: Rect, _col: u16, row: u16) {
+ self.focus = FocusArea::Controls;
+ match row.saturating_sub(area.y) {
+ 5 => self.cycle_theme(),
+ 6 => self.boring_mode = !self.boring_mode,
+ 10 => self.temporary_chat = !self.temporary_chat,
+ 13 => self.cycle_model(),
+ 17 => self.main_view = MainView::Thread,
+ _ => {}
+ }
+ }
+
+ fn handle_welcome_click(&mut self, content: Rect, col: u16, row: u16) {
+ for (index, rect) in welcome_card_rects(content) {
+ if contains(rect, col, row) {
+ self.focus = FocusArea::Controls;
+ self.popover = Popover::QuickAction(index);
+ return;
+ }
+ }
+
+ let relative_row = row.saturating_sub(content.y);
+ let prompt_start = welcome_prompt_start(content.width);
+ if relative_row >= prompt_start
+ && relative_row < prompt_start + MAX_VISIBLE_PROMPTS as u16 * 2
+ {
+ let prompt_index = ((relative_row - prompt_start) / 2) as usize;
+ if let Some(prompt) = PROMPTS.get(prompt_index) {
+ self.composer = (*prompt).to_string();
+ self.focus = FocusArea::Composer;
+ self.popover = Popover::None;
+ }
+ }
+ }
+
+ fn handle_popover_click(&mut self, layout: &AppLayout, col: u16, row: u16) -> bool {
+ let Some(rect) = popover_rect(layout, self.popover) else {
+ return false;
+ };
+
+ if !contains(rect, col, row) {
+ if self.popover != Popover::None {
+ if self.popover == Popover::SidebarSettings {
+ self.profile_create_open = false;
+ }
+ self.popover = Popover::None;
+ }
+ return false;
+ }
+
+ let relative_popover_row = row.saturating_sub(rect.y);
+
+ match self.popover {
+ Popover::Model => {
+ match relative_popover_row {
+ 1 => self.model_choice = ModelChoice::Gpt54,
+ 2 => self.model_choice = ModelChoice::Claude,
+ 3..=5 => {}
+ _ => {}
+ }
+ if matches!(relative_popover_row, 1..=5) {
+ self.popover = Popover::None;
+ }
+ }
+ Popover::Thinking => {
+ match relative_popover_row {
+ 2..=5 | 8 => self.thinking_mode = ThinkingMode::Think,
+ 9 => self.thinking_mode = ThinkingMode::Fast,
+ _ => {}
+ }
+ if matches!(relative_popover_row, 2..=5 | 8..=9) {
+ self.popover = Popover::None;
+ }
+ }
+ Popover::Settings => match relative_popover_row {
+ 1 => {
+ let rel_col = col.saturating_sub(rect.x);
+ self.theme_choice = match rel_col {
+ 0..=12 => ThemeChoice::Light,
+ 13..=15 => ThemeChoice::System,
+ _ => ThemeChoice::Dark,
+ };
+ }
+ 2 | 3 => {
+ self.boring_mode = !self.boring_mode;
+ }
+ 4 | 5 => {
+ self.main_view = MainView::Settings;
+ self.popover = Popover::None;
+ self.focus = FocusArea::Controls;
+ }
+ _ => {}
+ },
+ Popover::SidebarSettings => {
+ let relative_row = relative_popover_row;
+ if self.profile_create_open {
+ if relative_row == 5 {
+ self.profile_create_open = false;
+ self.popover = Popover::None;
+ }
+ } else if matches!(relative_row, 2 | 3) {
+ self.profile_create_open = true;
+ }
+ }
+ Popover::QuickAction(index) => {
+ if relative_popover_row >= 2 {
+ self.composer = quick_action_prompt(index).to_string();
+ self.focus = FocusArea::Composer;
+ self.popover = Popover::None;
+ }
+ }
+ Popover::None => {}
+ }
+
+ true
+ }
+
+ fn start_new_chat(&mut self) {
+ self.main_view = MainView::Thread;
+ self.active_thread = None;
+ self.sent_prompt = None;
+ self.turn_count = 0;
+ self.composer.clear();
+ self.focus = FocusArea::Composer;
+ }
+
+ fn send_demo_turn(&mut self) {
+ let prompt = self.composer.trim().to_string();
+ if prompt.is_empty() {
+ return;
+ }
+
+ self.sent_prompt = Some(prompt);
+ self.turn_count += 1;
+ self.active_thread = None;
+ self.composer.clear();
+ self.focus = FocusArea::Composer;
+ self.popover = Popover::None;
+ }
+
+ fn select_relative_thread(&mut self, delta: i8) {
+ let current = self.active_thread.unwrap_or(0);
+ let next = if delta < 0 {
+ current.saturating_sub(1)
+ } else {
+ (current + 1).min(THREADS.len() - 1)
+ };
+ self.active_thread = Some(next);
+ self.sent_prompt = None;
+ self.focus = FocusArea::Threads;
+ }
+
+ fn toggle_thinking_mode(&mut self) {
+ self.thinking_mode = match self.thinking_mode {
+ ThinkingMode::Fast => ThinkingMode::Think,
+ ThinkingMode::Think => ThinkingMode::Fast,
+ };
+ }
+
+ fn cycle_model(&mut self) {
+ self.model_choice = match self.model_choice {
+ ModelChoice::Gpt54 => ModelChoice::Gpt5Mini,
+ ModelChoice::Gpt5Mini => ModelChoice::Claude,
+ ModelChoice::Claude => ModelChoice::Gpt54,
+ };
+ }
+
+ fn cycle_theme(&mut self) {
+ self.theme_choice = match self.theme_choice {
+ ThemeChoice::Light => ThemeChoice::System,
+ ThemeChoice::System => ThemeChoice::Dark,
+ ThemeChoice::Dark => ThemeChoice::Light,
+ };
+ }
+
+ fn model_label(&self) -> &'static str {
+ match self.model_choice {
+ ModelChoice::Gpt54 => "GPT-5.4",
+ ModelChoice::Gpt5Mini => "GPT-5 mini",
+ ModelChoice::Claude => "Claude",
+ }
+ }
+
+ fn theme_label(&self) -> &'static str {
+ match self.theme_choice {
+ ThemeChoice::Light => "Light",
+ ThemeChoice::System => "System",
+ ThemeChoice::Dark => "Dark",
+ }
+ }
+
+ fn palette(&self) -> DemoPalette {
+ if self.boring_mode {
+ return match self.theme_choice {
+ ThemeChoice::Light => BORING_LIGHT_PALETTE,
+ ThemeChoice::System | ThemeChoice::Dark => BORING_DARK_PALETTE,
+ };
+ }
+
+ match self.theme_choice {
+ ThemeChoice::Light => LIGHT_PALETTE,
+ ThemeChoice::System | ThemeChoice::Dark => DARK_PALETTE,
+ }
+ }
+
+ fn next_focus(&mut self) {
+ self.focus = match self.focus {
+ FocusArea::Threads => FocusArea::Timeline,
+ FocusArea::Timeline => FocusArea::Composer,
+ FocusArea::Composer => FocusArea::Controls,
+ FocusArea::Controls => FocusArea::Threads,
+ };
+ }
+}
+
+struct ThreadItem {
+ title: &'static str,
+ group: &'static str,
+ preview: &'static str,
+}
+
+struct AppLayout {
+ sidebar: Option,
+ header: Rect,
+ content: Rect,
+ composer: Rect,
+ footer: Rect,
+}
+
+impl AppLayout {
+ fn from_area(area: Rect, sidebar_open: bool) -> Self {
+ let [app_area, footer] =
+ Layout::vertical([Constraint::Min(0), Constraint::Length(FOOTER_HEIGHT)]).areas(area);
+ let body_chunks = if sidebar_open && app_area.width > SIDEBAR_WIDTH + 42 {
+ Layout::horizontal([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(0)])
+ .split(app_area)
+ } else {
+ Layout::horizontal([Constraint::Min(0)]).split(app_area)
+ };
+
+ let (sidebar, main) = if body_chunks.len() == 2 {
+ (Some(body_chunks[0]), body_chunks[1])
+ } else {
+ (None, body_chunks[0])
+ };
+
+ let header = Rect {
+ x: main.x,
+ y: main.y,
+ width: main.width,
+ height: 3.min(main.height),
+ };
+ let composer_y = main
+ .y
+ .saturating_add(main.height.saturating_sub(COMPOSER_HEIGHT));
+ let composer = Rect {
+ x: main.x.saturating_add(1),
+ y: composer_y,
+ width: main.width.saturating_sub(2),
+ height: COMPOSER_HEIGHT,
+ };
+ let content_y = main.y.saturating_add(header.height);
+ let content = Rect {
+ x: main.x,
+ y: content_y,
+ width: main.width,
+ height: composer.y.saturating_sub(content_y),
+ };
+
+ Self {
+ sidebar,
+ header,
+ content,
+ composer,
+ footer,
+ }
+ }
+}
+
+const THREADS: &[ThreadItem] = &[
+ ThreadItem {
+ title: "Count Rs in strawberry",
+ group: "Today",
+ preview: "There are three Rs in strawberry.",
+ },
+ ThreadItem {
+ title: "New thread",
+ group: "Today",
+ preview: "A blank chat ready for the next idea.",
+ },
+ ThreadItem {
+ title: "New thread",
+ group: "Today",
+ preview: "Saved conversation placeholder.",
+ },
+ ThreadItem {
+ title: "New thread",
+ group: "Today",
+ preview: "Another recent thread.",
+ },
+];
+
+const PROMPTS: &[&str] = &[
+ "How does AI work?",
+ "Are black holes real?",
+ "How many Rs are in the word \"strawberry\"?",
+ "What is the meaning of life?",
+];
+
+fn main() -> io::Result<()> {
+ std::panic::set_hook(Box::new(console_error_panic_hook::hook));
+
+ let state = Rc::new(RefCell::new(DemoState::default()));
+ let backend = WebGl2Backend::new_with_options(
+ WebGl2BackendOptions::new()
+ .disable_auto_css_resize()
+ .canvas_padding_color(page_bg()),
+ )?;
+ let mut terminal = Terminal::new(backend)?;
+
+ terminal.on_key_event({
+ let state = state.clone();
+ move |key| state.borrow_mut().handle_key(key)
+ })?;
+
+ terminal.on_mouse_event({
+ let state = state.clone();
+ move |event| state.borrow_mut().handle_mouse(event)
+ })?;
+
+ terminal.draw_web(move |frame| {
+ let mut state = state.borrow_mut();
+ draw(frame, &mut state);
+ });
+
+ Ok(())
+}
+
+fn draw(frame: &mut Frame<'_>, state: &mut DemoState) {
+ let area = frame.area();
+ state.viewport = area;
+ set_active_palette(state);
+ sync_overlay_state(state);
+ Clear.render(area, frame.buffer_mut());
+ frame.render_widget(Block::new().style(Style::default().bg(page_bg())), area);
+
+ let layout = AppLayout::from_area(area, state.sidebar_open);
+ if let Some(sidebar) = layout.sidebar {
+ draw_sidebar(frame, sidebar, state);
+ }
+
+ draw_main_header(frame, layout.header, state);
+ if layout.sidebar.is_none() {
+ draw_compact_header(frame, layout.header);
+ }
+
+ if state.main_view == MainView::Settings {
+ draw_settings_view(frame, settings_view_area(&layout), state);
+ } else {
+ draw_timeline(frame, layout.content, state);
+ draw_composer(frame, layout.composer, state);
+ }
+
+ draw_footer(frame, layout.footer, state);
+ draw_popover(frame, &layout, state);
+}
+
+fn draw_sidebar(frame: &mut Frame<'_>, area: Rect, state: &DemoState) {
+ let block = Block::new().style(Style::default().bg(sidebar_bg()));
+ frame.render_widget(block, area);
+
+ let inner = Rect {
+ x: area.x + 1,
+ y: area.y,
+ width: area.width.saturating_sub(2),
+ height: area.height,
+ };
+
+ let [header, new_chat, search, threads, actions] = Layout::vertical([
+ Constraint::Length(3),
+ Constraint::Length(2),
+ Constraint::Length(3),
+ Constraint::Min(8),
+ Constraint::Length(4),
+ ])
+ .areas(inner);
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(
+ "T1 Chat",
+ Style::default().fg(text()).add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" "),
+ Span::styled(" ALPHA ", Style::default().fg(muted()).bg(surface_alt())),
+ ]))
+ .alignment(Alignment::Center),
+ Rect {
+ y: header.y.saturating_add(1),
+ height: 1,
+ ..header
+ },
+ );
+
+ frame.render_widget(
+ Paragraph::new(Line::from(Span::styled(
+ "New Chat",
+ Style::default()
+ .fg(Color::Rgb(255, 255, 255))
+ .add_modifier(Modifier::BOLD),
+ )))
+ .alignment(Alignment::Center)
+ .style(
+ Style::default()
+ .fg(Color::Rgb(255, 255, 255))
+ .bg(new_chat_bg()),
+ ),
+ Rect {
+ x: new_chat.x,
+ y: new_chat.y,
+ width: new_chat.width,
+ height: 1,
+ },
+ );
+
+ let search_text = Line::from(vec![
+ Span::raw(" "),
+ Span::styled("Search ", Style::default().fg(text())),
+ Span::styled("your threads...", Style::default().fg(muted())),
+ ]);
+ frame.render_widget(
+ Paragraph::new(search_text).style(Style::default().bg(sidebar_bg())),
+ search,
+ );
+
+ let mut rows = Vec::new();
+ let mut last_group = "";
+ for (index, thread) in THREADS.iter().enumerate() {
+ if thread.group != last_group {
+ rows.push(ListItem::new(Line::from(Span::styled(
+ thread.group,
+ Style::default().fg(muted()),
+ ))));
+ last_group = thread.group;
+ }
+ rows.push(thread_row(
+ index,
+ state.active_thread == Some(index),
+ thread,
+ ));
+ }
+
+ frame.render_widget(List::new(rows), threads);
+
+ let profile_icon = Rect {
+ x: actions.x + actions.width.saturating_sub(16),
+ y: actions.y.saturating_add(1),
+ width: 3,
+ height: 3.min(actions.height.saturating_sub(1)),
+ };
+ frame.render_widget(
+ Block::new().style(Style::default().fg(text()).bg(control_active())),
+ profile_icon,
+ );
+}
+
+fn draw_compact_header(frame: &mut Frame<'_>, area: Rect) {
+ let [brand, _] = Layout::horizontal([Constraint::Length(20), Constraint::Min(0)]).areas(area);
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(
+ " T1 Chat",
+ Style::default().fg(text()).add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" "),
+ Span::styled(" ALPHA ", Style::default().fg(muted()).bg(surface_alt())),
+ ])),
+ brand,
+ );
+}
+
+fn draw_main_header(frame: &mut Frame<'_>, area: Rect, _state: &DemoState) {
+ let top_band = Rect {
+ y: area.y.saturating_add(1),
+ height: area.height.saturating_sub(1),
+ width: area.width.saturating_sub(8),
+ ..area
+ };
+ let controls = Rect {
+ x: area.x.saturating_add(area.width.saturating_sub(8)),
+ y: area.y.saturating_add(1),
+ width: 8.min(area.width),
+ height: 1,
+ ..area
+ };
+ frame.render_widget(
+ Block::new().style(Style::default().bg(panel_bg())),
+ top_band,
+ );
+ frame.render_widget(
+ Paragraph::new(" ")
+ .alignment(Alignment::Center)
+ .style(Style::default().bg(panel_bg())),
+ controls,
+ );
+}
+
+fn draw_timeline(frame: &mut Frame<'_>, area: Rect, state: &DemoState) {
+ let block = Block::new()
+ .borders(Borders::RIGHT)
+ .border_style(if state.focus == FocusArea::Timeline {
+ Style::default().fg(accent())
+ } else {
+ Style::default().fg(divider())
+ })
+ .style(Style::default().bg(panel_bg()))
+ .padding(Padding::horizontal(3));
+ frame.render_widget(block, area);
+ let inner = timeline_inner(area);
+
+ if state.active_thread.is_none() && state.sent_prompt.is_none() {
+ draw_welcome(frame, inner, state);
+ return;
+ }
+
+ let lines = if let Some(prompt) = &state.sent_prompt {
+ sent_chat_lines(prompt, state.turn_count)
+ } else {
+ let active = state.active_thread.unwrap_or(0);
+ thread_chat_lines(&THREADS[active])
+ };
+
+ frame.render_widget(
+ Paragraph::new(Text::from(lines))
+ .wrap(Wrap { trim: false })
+ .style(Style::default().fg(text()).bg(panel_bg())),
+ inner,
+ );
+}
+
+fn draw_welcome(frame: &mut Frame<'_>, area: Rect, state: &DemoState) {
+ let status = if state.temporary_chat {
+ Line::from(Span::styled(
+ " Temporary chat",
+ Style::default().fg(accent()).bg(panel_bg()),
+ ))
+ } else {
+ Line::from(Span::styled(
+ "How can I help you?",
+ Style::default().fg(text()).bg(panel_bg()),
+ ))
+ };
+
+ frame.render_widget(
+ Paragraph::new(status).style(Style::default().bg(panel_bg())),
+ Rect {
+ y: area.y.saturating_add(5),
+ height: 1,
+ ..area
+ },
+ );
+
+ for (index, card) in welcome_card_rects(area) {
+ let label = WELCOME_CARDS[index].0;
+ frame.render_widget(
+ Block::bordered()
+ .border_type(BorderType::Rounded)
+ .border_style(Style::default().fg(divider()))
+ .style(Style::default().bg(surface_alt())),
+ card,
+ );
+ frame.render_widget(
+ Paragraph::new(label)
+ .alignment(Alignment::Center)
+ .style(Style::default().fg(text()).bg(surface_alt())),
+ Rect {
+ x: card.x.saturating_add(1),
+ y: card.y.saturating_add(1),
+ width: card.width.saturating_sub(2),
+ height: 1,
+ },
+ );
+ }
+
+ let prompt_y = area.y.saturating_add(welcome_prompt_start(area.width));
+ for (index, prompt) in PROMPTS.iter().take(MAX_VISIBLE_PROMPTS).enumerate() {
+ let y = prompt_y.saturating_add(index as u16 * 2);
+ frame.render_widget(
+ Paragraph::new(truncate(prompt, area.width.saturating_sub(1) as usize))
+ .style(Style::default().fg(muted()).bg(panel_bg())),
+ Rect {
+ y,
+ height: 1,
+ ..area
+ },
+ );
+ frame.render_widget(
+ Paragraph::new("─".repeat(area.width as usize))
+ .style(Style::default().fg(divider()).bg(panel_bg())),
+ Rect {
+ y: y.saturating_add(1),
+ height: 1,
+ ..area
+ },
+ );
+ }
+}
+
+fn draw_settings_view(frame: &mut Frame<'_>, area: Rect, state: &DemoState) {
+ let block = Block::new()
+ .borders(Borders::RIGHT)
+ .border_style(Style::default().fg(divider()))
+ .style(Style::default().bg(panel_bg()))
+ .padding(Padding::horizontal(3));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let lines = vec![
+ Line::from(""),
+ Line::from(Span::styled(
+ "Settings",
+ Style::default()
+ .fg(text())
+ .bg(panel_bg())
+ .add_modifier(Modifier::BOLD),
+ )),
+ Line::from(""),
+ settings_section("General"),
+ settings_row("Mode", "T1 Chat", false),
+ settings_row("Theme", state.theme_label(), true),
+ settings_row(
+ "Theme preset",
+ if state.boring_mode {
+ "Boring"
+ } else {
+ "Default"
+ },
+ true,
+ ),
+ settings_row("Assistant responses", "Streaming", false),
+ settings_row("Thread workspace", "Local", false),
+ settings_row("Confirm thread delete", "Enabled", false),
+ settings_row("Temporary chat", bool_label(state.temporary_chat), true),
+ Line::from(""),
+ settings_section("Models"),
+ settings_row("Text generation model", state.model_label(), true),
+ Line::from(""),
+ settings_section("Advanced"),
+ settings_row("Keybindings", "Open file", false),
+ settings_row("Back to thread", "Return", true),
+ ];
+
+ frame.render_widget(
+ Paragraph::new(Text::from(lines))
+ .wrap(Wrap { trim: false })
+ .style(Style::default().fg(text()).bg(panel_bg())),
+ inner,
+ );
+}
+
+fn settings_view_area(layout: &AppLayout) -> Rect {
+ Rect {
+ x: layout.content.x,
+ y: layout.content.y,
+ width: layout.content.width,
+ height: layout
+ .composer
+ .y
+ .saturating_add(layout.composer.height)
+ .saturating_sub(layout.content.y),
+ }
+}
+
+fn settings_section(label: &'static str) -> Line<'static> {
+ Line::from(Span::styled(
+ label,
+ Style::default()
+ .fg(muted())
+ .bg(panel_bg())
+ .add_modifier(Modifier::BOLD),
+ ))
+}
+
+fn settings_row(label: &'static str, value: &'static str, actionable: bool) -> Line<'static> {
+ let marker = if actionable { "> " } else { " " };
+ Line::from(vec![
+ Span::styled(marker, Style::default().fg(accent()).bg(panel_bg())),
+ Span::styled(
+ format!("{label:<24}"),
+ Style::default()
+ .fg(if actionable { text() } else { muted() })
+ .bg(panel_bg()),
+ ),
+ Span::styled(
+ value.to_string(),
+ Style::default().fg(text()).bg(panel_bg()),
+ ),
+ ])
+}
+
+fn timeline_inner(area: Rect) -> Rect {
+ Rect {
+ x: area.x.saturating_add(3),
+ y: area.y,
+ width: area.width.saturating_sub(7),
+ height: area.height,
+ }
+}
+
+fn welcome_card_rects(area: Rect) -> Vec<(usize, Rect)> {
+ let mut rects = Vec::new();
+ let mut x = area.x;
+ let mut y = area.y.saturating_add(WELCOME_CARD_ROW);
+ let right = area.x.saturating_add(area.width);
+
+ for (index, &(_, width)) in WELCOME_CARDS.iter().enumerate() {
+ if x > area.x && x.saturating_add(width) > right {
+ x = area.x;
+ y = y.saturating_add(3);
+ }
+ rects.push((
+ index,
+ Rect {
+ x,
+ y,
+ width,
+ height: 3,
+ },
+ ));
+ x = x.saturating_add(width).saturating_add(WELCOME_CARD_GAP);
+ }
+
+ rects
+}
+
+fn welcome_prompt_start(width: u16) -> u16 {
+ WELCOME_CARD_ROW
+ .saturating_add(welcome_card_rows(width).saturating_mul(3))
+ .saturating_add(2)
+}
+
+fn welcome_card_rows(width: u16) -> u16 {
+ let mut rows: u16 = 1;
+ let mut used: u16 = 0;
+ for &(_, card_width) in WELCOME_CARDS {
+ let next_width = if used == 0 {
+ card_width
+ } else {
+ used.saturating_add(WELCOME_CARD_GAP)
+ .saturating_add(card_width)
+ };
+ if used > 0 && next_width > width {
+ rows = rows.saturating_add(1);
+ used = card_width;
+ } else {
+ used = next_width;
+ }
+ }
+ rows
+}
+
+fn draw_composer(frame: &mut Frame<'_>, area: Rect, state: &DemoState) {
+ let border_style = if state.focus == FocusArea::Composer {
+ Style::default().fg(composer_border())
+ } else {
+ Style::default().fg(divider())
+ };
+ let block = Block::new()
+ .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
+ .border_type(BorderType::Rounded)
+ .border_style(border_style)
+ .style(Style::default().bg(surface()));
+ frame.render_widget(block, area);
+
+ let input_area = Rect {
+ x: area.x.saturating_add(2),
+ y: area.y.saturating_add(2),
+ width: area.width.saturating_sub(4),
+ height: area.height.saturating_sub(4),
+ };
+ let toolbar_area = Rect {
+ x: area.x.saturating_add(3),
+ y: area.y.saturating_add(area.height.saturating_sub(2)),
+ width: area.width.saturating_sub(6),
+ height: 1,
+ };
+
+ let composer_text = if state.composer.is_empty() {
+ Text::from(Line::from(Span::styled(
+ "Type your message here...",
+ Style::default().fg(muted()),
+ )))
+ } else {
+ Text::from(Line::from(Span::styled(
+ state.composer.as_str(),
+ Style::default().fg(text()),
+ )))
+ };
+
+ frame.render_widget(
+ Paragraph::new(composer_text)
+ .wrap(Wrap { trim: false })
+ .style(Style::default().fg(text()).bg(surface())),
+ input_area,
+ );
+
+ let toolbar_clear_style = Style::default().fg(muted()).bg(surface());
+ let [model, separator, mode, _, _send_slot] = Layout::horizontal([
+ Constraint::Length(14),
+ Constraint::Length(3),
+ Constraint::Length(10),
+ Constraint::Min(0),
+ Constraint::Length(5),
+ ])
+ .areas(toolbar_area);
+ frame.render_widget(
+ Paragraph::new(Line::from(Span::styled(" ", toolbar_clear_style)))
+ .style(toolbar_clear_style),
+ model,
+ );
+ frame.render_widget(
+ Paragraph::new(Line::from(Span::styled(
+ " ",
+ Style::default().bg(surface()),
+ )))
+ .style(Style::default().bg(surface())),
+ separator,
+ );
+ frame.render_widget(
+ Paragraph::new(Line::from(Span::styled(" ", toolbar_clear_style)))
+ .style(toolbar_clear_style),
+ mode,
+ );
+ let send_bg = if state.composer.trim().is_empty() {
+ surface_alt()
+ } else {
+ send_active()
+ };
+ let send = Rect {
+ x: area.x.saturating_add(area.width.saturating_sub(8)),
+ y: area.y.saturating_add(area.height.saturating_sub(3)),
+ width: 5.min(area.width.saturating_sub(2)),
+ height: 2.min(area.height.saturating_sub(2)),
+ };
+ frame.render_widget(
+ Block::new().style(Style::default().fg(text()).bg(send_bg)),
+ send,
+ );
+}
+
+fn draw_footer(frame: &mut Frame<'_>, area: Rect, _state: &DemoState) {
+ if area.height == 0 {
+ return;
+ }
+
+ frame.render_widget(Block::new().style(Style::default().bg(page_bg())), area);
+}
+
+fn draw_popover(frame: &mut Frame<'_>, layout: &AppLayout, state: &DemoState) {
+ let Some(area) = popover_rect(layout, state.popover) else {
+ return;
+ };
+
+ Clear.render(area, frame.buffer_mut());
+ if matches!(state.popover, Popover::Model | Popover::Thinking) {
+ frame.render_widget(Block::new().style(Style::default().bg(surface_alt())), area);
+ let inner = Rect {
+ x: area.x.saturating_add(2),
+ y: area.y.saturating_add(1),
+ width: area.width.saturating_sub(4),
+ height: area.height.saturating_sub(2),
+ };
+ let lines = match state.popover {
+ Popover::Model => model_menu_lines(state),
+ Popover::Thinking => thinking_menu_lines(state),
+ _ => Vec::new(),
+ };
+ frame.render_widget(
+ Paragraph::new(Text::from(lines))
+ .wrap(Wrap { trim: false })
+ .style(Style::default().bg(surface_alt())),
+ inner,
+ );
+ if state.popover == Popover::Model {
+ frame.render_widget(
+ Paragraph::new(Text::from(vec![
+ Line::from(Span::styled("│", Style::default().fg(subtle()))),
+ Line::from(Span::styled("│", Style::default().fg(subtle()))),
+ Line::from(Span::styled("│", Style::default().fg(subtle()))),
+ Line::from(Span::styled("│", Style::default().fg(subtle()))),
+ Line::from(Span::styled("│", Style::default().fg(subtle()))),
+ ]))
+ .style(Style::default().bg(surface_alt())),
+ Rect {
+ x: area.x.saturating_add(area.width.saturating_sub(4)),
+ y: area.y.saturating_add(1),
+ width: 1,
+ height: area.height.saturating_sub(2),
+ },
+ );
+ }
+ return;
+ }
+
+ let title = match state.popover {
+ Popover::Model => " model ",
+ Popover::Thinking => " effort ",
+ Popover::Settings => " chat ",
+ Popover::SidebarSettings => " profiles ",
+ Popover::QuickAction(index) => quick_action_title(index),
+ Popover::None => "",
+ };
+
+ let block = Block::bordered()
+ .title(title)
+ .border_style(Style::default().fg(accent()))
+ .style(Style::default().bg(surface_alt()));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let lines = match state.popover {
+ Popover::Model => model_menu_lines(state),
+ Popover::Thinking => thinking_menu_lines(state),
+ Popover::Settings => chat_settings_lines(state),
+ Popover::SidebarSettings => profile_settings_lines(state),
+ Popover::QuickAction(index) => vec![
+ Line::from(Span::styled(
+ "Choose this mode",
+ Style::default().fg(subtle()).bg(surface_alt()),
+ )),
+ option_line(quick_action_prompt(index), false),
+ ],
+ Popover::None => Vec::new(),
+ };
+
+ frame.render_widget(
+ Paragraph::new(Text::from(lines))
+ .wrap(Wrap { trim: false })
+ .style(Style::default().bg(surface_alt())),
+ inner,
+ );
+}
+
+fn popover_rect(layout: &AppLayout, popover: Popover) -> Option {
+ match popover {
+ Popover::None => None,
+ Popover::Model => Some(Rect {
+ x: layout.composer.x.saturating_add(3),
+ y: layout.composer.y.saturating_sub(8),
+ width: 40.min(layout.composer.width.saturating_sub(6)),
+ height: 8,
+ }),
+ Popover::Thinking => Some(Rect {
+ x: layout.composer.x.saturating_add(24),
+ y: layout.composer.y.saturating_sub(11),
+ width: 44.min(layout.composer.width.saturating_sub(28)),
+ height: 11,
+ }),
+ Popover::Settings => Some(Rect {
+ x: layout
+ .header
+ .x
+ .saturating_add(layout.header.width.saturating_sub(31)),
+ y: layout.header.y.saturating_add(3),
+ width: 30,
+ height: 6,
+ }),
+ Popover::SidebarSettings => layout.sidebar.map(|sidebar| Rect {
+ x: sidebar.x.saturating_add(3),
+ y: sidebar.y.saturating_add(sidebar.height.saturating_sub(12)),
+ width: sidebar.width.saturating_sub(5),
+ height: 8,
+ }),
+ Popover::QuickAction(index) => {
+ let content = timeline_inner(layout.content);
+ welcome_card_rects(content)
+ .into_iter()
+ .find(|(card_index, _)| *card_index == index)
+ .map(|(_, card)| Rect {
+ x: card.x,
+ y: card.y.saturating_add(3),
+ width: 24.min(content.width),
+ height: 5,
+ })
+ }
+ }
+}
+
+fn chat_settings_lines(_state: &DemoState) -> Vec> {
+ vec![
+ blank_popover_line(),
+ blank_popover_line(),
+ blank_popover_line(),
+ blank_popover_line(),
+ ]
+}
+
+fn profile_settings_lines(state: &DemoState) -> Vec> {
+ if state.profile_create_open {
+ return vec![
+ Line::from(Span::styled(
+ "Create a Profile",
+ Style::default()
+ .fg(text())
+ .bg(surface_alt())
+ .add_modifier(Modifier::BOLD),
+ )),
+ Line::from(Span::styled(
+ "Separate threads",
+ Style::default().fg(subtle()).bg(surface_alt()),
+ )),
+ Line::from(vec![
+ Span::styled(" Icon ", Style::default().fg(muted()).bg(surface_alt())),
+ Span::styled("Default", Style::default().fg(text()).bg(surface_alt())),
+ ]),
+ Line::from(Span::styled(
+ "Name Default",
+ Style::default().fg(muted()).bg(surface_alt()),
+ )),
+ option_line("Create Profile", false),
+ ];
+ }
+
+ vec![
+ Line::from(Span::styled(
+ "Default profile",
+ Style::default()
+ .fg(text())
+ .bg(surface_alt())
+ .add_modifier(Modifier::BOLD),
+ )),
+ option_line("T1 Chat", true),
+ option_line("Create profile", false),
+ ]
+}
+
+fn model_menu_lines(state: &DemoState) -> Vec> {
+ vec![
+ menu_line(" Codex", state.model_choice == ModelChoice::Gpt54, false),
+ menu_line(" Claude", state.model_choice == ModelChoice::Claude, false),
+ menu_line(" Cursor Soon", false, true),
+ menu_line(" OpenCode Soon", false, true),
+ menu_line(" Gemini Soon", false, true),
+ ]
+}
+
+fn thinking_menu_lines(state: &DemoState) -> Vec> {
+ vec![
+ section_line("Reasoning"),
+ menu_line(" Extra High", false, false),
+ menu_line(" High (default)", false, false),
+ menu_line(" Medium", false, false),
+ menu_line(" Low", state.thinking_mode == ThinkingMode::Think, false),
+ blank_popover_line(),
+ section_line("Fast Mode"),
+ menu_line(" Off", state.thinking_mode == ThinkingMode::Think, false),
+ menu_line(" On", state.thinking_mode == ThinkingMode::Fast, false),
+ ]
+}
+
+fn section_line(label: &'static str) -> Line<'static> {
+ Line::from(Span::styled(
+ label,
+ Style::default().fg(muted()).bg(surface_alt()),
+ ))
+}
+
+fn menu_line(label: &str, active: bool, disabled: bool) -> Line<'static> {
+ let padded_label = format!("{label:<34}");
+ let style = Style::default()
+ .fg(if disabled {
+ subtle()
+ } else if active {
+ text()
+ } else {
+ muted()
+ })
+ .bg(if active {
+ control_active()
+ } else {
+ surface_alt()
+ })
+ .add_modifier(if active {
+ Modifier::BOLD
+ } else {
+ Modifier::empty()
+ });
+ Line::from(Span::styled(padded_label, style))
+}
+
+fn blank_popover_line() -> Line<'static> {
+ Line::from(Span::styled(
+ " ".repeat(26),
+ Style::default().bg(surface_alt()),
+ ))
+}
+
+fn option_line(label: &str, active: bool) -> Line<'static> {
+ let prefix = if active { "> " } else { " " };
+ Line::from(vec![
+ Span::styled(prefix, Style::default().fg(accent()).bg(surface_alt())),
+ Span::styled(
+ label.to_string(),
+ Style::default().fg(text()).bg(surface_alt()),
+ ),
+ ])
+}
+
+fn bool_label(enabled: bool) -> &'static str {
+ if enabled {
+ "On"
+ } else {
+ "Off"
+ }
+}
+
+fn quick_action_title(index: usize) -> &'static str {
+ match index {
+ 0 => " create ",
+ 1 => " explore ",
+ 2 => " code ",
+ 3 => " learn ",
+ _ => " action ",
+ }
+}
+
+fn quick_action_prompt(index: usize) -> &'static str {
+ match index {
+ 0 => "Create a concise project plan",
+ 1 => "Explore an idea from first principles",
+ 2 => "Write a small code example",
+ 3 => "Teach me the key concept",
+ _ => "Start a new thread with a prompt",
+ }
+}
+
+fn sync_overlay_state(state: &DemoState) {
+ let view_class = if state.main_view == MainView::Settings {
+ "is-settings"
+ } else if state.main_view == MainView::Thread
+ && state.active_thread.is_none()
+ && state.sent_prompt.is_none()
+ {
+ "is-welcome"
+ } else {
+ "is-chat"
+ };
+
+ let theme_class = match state.theme_choice {
+ ThemeChoice::Light => "theme-light",
+ ThemeChoice::System => "theme-system",
+ ThemeChoice::Dark => "theme-dark",
+ };
+ let boring_class = if state.boring_mode {
+ "boring-on"
+ } else {
+ "boring-off"
+ };
+ let popover_class = match state.popover {
+ Popover::Model => "popover-model",
+ Popover::Thinking => "popover-thinking",
+ Popover::Settings => "popover-chat-settings",
+ Popover::SidebarSettings => "popover-profiles",
+ _ => "popover-none",
+ };
+ let profile_class = if state.profile_create_open {
+ "profile-create-open"
+ } else {
+ "profile-create-closed"
+ };
+ let composer_class = if state.composer.trim().is_empty() {
+ "composer-empty"
+ } else {
+ "composer-filled"
+ };
+ let temporary_class = if state.temporary_chat {
+ "temporary-on"
+ } else {
+ "temporary-off"
+ };
+ let thinking_class = match state.thinking_mode {
+ ThinkingMode::Fast => "thinking-fast",
+ ThinkingMode::Think => "thinking-low",
+ };
+ let model_class = match state.model_choice {
+ ModelChoice::Gpt54 => "model-gpt54",
+ ModelChoice::Gpt5Mini => "model-gpt5m",
+ ModelChoice::Claude => "model-claude",
+ };
+ let class_name = format!(
+ "{view_class} {theme_class} {boring_class} {popover_class} {profile_class} {composer_class} {temporary_class} {thinking_class} {model_class}"
+ );
+
+ if let Some(window) = ratzilla::web_sys::window() {
+ if let Some(document) = window.document() {
+ if let Some(body) = document.body() {
+ body.set_class_name(&class_name);
+ }
+ }
+ }
+}
+
+fn thread_row(_index: usize, active: bool, thread: &ThreadItem) -> ListItem<'static> {
+ let style = if active {
+ Style::default()
+ .fg(text())
+ .bg(surface_alt())
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(muted())
+ };
+ ListItem::new(Line::from(Span::styled(truncate(thread.title, 25), style)))
+}
+
+fn thread_chat_lines(thread: &ThreadItem) -> Vec> {
+ vec![
+ role_line("user", " 10:42 AM", accent()),
+ Line::from(match thread.title {
+ "Count Rs in strawberry" => "How many Rs are in the word \"strawberry\"?",
+ "New thread" => "Start a new thread with a prompt.",
+ _ => "Can you help me think this through?",
+ }),
+ Line::from(""),
+ role_line("assistant", " 10:43 AM", accent()),
+ Line::from(thread.preview),
+ Line::from(""),
+ Line::from("The short answer is visible in the conversation, with the context"),
+ Line::from("kept nearby so you can continue from the same thread later."),
+ Line::from(""),
+ Line::from(Span::styled(
+ "Ready for the next prompt.",
+ Style::default().fg(muted()),
+ )),
+ ]
+}
+
+fn sent_chat_lines(prompt: &str, turn_count: usize) -> Vec> {
+ vec![
+ role_line("user", " just now", accent()),
+ Line::from(prompt.to_string()),
+ Line::from(""),
+ role_line("assistant", " streaming", accent()),
+ Line::from(format!("Demo turn {turn_count}: I can work with that.")),
+ Line::from(""),
+ Line::from("Here is a concise answer in the same conversation surface,"),
+ Line::from("with the prompt preserved above and the composer ready for"),
+ Line::from("the next turn."),
+ ]
+}
+
+fn role_line<'a>(role: &'a str, time: &'a str, color: Color) -> Line<'a> {
+ Line::from(vec![
+ Span::styled(
+ role,
+ Style::default().fg(color).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(time, Style::default().fg(subtle())),
+ ])
+}
+
+fn thread_index_for_sidebar_row(row: u16) -> Option {
+ match row {
+ 9 => Some(0),
+ 10 => Some(1),
+ 11 => Some(2),
+ 12 => Some(3),
+ _ => None,
+ }
+}
+
+fn contains(rect: Rect, x: u16, y: u16) -> bool {
+ x >= rect.x
+ && x < rect.x.saturating_add(rect.width)
+ && y >= rect.y
+ && y < rect.y.saturating_add(rect.height)
+}
+
+fn set_active_palette(state: &DemoState) {
+ ACTIVE_PALETTE.with(|palette| palette.set(state.palette()));
+}
+
+fn palette_color(select: impl FnOnce(DemoPalette) -> Color) -> Color {
+ ACTIVE_PALETTE.with(|palette| select(palette.get()))
+}
+
+fn accent() -> Color {
+ palette_color(|palette| palette.accent)
+}
+
+fn new_chat_bg() -> Color {
+ palette_color(|palette| palette.new_chat_bg)
+}
+
+fn composer_border() -> Color {
+ palette_color(|palette| palette.composer_border)
+}
+
+fn send_active() -> Color {
+ palette_color(|palette| palette.send_active)
+}
+
+fn control_active() -> Color {
+ palette_color(|palette| palette.control_active)
+}
+
+fn text() -> Color {
+ palette_color(|palette| palette.text)
+}
+
+fn muted() -> Color {
+ palette_color(|palette| palette.muted)
+}
+
+fn subtle() -> Color {
+ palette_color(|palette| palette.subtle)
+}
+
+fn divider() -> Color {
+ palette_color(|palette| palette.divider)
+}
+
+fn page_bg() -> Color {
+ palette_color(|palette| palette.page_bg)
+}
+
+fn sidebar_bg() -> Color {
+ palette_color(|palette| palette.sidebar_bg)
+}
+
+fn panel_bg() -> Color {
+ palette_color(|palette| palette.panel_bg)
+}
+
+fn surface() -> Color {
+ palette_color(|palette| palette.surface)
+}
+
+fn surface_alt() -> Color {
+ palette_color(|palette| palette.surface_alt)
+}
+
+fn truncate(input: &str, max_width: usize) -> String {
+ if input.chars().count() <= max_width {
+ return input.to_string();
+ }
+
+ if max_width <= 1 {
+ return ".".to_string();
+ }
+
+ let mut value = input.chars().take(max_width - 1).collect::();
+ value.push('.');
+ value
+}
diff --git a/apps/tui/bin/t1chat.js b/apps/tui/bin/t1chat.js
new file mode 100755
index 00000000..0b76b92a
--- /dev/null
+++ b/apps/tui/bin/t1chat.js
@@ -0,0 +1,45 @@
+#!/usr/bin/env bun
+
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const entryPath = fileURLToPath(new URL("../dist/index.mjs", import.meta.url));
+
+function printError(error) {
+ process.stderr.write(
+ `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
+ );
+}
+
+process.env.T1CODE_CHAT_MODE = "1";
+
+if (process.versions.bun === undefined) {
+ const bunBin = process.env.T1CODE_BUN_BIN?.trim() || "bun";
+ const child = spawn(bunBin, [entryPath, ...process.argv.slice(2)], {
+ stdio: "inherit",
+ env: process.env,
+ });
+
+ child.once("exit", (code, signal) => {
+ if (signal) {
+ process.kill(process.pid, signal);
+ return;
+ }
+ process.exit(code ?? 1);
+ });
+
+ child.once("error", (error) => {
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
+ printError("t1code requires Bun on your PATH to launch the TUI runtime.");
+ process.exit(1);
+ return;
+ }
+ printError(error);
+ process.exit(1);
+ });
+} else {
+ import("../dist/index.mjs").catch((error) => {
+ printError(error);
+ process.exit(1);
+ });
+}
diff --git a/apps/tui/package.json b/apps/tui/package.json
index f18b9008..ee64c027 100644
--- a/apps/tui/package.json
+++ b/apps/tui/package.json
@@ -14,6 +14,7 @@
},
"bin": {
"t1": "./bin/t1code.js",
+ "t1chat": "./bin/t1chat.js",
"t1code": "./bin/t1code.js"
},
"files": [
diff --git a/apps/tui/src/profiles.ts b/apps/tui/src/profiles.ts
new file mode 100644
index 00000000..1fbe56d8
--- /dev/null
+++ b/apps/tui/src/profiles.ts
@@ -0,0 +1,72 @@
+/**
+ * Profile management for t1chat mode.
+ *
+ * Profiles let users organize conversations under different personas.
+ * Each profile has a name, icon, and unique ID. Threads can be
+ * associated with a profile so switching profiles filters the sidebar.
+ */
+
+export interface Profile {
+ id: string;
+ name: string;
+ icon: string;
+}
+
+/** Nerd Font icons available for profile selection. */
+export const PROFILE_ICON_OPTIONS: { icon: string; label: string }[] = [
+ { icon: "", label: "Chat" },
+ { icon: "", label: "Star" },
+ { icon: "", label: "Bookmark" },
+ { icon: "", label: "Heart" },
+ { icon: "", label: "Flag" },
+ { icon: "", label: "Lightning" },
+ { icon: "", label: "Play" },
+ { icon: "", label: "Sparkles" },
+ { icon: "", label: "Bell" },
+ { icon: "", label: "Bulb" },
+ { icon: "", label: "Home" },
+ { icon: "", label: "Folder" },
+ { icon: "", label: "Calendar" },
+ { icon: "", label: "Mail" },
+ { icon: "", label: "File" },
+ { icon: "", label: "Book" },
+ { icon: "", label: "Briefcase" },
+ { icon: "", label: "Database" },
+ { icon: "", label: "Cube" },
+ { icon: "", label: "Music" },
+ { icon: "", label: "Camera" },
+ { icon: "", label: "Eye" },
+ { icon: "", label: "Globe" },
+ { icon: "", label: "Graduate" },
+];
+
+export const DEFAULT_PROFILE: Profile = {
+ id: "default",
+ name: "Default",
+ icon: "",
+};
+
+export function createProfile(name: string, icon: string): Profile {
+ const slug = name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+ return {
+ id: `${slug}-${Date.now()}`,
+ name,
+ icon,
+ };
+}
+
+export function reorderProfiles(
+ profiles: Profile[],
+ fromIndex: number,
+ toIndex: number,
+): Profile[] {
+ const result = [...profiles];
+ const [moved] = result.splice(fromIndex, 1);
+ if (moved) {
+ result.splice(toIndex, 0, moved);
+ }
+ return result;
+}
diff --git a/apps/tui/src/responsiveLayout.ts b/apps/tui/src/responsiveLayout.ts
index 7d6ad5b6..59d377cd 100644
--- a/apps/tui/src/responsiveLayout.ts
+++ b/apps/tui/src/responsiveLayout.ts
@@ -27,6 +27,7 @@ export type TuiResponsiveLayout = Readonly<{
export function resolveTuiResponsiveLayout(input: {
viewportColumns: number;
sidebarCollapsedPreference: boolean;
+ isChatMode?: boolean;
}): TuiResponsiveLayout {
const openSidebarMainPanelColumns = input.viewportColumns - TUI_SIDEBAR_WIDTH - 1;
const showSidebarToggle =
@@ -53,7 +54,7 @@ export function resolveTuiResponsiveLayout(input: {
// should track sidebar visibility rather than the overall terminal width.
showWindowDots: showSidebar,
showSidebarAlphaBadge: showSidebar,
- sidebarTitle: showSidebar ? "T1 Code" : "T1",
+ sidebarTitle: showSidebar ? (input.isChatMode ? "T1 Chat" : "T1 Code") : "T1",
showHeaderProjectBadge: input.viewportColumns >= 144,
showComposerModeLabels,
showComposerModelLabel,
diff --git a/apps/tui/src/theme.ts b/apps/tui/src/theme.ts
index aa7676bc..43971d07 100644
--- a/apps/tui/src/theme.ts
+++ b/apps/tui/src/theme.ts
@@ -16,12 +16,14 @@ export interface TerminalColors {
export type TuiColor = string;
export const TERMINAL_MATCH_THEME_ID = "terminal-match" as const;
-export const TUI_THEME_IDS = ["default", TERMINAL_MATCH_THEME_ID] as const;
+export const BORING_THEME_ID = "boring" as const;
+export const TUI_THEME_IDS = ["default", TERMINAL_MATCH_THEME_ID, BORING_THEME_ID] as const;
export type TuiThemeId = (typeof TUI_THEME_IDS)[number];
export const DEFAULT_TUI_THEME_ID = "default" as const;
export const TUI_THEME_LABELS: Record = {
default: "Default",
[TERMINAL_MATCH_THEME_ID]: "Terminal Match",
+ [BORING_THEME_ID]: "Boring",
};
export type TuiThemeMode = "light" | "dark";
@@ -55,44 +57,44 @@ export interface ResolveTuiThemeOptions {
}
const DEFAULT_DARK_PALETTE = {
- canvas: "#171717",
- sidebar: "#151515",
- main: "#171717",
- surface: "#1b1b1b",
- surfaceAlt: "#1f1f1f",
- input: "#111111",
- surfaceUser: "#202020",
+ canvas: "#21141e",
+ sidebar: "#1a0f18",
+ main: "#21141e",
+ surface: "#2a1825",
+ surfaceAlt: "#311e2c",
+ input: "#1a0f18",
+ surfaceUser: "#2a1825",
surfacePlan: "#1f221c",
surfaceWarn: "#262016",
- surfaceInfo: "#1d2026",
- footer: "#171717",
- diff: "#1b1b1b",
- popup: "#1c1c1c",
+ surfaceInfo: "#261a2e",
+ footer: "#21141e",
+ diff: "#2a1825",
+ popup: "#2a1825",
scrim: "#00000099",
- border: "#252525",
- divider: "#2d2d2d",
+ border: "#3d2438",
+ divider: "#3d2438",
control: "transparent",
- controlHover: "#202020",
- controlActive: "#292929",
- controlActiveStrong: "#1e1e1e",
- controlInset: "#141414",
- controlInsetHover: "#1a1a1a",
- composerPanel: "#1a1a1a",
- composerBorder: "#2a3f95",
- composerBorderMuted: "#313131",
- composerSend: "#2f438e",
- composerSendHover: "#3c57ba",
+ controlHover: "#311e2c",
+ controlActive: "#3d2438",
+ controlActiveStrong: "#2a1825",
+ controlInset: "#1a0f18",
+ controlInsetHover: "#21141e",
+ composerPanel: "#2a1825",
+ composerBorder: "#a3004c",
+ composerBorderMuted: "#3d2438",
+ composerSend: "#a3004c",
+ composerSendHover: "#e33f86",
composerStop: "#dc2626",
composerStopHover: "#ef4444",
- accent: "#7c87ff",
+ accent: "#e33f86",
cursor: "#d4d4d4",
- selection: "#1f4f95",
- selectionActive: "#2b61b0",
- text: "#f5f5f5",
- muted: "#a3a3a3",
- subtle: "#737373",
+ selection: "#5c1a3e",
+ selectionActive: "#7a2450",
+ text: "#f9f8fb",
+ muted: "#b89eb5",
+ subtle: "#8a6b87",
success: "#10b981",
- info: "#3b82f6",
+ info: "#c074b2",
warning: "#f59e0b",
claude: "#d97757",
macRed: "#ff5f57",
@@ -105,23 +107,23 @@ export type TuiPalette = { [Key in keyof TuiPaletteShape]: TuiColor };
const DEFAULT_THEME_DETAILS = {
attachmentPillTones: [
- { backgroundColor: "#1d2026", textColor: "#3b82f6" },
- { backgroundColor: "#241b2f", textColor: "#a78bfa" },
+ { backgroundColor: "#2e1528", textColor: "#e33f86" },
+ { backgroundColor: "#241b2f", textColor: "#c074b2" },
{ backgroundColor: "#2a2417", textColor: "#facc15" },
{ backgroundColor: "#2a1b1b", textColor: "#f87171" },
{ backgroundColor: "#1c2721", textColor: "#34d399" },
{ backgroundColor: "#272019", textColor: "#fb923c" },
],
codeBlock: {
- background: "#101010",
- language: "#8a8a8a",
- copyIcon: "#9a9a9a",
+ background: "#1a0f18",
+ language: "#8a6b87",
+ copyIcon: "#b89eb5",
},
status: {
- awaitingInput: "#818cf8",
- working: "#7dd3fc",
+ awaitingInput: "#e33f86",
+ working: "#c074b2",
planReady: "#a78bfa",
- pulse: "#3b82f6",
+ pulse: "#e33f86",
},
diffViewer: {
addedBg: "#173124",
@@ -157,41 +159,41 @@ const DEFAULT_DARK_THEME: TuiTheme = {
const DEFAULT_LIGHT_PALETTE: TuiPalette = {
...DEFAULT_DARK_PALETTE,
- canvas: "#f5f5f5",
- sidebar: "#eeeeee",
- main: "#f7f7f7",
+ canvas: "#f2e1f4",
+ sidebar: "#ead0ef",
+ main: "#fdf7fd",
surface: "#ffffff",
- surfaceAlt: "#f1f1f1",
+ surfaceAlt: "#f5eaf6",
input: "#ffffff",
- surfaceUser: "#ececec",
+ surfaceUser: "#f0ddf2",
surfacePlan: "#eef6ec",
surfaceWarn: "#fff5e6",
- surfaceInfo: "#eef4ff",
- footer: "#f7f7f7",
+ surfaceInfo: "#f5eaff",
+ footer: "#fdf7fd",
diff: "#fafafa",
popup: "#ffffff",
scrim: "#00000022",
- border: "#dddddd",
- divider: "#d8d8d8",
- controlHover: "#ebebeb",
- controlActive: "#e2e2e2",
- controlActiveStrong: "#cdcdcd",
- controlInset: "#e7e7e7",
- controlInsetHover: "#dddddd",
+ border: "#efbdeb",
+ divider: "#e0b8dc",
+ controlHover: "#f0ddf2",
+ controlActive: "#e6cce9",
+ controlActiveStrong: "#d9b8dd",
+ controlInset: "#ead0ef",
+ controlInsetHover: "#e0c2e4",
composerPanel: "#ffffff",
- composerBorder: "#0891b2",
- composerBorderMuted: "#d0d0d0",
- composerSend: "#60a5fa",
- composerSendHover: "#3b82f6",
- accent: "#0891b2",
+ composerBorder: "#e33f86",
+ composerBorderMuted: "#e0b8dc",
+ composerSend: "#e33f86",
+ composerSendHover: "#ca0277",
+ accent: "#ca0277",
cursor: "#a3a3a3",
- selection: "#dbeafe",
- selectionActive: "#bfdbfe",
- text: "#171717",
- muted: "#666666",
- subtle: "#8a8a8a",
+ selection: "#f5d0e8",
+ selectionActive: "#f0b8dd",
+ text: "#501854",
+ muted: "#7a3f7e",
+ subtle: "#9a6b9e",
success: "#059669",
- info: "#2563eb",
+ info: "#8b3fa0",
warning: "#d97706",
claude: "#c96d4d",
};
@@ -207,6 +209,84 @@ const DEFAULT_LIGHT_THEME: TuiTheme = {
},
};
+const BORING_DARK_PALETTE: TuiPalette = {
+ ...DEFAULT_DARK_PALETTE,
+ canvas: "#151515",
+ sidebar: "#1a1a1a",
+ main: "#151515",
+ surface: "#1e1e1e",
+ surfaceAlt: "#222222",
+ input: "#1a1a1a",
+ surfaceUser: "#1e1e1e",
+ footer: "#151515",
+ popup: "#1e1e1e",
+ border: "#282828",
+ divider: "#282828",
+ controlHover: "#252525",
+ controlActive: "#2a2a2a",
+ controlActiveStrong: "#222222",
+ controlInset: "#1a1a1a",
+ controlInsetHover: "#202020",
+ composerPanel: "#1e1e1e",
+ composerBorder: "#763750",
+ composerBorderMuted: "#333333",
+ composerSend: "#763750",
+ composerSendHover: "#ad5273",
+ accent: "#ad5273",
+ selection: "#3a2030",
+ selectionActive: "#4a2840",
+ text: "#e6e6e6",
+ muted: "#b0b0b0",
+ subtle: "#707070",
+ info: "#888888",
+};
+
+const BORING_LIGHT_PALETTE: TuiPalette = {
+ ...DEFAULT_LIGHT_PALETTE,
+ canvas: "#ebebeb",
+ sidebar: "#e0e0e0",
+ main: "#f0f0f0",
+ surface: "#ffffff",
+ surfaceAlt: "#e8e8e8",
+ input: "#ffffff",
+ surfaceUser: "#e0e0e0",
+ footer: "#f0f0f0",
+ popup: "#ffffff",
+ border: "#d4d4d4",
+ divider: "#d0d0d0",
+ controlHover: "#e0e0e0",
+ controlActive: "#d4d4d4",
+ controlActiveStrong: "#c8c8c8",
+ controlInset: "#e0e0e0",
+ controlInsetHover: "#d8d8d8",
+ composerPanel: "#ffffff",
+ composerBorder: "#ad5273",
+ composerBorderMuted: "#c9c9c9",
+ composerSend: "#ad5273",
+ composerSendHover: "#8a3f5c",
+ accent: "#ad5273",
+ selection: "#e8d0dd",
+ selectionActive: "#ddbece",
+ text: "#171717",
+ muted: "#616161",
+ subtle: "#8a8a8a",
+ info: "#666666",
+};
+
+const BORING_DARK_THEME: TuiTheme = {
+ ...DEFAULT_DARK_THEME,
+ id: BORING_THEME_ID,
+ mode: "dark",
+ palette: BORING_DARK_PALETTE,
+};
+
+const BORING_LIGHT_THEME: TuiTheme = {
+ ...DEFAULT_LIGHT_THEME,
+ id: BORING_THEME_ID,
+ mode: "light",
+ palette: BORING_LIGHT_PALETTE,
+};
+
export const DEFAULT_TUI_THEME = DEFAULT_DARK_THEME;
function defaultThemeForMode(mode: TuiThemeMode): TuiTheme {
@@ -714,6 +794,8 @@ export function resolveTuiThemeMode(
const THEME_CACHE = new Map([
[`${DEFAULT_TUI_THEME_ID}:dark`, DEFAULT_DARK_THEME],
[`${DEFAULT_TUI_THEME_ID}:light`, DEFAULT_LIGHT_THEME],
+ [`${BORING_THEME_ID}:dark`, BORING_DARK_THEME],
+ [`${BORING_THEME_ID}:light`, BORING_LIGHT_THEME],
]);
export function resolveTuiTheme(
diff --git a/apps/tui/src/ui.tsx b/apps/tui/src/ui.tsx
index bc516814..a6f4d4e0 100644
--- a/apps/tui/src/ui.tsx
+++ b/apps/tui/src/ui.tsx
@@ -194,6 +194,8 @@ import {
shouldClearPendingCreatedThread,
} from "./threadSelection";
import { resolveWorkEntryIcon } from "./workEntryIcons";
+import { createProfile, PROFILE_ICON_OPTIONS } from "./profiles";
+import { BORING_THEME_ID } from "./theme";
type FocusArea =
| "projects"
@@ -213,7 +215,8 @@ type OverlayMenu =
| "sidebar-sort"
| "git-actions"
| "composer-env"
- | "composer-branch";
+ | "composer-branch"
+ | "chat-settings";
type SettingsSelectKind =
| "theme"
| "theme-preset"
@@ -1315,6 +1318,40 @@ function AttachmentPill({
);
}
+function ChatCategoryButton(props: { icon: string; label: string; onPress: () => void }) {
+ const [hoveredCategory, setHoveredCategory] = useState(false);
+ return (
+ setHoveredCategory(true)}
+ onMouseOut={() => setHoveredCategory(false)}
+ onMouseDown={props.onPress}
+ style={{
+ backgroundColor: hoveredCategory ? RGBA.fromHex("#a23b67") : PALETTE.surfaceAlt,
+ paddingLeft: 2,
+ paddingRight: 2,
+ paddingTop: 0,
+ paddingBottom: 0,
+ marginRight: 1,
+ marginBottom: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ border: true,
+ borderStyle: "rounded",
+ borderColor: hoveredCategory ? RGBA.fromHex("#a23b67") : PALETTE.border,
+ }}
+ >
+
+
+
+ );
+}
+
function PathSuggestionRow(props: {
entry: ProjectEntry;
active?: boolean;
@@ -2800,6 +2837,20 @@ export function App({
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const previewAttachmentCacheRef = useRef