Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-lions-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@techatnyu/ralph": patch
---

Add the daemon-backed TUI, docs site, and release tooling.
2 changes: 1 addition & 1 deletion apps/docs/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions apps/tui/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
"typescript": "^5"
},
"dependencies": {
"@techatnyu/ralphd": "workspace:*",
"@crustjs/core": "^0.0.13",
"@crustjs/plugins": "^0.0.16",
"@techatnyu/ralphd": "workspace:*",
"@opentui/core": "^0.1.77",
"@opentui/react": "^0.1.77",
"react": "^19.2.4"
Expand Down
4 changes: 2 additions & 2 deletions apps/tui/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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" })
Expand Down
53 changes: 50 additions & 3 deletions apps/tui/src/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>();
const [data, setData] = useState<DashboardData>();
Expand Down Expand Up @@ -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;
}
Expand All @@ -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];
Expand Down Expand Up @@ -180,9 +199,37 @@ export function App({ onQuit }: AppProps) {

<box flexDirection="column" marginTop={1}>
<text attributes={TextAttributes.DIM}>
{error ?? "j/k or arrows: select r: refresh q: quit"}
{error ?? "j/k or arrows: select enter: chat r: refresh q: quit"}
</text>
</box>
</box>
);
}

export function App({ onQuit }: AppProps) {
const [view, setView] = useState<View>({ type: "dashboard" });

if (view.type === "chat") {
return (
<Chat
instanceId={view.instanceId}
instanceName={view.instanceName}
onBack={() => setView({ type: "dashboard" })}
onQuit={onQuit}
/>
);
}

return (
<Dashboard
onQuit={onQuit}
onSelectInstance={(instance) =>
setView({
type: "chat",
instanceId: instance.id,
instanceName: instance.name,
})
}
/>
);
}
226 changes: 226 additions & 0 deletions apps/tui/src/components/chat.tsx
Original file line number Diff line number Diff line change
@@ -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<DaemonJob> {
// 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<ChatMessage[]>([
msg(
"assistant",
`Connected to instance "${instanceName}". Send a message to start.`,
),
]);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const sendLockRef = useRef(false);
const chatScrollRef = useRef<ScrollBoxRenderable | null>(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 (
<box flexDirection="column" flexGrow={1} width="100%">
<box flexShrink={0} height={1} width="100%">
<text attributes={TextAttributes.DIM}>
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
</text>
</box>

<scrollbox
ref={chatScrollRef}
flexGrow={1}
flexShrink={1}
minHeight={0}
width="100%"
border={true}
padding={0}
stickyScroll={true}
stickyStart="bottom"
marginTop={0}
marginBottom={0}
>
{messages.map((message) => {
const label =
message.role === "user"
? "You"
: message.role === "assistant"
? "Assistant"
: "System";

return (
<box key={message.id} flexDirection="column" marginBottom={1}>
<text attributes={TextAttributes.BOLD}>{label}</text>
<text>{message.content}</text>
</box>
);
})}
{isLoading ? (
<text attributes={TextAttributes.DIM}>Assistant is thinking...</text>
) : null}
</scrollbox>

<box
flexShrink={0}
height={3}
width="100%"
border={true}
borderColor="#ffffff"
>
<input
focused={true}
value={inputValue}
placeholder={placeholder}
onInput={setInputValue}
onChange={setInputValue}
onSubmit={(value) => {
const submittedValue =
typeof value === "string" ? value : inputValue;
void sendMessage(submittedValue);
}}
/>
</box>
</box>
);
}
Loading
Loading