diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 448ea4733df092963e0b73346d21e497db80f393..881ab29a70ff6a05f48123909369201347b45e3d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2,13 +2,17 @@ import { fixPath } from "./fixPath"; fixPath(); import path from "node:path"; +import { spawn } from "node:child_process"; import { BrowserWindow, app, ipcMain, session } from "electron"; import { IPC_CHANNELS, + type TerminalCommandInput, + type TerminalCommandResult, agentConfigSchema, agentSessionIdSchema, newTodoInputSchema, + terminalCommandInputSchema, todoIdSchema, } from "@acme/contracts"; import { ProcessManager } from "./processManager"; @@ -70,6 +74,11 @@ function registerIpcHandlers(): void { return todoStore.remove(todoIdSchema.parse(id)); }); + // Terminal handlers + ipcMain.handle(IPC_CHANNELS.terminalRun, async (_event, payload: unknown) => { + return runTerminalCommand(terminalCommandInputSchema.parse(payload)); + }); + // Agent handlers ipcMain.handle(IPC_CHANNELS.agentSpawn, async (_event, config: unknown) => { return processManager.spawn(agentConfigSchema.parse(config)); @@ -87,6 +96,66 @@ function registerIpcHandlers(): void { ); } +async function runTerminalCommand( + input: TerminalCommandInput, +): Promise { + const shellPath = + process.platform === "win32" + ? process.env.ComSpec ?? "cmd.exe" + : process.env.SHELL ?? "/bin/sh"; + + const args = + process.platform === "win32" + ? ["/d", "/s", "/c", input.command] + : ["-lc", input.command]; + + return new Promise((resolve, reject) => { + const child = spawn(shellPath, args, { + cwd: input.cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, 1_000).unref(); + }, input.timeoutMs ?? 30_000); + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + + child.on("close", (code, signal) => { + clearTimeout(timeout); + resolve({ + stdout, + stderr, + code: code ?? null, + signal: signal ?? null, + timedOut, + }); + }); + }); +} + function setupEventForwarding(window: BrowserWindow): void { const onOutput = (chunk: unknown) => { if (!window.isDestroyed()) { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0eb6bd74f7364e013ba9e3f2585c0add1fa45c36..b9207d85cd3da18ca16e4280bb4abdb3dc4f208c 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,6 +9,9 @@ const nativeApi: NativeApi = { toggle: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosToggle, id), remove: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosRemove, id), }, + terminal: { + run: (input) => ipcRenderer.invoke(IPC_CHANNELS.terminalRun, input), + }, agent: { spawn: (config) => ipcRenderer.invoke(IPC_CHANNELS.agentSpawn, config), kill: (sessionId) => ipcRenderer.invoke(IPC_CHANNELS.agentKill, sessionId), diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index 2f5b038b30215876833fa5bf546cb7450a1ba696..1774e2962d99238fea2256c5e9acfe0bfbb73759 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -1,19 +1,19 @@ -import { - type FormEvent, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; +import { type FormEvent, useMemo, useState } from "react"; -import type { Todo } from "@acme/contracts"; +import type { TerminalCommandResult } from "@acme/contracts"; -function formatCreatedAt(isoDate: string): string { +interface TerminalEntry { + id: string; + command: string; + result: TerminalCommandResult; + createdAt: string; +} + +function formatTimestamp(isoDate: string): string { return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", hour: "numeric", minute: "2-digit", + second: "2-digit", }).format(new Date(isoDate)); } @@ -27,102 +27,52 @@ function readNativeApi() { export default function App() { const api = useMemo(() => readNativeApi(), []); - const [todos, setTodos] = useState([]); - const [title, setTitle] = useState(""); - const [isLoading, setIsLoading] = useState(true); + const [command, setCommand] = useState(""); + const [history, setHistory] = useState([]); + const [isRunning, setIsRunning] = useState(false); const [error, setError] = useState(null); - const loadTodos = useCallback(async () => { - if (!api) { - return; - } - - setError(null); - - try { - const nextTodos = await api.todos.list(); - setTodos(nextTodos); - } catch (loadError) { - const message = - loadError instanceof Error - ? loadError.message - : "Could not fetch todos."; - setError(message); - } finally { - setIsLoading(false); - } - }, [api]); - - useEffect(() => { - void loadTodos(); - }, [loadTodos]); - const onSubmit = async (event: FormEvent) => { event.preventDefault(); - if (!api) { + if (!api || isRunning) { return; } - const trimmed = title.trim(); + const trimmed = command.trim(); if (!trimmed) { return; } setError(null); + setIsRunning(true); try { - const nextTodos = await api.todos.add({ title: trimmed }); - setTodos(nextTodos); - setTitle(""); - } catch (addError) { + const result = await api.terminal.run({ command: trimmed }); + setHistory((previous) => [ + ...previous, + { + id: crypto.randomUUID(), + command: trimmed, + result, + createdAt: new Date().toISOString(), + }, + ]); + setCommand(""); + } catch (runError) { const message = - addError instanceof Error ? addError.message : "Could not add todo."; - setError(message); - } - }; - - const onToggle = async (id: string) => { - if (!api) { - return; - } - - setError(null); - - try { - const nextTodos = await api.todos.toggle(id); - setTodos(nextTodos); - } catch (toggleError) { - const message = - toggleError instanceof Error - ? toggleError.message - : "Could not toggle todo."; - setError(message); - } - }; - - const onRemove = async (id: string) => { - if (!api) { - return; - } - - setError(null); - - try { - const nextTodos = await api.todos.remove(id); - setTodos(nextTodos); - } catch (removeError) { - const message = - removeError instanceof Error - ? removeError.message - : "Could not remove todo."; + runError instanceof Error + ? runError.message + : "Could not execute command."; setError(message); + } finally { + setIsRunning(false); } }; if (!api) { return ( -
+

Native bridge unavailable

@@ -134,100 +84,102 @@ export default function App() { } return ( -

-
-

- Long-Horizon TODOs +
+
+

+ Demo Terminal +

+

+ Shell Command Runner

-

- Renderer is isolated. All native access is routed through a typed - preload API. +

+ Runs one command at a time in your shell and prints stdout/stderr.

setTitle(event.target.value)} - placeholder="Write a task..." - className="h-11 flex-1 rounded-xl border border-slate-300 bg-white px-4 text-sm outline-none ring-0 transition focus:border-slate-500" - maxLength={280} + value={command} + onChange={(event) => setCommand(event.target.value)} + placeholder="e.g. ls -la" + className="h-11 flex-1 rounded-xl border border-emerald-500/50 bg-slate-950 px-4 font-mono text-sm text-emerald-100 outline-none ring-0 transition focus:border-emerald-300" + maxLength={4000} /> +
-
- {isLoading ? ( -

Loading todos...

- ) : null} + {error ? ( +
+ {error} +
+ ) : null} - {error ? ( -
- {error} +
+ {history.length === 0 ? ( +
+ No commands run yet.
) : null} - {!isLoading && todos.length === 0 ? ( -

- No todos yet. Add one above. -

- ) : null} + {history.map((entry) => ( +
+
+

$ {entry.command}

+

{formatTimestamp(entry.createdAt)}

+
-
    - {todos.map((todo) => ( -
  • - +
    +                    {entry.result.stderr}
    +                  
    +
+ ) : null} + + {!entry.result.stdout && !entry.result.stderr ? ( +

(no output)

+ ) : null} + - - - ))} - +
+ exit={entry.result.code ?? "null"} signal= + {entry.result.signal ?? "null"} timedOut= + {entry.result.timedOut ? "yes" : "no"} +
+ + ))}
); diff --git a/apps/renderer/src/index.css b/apps/renderer/src/index.css index 0c8daf02c33d91b8883defab817a5354f4ff767f..258af8d755b87de02c12fb69d1a951bd501e4f9a 100644 --- a/apps/renderer/src/index.css +++ b/apps/renderer/src/index.css @@ -9,9 +9,10 @@ } body { - font-family: "Avenir Next", "Avenir", "Segoe UI", sans-serif; + font-family: "Iowan Old Style", "Georgia", serif; min-height: 100vh; - background: radial-gradient(circle at top left, #dbeafe 0%, transparent 40%), - radial-gradient(circle at 90% 20%, #e9d5ff 0%, transparent 35%), - linear-gradient(120deg, #f8fafc 0%, #eef2ff 50%, #f8fafc 100%); + background: + radial-gradient(circle at top left, #d1fae5 0%, transparent 38%), + radial-gradient(circle at 88% 20%, #fef3c7 0%, transparent 28%), + linear-gradient(130deg, #f8fafc 0%, #eef6f2 50%, #f8fafc 100%); } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1685d691ba0c160fecaad668048047503a21a418..11ba982101b00ccd4a46b0db03a0ec1a5a50bfc4 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,3 +1,4 @@ export * from "./todo"; export * from "./ipc"; export * from "./agent"; +export * from "./terminal"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index fa6cd3290812005695224c08e266d0839d6b30e1..b6f28a0cc1f0c1120fde14915217f164b9e32d18 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,4 +1,5 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; +import type { TerminalCommandInput, TerminalCommandResult } from "./terminal"; import type { NewTodoInput, Todo } from "./todo"; export const IPC_CHANNELS = { @@ -6,6 +7,7 @@ export const IPC_CHANNELS = { todosAdd: "todos:add", todosToggle: "todos:toggle", todosRemove: "todos:remove", + terminalRun: "terminal:run", agentSpawn: "agent:spawn", agentKill: "agent:kill", agentWrite: "agent:write", @@ -20,6 +22,9 @@ export interface NativeApi { toggle: (id: string) => Promise; remove: (id: string) => Promise; }; + terminal: { + run: (input: TerminalCommandInput) => Promise; + }; agent: { spawn: (config: AgentConfig) => Promise; kill: (sessionId: string) => Promise; diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e5b9aab837fb95ebd2697ae4f775d8486cf8fff --- /dev/null +++ b/packages/contracts/src/terminal.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { terminalCommandInputSchema } from "./terminal"; + +describe("terminalCommandInputSchema", () => { + it("accepts a valid command", () => { + const result = terminalCommandInputSchema.safeParse({ + command: "echo hello", + }); + expect(result.success).toBe(true); + }); + + it("trims whitespace", () => { + const result = terminalCommandInputSchema.parse({ + command: " echo hello ", + }); + expect(result.command).toBe("echo hello"); + }); + + it("rejects an empty command", () => { + const result = terminalCommandInputSchema.safeParse({ command: " " }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts new file mode 100644 index 0000000000000000000000000000000000000000..33126ec96a34d53220918fe363756bbd24bd2427 --- /dev/null +++ b/packages/contracts/src/terminal.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const terminalCommandInputSchema = z.object({ + command: z.string().trim().min(1).max(4000), + cwd: z.string().min(1).optional(), + timeoutMs: z.number().int().positive().max(120_000).optional(), +}); + +export const terminalCommandResultSchema = z.object({ + stdout: z.string(), + stderr: z.string(), + code: z.number().nullable(), + signal: z.string().nullable(), + timedOut: z.boolean(), +}); + +export type TerminalCommandInput = z.infer; +export type TerminalCommandResult = z.infer;