diff --git a/Cargo.lock b/Cargo.lock index f272017..50b91e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,23 +146,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "cfonts" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8f9b961805729ae5f1e19cb7e364c8e6b77434cc02f0938a77ac150e97c161" -dependencies = [ - "enable-ansi-support", - "exitcode", - "rand 0.9.2", - "serde", - "serde_json", - "strum 0.27.2", - "strum_macros 0.27.2", - "supports-color", - "terminal_size", -] - [[package]] name = "clap" version = "4.5.60" @@ -208,7 +191,6 @@ name = "coding-human" version = "0.1.0" dependencies = [ "anyhow", - "cfonts", "clap", "crossterm", "dotenvy", @@ -216,6 +198,7 @@ dependencies = [ "indicatif", "ratatui", "reqwest", + "serde", "serde_json", "tokio", "tokio-tungstenite", @@ -271,9 +254,10 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", + "futures-core", "mio", "parking_lot", - "rustix 0.38.44", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -371,15 +355,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "enable-ansi-support" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa4ff3ae2a9aa54bf7ee0983e59303224de742818c1822d89f07da9856d9bc60" -dependencies = [ - "windows-sys 0.42.0", -] - [[package]] name = "encode_unicode" version = "1.0.0" @@ -402,12 +377,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "exitcode" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de853764b47027c2e862a995c34978ffa63c1501f2e15f987ba11bd4f9bba193" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -830,12 +799,6 @@ dependencies = [ "serde", ] -[[package]] -name = "is_ci" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -879,12 +842,6 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.1" @@ -1181,7 +1138,7 @@ dependencies = [ "itertools", "lru", "paste", - "strum 0.26.3", + "strum", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -1263,23 +1220,10 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.4.15", + "linux-raw-sys", "windows-sys 0.59.0", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" version = "0.23.37" @@ -1482,15 +1426,9 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros 0.26.4", + "strum_macros", ] -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" - [[package]] name = "strum_macros" version = "0.26.4" @@ -1504,33 +1442,12 @@ dependencies = [ "syn", ] -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "supports-color" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" -dependencies = [ - "is_ci", -] - [[package]] name = "syn" version = "2.0.117" @@ -1562,16 +1479,6 @@ dependencies = [ "syn", ] -[[package]] -name = "terminal_size" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" -dependencies = [ - "rustix 1.1.4", - "windows-sys 0.60.2", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -1998,21 +1905,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -2082,12 +1974,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2100,12 +1986,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2118,12 +1998,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2148,12 +2022,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2166,12 +2034,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2184,12 +2046,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2202,12 +2058,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index fbbe570..8a48bf9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,8 @@ path = "src/main.rs" [dependencies] clap = { version = "4", features = ["derive"] } -cfonts = "1" ratatui = "0.29" -crossterm = "0.28" +crossterm = { version = "0.28", features = ["event-stream"] } tokio = { version = "1", features = ["full"] } tokio-tungstenite = "0.23" serde_json = "1" @@ -20,3 +19,4 @@ futures = "0.3" indicatif = "0.17" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } anyhow = "1" +serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 189a01c..1e9c401 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ -# ut.code(); CLI +# Coding Human CLI -A terminal UI for browsing the [ut.code();](https://utcode.net) community. +A terminal-based tool to connect with available programmers for Q&A sessions via WebSocket. -![demo](docs/images/cli.png) +## Features + +- **TUI Queue Browser** — View available programmers in a live queue and connect to one with a question +- **Programmer Registration** — Register as a programmer to answer questions from users +- **Streaming Responses** — Real-time response streaming with multi-round Q&A support +- **WebSocket-based** — Built on Cloudflare Workers + Durable Objects backend for live connections ## Install @@ -12,25 +17,75 @@ cargo install --git https://github.com/ut-code/cli ## Usage +### Default (TUI Queue Browser) + +```sh +coding-human +``` + +Launches an interactive TUI to: +1. Fetch the live queue of available programmers +2. Select a programmer +3. Ask a question +4. Stream the response in real-time +5. Ask follow-up questions or switch programmers + +**Navigation:** +- `j/k` or `↑/↓` — Move up/down in lists +- `Enter` — Select or send +- `Esc`/`q` — Cancel or quit +- `b` — Go back to programmer list (after response) + +### Register as a Programmer + ```sh -utcode +coding-human join ``` -Launches a full-screen interactive TUI with four categories: +Registers you in the queue and enters a loop to: +1. Wait for a user's question +2. Read your response from stdin (Ctrl+D to finish each response) +3. Send it to the user +4. Wait for the next question + +### Legacy Commands + +```sh +coding-human ask +``` +Non-TUI version to select a programmer and ask a question. + +```sh +coding-human respond +``` +Legacy direct response mode using a specific room ID. + +## Server + +The backend is a Hono + Cloudflare Workers project (`server/` directory). + +**Key Routes:** +- `POST /rooms` — Create a new room (returns `{ roomId }`) +- `POST /queue/register` — Register programmer in queue +- `DELETE /queue/{roomId}` — Remove from queue +- `GET /queue` — Fetch all queued programmers +- `WebSocket /rooms/{roomId}/user` — User side of Q&A +- `WebSocket /rooms/{roomId}/programmer` — Programmer side of Q&A + +## Environment + +Set `SERVER_URL` in `.env` (defaults to `http://localhost:8787`): + +``` +SERVER_URL=http://localhost:8787 +``` -| Category | Contents | -|----------|----------| -| About | Organization overview, activities, tech stack, and contact info | -| Projects | Active projects built by ut.code(); members | -| Articles | Recent articles and event reports from utcode.net | -| Members | Current members and their areas of work | +The CLI automatically converts `http://` ↔ `ws://` and `https://` ↔ `wss://`. -### Navigation +## Protocol -| Key | Action | -|-----|--------| -| `Tab` / `→` / `l` | Move focus to the next panel | -| `Shift+Tab` / `←` / `h` | Move focus to the previous panel | -| `↓` / `j` | Select next item | -| `↑` / `k` | Select previous item | -| `q` / `Esc` | Quit | +- User sends a question as text over WebSocket +- Programmer sends response lines +- Programmer sends `[DONE]` as a text message to signal end of response +- Connection stays alive for multiple rounds of Q&A +- `/quit` at the question prompt (non-TUI) or `Esc` in TUI exits the session diff --git a/server/src/index.ts b/server/src/index.ts index 5fcf9f9..a531b09 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,9 +1,10 @@ import { Hono } from "hono"; import { RoomSession } from "./room"; +import { ProgrammerQueue } from "./queue"; -export { RoomSession }; +export { RoomSession, ProgrammerQueue }; -type Bindings = { ROOM: DurableObjectNamespace }; +type Bindings = { ROOM: DurableObjectNamespace; QUEUE: DurableObjectNamespace }; const app = new Hono<{ Bindings: Bindings }>(); @@ -24,4 +25,23 @@ app.get("/rooms/:id/programmer", (c) => { return stub.fetch(c.req.raw); }); +// Queue endpoints +app.post("/queue/register", (c) => { + const queueId = c.env.QUEUE.idFromName("global"); + const stub = c.env.QUEUE.get(queueId); + return stub.fetch(c.req.raw); +}); + +app.delete("/queue/:roomId", (c) => { + const queueId = c.env.QUEUE.idFromName("global"); + const stub = c.env.QUEUE.get(queueId); + return stub.fetch(c.req.raw); +}); + +app.get("/queue", (c) => { + const queueId = c.env.QUEUE.idFromName("global"); + const stub = c.env.QUEUE.get(queueId); + return stub.fetch(c.req.raw); +}); + export default app; diff --git a/server/src/queue.ts b/server/src/queue.ts new file mode 100644 index 0000000..6d80bde --- /dev/null +++ b/server/src/queue.ts @@ -0,0 +1,64 @@ +import { DurableObject } from "cloudflare:workers"; + +export interface QueueEntry { + roomId: string; + name: string; +} + +export class ProgrammerQueue extends DurableObject { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const method = request.method; + const pathname = url.pathname; + + if (method === "POST" && pathname === "/queue/register") { + return this.handleRegister(request); + } else if (method === "DELETE" && pathname.match(/^\/queue\/[^/]+$/)) { + const roomId = pathname.split("/").pop(); + return this.handleDelete(roomId!); + } else if (method === "GET" && pathname === "/queue") { + return this.handleGetQueue(); + } + + return new Response("Not found", { status: 404 }); + } + + private async handleRegister(request: Request): Promise { + try { + const body = await request.json(); + if (!body.roomId || typeof body.roomId !== "string" || + !body.name || typeof body.name !== "string") { + return new Response("Missing or invalid fields: roomId and name are required", { + status: 400, + }); + } + await this.ctx.storage.put(`entry:${body.roomId}`, { + roomId: body.roomId, + name: body.name, + } satisfies QueueEntry); + return new Response(JSON.stringify({ roomId: body.roomId }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (e) { + return new Response("Invalid request body", { status: 400 }); + } + } + + private async handleDelete(roomId: string): Promise { + await this.ctx.storage.delete(`entry:${roomId}`); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + private async handleGetQueue(): Promise { + const map = await this.ctx.storage.list({ prefix: "entry:" }); + const entries = Array.from(map.values()); + return new Response(JSON.stringify(entries), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } +} diff --git a/server/src/room.ts b/server/src/room.ts index 7303706..412471e 100644 --- a/server/src/room.ts +++ b/server/src/room.ts @@ -1,12 +1,28 @@ import { DurableObject } from "cloudflare:workers"; export class RoomSession extends DurableObject { - userSocket: WebSocket | null = null; - programmerSocket: WebSocket | null = null; - async fetch(request: Request): Promise { + // Reject non-WebSocket requests + if (request.headers.get("Upgrade") !== "websocket") { + return new Response("Expected WebSocket upgrade", { status: 426 }); + } + const url = new URL(request.url); - const role = url.pathname.endsWith("/user") ? "user" : "programmer"; + const isUser = url.pathname.endsWith("/user"); + const isProgrammer = url.pathname.endsWith("/programmer"); + + if (!isUser && !isProgrammer) { + return new Response("Invalid room endpoint", { status: 400 }); + } + + const role = isUser ? "user" : "programmer"; + + // Enforce one connection per role + if (this.ctx.getWebSockets(role).length > 0) { + return new Response(`A ${role} is already connected to this room`, { + status: 409, + }); + } const { 0: client, 1: server } = new WebSocketPair(); this.ctx.acceptWebSocket(server, [role]); @@ -16,28 +32,40 @@ export class RoomSession extends DurableObject { webSocketMessage(ws: WebSocket, message: string) { const tags = this.ctx.getTags(ws); if (tags.includes("user")) { - // userからの質問をprogrammerに転送 + // Forward user's question to the programmer const programmers = this.ctx.getWebSockets("programmer"); programmers[0]?.send(message); } else { - // programmerからの回答をuserに転送 + // Forward programmer's response (including [DONE]) to the user const users = this.ctx.getWebSockets("user"); - if (message === "[DONE]") { - users[0]?.send("[DONE]"); - } else { - users[0]?.send(message); - } + users[0]?.send(message); } } webSocketClose(ws: WebSocket) { const tags = this.ctx.getTags(ws); if (tags.includes("user")) { - this.programmerSocket?.close(); - this.userSocket = null; + // User disconnected — notify and close the programmer side + for (const sock of this.ctx.getWebSockets("programmer")) { + sock.close(1000, "User disconnected"); + } } else { - this.userSocket?.close(); - this.programmerSocket = null; + // Programmer disconnected — notify and close the user side + for (const sock of this.ctx.getWebSockets("user")) { + sock.close(1000, "Programmer disconnected"); + } + } + } + + webSocketError(ws: WebSocket, error: unknown) { + const tags = this.ctx.getTags(ws); + const role = tags.includes("user") ? "user" : "programmer"; + console.error(`WebSocket error on ${role} connection:`, error); + + // Close the other side so neither party hangs + const other = role === "user" ? "programmer" : "user"; + for (const sock of this.ctx.getWebSockets(other)) { + sock.close(1011, `${role} connection error`); } } } diff --git a/server/worker-configuration.d.ts b/server/worker-configuration.d.ts index f22f1fb..de1ae07 100644 --- a/server/worker-configuration.d.ts +++ b/server/worker-configuration.d.ts @@ -1,13 +1,14 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: b2c375e509d84cd558e7e1b33311a57a) +// Generated by Wrangler by running `wrangler types` (hash: 11c2a052753c223551c7f7a56effa769) // Runtime types generated with workerd@1.20260312.1 2026-03-14 declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); - durableNamespaces: "RoomSession"; + durableNamespaces: "RoomSession" | "ProgrammerQueue"; } interface Env { ROOM: DurableObjectNamespace; + QUEUE: DurableObjectNamespace; } } interface Env extends Cloudflare.Env {} diff --git a/server/wrangler.jsonc b/server/wrangler.jsonc index 1053aa1..b3326ab 100644 --- a/server/wrangler.jsonc +++ b/server/wrangler.jsonc @@ -8,6 +8,10 @@ { "name": "ROOM", "class_name": "RoomSession" + }, + { + "name": "QUEUE", + "class_name": "ProgrammerQueue" } ] }, @@ -15,6 +19,10 @@ { "tag": "v1", "new_classes": ["RoomSession"] + }, + { + "tag": "v2", + "new_classes": ["ProgrammerQueue"] } ] // "compatibility_flags": [ diff --git a/src/ask.rs b/src/ask.rs index 3f7e264..7bfd52d 100644 --- a/src/ask.rs +++ b/src/ask.rs @@ -6,6 +6,8 @@ use reqwest::Client; use std::io::{self, Write}; use tokio_tungstenite::{connect_async, tungstenite::Message}; +use crate::util::build_ws_url; + pub async fn run() -> Result<()> { dotenvy::dotenv().ok(); let server_url = @@ -13,7 +15,11 @@ pub async fn run() -> Result<()> { // Step 1: POST /rooms to get a roomid let client = Client::new(); - let room_response = client.post(format!("{}/rooms", server_url)).send().await?; + let room_response = client + .post(format!("{}/rooms", server_url)) + .send() + .await? + .error_for_status()?; let room_data: serde_json::Value = room_response.json().await?; let room_id = room_data["roomId"] .as_str() @@ -22,18 +28,7 @@ pub async fn run() -> Result<()> { println!("Room ID: {}", room_id); // Step 2: Connect to the room via WebSocket - let ws_scheme = if server_url.starts_with("https://") { - "wss" - } else { - "ws" - }; - let server_host = server_url - .strip_prefix("https://") - .or_else(|| server_url.strip_prefix("http://")) - .unwrap_or(&server_url); - - let ws_url = format!("{}://{}/rooms/{}/user", ws_scheme, server_host, room_id); - + let ws_url = build_ws_url(&server_url, &format!("/rooms/{}/user", room_id)); let (ws_stream, _) = connect_async(&ws_url).await?; let (mut write, mut read) = ws_stream.split(); @@ -53,12 +48,17 @@ pub async fn run() -> Result<()> { break; } + if question.is_empty() { + continue; + } + // Send the question write.send(Message::text(question)).await?; // Show "thinking" spinner let spinner = ProgressBar::new_spinner(); spinner.set_message("Thinking"); + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); // Wait for response chunks until [DONE] let mut first_chunk = true; @@ -70,7 +70,7 @@ pub async fn run() -> Result<()> { first_chunk = false; } if msg == "[DONE]" { - // Programmer finished — back to Question prompt + println!(); break; } print!("{}", msg); diff --git a/src/join.rs b/src/join.rs new file mode 100644 index 0000000..23348b1 --- /dev/null +++ b/src/join.rs @@ -0,0 +1,117 @@ +use anyhow::Result; +use futures::sink::SinkExt; +use futures::stream::StreamExt; +use reqwest::Client; +use tokio::io::AsyncBufReadExt; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use crate::util::build_ws_url; + +async fn deregister(client: &Client, server_url: &str, room_id: &str) { + let _ = client + .delete(format!("{}/queue/{}", server_url, room_id)) + .send() + .await; +} + +pub async fn run(name: String) -> Result<()> { + dotenvy::dotenv().ok(); + let server_url = + std::env::var("SERVER_URL").unwrap_or_else(|_| "http://localhost:8787".to_string()); + + let client = Client::new(); + + // Step 1: POST /rooms to create a room + let room_response = client + .post(format!("{}/rooms", server_url)) + .send() + .await? + .error_for_status()?; + let room_data: serde_json::Value = room_response.json().await?; + let room_id = room_data["roomId"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Server did not return roomId"))? + .to_string(); + + // Step 2: POST /queue/register { roomId, name } + // If this fails, the room is orphaned — but rooms are ephemeral DO instances so this is acceptable. + let queue_response = client + .post(format!("{}/queue/register", server_url)) + .json(&serde_json::json!({ + "roomId": room_id, + "name": name + })) + .send() + .await?; + + if !queue_response.status().is_success() { + return Err(anyhow::anyhow!("Failed to register in queue")); + } + + println!("Joined as '{}' - Room ID: {}", name, room_id); + println!("Waiting for user's question...\n"); + println!("(Tip: type your answer line by line, then press Enter on a blank line to send)\n"); + + // Step 3: Connect to the room via WebSocket as programmer + let ws_url = build_ws_url(&server_url, &format!("/rooms/{}/programmer", room_id)); + + let (ws_stream, _) = match connect_async(&ws_url).await { + Ok(r) => r, + Err(e) => { + deregister(&client, &server_url, &room_id).await; + return Err(e.into()); + } + }; + let (mut write, mut read) = ws_stream.split(); + + let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); + + // Step 4: Q&A loop + let result: Result<()> = 'qa: loop { + // Wait for the user's question + let question: String = 'recv: loop { + match read.next().await { + Some(Ok(Message::Text(msg))) => break 'recv msg, + Some(Ok(Message::Close(_))) => { + println!("User disconnected"); + break 'qa Ok(()); + } + Some(Err(e)) => break 'qa Err(anyhow::anyhow!("WebSocket error: {}", e)), + None => break 'qa Err(anyhow::anyhow!("Connection lost")), + _ => continue 'recv, + } + }; + + println!("User question: {}\n", question); + println!("Answer (blank line to finish):\n"); + + // Read lines and send each immediately. A blank line ends the response. + 'answer: loop { + let mut line = String::new(); + match stdin.read_line(&mut line).await { + Ok(0) => { + // stdin closed (process exit) — deregister and quit cleanly + break 'qa Ok(()); + } + Ok(_) => { + let trimmed = line.trim_end_matches(|c| c == '\n' || c == '\r'); + if trimmed.is_empty() { + // Blank line — end of this response + if let Err(e) = write.send(Message::Text("[DONE]".to_string())).await { + break 'qa Err(e.into()); + } + println!("[Response sent. Waiting for next question...]\n"); + break 'answer; + } + if let Err(e) = write.send(Message::text(trimmed)).await { + break 'qa Err(e.into()); + } + } + Err(e) => break 'qa Err(e.into()), + } + } + }; + + deregister(&client, &server_url, &room_id).await; + result +} diff --git a/src/main.rs b/src/main.rs index 000532f..4533332 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod ask; +mod join; +mod queue_select_tui; mod respond; -mod ui; +mod util; use clap::{Parser, Subcommand}; @@ -16,7 +18,12 @@ struct Cli { enum Commands { /// Ask a question and get a response Ask, - /// Respond to user questions as a programmer + /// Join as a programmer to answer questions + Join { + /// Your name + name: String, + }, + /// Respond to user questions as a programmer (legacy) Respond { /// The room ID to connect to roomid: String, @@ -34,6 +41,12 @@ async fn main() { std::process::exit(1); } } + Some(Commands::Join { name }) => { + if let Err(e) = join::run(name).await { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } Some(Commands::Respond { roomid }) => { if let Err(e) = respond::run(roomid).await { eprintln!("Error: {}", e); @@ -41,7 +54,10 @@ async fn main() { } } None => { - ui::run().unwrap(); + if let Err(e) = queue_select_tui::run().await { + eprintln!("Error: {}", e); + std::process::exit(1); + } } } } diff --git a/src/queue_select_tui.rs b/src/queue_select_tui.rs new file mode 100644 index 0000000..796ea1c --- /dev/null +++ b/src/queue_select_tui.rs @@ -0,0 +1,349 @@ +use anyhow::Result; +use crossterm::{ + event::{Event, EventStream, KeyCode}, + terminal::{disable_raw_mode, enable_raw_mode}, +}; +use futures::sink::SinkExt; +use futures::stream::StreamExt; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + Frame, Terminal, +}; +use reqwest::Client; +use serde::Deserialize; +use std::io; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use crate::util::build_ws_url; + +#[derive(Deserialize, Clone)] +struct QueueEntry { + #[serde(rename = "roomId")] + room_id: String, + name: String, +} + +enum AppState { + SelectingProgrammer, + AskingQuestion, + Streaming { response: String }, + Done { response: String }, +} + +struct App { + programmers: Vec, + state: AppState, + question: String, + selected: usize, +} + +impl App { + fn new(programmers: Vec) -> Self { + App { + programmers, + state: AppState::SelectingProgrammer, + question: String::new(), + selected: 0, + } + } + + fn move_down(&mut self) { + if matches!(self.state, AppState::SelectingProgrammer) { + // Use checked comparison to avoid usize underflow panic on empty list + if self.selected + 1 < self.programmers.len() { + self.selected += 1; + } + } + } + + fn move_up(&mut self) { + if matches!(self.state, AppState::SelectingProgrammer) { + if self.selected > 0 { + self.selected -= 1; + } + } + } + + fn select(&mut self) { + self.state = AppState::AskingQuestion; + } +} + +/// Drop guard that restores the terminal on drop (handles panics too). +struct TerminalGuard; +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + } +} + +pub async fn run() -> Result<()> { + dotenvy::dotenv().ok(); + let server_url = + std::env::var("SERVER_URL").unwrap_or_else(|_| "http://localhost:8787".to_string()); + + // Fetch queue (before entering raw mode so println! works normally) + let client = Client::new(); + let programmers = fetch_queue(&client, &server_url).await?; + + if programmers.is_empty() { + println!("No programmers available right now."); + return Ok(()); + } + + // Setup terminal + enable_raw_mode()?; + let _guard = TerminalGuard; // restores raw mode on drop/panic + + let stdout = io::stdout(); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + // Create app + let mut app = App::new(programmers); + + // Run app + let res = run_app(&mut terminal, &mut app, &server_url, &client).await; + + // Restore terminal + terminal.clear()?; + terminal.show_cursor()?; + // _guard drops here, calling disable_raw_mode() + + res +} + +async fn fetch_queue(client: &Client, server_url: &str) -> Result> { + let resp = client + .get(format!("{}/queue", server_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) +} + +async fn run_app( + terminal: &mut Terminal>, + app: &mut App, + server_url: &str, + client: &Client, +) -> Result<()> { + let mut event_stream = EventStream::new(); + + loop { + terminal.draw(|f| ui(f, app))?; + + let Some(event_result) = event_stream.next().await else { + break; + }; + + if let Event::Key(key) = event_result? { + match app.state { + AppState::SelectingProgrammer => match key.code { + KeyCode::Char('j') | KeyCode::Down => app.move_down(), + KeyCode::Char('k') | KeyCode::Up => app.move_up(), + KeyCode::Enter => app.select(), + KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + _ => {} + }, + AppState::AskingQuestion => match key.code { + KeyCode::Char(c) => app.question.push(c), + KeyCode::Backspace => { + app.question.pop(); + } + KeyCode::Enter => { + if !app.question.is_empty() { + let selected_prog = app.programmers[app.selected].clone(); + let question = app.question.clone(); + stream_response( + terminal, + app, + server_url, + selected_prog, + question, + &mut event_stream, + ) + .await?; + } + } + KeyCode::Esc => { + app.state = AppState::SelectingProgrammer; + app.selected = 0; + app.question.clear(); + // Refresh the programmer list + match fetch_queue(client, server_url).await { + Ok(p) => app.programmers = p, + Err(_) => {} // keep stale list on network error + } + } + _ => {} + }, + AppState::Streaming { .. } => { + // key events during streaming are handled inside stream_response + } + AppState::Done { .. } => match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Enter => { + app.question.clear(); + app.state = AppState::AskingQuestion; + } + KeyCode::Char('b') => { + app.question.clear(); + app.selected = 0; + // Refresh the programmer list before going back + match fetch_queue(client, server_url).await { + Ok(p) => app.programmers = p, + Err(_) => {} + } + app.state = AppState::SelectingProgrammer; + } + _ => {} + }, + } + } + } + + Ok(()) +} + +async fn stream_response( + terminal: &mut Terminal>, + app: &mut App, + server_url: &str, + programmer: QueueEntry, + question: String, + event_stream: &mut EventStream, +) -> Result<()> { + let ws_url = build_ws_url(server_url, &format!("/rooms/{}/user", programmer.room_id)); + + let (ws_stream, _) = connect_async(&ws_url).await?; + let (mut write, mut read) = ws_stream.split(); + + write.send(Message::text(question)).await?; + + let mut response = String::new(); + app.state = AppState::Streaming { + response: response.clone(), + }; + + loop { + tokio::select! { + msg = read.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + if text == "[DONE]" { + break; + } + response.push_str(&text); + app.state = AppState::Streaming { response: response.clone() }; + terminal.draw(|f| ui(f, app))?; + } + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, + _ => {} + } + } + evt = event_stream.next() => { + if let Some(Ok(Event::Key(key))) = evt { + if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) { + break; + } + } + } + } + } + + app.state = AppState::Done { response }; + + Ok(()) +} + +fn ui(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(3), + ] + .as_ref(), + ) + .split(f.area()); + + let title = Paragraph::new("Coding Human - Select Programmer").style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + f.render_widget(title, chunks[0]); + + match &app.state { + AppState::SelectingProgrammer => { + let items: Vec = app + .programmers + .iter() + .enumerate() + .map(|(idx, prog)| { + let style = if idx == app.selected { + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(format!(" {}", prog.name)).style(style) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().title("Programmers").borders(Borders::ALL)) + .style(Style::default().fg(Color::White)); + + f.render_widget(list, chunks[1]); + + let help = Paragraph::new("j/k or ↑/↓ to navigate | Enter to select | q/Esc to quit"); + f.render_widget(help, chunks[2]); + } + AppState::AskingQuestion => { + let question_para = Paragraph::new(app.question.as_str()) + .block( + Block::default() + .title("Your Question") + .borders(Borders::ALL), + ) + .style(Style::default().fg(Color::Green)); + f.render_widget(question_para, chunks[1]); + + let help = Paragraph::new("Type your question | Enter to send | Esc to cancel"); + f.render_widget(help, chunks[2]); + } + AppState::Streaming { response } => { + let response_para = Paragraph::new(response.as_str()) + .block( + Block::default() + .title("Response (streaming...)") + .borders(Borders::ALL), + ) + .wrap(Wrap { trim: false }); + f.render_widget(response_para, chunks[1]); + + let help = Paragraph::new("q/Esc to cancel"); + f.render_widget(help, chunks[2]); + } + AppState::Done { response } => { + let response_para = Paragraph::new(response.as_str()) + .block(Block::default().title("Response").borders(Borders::ALL)) + .wrap(Wrap { trim: false }); + f.render_widget(response_para, chunks[1]); + + let help = + Paragraph::new("Enter to ask another question | b to go back | q/Esc to quit"); + f.render_widget(help, chunks[2]); + } + } +} diff --git a/src/respond.rs b/src/respond.rs index 7eeb60f..241e9c0 100644 --- a/src/respond.rs +++ b/src/respond.rs @@ -4,35 +4,23 @@ use futures::stream::StreamExt; use tokio::io::AsyncBufReadExt; use tokio_tungstenite::{connect_async, tungstenite::Message}; +use crate::util::build_ws_url; + pub async fn run(room_id: String) -> Result<()> { dotenvy::dotenv().ok(); let server_url = std::env::var("SERVER_URL").unwrap_or_else(|_| "http://localhost:8787".to_string()); - // Step 1: Connect to the room via WebSocket as programmer - let ws_scheme = if server_url.starts_with("https://") { - "wss" - } else { - "ws" - }; - let server_host = server_url - .strip_prefix("https://") - .or_else(|| server_url.strip_prefix("http://")) - .unwrap_or(&server_url); - - let ws_url = format!( - "{}://{}/rooms/{}/programmer", - ws_scheme, server_host, room_id - ); + let ws_url = build_ws_url(&server_url, &format!("/rooms/{}/programmer", room_id)); let (ws_stream, _) = connect_async(&ws_url).await?; let (mut write, mut read) = ws_stream.split(); - let mut async_stdin = tokio::io::BufReader::new(tokio::io::stdin()); + let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); + + println!("(Tip: type your answer line by line, then press Enter on a blank line to send)\n"); - // Step 2: Q&A loop loop { - // Wait for the user's question println!("Waiting for user's question..."); let question = loop { match read.next().await { @@ -47,23 +35,23 @@ pub async fn run(room_id: String) -> Result<()> { } }; - // Display the question println!("\nUser question: {}\n", question); - println!("Answer (Ctrl+D to finish):\n"); + println!("Answer (blank line to finish):\n"); - // Read stdin line by line and send over WebSocket - // Ctrl+D (EOF) sends [DONE] and loops back to wait for next question loop { let mut line = String::new(); - if async_stdin.read_line(&mut line).await? == 0 { - // EOF — signal end of response + if stdin.read_line(&mut line).await? == 0 { + // stdin closed — exit cleanly + return Ok(()); + } + let trimmed = line.trim_end_matches(|c| c == '\n' || c == '\r'); + if trimmed.is_empty() { + // Blank line — end of this response write.send(Message::Text("[DONE]".to_string())).await?; - println!(); + println!("[Response sent. Waiting for next question...]\n"); break; } - write - .send(Message::text(line.trim_end_matches('\n'))) - .await?; + write.send(Message::text(trimmed)).await?; } } } diff --git a/src/ui/app.rs b/src/ui/app.rs deleted file mode 100644 index 8012233..0000000 --- a/src/ui/app.rs +++ /dev/null @@ -1,164 +0,0 @@ -use crossterm::{ - event::{self, Event, KeyCode}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use ratatui::{ - backend::CrosstermBackend, - layout::{Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, - Terminal, -}; -use std::io; - -use super::data::{content_entries, CATEGORIES}; -use super::logo::build_logo; -use super::panel::{focused_block, Panel}; - -pub fn run() -> io::Result<()> { - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let logo_lines = build_logo(); - let logo_height = logo_lines.len() as u16 + 2; - - let mut category_state = ListState::default(); - category_state.select(Some(0)); - - let mut content_state = ListState::default(); - content_state.select(Some(0)); - - let mut focus = Panel::Category; - - loop { - let cat_idx = category_state.selected().unwrap_or(0); - let entries = content_entries(cat_idx); - let con_idx = content_state - .selected() - .unwrap_or(0) - .min(entries.len().saturating_sub(1)); - - let detail_text: &str = entries.get(con_idx).map(|e| e.detail).unwrap_or(""); - - terminal.draw(|f| { - // Vertical split: logo | body - let vertical = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(logo_height), Constraint::Min(0)]) - .split(f.area()); - - let logo = - Paragraph::new(logo_lines.clone()).block(Block::default().borders(Borders::NONE)); - f.render_widget(logo, vertical[0]); - - // Horizontal split: category | content | details - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(16), - Constraint::Length(22), - Constraint::Min(0), - ]) - .split(vertical[1]); - - // Category panel - let cat_items: Vec = CATEGORIES.iter().map(|s| ListItem::new(*s)).collect(); - let cat_highlight = if focus == Panel::Category { - Style::default() - .fg(Color::Black) - .bg(Color::Green) - .add_modifier(Modifier::BOLD) - } else { - Style::default().add_modifier(Modifier::DIM) - }; - let cat_list = List::new(cat_items) - .block(focused_block("Category", focus == Panel::Category)) - .highlight_style(cat_highlight) - .highlight_symbol(" "); - f.render_stateful_widget(cat_list, chunks[0], &mut category_state); - - // Content panel - let con_items: Vec = entries - .iter() - .map(|e| ListItem::new(format!(" {}", e.name))) - .collect(); - let con_highlight = if focus == Panel::Content { - Style::default() - .fg(Color::Black) - .bg(Color::Green) - .add_modifier(Modifier::BOLD) - } else { - Style::default().add_modifier(Modifier::DIM) - }; - let con_list = List::new(con_items) - .block(focused_block(CATEGORIES[cat_idx], focus == Panel::Content)) - .highlight_style(con_highlight) - .highlight_symbol(" "); - f.render_stateful_widget(con_list, chunks[1], &mut content_state); - - // Details panel - let detail_title = entries.get(con_idx).map(|e| e.name).unwrap_or("Details"); - let detail_para = Paragraph::new(detail_text) - .block(focused_block(detail_title, focus == Panel::Details)) - .wrap(Wrap { trim: false }); - f.render_widget(detail_para, chunks[2]); - })?; - - if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => break, - - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - focus = focus.next(); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - focus = focus.prev(); - } - - KeyCode::Down | KeyCode::Char('j') => match focus { - Panel::Category => { - let next = (cat_idx + 1) % CATEGORIES.len(); - category_state.select(Some(next)); - content_state.select(Some(0)); - } - Panel::Content => { - let len = entries.len(); - if len > 0 { - content_state.select(Some((con_idx + 1) % len)); - } - } - Panel::Details => {} - }, - KeyCode::Up | KeyCode::Char('k') => match focus { - Panel::Category => { - let prev = if cat_idx == 0 { - CATEGORIES.len() - 1 - } else { - cat_idx - 1 - }; - category_state.select(Some(prev)); - content_state.select(Some(0)); - } - Panel::Content => { - let len = entries.len(); - if len > 0 { - let prev = if con_idx == 0 { len - 1 } else { con_idx - 1 }; - content_state.select(Some(prev)); - } - } - Panel::Details => {} - }, - - _ => {} - } - } - } - - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - Ok(()) -} diff --git a/src/ui/data.rs b/src/ui/data.rs deleted file mode 100644 index 49b4d7c..0000000 --- a/src/ui/data.rs +++ /dev/null @@ -1,366 +0,0 @@ -pub struct ContentEntry { - pub name: &'static str, - pub detail: &'static str, -} - -pub const CATEGORIES: &[&str] = &["About", "Projects", "Articles", "Members"]; - -pub const PROJECTS: &[ContentEntry] = &[ - ContentEntry { - name: "shortcut-game", - detail: "A game for learning keyboard shortcuts, targeting users who want to improve their speed and efficiency in editors/OS. Built with SvelteKit.", - }, - ContentEntry { - name: "my-code", - detail: "An interactive learning platform for programming, aimed at beginners. Features in-browser code execution and AI chat to help students learn. Built with TypeScript and AI services.", - }, - ContentEntry { - name: "cli", - detail: "A terminal-based CLI made for ut.code(); members to browse info and resources conveniently. Implemented in Rust using ratatui.", - }, - ContentEntry { - name: "syllabus", - detail: "A web app to help university students navigate course registration and class planning. Uses TypeScript and web data scraping for accurate info.", - }, - ContentEntry { - name: "space-simulator", - detail: "An educational web app simulating orbital mechanics and space environments for students interested in physics/space. Implemented with React, TypeScript, and Vite.", - }, - ContentEntry { - name: "CourseMate", - detail: "A web app for students to discover and connect with classmates taking the same university courses. Built with TypeScript and web technologies.", - }, - ContentEntry { - name: "create-cpu", - detail: "A browser-based circuit/CPU simulator aimed at students learning computer architecture. Lets users create logic circuits by connecting gates and wires. Built with TypeScript for the web.", - }, - ContentEntry { - name: "dot-turor", - detail: "A planned app to help new developers set up their dotfiles and dev environments with best practices. Target: beginner programmers. Tech: To be decided.", - }, - ContentEntry { - name: "menu", - detail: "A meal proposal app that suggests menus based on user answers to a series of questions. Developed in Jupyter Notebook and Python.", - }, - ContentEntry { - name: "rollcron", - detail: "A cron job scheduler designed for automated, version-controlled workflows, synchronizing job definitions from git repositories. Implemented in Rust.", - }, - ContentEntry { - name: "memomap", - detail: "A spatial note-taking app for leaving geo-tagged memos on a map. Built as a mobile-first application with Flutter (Dart).", - }, - ContentEntry { - name: "discord-bots", - detail: "A set of Discord bots enabling automation, moderation, and notifications in the ut.code(); community server. Implemented in TypeScript.", - }, - ContentEntry { - name: "komabanavi", - detail: "A web map app for campus navigation. Designed for students and visitors to find buildings and facilities at Komaba campus. Built with TypeScript.", - }, - ContentEntry { - name: "hitori-mahjong", - detail: "A solo play mahjong app aimed at practicing tile recognition and scoring. Built as a web app using TypeScript.", - }, - ContentEntry { - name: "itsuhima", - detail: "A web app for easily coordinating shared availability for small groups and events. Targeted at university students. Written in TypeScript.", - }, - ContentEntry { - name: "extralearn", - detail: "The next step after ut.code(); Learn: an extended learning platform with advanced MDX-based content, especially for those wanting to deepen their programming knowledge. Uses React/TypeScript/MDX.", - }, - ContentEntry { - name: "Raxcel", - detail: "A desktop spreadsheet app for users seeking a lightweight, Excel-style experience on their desktop. Built with Svelte and desktop frameworks.", - }, - ContentEntry { - name: "card-game", - detail: "A web-based card game platform for experimenting with turn-based mechanics and multiplayer support. Built with TypeScript for the web.", - }, - ContentEntry { - name: "ut-bridge", - detail: "A TypeScript app designed to help connect students across university departments and interests. Was used for peer introduction and collaboration. Currently suspended.", - }, -]; - -pub const MEMBERS: &[ContentEntry] = &[ - ContentEntry { - name: "nakomochi", - detail: "Current (5th) representative of ut.code();. Leader of the shortcut-game project and active in web app and CLI tooling across Svelte, TypeScript, and Rust.", - }, - ContentEntry { - name: "chvmvd", - detail: "3rd representative of ut.code();. Key maintainer of educational materials (including utcode-learn), focused on algorithm education and open-source TypeScript projects.", - }, - ContentEntry { - name: "aster-void", - detail: "Maintainer of extralearn (extra.utcode.net). Focused on \ - systems programming and low-level tooling.", - }, - ContentEntry { - name: "na-trium-144", - detail: "Leader of the my-code project. Prolific contributor spanning \ - TypeScript, C++, and systems tooling. Creator of webcface \ - (a web-based communication framework) and Nikochan.", - }, - ContentEntry { - name: "nakaterm", - detail: "Interested in terminal UIs and developer experience. Author \ - of multiple open-source CLI utilities within the ut.code(); \ - ecosystem.", - }, - ContentEntry { - name: "Tsurubara-UTcode", - detail: "Interested in computer vision and hackathon projects. \ - Contributed to team projects including umaproject and \ - coetecohack.", - }, - ContentEntry { - name: "tknkaa", - detail: "Builder of this CLI. Interested in Rust, TUI design, and \ - developer tooling. Maintains hitori-mahjong and Raxcel.", - }, - ContentEntry { - name: "chelproc", - detail: "Founder and 1st representative of ut.code();. TypeScript developer, contributed setup scripts and infrastructure for community learning servers.", - }, - ContentEntry { - name: "Tatsu723", - detail: "An ut.code(); member actively contributing to community \ - development projects.", - }, - ContentEntry { - name: "faithia-anastasia", - detail: "An ut.code(); member diving into the community and its \ - projects.", - }, - ContentEntry { - name: "shirokuma222", - detail: "An ut.code(); member contributing to community projects \ - and development initiatives.", - }, - ContentEntry { - name: "RRRyoma", - detail: "An ut.code(); member contributing to community projects \ - and development initiatives.", - }, - ContentEntry { - name: "Yokomi422", - detail: "An ut.code(); member engaged in community projects and \ - collaborative development.", - }, - ContentEntry { - name: "tsatohiro", - detail: "An ut.code(); member contributing to community projects \ - and development initiatives.", - }, - ContentEntry { - name: "SotaTamura", - detail: "An ut.code(); member contributing to the community's \ - growing portfolio of projects.", - }, - ContentEntry { - name: "Rn86222", - detail: "An ut.code(); member bringing research depth and academic \ - perspective to community projects.", - }, - ContentEntry { - name: "taka231", - detail: "Interested in systems programming and compilers. Built an \ - HLS compiler, eBPF loader, WebAssembly JIT, Brainfuck JIT, \ - and a C compiler (recc), mostly in Rust.", - }, - ContentEntry { - name: "KaichiManabe", - detail: "The 4th representative of ut.code();, taking office from \ - September 2025. Leads the community's direction and \ - coordinates projects and events.", - }, - ContentEntry { - name: "brdgb", - detail: "Interested in bridging systems engineering and software \ - development. Contributes an interdisciplinary perspective \ - to ut.code(); projects.", - }, - ContentEntry { - name: "claude", - detail: "An AI assistant by Anthropic and honorary member of the \ - ut.code(); community. Helps members with code, documentation, \ - and this very CLI.", - }, -]; - -pub const ARTICLES: &[ContentEntry] = &[ - ContentEntry { - name: "Hackathon (2025/12/21)", - detail: "ut.code(); held an internal hackathon in December themed \ - \"CLI tool\" with a tech restriction of Python or Rust. \ - Teams kicked off on Dec 6, built projects over several days, \ - and presented results. A fun challenge outside the usual TypeScript stack.", - }, - ContentEntry { - name: "6th General Meeting (2025/12/13)", - detail: "The 6th ut.code(); general assembly was held on December 13. \ - The meeting included a leadership handover ceremony and updates \ - from active projects on their progress.", - }, - ContentEntry { - name: "5th General Meeting (2025/11/29)", - detail: "The 5th ut.code(); general assembly was held on November 29. \ - Standing projects presented their annual work, and the community \ - reviewed ongoing initiatives.", - }, - ContentEntry { - name: "Komaba Festival (2025/11/28)", - detail: "ut.code(); exhibited at the 76th Komaba Festival (Nov 22-24, 2025) \ - with a booth called \"Programming Just for You\". Members demonstrated \ - projects and engaged visitors with interactive programming experiences.", - }, - ContentEntry { - name: "4th General Meeting (2025/11/12)", - detail: "The 4th ut.code(); general assembly was held on October 25. \ - Standing projects reported progress, and the community discussed \ - upcoming events and initiatives.", - }, - ContentEntry { - name: "Komaba Fest Kickoff (2025/10/11)", - detail: "ut.code(); held a kickoff meeting on Oct 11 to prepare for the \ - 76th Komaba Festival. Teams selected projects to exhibit and \ - planned their interactive demonstrations.", - }, - ContentEntry { - name: "RIZAP Hackathon (2025/09/30)", - detail: "ut.code(); co-hosted a hackathon with RIZAP Technologies on Sep 30 \ - under the theme \"Health x Technology\". Teams built products \ - combining wellness concepts with software engineering.", - }, - ContentEntry { - name: "3rd General Meeting (2025/09/27)", - detail: "The 3rd ut.code(); general assembly marked the first meeting under \ - the new leadership. Projects presented updates and the community \ - discussed its direction for the rest of the year.", - }, - ContentEntry { - name: "New Leadership (2025/09/23)", - detail: "ut.code(); announced a leadership change effective September 1, 2025. \ - Kaichi Manabe became the 4th representative, taking over from the \ - previous leader.", - }, - ContentEntry { - name: "Summer Camp (2025/09/17)", - detail: "ut.code(); held its summer retreat Sep 17-19 at the University of \ - Tokyo Yamanaka Seminar House. Members worked on projects, held \ - technical sessions, and bonded as a community.", - }, - ContentEntry { - name: "GMO Office Tour (2025/08/22)", - detail: "First and second-year ut.code(); members visited GMO Media's office \ - on Aug 22 for an office tour and engineer roundtable, gaining insight \ - into professional software development.", - }, - ContentEntry { - name: "May Festival (2025/06/08)", - detail: "ut.code(); exhibited at the 98th May Festival (May 24-25, 2025). \ - Displays included intro programming workshops, a hackathon showcase \ - by new members, and the shortcut-key puzzle game.", - }, - ContentEntry { - name: "New Member Camp (2025/05/12)", - detail: "ut.code(); held its first-ever new member retreat May 3-5 at \ - the Yamanaka Seminar House. New members bonded and got oriented \ - to the community during Golden Week.", - }, - ContentEntry { - name: "Newcomers Hackathon (2025/05/11)", - detail: "ut.code(); ran a two-day newcomer hackathon May 10-11, giving new \ - members hands-on experience building projects from scratch and \ - presenting their work to the community.", - }, - ContentEntry { - name: "Joint Welcome Event (2025/03/24)", - detail: "ut.code(); co-organized a joint welcome event for incoming students \ - alongside other University of Tokyo engineering clubs, held on \ - Apr 9 and Apr 15.", - }, - ContentEntry { - name: "Spring Camp (2025/03/01)", - detail: "ut.code(); held its spring retreat Feb 20-22 at the Yamanaka \ - Seminar House. Members visited Lake Yamanaka, worked on projects, \ - and participated in team activities.", - }, - ContentEntry { - name: "2nd General Meeting (2025/02/16)", - detail: "The 2nd ut.code(); general assembly focused on project progress \ - reports and recent technology topics. The community established \ - a cadence of meetings every 1-2 months.", - }, - ContentEntry { - name: "Project Launch Party (2024/12/22)", - detail: "ut.code(); held a project founding party on Dec 21, 2024, where \ - members brainstormed product ideas over pizza and voted on new \ - projects to pursue in 2025.", - }, -]; - -pub const ABOUT: &[ContentEntry] = &[ - ContentEntry { - name: "Overview", - detail: "ut.code(); is a software engineering community at the University \ - of Tokyo, founded in 2019. Around 30 members strong, the club \ - is based in room 313B of the Komaba Student Hall and affiliated \ - with the Faculty of Engineering Teiyukai (2025).", - }, - ContentEntry { - name: "Activities", - detail: "The club operates across three pillars:\n\ - - Learning & Education: ut.code(); Learn, seminars\n\ - - Exchange: May Festival, Komaba Festival, retreats\n\ - - Development: projects, hackathons\n\n\ - Members typically meet weekly for evening project sessions \ - plus 1-2 work sessions per month.", - }, - ContentEntry { - name: "Beginners Welcome", - detail: "Complete beginners are fully welcome. ut.code(); publishes \ - learning materials that take total novices to building full-stack \ - applications, backed by study groups and workshops. No restrictions \ - on university, year, or academic background.", - }, - ContentEntry { - name: "Tech Stack", - detail: "Specific tech varies by project, but common choices include:\n\ - - Language: TypeScript, Rust, Python\n\ - - Frontend: React / Next.js, Svelte(Kit)\n\ - - UI: MUI, Tailwind, DaisyUI\n\ - - Backend: Hono, Express, Prisma, Drizzle\n\ - - DB: Supabase, Neon\n\ - - Infra: Cloudflare, Fly.io, Render\n\ - - Tools: Notion, Discord, GitHub", - }, - ContentEntry { - name: "Locations", - detail: "ut.code(); members work from multiple locations:\n\ - - Club room: Komaba Student Hall 313B\n\ - - Hongo Library Project Box\n\ - - Online via Discord", - }, - ContentEntry { - name: "Social & Contact", - detail: "Find ut.code(); online:\n\ - - X (Twitter): @utokyo_code\n\ - - GitHub: github.com/ut-code\n\ - - Website: utcode.net\n\n\ - Donations and sponsorships are welcome — see utcode.net/donation for details.", - }, -]; - -pub fn content_entries(category: usize) -> &'static [ContentEntry] { - match category { - 0 => ABOUT, - 1 => PROJECTS, - 2 => ARTICLES, - 3 => MEMBERS, - _ => &[], - } -} diff --git a/src/ui/logo.rs b/src/ui/logo.rs deleted file mode 100644 index 9da2a36..0000000 --- a/src/ui/logo.rs +++ /dev/null @@ -1,40 +0,0 @@ -use cfonts::{render, Colors, Fonts, Options}; -use ratatui::{ - style::{Color, Style}, - text::{Line, Span}, -}; - -pub fn build_logo() -> Vec> { - let parts: &[(&str, Option)] = - &[("ut.", None), ("code", Some(Color::Green)), ("();", None)]; - let row_groups: Vec<(Vec, Option)> = parts - .iter() - .map(|(text, color)| { - let rendered = render(Options { - text: String::from(*text), - font: Fonts::FontBlock, - colors: vec![Colors::System], - spaceless: true, - ..Options::default() - }); - let lines: Vec = rendered.text.lines().map(String::from).collect(); - (lines, *color) - }) - .collect(); - let num_rows = row_groups.iter().map(|(g, _)| g.len()).max().unwrap_or(0); - (0..num_rows) - .map(|i| { - let spans: Vec> = row_groups - .iter() - .map(|(g, color)| { - let text: String = g.get(i).cloned().unwrap_or_default(); - match color { - Some(c) => Span::styled(text, Style::default().fg(*c)), - None => Span::raw(text), - } - }) - .collect(); - Line::from(spans) - }) - .collect() -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs deleted file mode 100644 index f7f156f..0000000 --- a/src/ui/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod app; -pub mod data; -pub mod logo; -pub mod panel; - -pub use app::run; diff --git a/src/ui/panel.rs b/src/ui/panel.rs deleted file mode 100644 index 08707e9..0000000 --- a/src/ui/panel.rs +++ /dev/null @@ -1,41 +0,0 @@ -use ratatui::{ - style::{Color, Style}, - widgets::{Block, Borders}, -}; - -#[derive(Clone, Copy, PartialEq)] -pub enum Panel { - Category, - Content, - Details, -} - -impl Panel { - pub fn next(self) -> Self { - match self { - Panel::Category => Panel::Content, - Panel::Content => Panel::Details, - Panel::Details => Panel::Category, - } - } - - pub fn prev(self) -> Self { - match self { - Panel::Category => Panel::Details, - Panel::Content => Panel::Category, - Panel::Details => Panel::Content, - } - } -} - -pub fn focused_block(title: &str, focused: bool) -> Block<'_> { - let border_style = if focused { - Style::default().fg(Color::Green) - } else { - Style::default() - }; - Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .title(format!(" {} ", title)) -} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..6ced734 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,12 @@ +/// Build a WebSocket URL from an HTTP server URL and a path suffix. +/// http:// → ws://, https:// → wss:// +pub fn build_ws_url(server_url: &str, path: &str) -> String { + let (scheme, host) = if let Some(host) = server_url.strip_prefix("https://") { + ("wss", host) + } else if let Some(host) = server_url.strip_prefix("http://") { + ("ws", host) + } else { + ("ws", server_url) + }; + format!("{}://{}{}", scheme, host, path) +}