From 1f604ec09e754fbfc32aab33c740f07ef33f828d Mon Sep 17 00:00:00 2001 From: Lucent Lu <1215.lucent@gmail.com> Date: Tue, 24 Feb 2026 21:57:13 -0500 Subject: [PATCH 1/7] basic chat interface --- apps/tui/package.json | 1 + apps/tui/src/index.tsx | 224 +++++++++++++++++++++++++++++++++++++++-- bun.lock | 9 +- 3 files changed, 226 insertions(+), 8 deletions(-) diff --git a/apps/tui/package.json b/apps/tui/package.json index 7031870..88e8494 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -14,6 +14,7 @@ "typescript": "^5" }, "dependencies": { + "@openrouter/sdk": "^0.9.11", "@opentui/core": "^0.1.77", "@opentui/react": "^0.1.77", "react": "^19.2.4" diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index aa79769..7905931 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -1,16 +1,228 @@ import { createCliRenderer, TextAttributes } from "@opentui/core"; -import { createRoot } from "@opentui/react"; +import { createRoot, useKeyboard } from "@opentui/react"; +import { OpenRouter } from "@openrouter/sdk"; +import { useMemo, useRef, useState } from "react"; + +type Role = "user" | "assistant" | "system"; + +type ChatMessage = { + role: Role; + content: string; +}; + +const OPENROUTER_MODEL = "stepfun/step-3.5-flash:free"; + +const runtime = { + isShuttingDown: false, + abortController: null as AbortController | null +}; + +function shutdownApp() { + if (runtime.isShuttingDown) { + return; + } + + runtime.isShuttingDown = true; + runtime.abortController?.abort(); + process.exit(0); +} + +function createOpenRouterClient(): OpenRouter { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + throw new Error("Missing OPENROUTER_API_KEY environment variable."); + } + + return new OpenRouter({ apiKey }); +} function App() { + const [messages, setMessages] = useState([ + { + role: "assistant", + content: "Hello! I am connected to OpenRouter. Ask me anything." + } + ]); + const [inputValue, setInputValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [reasoningTokens, setReasoningTokens] = useState(null); + const sendLockRef = useRef(false); + + const placeholder = useMemo(() => { + if (isLoading) { + return "Waiting for model response..."; + } + + return "Type a message and press Enter"; + }, [isLoading]); + + useKeyboard((event) => { + if (event.ctrl && event.name === "c") { + shutdownApp(); + } + + if (event.name === "escape") { + shutdownApp(); + } + }); + + const sendMessage = async (rawValue: string) => { + if (sendLockRef.current) { + return; + } + + const trimmedValue = rawValue.trim(); + if (!trimmedValue || isLoading) { + return; + } + + sendLockRef.current = true; + + setErrorMessage(null); + setReasoningTokens(null); + setInputValue(""); + + const nextMessages = [...messages, { role: "user" as const, content: trimmedValue }]; + setMessages(nextMessages); + setIsLoading(true); + + try { + const abortController = new AbortController(); + runtime.abortController = abortController; + setMessages((previous) => [...previous, { role: "assistant", content: "" }]); + + const openrouter = createOpenRouterClient(); + const stream = await openrouter.chat.send({ + httpReferer: process.env.OPENROUTER_HTTP_REFERER ?? "https://github.com/TechAtNYU/ralph", + xTitle: process.env.OPENROUTER_APP_NAME ?? "Ralph OpenTUI", + chatGenerationParams: { + model: OPENROUTER_MODEL, + messages: nextMessages, + stream: true + } + }, { + signal: abortController.signal + }); + + for await (const chunk of stream) { + if (abortController.signal.aborted || runtime.isShuttingDown) { + break; + } + + const content = chunk.choices[0]?.delta?.content; + if (content) { + setMessages((previous) => { + const updated = [...previous]; + const assistantIndex = updated.length - 1; + const assistantMessage = updated[assistantIndex]; + + if (assistantMessage?.role === "assistant") { + updated[assistantIndex] = { + ...assistantMessage, + content: assistantMessage.content + content + }; + } + + return updated; + }); + } + + const tokens = chunk.usage?.completionTokensDetails?.reasoningTokens; + if (typeof tokens === "number") { + setReasoningTokens(tokens); + } + } + } catch (error) { + if (runtime.isShuttingDown) { + return; + } + + if (error instanceof Error && error.name === "RequestAbortedError") { + return; + } + + const message = error instanceof Error ? error.message : "Unknown error while calling OpenRouter."; + setErrorMessage(message); + setMessages((previous) => { + if (previous.length === 0) { + return previous; + } + + const updated = [...previous]; + const lastMessage = updated[updated.length - 1]; + + if (lastMessage?.role === "assistant" && lastMessage.content.length === 0) { + updated.pop(); + } + + return updated; + }); + } finally { + sendLockRef.current = false; + runtime.abortController = null; + if (!runtime.isShuttingDown) { + setIsLoading(false); + } + } + }; + return ( - - - - What will you build? + + + + Ralph OpenRouter Chat · model: {OPENROUTER_MODEL} + {typeof reasoningTokens === "number" ? ` · reasoning tokens: ${reasoningTokens}` : ""} + {errorMessage ? ` · error: ${errorMessage}` : ""} · esc / ctrl+c to quit + + + + + {messages.map((message, index) => { + const label = message.role === "user" ? "You" : message.role === "assistant" ? "Assistant" : "System"; + + return ( + + {label} + {message.content} + + ); + })} + {isLoading ? ( + Assistant is thinking... + ) : null} + + + + { + const submittedValue = typeof value === "string" ? value : inputValue; + void sendMessage(submittedValue); + }} + /> ); } -const renderer = await createCliRenderer(); +const renderer = await createCliRenderer({ + onDestroy: () => { + runtime.abortController?.abort(); + runtime.isShuttingDown = true; + process.exit(0); + } +}); createRoot(renderer).render(); diff --git a/bun.lock b/bun.lock index e0f2cdc..7b89537 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "apps/tui": { "name": "@techatnyu/ralph", "dependencies": { + "@openrouter/sdk": "^0.9.11", "@opentui/core": "^0.1.77", "@opentui/react": "^0.1.77", "react": "^19.2.4", @@ -154,6 +155,8 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@openrouter/sdk": ["@openrouter/sdk@0.9.11", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-BgFu6NcIJO4a9aVjr04y3kZ8pyM71j15I+bzfVAGEvxnj+KQNIkBYQGgwrG3D+aT1QpDKLki8btcQmpaxUas6A=="], + "@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="], @@ -176,7 +179,7 @@ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], @@ -208,7 +211,7 @@ "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], - "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], @@ -466,6 +469,8 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "bun-types/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], From 9695dfed6983e56e696b922cf4940049ddd2cd02 Mon Sep 17 00:00:00 2001 From: Lucent Lu <1215.lucent@gmail.com> Date: Tue, 24 Feb 2026 22:01:13 -0500 Subject: [PATCH 2/7] add scrollability --- apps/tui/src/index.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index 7905931..52d1d9f 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -2,6 +2,7 @@ import { createCliRenderer, TextAttributes } from "@opentui/core"; import { createRoot, useKeyboard } from "@opentui/react"; import { OpenRouter } from "@openrouter/sdk"; import { useMemo, useRef, useState } from "react"; +import type { ScrollBoxRenderable } from "@opentui/core"; type Role = "user" | "assistant" | "system"; @@ -48,6 +49,7 @@ function App() { const [errorMessage, setErrorMessage] = useState(null); const [reasoningTokens, setReasoningTokens] = useState(null); const sendLockRef = useRef(false); + const chatScrollRef = useRef(null); const placeholder = useMemo(() => { if (isLoading) { @@ -65,6 +67,22 @@ function App() { if (event.name === "escape") { shutdownApp(); } + + if (event.name === "pageup") { + chatScrollRef.current?.scrollBy(-1, "viewport"); + } + + if (event.name === "pagedown") { + chatScrollRef.current?.scrollBy(1, "viewport"); + } + + if (event.ctrl && event.name === "u") { + chatScrollRef.current?.scrollBy(-0.5, "viewport"); + } + + if (event.ctrl && event.name === "d") { + chatScrollRef.current?.scrollBy(0.5, "viewport"); + } }); const sendMessage = async (rawValue: string) => { @@ -173,11 +191,12 @@ function App() { Ralph OpenRouter Chat · model: {OPENROUTER_MODEL} {typeof reasoningTokens === "number" ? ` · reasoning tokens: ${reasoningTokens}` : ""} - {errorMessage ? ` · error: ${errorMessage}` : ""} · esc / ctrl+c to quit + {errorMessage ? ` · error: ${errorMessage}` : ""} · PgUp/PgDn or Ctrl+U/Ctrl+D scroll · esc / ctrl+c quit Date: Tue, 24 Feb 2026 22:05:55 -0500 Subject: [PATCH 3/7] basic chat window --- apps/tui/src/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index 52d1d9f..6be6967 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -186,8 +186,8 @@ function App() { }; return ( - - + + Ralph OpenRouter Chat · model: {OPENROUTER_MODEL} {typeof reasoningTokens === "number" ? ` · reasoning tokens: ${reasoningTokens}` : ""} @@ -198,6 +198,9 @@ function App() { - + Date: Tue, 24 Feb 2026 22:10:11 -0500 Subject: [PATCH 4/7] added faster model for testing --- apps/tui/src/index.tsx | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index 6be6967..d41382f 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -11,7 +11,8 @@ type ChatMessage = { content: string; }; -const OPENROUTER_MODEL = "stepfun/step-3.5-flash:free"; +//const OPENROUTER_MODEL = "stepfun/step-3.5-flash:free"; +const OPENROUTER_MODEL = "liquid/lfm-2.5-1.2b-instruct:free"; const runtime = { isShuttingDown: false, @@ -85,6 +86,24 @@ function App() { } }); + const appendErrorToChat = (message: string) => { + setMessages((previous) => { + const updated = [...previous]; + const lastMessage = updated[updated.length - 1]; + + if (lastMessage?.role === "assistant" && lastMessage.content.length === 0) { + updated.pop(); + } + + updated.push({ + role: "system", + content: `Error: ${message}` + }); + + return updated; + }); + }; + const sendMessage = async (rawValue: string) => { if (sendLockRef.current) { return; @@ -128,6 +147,12 @@ function App() { break; } + if (chunk.error?.message) { + setErrorMessage(chunk.error.message); + appendErrorToChat(chunk.error.message); + break; + } + const content = chunk.choices[0]?.delta?.content; if (content) { setMessages((previous) => { @@ -162,20 +187,7 @@ function App() { const message = error instanceof Error ? error.message : "Unknown error while calling OpenRouter."; setErrorMessage(message); - setMessages((previous) => { - if (previous.length === 0) { - return previous; - } - - const updated = [...previous]; - const lastMessage = updated[updated.length - 1]; - - if (lastMessage?.role === "assistant" && lastMessage.content.length === 0) { - updated.pop(); - } - - return updated; - }); + appendErrorToChat(message); } finally { sendLockRef.current = false; runtime.abortController = null; From bed7e82ee3752c93fc4193206e57f1579e619d33 Mon Sep 17 00:00:00 2001 From: Chenxin Yan Date: Wed, 1 Apr 2026 17:50:46 -0400 Subject: [PATCH 5/7] add changeset --- .changeset/quiet-lions-jog.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quiet-lions-jog.md diff --git a/.changeset/quiet-lions-jog.md b/.changeset/quiet-lions-jog.md new file mode 100644 index 0000000..0e65209 --- /dev/null +++ b/.changeset/quiet-lions-jog.md @@ -0,0 +1,5 @@ +--- +"@techatnyu/ralph": patch +--- + +Add the daemon-backed TUI, docs site, and release tooling. From ce26bf80d6d2ad4b34ed597f91fd230a70d409d9 Mon Sep 17 00:00:00 2001 From: Chenxin Yan Date: Wed, 1 Apr 2026 18:28:56 -0400 Subject: [PATCH 6/7] clean up --- apps/docs/content/docs/index.mdx | 2 +- apps/tui/README.md | 4 ++-- apps/tui/package.json | 1 - apps/tui/src/cli.ts | 4 ++-- bun.lock | 3 --- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index da68d22..07c0b6f 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -8,7 +8,7 @@ Welcome to **Ralph**, a Tech@NYU project. ## What is Ralph? -Ralph is a TUI that runs a ralph loop on top of OpenCode. +Ralph is a terminal UI and CLI backed by the local `ralphd` daemon. ## Quick Links diff --git a/apps/tui/README.md b/apps/tui/README.md index ad89ef2..d8da285 100644 --- a/apps/tui/README.md +++ b/apps/tui/README.md @@ -1,8 +1,8 @@ # Ralph TUI + Local Daemon -This app uses a local daemon (`ralphd`) for long-running loop jobs. +This app talks to the local daemon (`ralphd`) for all runtime work. -End users should normally run `ralph`. The TUI will start `ralphd` when needed. +End users should normally run `ralph`. The TUI will start `ralphd` when needed in packaged builds. In local development, run `bun run dev` from the repo root. Turborepo will run the TUI and daemon together, and the TUI will wait for the foreground daemon diff --git a/apps/tui/package.json b/apps/tui/package.json index 217d850..69579c2 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -22,7 +22,6 @@ "dependencies": { "@crustjs/core": "^0.0.13", "@crustjs/plugins": "^0.0.16", - "@openrouter/sdk": "^0.9.11", "@techatnyu/ralphd": "workspace:*", "@opentui/core": "^0.1.77", "@opentui/react": "^0.1.77", diff --git a/apps/tui/src/cli.ts b/apps/tui/src/cli.ts index a0daf97..1b540b6 100644 --- a/apps/tui/src/cli.ts +++ b/apps/tui/src/cli.ts @@ -31,7 +31,7 @@ function printJson(value: unknown): void { } const cli = new Crust("ralph") - .meta({ description: "Coding Agent Ochestration TUI" }) + .meta({ description: "Coding agent orchestration TUI" }) .use(helpPlugin()) .run(async () => { await runTui(); @@ -181,7 +181,7 @@ const cli = new Crust("ralph") ) .command("instance", (instanceCommand) => instanceCommand - .meta({ description: "Manage OpenCode instances" }) + .meta({ description: "Manage daemon instances" }) .command("create", (cmd) => cmd .meta({ description: "Create a managed instance" }) diff --git a/bun.lock b/bun.lock index 627d84f..5c4240b 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,6 @@ "dependencies": { "@crustjs/core": "^0.0.13", "@crustjs/plugins": "^0.0.16", - "@openrouter/sdk": "^0.9.11", "@opentui/core": "^0.1.77", "@opentui/react": "^0.1.77", "@techatnyu/ralphd": "workspace:*", @@ -343,8 +342,6 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.13", "", {}, "sha512-/M6HlNnba+xf1EId6qFb2tG0cvq0db3PCQDug1glrf8wYOU57LYNF8WvHX9zoDKPTMv0F+O4pcP/8J+WvDaxHA=="], - "@openrouter/sdk": ["@openrouter/sdk@0.9.11", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-BgFu6NcIJO4a9aVjr04y3kZ8pyM71j15I+bzfVAGEvxnj+KQNIkBYQGgwrG3D+aT1QpDKLki8btcQmpaxUas6A=="], - "@opentui/core": ["@opentui/core@0.1.95", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.95", "@opentui/core-darwin-x64": "0.1.95", "@opentui/core-linux-arm64": "0.1.95", "@opentui/core-linux-x64": "0.1.95", "@opentui/core-win32-arm64": "0.1.95", "@opentui/core-win32-x64": "0.1.95", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Ha73I+PPSy6Jk8CTZgdGRHU+nnmrPAs7m6w0k6ge1/kWbcNcZB0lY67sWQMdoa6bSINQMNWg7SjbNCC9B/0exg=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.95", "", { "os": "darwin", "cpu": "arm64" }, "sha512-92joqr0ucGaIBCl9uYhe5DwAPbgGMTaCsCeY8Yf3VQ72wjGbOTwnC1TvU5wC6bUmiyqfijCqMyuUnj83teIVVQ=="], From 4abd2a24564ce78fd67dba106d2116f7f1f131f6 Mon Sep 17 00:00:00 2001 From: Chenxin Yan Date: Wed, 1 Apr 2026 19:02:41 -0400 Subject: [PATCH 7/7] setup chatting with daemon --- apps/tui/src/components/app.tsx | 53 ++++- apps/tui/src/components/chat.tsx | 226 ++++++++++++++++++ apps/tui/src/components/onboarding.tsx | 53 +++++ apps/tui/src/index.tsx | 307 ++----------------------- apps/tui/src/lib/onboarding.ts | 145 +++++++----- 5 files changed, 433 insertions(+), 351 deletions(-) create mode 100644 apps/tui/src/components/chat.tsx create mode 100644 apps/tui/src/components/onboarding.tsx diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index 06724e3..070236e 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -8,6 +8,11 @@ import type { } from "@techatnyu/ralphd"; import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useState } from "react"; +import { Chat } from "./chat"; + +type View = + | { type: "dashboard" } + | { type: "chat"; instanceId: string; instanceName: string }; interface DashboardData { health: HealthResult; @@ -40,7 +45,13 @@ function countJobsByState( return { running, queued }; } -export function App({ onQuit }: AppProps) { +function Dashboard({ + onQuit, + onSelectInstance, +}: { + onQuit(): void; + onSelectInstance(instance: ManagedInstance): void; +}) { const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [data, setData] = useState(); @@ -84,7 +95,7 @@ export function App({ onQuit }: AppProps) { }, [refresh]); useKeyboard((key) => { - if (key.name === "q") { + if (key.name === "q" || (key.ctrl && key.name === "c")) { onQuit(); return; } @@ -109,6 +120,14 @@ export function App({ onQuit }: AppProps) { void refresh(next); return; } + + if (key.name === "return") { + const selected = data.instances[selectedIndex]; + if (selected) { + onSelectInstance(selected); + } + return; + } }); const selected = data?.instances[selectedIndex]; @@ -180,9 +199,37 @@ export function App({ onQuit }: AppProps) { - {error ?? "j/k or arrows: select r: refresh q: quit"} + {error ?? "j/k or arrows: select enter: chat r: refresh q: quit"} ); } + +export function App({ onQuit }: AppProps) { + const [view, setView] = useState({ type: "dashboard" }); + + if (view.type === "chat") { + return ( + setView({ type: "dashboard" })} + onQuit={onQuit} + /> + ); + } + + return ( + + setView({ + type: "chat", + instanceId: instance.id, + instanceName: instance.name, + }) + } + /> + ); +} diff --git a/apps/tui/src/components/chat.tsx b/apps/tui/src/components/chat.tsx new file mode 100644 index 0000000..e61dcdb --- /dev/null +++ b/apps/tui/src/components/chat.tsx @@ -0,0 +1,226 @@ +import type { ScrollBoxRenderable } from "@opentui/core"; +import { TextAttributes } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import type { DaemonJob } from "@techatnyu/ralphd"; +import { daemon } from "@techatnyu/ralphd"; +import { useCallback, useMemo, useRef, useState } from "react"; + +type Role = "user" | "assistant" | "system"; + +interface ChatMessage { + id: number; + role: Role; + content: string; +} + +let messageIdCounter = 0; +function msg(role: Role, content: string): ChatMessage { + return { id: ++messageIdCounter, role, content }; +} + +interface ChatProps { + instanceId: string; + instanceName: string; + onBack(): void; + onQuit(): void; +} + +const JOB_POLL_INTERVAL_MS = 500; + +async function waitForJob(jobId: string): Promise { + // biome-ignore lint/correctness/noConstantCondition: polling loop + while (true) { + const result = await daemon.getJob(jobId); + if ( + result.job.state === "succeeded" || + result.job.state === "failed" || + result.job.state === "cancelled" + ) { + return result.job; + } + await Bun.sleep(JOB_POLL_INTERVAL_MS); + } +} + +export function Chat({ instanceId, instanceName, onBack, onQuit }: ChatProps) { + const [messages, setMessages] = useState([ + msg( + "assistant", + `Connected to instance "${instanceName}". Send a message to start.`, + ), + ]); + const [inputValue, setInputValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [sessionId, setSessionId] = useState(null); + const sendLockRef = useRef(false); + const chatScrollRef = useRef(null); + + const placeholder = useMemo(() => { + if (isLoading) { + return "Waiting for response..."; + } + return "Type a message and press Enter"; + }, [isLoading]); + + useKeyboard((event) => { + if (event.ctrl && event.name === "c") { + onQuit(); + } + + if (event.name === "escape") { + if (!isLoading) { + onBack(); + } + } + + if (event.name === "pageup") { + chatScrollRef.current?.scrollBy(-1, "viewport"); + } + + if (event.name === "pagedown") { + chatScrollRef.current?.scrollBy(1, "viewport"); + } + + if (event.ctrl && event.name === "u") { + chatScrollRef.current?.scrollBy(-0.5, "viewport"); + } + + if (event.ctrl && event.name === "d") { + chatScrollRef.current?.scrollBy(0.5, "viewport"); + } + }); + + const sendMessage = useCallback( + async (rawValue: string) => { + if (sendLockRef.current) { + return; + } + + const trimmedValue = rawValue.trim(); + if (!trimmedValue || isLoading) { + return; + } + + sendLockRef.current = true; + setErrorMessage(null); + setInputValue(""); + + setMessages((prev) => [...prev, msg("user", trimmedValue)]); + setIsLoading(true); + + try { + const session: + | { type: "new" } + | { type: "existing"; sessionId: string } = sessionId + ? { type: "existing", sessionId } + : { type: "new" }; + + const submitted = await daemon.submitJob({ + instanceId, + session, + task: { + type: "prompt", + prompt: trimmedValue, + }, + }); + + const finished = await waitForJob(submitted.job.id); + + // Track the session for follow-up messages + if (finished.sessionId && !sessionId) { + setSessionId(finished.sessionId); + } + + if (finished.state === "succeeded") { + const output = finished.outputText?.trim() || "(empty response)"; + setMessages((prev) => [...prev, msg("assistant", output)]); + } else if (finished.state === "cancelled") { + setMessages((prev) => [...prev, msg("system", "Job was cancelled.")]); + } else { + const errMsg = finished.error ?? "Job failed with no error message."; + setErrorMessage(errMsg); + setMessages((prev) => [...prev, msg("system", `Error: ${errMsg}`)]); + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Unknown error while submitting job."; + setErrorMessage(message); + setMessages((prev) => [...prev, msg("system", `Error: ${message}`)]); + } finally { + sendLockRef.current = false; + setIsLoading(false); + } + }, + [instanceId, sessionId, isLoading], + ); + + return ( + + + + Ralph Chat · {instanceName} + {sessionId ? ` · session: ${sessionId.slice(0, 8)}` : ""} + {errorMessage ? ` · error: ${errorMessage}` : ""} · PgUp/PgDn or + Ctrl+U/Ctrl+D scroll · esc back · ctrl+c quit + + + + + {messages.map((message) => { + const label = + message.role === "user" + ? "You" + : message.role === "assistant" + ? "Assistant" + : "System"; + + return ( + + {label} + {message.content} + + ); + })} + {isLoading ? ( + Assistant is thinking... + ) : null} + + + + { + const submittedValue = + typeof value === "string" ? value : inputValue; + void sendMessage(submittedValue); + }} + /> + + + ); +} diff --git a/apps/tui/src/components/onboarding.tsx b/apps/tui/src/components/onboarding.tsx new file mode 100644 index 0000000..2b9cccb --- /dev/null +++ b/apps/tui/src/components/onboarding.tsx @@ -0,0 +1,53 @@ +import { TextAttributes } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import type { OnboardingCheck } from "../lib/onboarding"; + +interface OnboardingErrorProps { + checks: OnboardingCheck[]; + onQuit(): void; +} + +export function OnboardingError({ checks, onQuit }: OnboardingErrorProps) { + useKeyboard((key) => { + if ( + key.name === "q" || + key.name === "escape" || + (key.ctrl && key.name === "c") + ) { + onQuit(); + } + }); + + return ( + + + + + Setup incomplete — some checks failed: + + + + {checks.map((check) => ( + + + {check.ok ? "[ok]" : "[!!]"} {check.label} + + {check.message ? ( + + {" "} + {check.message} + + ) : null} + + ))} + + + + Fix the issues above and restart Ralph. Press q to quit. + + + + ); +} diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index dbf0c0e..e211fbc 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -1,303 +1,20 @@ -import { OpenRouter } from "@openrouter/sdk"; -import type { ScrollBoxRenderable } from "@opentui/core"; -import { createCliRenderer, TextAttributes } from "@opentui/core"; -import { createRoot, useKeyboard } from "@opentui/react"; -import { useMemo, useRef, useState } from "react"; - -type Role = "user" | "assistant" | "system"; - -type ChatMessage = { - role: Role; - content: string; -}; - -//const OPENROUTER_MODEL = "stepfun/step-3.5-flash:free"; -const OPENROUTER_MODEL = "liquid/lfm-2.5-1.2b-instruct:free"; +import { createCliRenderer } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import { App } from "./components/app"; +import { OnboardingError } from "./components/onboarding"; +import { runOnboardingChecks } from "./lib/onboarding"; const runtime = { isShuttingDown: false, - abortController: null as AbortController | null, }; -function shutdownApp(onQuit: () => void) { - if (runtime.isShuttingDown) { - return; - } - - runtime.isShuttingDown = true; - runtime.abortController?.abort(); - onQuit(); -} - -function createOpenRouterClient(): OpenRouter { - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { - throw new Error("Missing OPENROUTER_API_KEY environment variable."); - } - - return new OpenRouter({ apiKey }); -} - -interface AppProps { - onQuit(): void; -} - -function App({ onQuit }: AppProps) { - const [messages, setMessages] = useState([ - { - role: "assistant", - content: "Hello! I am connected to OpenRouter. Ask me anything.", - }, - ]); - const [inputValue, setInputValue] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const [reasoningTokens, setReasoningTokens] = useState(null); - const sendLockRef = useRef(false); - const chatScrollRef = useRef(null); - - const placeholder = useMemo(() => { - if (isLoading) { - return "Waiting for model response..."; - } - - return "Type a message and press Enter"; - }, [isLoading]); - - useKeyboard((event) => { - if (event.ctrl && event.name === "c") { - shutdownApp(onQuit); - } - - if (event.name === "escape") { - shutdownApp(onQuit); - } - - if (event.name === "pageup") { - chatScrollRef.current?.scrollBy(-1, "viewport"); - } - - if (event.name === "pagedown") { - chatScrollRef.current?.scrollBy(1, "viewport"); - } - - if (event.ctrl && event.name === "u") { - chatScrollRef.current?.scrollBy(-0.5, "viewport"); - } - - if (event.ctrl && event.name === "d") { - chatScrollRef.current?.scrollBy(0.5, "viewport"); - } - }); - - const appendErrorToChat = (message: string) => { - setMessages((previous) => { - const updated = [...previous]; - const lastMessage = updated[updated.length - 1]; - - if ( - lastMessage?.role === "assistant" && - lastMessage.content.length === 0 - ) { - updated.pop(); - } - - updated.push({ - role: "system", - content: `Error: ${message}`, - }); - - return updated; - }); - }; - - const sendMessage = async (rawValue: string) => { - if (sendLockRef.current) { - return; - } - - const trimmedValue = rawValue.trim(); - if (!trimmedValue || isLoading) { - return; - } - - sendLockRef.current = true; - - setErrorMessage(null); - setReasoningTokens(null); - setInputValue(""); - - const nextMessages = [ - ...messages, - { role: "user" as const, content: trimmedValue }, - ]; - setMessages(nextMessages); - setIsLoading(true); - - try { - const abortController = new AbortController(); - runtime.abortController = abortController; - setMessages((previous) => [ - ...previous, - { role: "assistant", content: "" }, - ]); - - const openrouter = createOpenRouterClient(); - const stream = await openrouter.chat.send( - { - httpReferer: - process.env.OPENROUTER_HTTP_REFERER ?? - "https://github.com/TechAtNYU/ralph", - xTitle: process.env.OPENROUTER_APP_NAME ?? "Ralph OpenTUI", - chatGenerationParams: { - model: OPENROUTER_MODEL, - messages: nextMessages, - stream: true, - }, - }, - { - signal: abortController.signal, - }, - ); - - for await (const chunk of stream) { - if (abortController.signal.aborted || runtime.isShuttingDown) { - break; - } - - if (chunk.error?.message) { - setErrorMessage(chunk.error.message); - appendErrorToChat(chunk.error.message); - break; - } - - const content = chunk.choices[0]?.delta?.content; - if (content) { - setMessages((previous) => { - const updated = [...previous]; - const assistantIndex = updated.length - 1; - const assistantMessage = updated[assistantIndex]; - - if (assistantMessage?.role === "assistant") { - updated[assistantIndex] = { - ...assistantMessage, - content: assistantMessage.content + content, - }; - } - - return updated; - }); - } - - const tokens = chunk.usage?.completionTokensDetails?.reasoningTokens; - if (typeof tokens === "number") { - setReasoningTokens(tokens); - } - } - } catch (error) { - if (runtime.isShuttingDown) { - return; - } - - if (error instanceof Error && error.name === "RequestAbortedError") { - return; - } - - const message = - error instanceof Error - ? error.message - : "Unknown error while calling OpenRouter."; - setErrorMessage(message); - appendErrorToChat(message); - } finally { - sendLockRef.current = false; - runtime.abortController = null; - if (!runtime.isShuttingDown) { - setIsLoading(false); - } - } - }; - - return ( - - - - Ralph OpenRouter Chat · model: {OPENROUTER_MODEL} - {typeof reasoningTokens === "number" - ? ` · reasoning tokens: ${reasoningTokens}` - : ""} - {errorMessage ? ` · error: ${errorMessage}` : ""} · PgUp/PgDn or - Ctrl+U/Ctrl+D scroll · esc / ctrl+c quit - - - - - {messages.map((message, index) => { - const label = - message.role === "user" - ? "You" - : message.role === "assistant" - ? "Assistant" - : "System"; - - return ( - - {label} - {message.content} - - ); - })} - {isLoading ? ( - Assistant is thinking... - ) : null} - - - - { - const submittedValue = - typeof value === "string" ? value : inputValue; - void sendMessage(submittedValue); - }} - /> - - - ); -} - export async function runTui(): Promise { runtime.isShuttingDown = false; + + const onboarding = await runOnboardingChecks(); + const renderer = await createCliRenderer({ onDestroy: () => { - runtime.abortController?.abort(); - runtime.abortController = null; runtime.isShuttingDown = true; }, }); @@ -311,13 +28,15 @@ export async function runTui(): Promise { closed = true; runtime.isShuttingDown = true; - runtime.abortController?.abort(); - runtime.abortController = null; root.unmount(); renderer.destroy(); }; - root.render(); + if (onboarding.ok) { + root.render(); + } else { + root.render(); + } } if (import.meta.main) { diff --git a/apps/tui/src/lib/onboarding.ts b/apps/tui/src/lib/onboarding.ts index 70182d1..a73b034 100644 --- a/apps/tui/src/lib/onboarding.ts +++ b/apps/tui/src/lib/onboarding.ts @@ -1,3 +1,5 @@ +import { daemon, ensureDaemonRunning } from "@techatnyu/ralphd"; + const DEFAULT_TIMEOUT_MS = 10_000; type CommandResult = { @@ -9,12 +11,12 @@ type CommandResult = { async function runCommand( command: string, args: string[], - options: { inheritStdio?: boolean; timeoutMs?: number } = {}, + options: { timeoutMs?: number } = {}, ): Promise { const proc = Bun.spawn([command, ...args], { - stdout: options.inheritStdio ? "inherit" : "pipe", - stderr: options.inheritStdio ? "inherit" : "pipe", - stdin: options.inheritStdio ? "inherit" : "ignore", + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", }); const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -23,12 +25,8 @@ async function runCommand( try { const [exitCode, stdout, stderr] = await Promise.all([ proc.exited, - options.inheritStdio - ? Promise.resolve("") - : new Response(proc.stdout).text(), - options.inheritStdio - ? Promise.resolve("") - : new Response(proc.stderr).text(), + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), ]); return { exitCode, stdout, stderr }; @@ -37,74 +35,113 @@ async function runCommand( } } -export async function isOpencodeInstalled(): Promise { +export interface OnboardingCheck { + label: string; + ok: boolean; + message?: string; +} + +export interface OnboardingResult { + ok: boolean; + checks: OnboardingCheck[]; +} + +async function checkOpencodeInstalled(): Promise { try { const result = await runCommand("opencode", ["--version"]); - return result.exitCode === 0; + if (result.exitCode === 0) { + return { label: "OpenCode installed", ok: true }; + } + return { + label: "OpenCode installed", + ok: false, + message: + "`opencode` exited with a non-zero status. Reinstall with: npm install -g opencode", + }; } catch { - return false; + return { + label: "OpenCode installed", + ok: false, + message: + "`opencode` is not installed or not in PATH. Install it with: npm install -g opencode", + }; } } -export async function hasOpencodeAuth(): Promise { +async function checkOpencodeAuth(): Promise { try { const result = await runCommand("opencode", ["auth", "list"]); if (result.exitCode !== 0) { - return false; + return { + label: "OpenCode authenticated", + ok: false, + message: + "No auth configured. Run `opencode auth login` in your terminal first.", + }; } const output = (result.stdout + result.stderr).trim(); - return output.length > 0; + if (output.length > 0) { + return { label: "OpenCode authenticated", ok: true }; + } + return { + label: "OpenCode authenticated", + ok: false, + message: + "No auth configured. Run `opencode auth login` in your terminal first.", + }; } catch { - return false; + return { + label: "OpenCode authenticated", + ok: false, + message: "Could not verify auth. Is `opencode` installed?", + }; } } -export async function loginOpencode(): Promise { +async function checkDaemonRunning(): Promise { try { - const result = await runCommand("opencode", ["auth", "login"], { - inheritStdio: true, - timeoutMs: 5 * 60_000, - }); - return result.exitCode === 0; - } catch { - return false; - } -} - -export type OnboardingResult = { ok: true } | { ok: false; message: string }; - -export async function ensureOpencodeReady(): Promise { - const installed = await isOpencodeInstalled(); - if (!installed) { + const ready = await ensureDaemonRunning(); + if (ready) { + const health = await daemon.health(); + return { + label: "Daemon running", + ok: true, + message: `pid ${health.pid}, uptime ${health.uptimeSeconds}s`, + }; + } return { + label: "Daemon running", ok: false, message: - "`opencode` is not installed or not in PATH. Install it with: npm install -g opencode", + "ralphd could not be started. Run `ralph daemon start` manually.", }; - } - - const authed = await hasOpencodeAuth(); - if (authed) { - return { ok: true }; - } - - console.log("No OpenCode auth found. Starting `opencode auth login`..."); - const loggedIn = await loginOpencode(); - - if (!loggedIn) { + } catch { return { + label: "Daemon running", ok: false, - message: "OpenCode login did not complete successfully.", + message: + "ralphd could not be reached. Run `ralph daemon start` manually.", }; } +} - const authedAfterLogin = await hasOpencodeAuth(); - if (!authedAfterLogin) { - return { - ok: false, - message: "OpenCode login finished, but no auth was detected afterward.", - }; - } +export async function runOnboardingChecks(): Promise { + const opencodeInstalled = await checkOpencodeInstalled(); + + // Only check auth if opencode is installed + const opencodeAuth = opencodeInstalled.ok + ? await checkOpencodeAuth() + : { + label: "OpenCode authenticated", + ok: false, + message: "Skipped (opencode not installed)", + }; + + const daemonRunning = await checkDaemonRunning(); - return { ok: true }; + const checks = [opencodeInstalled, opencodeAuth, daemonRunning]; + return { + ok: checks.every((check) => check.ok), + checks, + }; }