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://img.shields.io/badge/license-MIT-111111?style=flat-square)](./LICENSE) [![npm](https://img.shields.io/npm/v/%40maria__rcks%2Ft1code?color=111111&label=npm&style=flat-square)](https://www.npmjs.com/package/@maria_rcks/t1code) -[![GitHub](https://img.shields.io/badge/github-maria--rcks%2Ft1code-111111?style=flat-square&logo=github)](https://github.com/maria-rcks/t1code) +[![GitHub](https://img.shields.io/badge/github-ahzs645%2Ft1chat-111111?style=flat-square&logo=github)](https://github.com/ahzs645/t1chat) t1code terminal UI screenshot @@ -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 terminal UI screenshot +
+ +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 + + + + + + + + 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>(new Map()); const composerDraftsByThreadIdRef = useRef>>({}); + + const [isChatMode, setIsChatMode] = useState(process.env.T1CODE_CHAT_MODE === "1"); + const [tempChatMode, setTempChatMode] = useState(false); + const [sidebarSearchQuery, setSidebarSearchQuery] = useState(""); + const [chatProfiles, setChatProfiles] = useState([ + { id: "default", name: "Default", icon: "󰭹" }, + ]); + const [activeProfileId, setActiveProfileId] = useState("default"); + const [threadProfileMap, setThreadProfileMap] = useState>({}); + const [showProfileCreate, setShowProfileCreate] = useState(false); + const [newProfileName, setNewProfileName] = useState(""); + const [newProfileIconIndex, setNewProfileIconIndex] = useState(0); + const [profileNameFocused, setProfileNameFocused] = useState(false); + const [profileIconFocused, setProfileIconFocused] = useState(false); const updateAppSettings = useCallback((patch: Partial) => { setAppSettings((current) => normalizeAppSettings({ ...current, ...patch })); }, []); @@ -3370,6 +3421,7 @@ export function App({ const responsiveLayout = resolveTuiResponsiveLayout({ viewportColumns: totalColumns, sidebarCollapsedPreference, + isChatMode, }); const showSidebarOverlay = !responsiveLayout.showSidebar && sidebarOverlayOpen; const showFullDiffView = mainView === "thread" && diffOpen; @@ -4053,7 +4105,7 @@ export function App({ const requestAppExit = useCallback(() => { setConfirmDialog({ - title: "Quit T1 Code?", + title: isChatMode ? "Quit T1 Chat?" : "Quit T1 Code?", body: "Press Ctrl-C again or Enter to quit. Press Escape to stay in the session.", confirmLabel: "Quit", escapeBehavior: "cancel", @@ -5744,6 +5796,9 @@ export function App({ setExpandedProjectIds((current) => ensureProjectExpanded(current, projectId)); setFocusArea("composer"); setStatus("New thread"); + if (isChatMode) { + setThreadProfileMap((current) => ({ ...current, [existingDraft.id]: activeProfileId })); + } setTimeout(() => { composerRef.current?.focus(); }, 0); @@ -7107,7 +7162,9 @@ export function App({ ? activeDraftThread ? "Start a new thread with a prompt" : "Ask for follow-up changes or attach images" - : COMPOSER_PLACEHOLDER; + : isChatMode + ? "Type your message here..." + : COMPOSER_PLACEHOLDER; const composerPathTrigger = detectTrailingComposerPathTrigger(composer); const showPathSuggestions = composerIsFocused && @@ -7722,7 +7779,7 @@ export function App({ style={{ width: responsiveLayout.showSidebar ? responsiveLayout.sidebarWidth : TUI_SIDEBAR_WIDTH, backgroundColor: sidebarBg, - border: ["right"], + border: isChatMode ? [] : ["right"], borderColor: PALETTE.divider, flexDirection: "column", ...(showSidebarOverlay @@ -7745,8 +7802,15 @@ export function App({ paddingRight: 2, }} > - - {responsiveLayout.showWindowDots ? : null} + + {responsiveLayout.showWindowDots && !isChatMode ? : null} + {isChatMode ? ( + + { + if (activeProjectId) { + openDraftThread(activeProjectId); + } + }} + style={{ + backgroundColor: RGBA.fromHex("#a23b67"), + height: 1, + justifyContent: "center", + alignItems: "center", + paddingTop: 0, + paddingBottom: 0, + }} + > + + + setFocusArea("projects")} + style={{ + height: 1, + flexDirection: "row", + alignItems: "center", + marginTop: 1, + paddingBottom: 0, + position: "relative", + }} + > + + {sidebarSearchQuery ? null : ( + + )} + setSidebarSearchQuery(value)} + style={{ + flexGrow: 1, + backgroundColor: sidebarBg, + textColor: PALETTE.muted, + focusedTextColor: PALETTE.muted, + focusedBackgroundColor: sidebarBg, + }} + /> + + + + + + ) : null} + - { - toggleSidebarSortMenu(event); + {isChatMode ? null : ( + { + toggleSidebarSortMenu(event); + }, }, - }, - { - icon: "+", - onPress: () => { - openProjectPathPrompt(); + { + icon: "+", + onPress: () => { + openProjectPathPrompt(); + }, }, - }, - ]} - /> + ]} + /> + )} - {projects.length === 0 ? ( + {!isChatMode && projects.length === 0 ? ( ) : null} - {sortedProjects.map((project) => { - const projectThreads = threadsByProject.get(project.id) ?? []; - const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const isProjectExpanded = expandedProjectIds.has(project.id); - const isProjectActive = project.id === activeProjectId; - const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => - resolveThreadStatusPillForTui(thread, { - forceUnread: locallyUnreadThreadIds.has(thread.id), - locallyVisitedAt: locallyVisitedThreads[thread.id], - }), - ), - ); - - return ( - - { - closeSidebarContextMenu(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - selectProject(project.id); - setExpandedProjectIds((current) => - resolveProjectExpansionOnRowPress({ - expandedProjectIds: current, - projectId: project.id, - isProjectActive, - }), - ); - }} - onSecondaryPress={(event) => { - openProjectContextMenu(project.id, event); - }} - > - - - - { - closeSidebarContextMenu(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - selectProject(project.id); - openDraftThread(project.id); - }} - /> - + {isChatMode + ? (() => { + const allThreads = sortedProjects.flatMap((project) => { + const projectThreads = threadsByProject.get(project.id) ?? []; + return projectThreads.map((thread) => ({ ...thread, projectId: project.id })); + }); + allThreads.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + const profileThreads = allThreads.filter((t) => { + const threadProfile = threadProfileMap[t.id]; + if (!threadProfile) return activeProfileId === "default"; + return threadProfile === activeProfileId; + }); + const searchLower = sidebarSearchQuery.toLowerCase().trim(); + const filteredThreads = searchLower + ? profileThreads.filter((t) => t.title.toLowerCase().includes(searchLower)) + : profileThreads; + + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterdayStart = new Date(todayStart.getTime() - 86400000); + const weekStart = new Date(todayStart.getTime() - 7 * 86400000); + const monthStart = new Date(todayStart.getTime() - 30 * 86400000); + + type TimeGroup = { label: string; threads: typeof allThreads }; + const groups: TimeGroup[] = [ + { label: "Today", threads: [] }, + { label: "Yesterday", threads: [] }, + { label: "Last 7 Days", threads: [] }, + { label: "Last 30 Days", threads: [] }, + { label: "Older", threads: [] }, + ]; + + for (const thread of filteredThreads) { + const date = new Date(thread.updatedAt); + if (date >= todayStart) groups[0]!.threads.push(thread); + else if (date >= yesterdayStart) groups[1]!.threads.push(thread); + else if (date >= weekStart) groups[2]!.threads.push(thread); + else if (date >= monthStart) groups[3]!.threads.push(thread); + else groups[4]!.threads.push(thread); + } - {isProjectExpanded ? ( - - {projectThreads.length > 0 ? ( - projectThreads.map((thread) => { + return groups + .filter((g) => g.threads.length > 0) + .map((group) => ( + + + {group.threads.map((thread) => { + const isActive = thread.id === activeThreadId; + const isSelected = selectedThreadIds.has(thread.id); const status = threadStatus(thread, { forceUnread: locallyUnreadThreadIds.has(thread.id), locallyVisitedAt: locallyVisitedThreads[thread.id], }); - const isActive = thread.id === activeThreadId; - const isSelected = selectedThreadIds.has(thread.id); return ( t.id), ); }} onSecondaryPress={(event) => { - openThreadContextMenu(project.id, thread.id, event); + openThreadContextMenu(thread.projectId, thread.id, event); }} > - - - ); - }) - ) : ( + })} + + )); + })() + : null} + + {!isChatMode + ? sortedProjects.map((project) => { + const projectThreads = threadsByProject.get(project.id) ?? []; + const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); + const isProjectExpanded = expandedProjectIds.has(project.id); + const isProjectActive = project.id === activeProjectId; + const projectStatus = resolveProjectStatusIndicator( + projectThreads.map((thread) => + resolveThreadStatusPillForTui(thread, { + forceUnread: locallyUnreadThreadIds.has(thread.id), + locallyVisitedAt: locallyVisitedThreads[thread.id], + }), + ), + ); + + return ( + + { + closeSidebarContextMenu(); + if (selectedThreadIds.size > 0) { + clearSelection(); + } + selectProject(project.id); + setExpandedProjectIds((current) => + resolveProjectExpansionOnRowPress({ + expandedProjectIds: current, + projectId: project.id, + isProjectActive, + }), + ); + }} + onSecondaryPress={(event) => { + openProjectContextMenu(project.id, event); + }} + > + + + + { + closeSidebarContextMenu(); + if (selectedThreadIds.size > 0) { + clearSelection(); + } + selectProject(project.id); + openDraftThread(project.id); + }} + /> + + + {isProjectExpanded ? ( - + {projectThreads.length > 0 ? ( + projectThreads.map((thread) => { + const status = threadStatus(thread, { + forceUnread: locallyUnreadThreadIds.has(thread.id), + locallyVisitedAt: locallyVisitedThreads[thread.id], + }); + const isActive = thread.id === activeThreadId; + const isSelected = selectedThreadIds.has(thread.id); + return ( + { + closeSidebarContextMenu(); + handleThreadClick( + event, + project.id, + thread.id, + orderedProjectThreadIds, + ); + }} + onSecondaryPress={(event) => { + openThreadContextMenu(project.id, thread.id, event); + }} + > + + {status ? ( + + ) : null} + + + + + + + + + ); + }) + ) : ( + + + + )} - )} + ) : null} - ) : null} - - ); - })} + ); + }) + : null} - - { - if (mainView === "settings") { - returnToThreadView(); - return; - } - openMainView("settings"); - }} - > - - - - { - if (mainView === "keybindings") { - returnToThreadView(); - return; - } - openMainView("keybindings"); + {isChatMode ? ( + - - - - - - ) : null} - - - - - {responsiveLayout.showSidebarToggle ? ( - toggleSidebarVisibility()} - /> - ) : null} - - - - {mainView === "thread" && activeProject && responsiveLayout.showHeaderProjectBadge ? ( - - ) : null} - + {showProfileCreate ? ( + + + + + { + setNewProfileIconIndex((i) => + i > 0 ? i - 1 : PROFILE_ICON_OPTIONS.length - 1, + ); + setProfileIconFocused(true); + setProfileNameFocused(false); + }} + style={{ + border: true, + borderStyle: "rounded", + borderColor: profileIconFocused ? PALETTE.composerBorder : PALETTE.border, + paddingLeft: 1, + paddingRight: 1, + marginRight: 1, + backgroundColor: PALETTE.surfaceAlt, + justifyContent: "center", + alignItems: "center", + }} + > + + + { + setProfileNameFocused(true); + setProfileIconFocused(false); + }} + style={{ + flexGrow: 1, + border: true, + borderStyle: "rounded", + borderColor: profileNameFocused ? PALETTE.composerBorder : PALETTE.border, + backgroundColor: PALETTE.surfaceAlt, + justifyContent: "center", + paddingLeft: 1, + }} + > + { + setNewProfileName(value.slice(0, 50)); + setProfileNameFocused(true); + }} + style={{ + flexGrow: 1, + backgroundColor: PALETTE.surfaceAlt, + textColor: PALETTE.text, + focusedTextColor: PALETTE.text, + focusedBackgroundColor: PALETTE.surfaceAlt, + }} + /> + + + { + if (newProfileName.trim()) { + const icon = PROFILE_ICON_OPTIONS[newProfileIconIndex]?.icon ?? "󰭹"; + const profile = createProfile(newProfileName.trim(), icon); + setChatProfiles((prev) => [...prev, profile]); + setActiveProfileId(profile.id); + setNewProfileName(""); + setNewProfileIconIndex(0); + setProfileNameFocused(false); + setProfileIconFocused(false); + setShowProfileCreate(false); + } + }} + style={{ + backgroundColor: newProfileName.trim() + ? PALETTE.composerSend + : PALETTE.controlActive, + height: 1, + justifyContent: "center", + alignItems: "center", + }} + > + + + + ) : null} + + + {chatProfiles.map((profile) => ( + setActiveProfileId(profile.id)} + style={{ + marginRight: 1, + width: 3, + height: 3, + justifyContent: "center", + alignItems: "center", + backgroundColor: + profile.id === activeProfileId ? PALETTE.controlActive : "transparent", + }} + > + + + ))} + + { + setShowProfileCreate((v) => !v); + if (showProfileCreate) { + setNewProfileName(""); + setNewProfileIconIndex(0); + setProfileNameFocused(false); + } + }} + style={{ + width: 3, + height: 3, + justifyContent: "center", + alignItems: "center", + }} + > + + + + + ) : ( + + { + if (mainView === "settings") { + returnToThreadView(); + return; + } + openMainView("settings"); + }} + > + + + + { + if (mainView === "keybindings") { + returnToThreadView(); + return; + } + openMainView("keybindings"); + }} + > + + + + + )} + + ) : null} - - {mainView === "settings" ? ( - 0} - disabled={changedSettingLabels.length === 0} - compact - marginRight={1} - onPress={() => restoreDefaultSettings()} - /> - ) : mainView === "keybindings" ? null : ( - <> + + {isChatMode && responsiveLayout.showSidebar ? ( + <> + {/* Row 1: top bar, full sidebar bg connects to sidebar */} + + {/* Row 2: icons row, main bg on left, sidebar bg with icons on right */} + + + setTempChatMode((prev) => !prev)} /> { + setOverlayMenu((current) => + current === "chat-settings" ? null : "chat-settings", + ); + }} /> - + + + {/* Row 3: bottom padding, sidebar bg only on right to match icon area */} + + + + + + ) : null} + {isChatMode && responsiveLayout.showSidebar ? null : ( + + {isChatMode && responsiveLayout.showSidebar ? null : ( + + {responsiveLayout.showSidebarToggle ? ( + toggleSidebarVisibility()} + /> + ) : null} + + + + {mainView === "thread" && + activeProject && + responsiveLayout.showHeaderProjectBadge ? ( + + ) : null} + + )} + + {isChatMode && responsiveLayout.showSidebar ? null : ( + + {mainView === "settings" ? ( + 0} + disabled={changedSettingLabels.length === 0} + compact + marginRight={1} + onPress={() => restoreDefaultSettings()} + /> + ) : mainView === "keybindings" ? null : isChatMode ? ( + <> + setTempChatMode((prev) => !prev)} + /> + { + openMainView("settings"); + }} + /> + + ) : ( + <> + + + + )} + )} - + )} @@ -8186,6 +8701,17 @@ export function App({ {mainView === "settings" ? ( <> + setIsChatMode((v) => !v)} + /> + } + /> - {!activeProject && !activeThread && !activeDraftThread ? ( + {isChatMode && (!activeThread || activeThread.messages.length === 0) ? ( + + + {tempChatMode ? ( + + + + + ) : ( + + )} + + + + {[ + { icon: "󰛕", label: "Create" }, + { icon: "󰎕", label: "Explore" }, + { icon: "󰅪", label: "Code" }, + { icon: "󰑴", label: "Learn" }, + ].map((item) => ( + { + syncComposerValueRefSoon(); + setComposer(`${item.label} `); + setTimeout(() => composerRef.current?.focus(), 0); + }} + /> + ))} + + + + {[ + "How does AI work?", + "Are black holes real?", + 'How many Rs are in the word "strawberry"?', + "What is the meaning of life?", + ].map((q, i) => ( + { + e.preventDefault(); + e.stopPropagation(); + syncComposerValueRefSoon(); + setComposer(q); + setTimeout(() => composerRef.current?.focus(), 0); + }} + style={{ + border: i > 0 ? ["top"] : [], + borderColor: PALETTE.divider, + paddingTop: 0, + paddingBottom: 0, + height: i === 0 ? 1 : 2, + alignItems: "flex-start", + justifyContent: "center", + width: "100%", + }} + > + + + ))} + + + ) : !activeProject && !activeThread && !activeDraftThread ? ( ) : ( - + )} + {null} {activePendingApproval ? ( <> @@ -9769,30 +10386,38 @@ export function App({ /> ) : null} - {responsiveLayout.showComposerDividers ? : null} - - {responsiveLayout.showComposerDividers ? : null} - + {!isChatMode && responsiveLayout.showComposerDividers ? ( + + ) : null} + {!isChatMode ? ( + + ) : null} + {!isChatMode && responsiveLayout.showComposerDividers ? ( + + ) : null} + {!isChatMode ? ( + + ) : null} {activePendingProgress ? ( <> @@ -9852,7 +10477,7 @@ export function App({ )} - {activeProjectId && isGitRepo ? ( + {activeProjectId && isGitRepo && !isChatMode ? ( ) : null} + {overlayMenu === "chat-settings" ? ( + { + event.preventDefault(); + event.stopPropagation?.(); + }} + > + + + { + updateAppSettings({ theme: "light" }); + setOverlayMenu(null); + }} + style={{ + paddingLeft: 1, + paddingRight: 1, + backgroundColor: + appSettings.theme === "light" ? PALETTE.controlActive : "transparent", + }} + > + + + { + updateAppSettings({ theme: "system" }); + setOverlayMenu(null); + }} + style={{ + paddingLeft: 1, + paddingRight: 1, + backgroundColor: + appSettings.theme === "system" || !appSettings.theme + ? PALETTE.controlActive + : "transparent", + }} + > + + + { + updateAppSettings({ theme: "dark" }); + setOverlayMenu(null); + }} + style={{ + paddingLeft: 1, + paddingRight: 1, + backgroundColor: + appSettings.theme === "dark" ? PALETTE.controlActive : "transparent", + }} + > + + + + { + setTuiThemeId((current) => + current === BORING_THEME_ID ? "default" : BORING_THEME_ID, + ); + }} + style={{ height: 1, flexDirection: "row", alignItems: "center" }} + > + + + + + + + + { + openMainView("settings"); + setOverlayMenu(null); + }} + style={{ height: 1, flexDirection: "row", alignItems: "center" }} + > + + + + + ) : null} + {confirmDialog ? (