authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-07 19:40:41-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-07 19:40:41-08:00
log58261b9ba01e4c73f2ef0298b3d8b59f7af7c464
treecf183ef02596a6c1553bf8d650a487a4925e1835
parent25825929ba181966870f32c78f83995e16c2d8cd
signature Commit is signed but in an unrecognized format.

terminal


8 files changed, 237 insertions(+), 164 deletions(-)

apps/desktop/src/main.ts+69
...@@ -2,13 +2,17 @@ import { fixPath } from "./fixPath";...@@ -2,13 +2,17 @@ import { fixPath } from "./fixPath";
2fixPath();2fixPath();
33
4import path from "node:path";4import path from "node:path";
5import { spawn } from "node:child_process";
5import { BrowserWindow, app, ipcMain, session } from "electron";6import { BrowserWindow, app, ipcMain, session } from "electron";
67
7import {8import {
8 IPC_CHANNELS,9 IPC_CHANNELS,
10 type TerminalCommandInput,
11 type TerminalCommandResult,
9 agentConfigSchema,12 agentConfigSchema,
10 agentSessionIdSchema,13 agentSessionIdSchema,
11 newTodoInputSchema,14 newTodoInputSchema,
15 terminalCommandInputSchema,
12 todoIdSchema,16 todoIdSchema,
13} from "@acme/contracts";17} from "@acme/contracts";
14import { ProcessManager } from "./processManager";18import { ProcessManager } from "./processManager";
...@@ -70,6 +74,11 @@ function registerIpcHandlers(): void {...@@ -70,6 +74,11 @@ function registerIpcHandlers(): void {
70 return todoStore.remove(todoIdSchema.parse(id));74 return todoStore.remove(todoIdSchema.parse(id));
71 });75 });
7276
77 // Terminal handlers
78 ipcMain.handle(IPC_CHANNELS.terminalRun, async (_event, payload: unknown) => {
79 return runTerminalCommand(terminalCommandInputSchema.parse(payload));
80 });
81
73 // Agent handlers82 // Agent handlers
74 ipcMain.handle(IPC_CHANNELS.agentSpawn, async (_event, config: unknown) => {83 ipcMain.handle(IPC_CHANNELS.agentSpawn, async (_event, config: unknown) => {
75 return processManager.spawn(agentConfigSchema.parse(config));84 return processManager.spawn(agentConfigSchema.parse(config));
...@@ -87,6 +96,66 @@ function registerIpcHandlers(): void {...@@ -87,6 +96,66 @@ function registerIpcHandlers(): void {
87 );96 );
88}97}
8998
99async function runTerminalCommand(
100 input: TerminalCommandInput,
101): Promise<TerminalCommandResult> {
102 const shellPath =
103 process.platform === "win32"
104 ? process.env.ComSpec ?? "cmd.exe"
105 : process.env.SHELL ?? "/bin/sh";
106
107 const args =
108 process.platform === "win32"
109 ? ["/d", "/s", "/c", input.command]
110 : ["-lc", input.command];
111
112 return new Promise((resolve, reject) => {
113 const child = spawn(shellPath, args, {
114 cwd: input.cwd,
115 env: process.env,
116 stdio: ["ignore", "pipe", "pipe"],
117 });
118
119 let stdout = "";
120 let stderr = "";
121 let timedOut = false;
122
123 const timeout = setTimeout(() => {
124 timedOut = true;
125 child.kill("SIGTERM");
126 setTimeout(() => {
127 if (!child.killed) {
128 child.kill("SIGKILL");
129 }
130 }, 1_000).unref();
131 }, input.timeoutMs ?? 30_000);
132
133 child.stdout?.on("data", (chunk: Buffer) => {
134 stdout += chunk.toString();
135 });
136
137 child.stderr?.on("data", (chunk: Buffer) => {
138 stderr += chunk.toString();
139 });
140
141 child.on("error", (error) => {
142 clearTimeout(timeout);
143 reject(error);
144 });
145
146 child.on("close", (code, signal) => {
147 clearTimeout(timeout);
148 resolve({
149 stdout,
150 stderr,
151 code: code ?? null,
152 signal: signal ?? null,
153 timedOut,
154 });
155 });
156 });
157}
158
90function setupEventForwarding(window: BrowserWindow): void {159function setupEventForwarding(window: BrowserWindow): void {
91 const onOutput = (chunk: unknown) => {160 const onOutput = (chunk: unknown) => {
92 if (!window.isDestroyed()) {161 if (!window.isDestroyed()) {
apps/desktop/src/preload.ts+3
...@@ -9,6 +9,9 @@ const nativeApi: NativeApi = {...@@ -9,6 +9,9 @@ const nativeApi: NativeApi = {
9 toggle: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosToggle, id),9 toggle: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosToggle, id),
10 remove: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosRemove, id),10 remove: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosRemove, id),
11 },11 },
12 terminal: {
13 run: (input) => ipcRenderer.invoke(IPC_CHANNELS.terminalRun, input),
14 },
12 agent: {15 agent: {
13 spawn: (config) => ipcRenderer.invoke(IPC_CHANNELS.agentSpawn, config),16 spawn: (config) => ipcRenderer.invoke(IPC_CHANNELS.agentSpawn, config),
14 kill: (sessionId) => ipcRenderer.invoke(IPC_CHANNELS.agentKill, sessionId),17 kill: (sessionId) => ipcRenderer.invoke(IPC_CHANNELS.agentKill, sessionId),
apps/renderer/src/App.tsx+112-160
...@@ -1,19 +1,19 @@...@@ -1,19 +1,19 @@
1import {1import { type FormEvent, useMemo, useState } from "react";
2 type FormEvent,
3 useCallback,
4 useEffect,
5 useMemo,
6 useState,
7} from "react";
82
9import type { Todo } from "@acme/contracts";3import type { TerminalCommandResult } from "@acme/contracts";
104
11function formatCreatedAt(isoDate: string): string {5interface TerminalEntry {
6 id: string;
7 command: string;
8 result: TerminalCommandResult;
9 createdAt: string;
10}
11
12function formatTimestamp(isoDate: string): string {
12 return new Intl.DateTimeFormat(undefined, {13 return new Intl.DateTimeFormat(undefined, {
13 month: "short",
14 day: "numeric",
15 hour: "numeric",14 hour: "numeric",
16 minute: "2-digit",15 minute: "2-digit",
16 second: "2-digit",
17 }).format(new Date(isoDate));17 }).format(new Date(isoDate));
18}18}
1919
...@@ -27,102 +27,52 @@ function readNativeApi() {...@@ -27,102 +27,52 @@ function readNativeApi() {
2727
28export default function App() {28export default function App() {
29 const api = useMemo(() => readNativeApi(), []);29 const api = useMemo(() => readNativeApi(), []);
30 const [todos, setTodos] = useState<Todo[]>([]);30 const [command, setCommand] = useState("");
31 const [title, setTitle] = useState("");31 const [history, setHistory] = useState<TerminalEntry[]>([]);
32 const [isLoading, setIsLoading] = useState(true);32 const [isRunning, setIsRunning] = useState(false);
33 const [error, setError] = useState<string | null>(null);33 const [error, setError] = useState<string | null>(null);
3434
35 const loadTodos = useCallback(async () => {
36 if (!api) {
37 return;
38 }
39
40 setError(null);
41
42 try {
43 const nextTodos = await api.todos.list();
44 setTodos(nextTodos);
45 } catch (loadError) {
46 const message =
47 loadError instanceof Error
48 ? loadError.message
49 : "Could not fetch todos.";
50 setError(message);
51 } finally {
52 setIsLoading(false);
53 }
54 }, [api]);
55
56 useEffect(() => {
57 void loadTodos();
58 }, [loadTodos]);
59
60 const onSubmit = async (event: FormEvent<HTMLFormElement>) => {35 const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
61 event.preventDefault();36 event.preventDefault();
6237
63 if (!api) {38 if (!api || isRunning) {
64 return;39 return;
65 }40 }
6641
67 const trimmed = title.trim();42 const trimmed = command.trim();
68 if (!trimmed) {43 if (!trimmed) {
69 return;44 return;
70 }45 }
7146
72 setError(null);47 setError(null);
48 setIsRunning(true);
7349
74 try {50 try {
75 const nextTodos = await api.todos.add({ title: trimmed });51 const result = await api.terminal.run({ command: trimmed });
76 setTodos(nextTodos);52 setHistory((previous) => [
77 setTitle("");53 ...previous,
78 } catch (addError) {54 {
79 const message =55 id: crypto.randomUUID(),
80 addError instanceof Error ? addError.message : "Could not add todo.";56 command: trimmed,
81 setError(message);57 result,
82 }58 createdAt: new Date().toISOString(),
83 };59 },
8460 ]);
85 const onToggle = async (id: string) => {61 setCommand("");
86 if (!api) {62 } catch (runError) {
87 return;
88 }
89
90 setError(null);
91
92 try {
93 const nextTodos = await api.todos.toggle(id);
94 setTodos(nextTodos);
95 } catch (toggleError) {
96 const message =63 const message =
97 toggleError instanceof Error64 runError instanceof Error
98 ? toggleError.message65 ? runError.message
99 : "Could not toggle todo.";66 : "Could not execute command.";
100 setError(message);
101 }
102 };
103
104 const onRemove = async (id: string) => {
105 if (!api) {
106 return;
107 }
108
109 setError(null);
110
111 try {
112 const nextTodos = await api.todos.remove(id);
113 setTodos(nextTodos);
114 } catch (removeError) {
115 const message =
116 removeError instanceof Error
117 ? removeError.message
118 : "Could not remove todo.";
119 setError(message);67 setError(message);
68 } finally {
69 setIsRunning(false);
120 }70 }
121 };71 };
12272
123 if (!api) {73 if (!api) {
124 return (74 return (
125 <main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-8">75 <main className="mx-auto flex min-h-screen w-full max-w-4xl items-center justify-center px-8">
126 <section className="rounded-3xl border border-amber-200 bg-amber-50 px-6 py-5 text-amber-900 shadow-soft">76 <section className="rounded-3xl border border-amber-200 bg-amber-50 px-6 py-5 text-amber-900 shadow-soft">
127 <h1 className="text-lg font-semibold">Native bridge unavailable</h1>77 <h1 className="text-lg font-semibold">Native bridge unavailable</h1>
128 <p className="mt-2 text-sm">78 <p className="mt-2 text-sm">
...@@ -134,100 +84,102 @@ export default function App() {...@@ -134,100 +84,102 @@ export default function App() {
134 }84 }
13585
136 return (86 return (
137 <main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col px-6 py-12 text-slate-900 sm:px-10">87 <main className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-6 py-10 text-slate-900 sm:px-10">
138 <header className="rounded-3xl border border-slate-200/80 bg-white/80 p-7 shadow-soft backdrop-blur">88 <header className="rounded-3xl border border-slate-800/80 bg-slate-900 p-7 text-slate-100 shadow-soft">
139 <h1 className="text-3xl font-semibold tracking-tight">89 <p className="text-xs uppercase tracking-[0.18em] text-emerald-300">
140 Long-Horizon TODOs90 Demo Terminal
91 </p>
92 <h1 className="mt-2 font-mono text-3xl font-semibold tracking-tight">
93 Shell Command Runner
141 </h1>94 </h1>
142 <p className="mt-2 text-sm text-slate-600">95 <p className="mt-3 text-sm text-slate-300">
143 Renderer is isolated. All native access is routed through a typed96 Runs one command at a time in your shell and prints stdout/stderr.
144 preload API.
145 </p>97 </p>
14698
147 <form className="mt-6 flex gap-3" onSubmit={onSubmit}>99 <form className="mt-6 flex gap-3" onSubmit={onSubmit}>
148 <input100 <input
149 value={title}101 value={command}
150 onChange={(event) => setTitle(event.target.value)}102 onChange={(event) => setCommand(event.target.value)}
151 placeholder="Write a task..."103 placeholder="e.g. ls -la"
152 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"104 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"
153 maxLength={280}105 maxLength={4000}
154 />106 />
155 <button107 <button
156 type="submit"108 type="submit"
157 className="h-11 rounded-xl bg-slate-900 px-5 text-sm font-medium text-white transition hover:bg-slate-700"109 className="h-11 rounded-xl bg-emerald-500 px-5 text-sm font-semibold text-slate-950 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:bg-emerald-800 disabled:text-emerald-300"
110 disabled={isRunning}
111 >
112 {isRunning ? "Running..." : "Run"}
113 </button>
114 <button
115 type="button"
116 className="h-11 rounded-xl border border-slate-600 px-5 text-sm font-medium text-slate-200 transition hover:border-slate-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
117 disabled={history.length === 0 || isRunning}
118 onClick={() => setHistory([])}
158 >119 >
159 Add120 Clear
160 </button>121 </button>
161 </form>122 </form>
162 </header>123 </header>
163124
164 <section className="mt-6 rounded-3xl border border-slate-200/70 bg-white/80 p-4 shadow-soft backdrop-blur sm:p-6">125 {error ? (
165 {isLoading ? (126 <div className="mt-6 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
166 <p className="text-sm text-slate-500">Loading todos...</p>127 {error}
167 ) : null}128 </div>
129 ) : null}
168130
169 {error ? (131 <section className="mt-6 flex-1 space-y-4">
170 <div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">132 {history.length === 0 ? (
171 {error}133 <div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-4 py-6 text-sm text-slate-500">
134 No commands run yet.
172 </div>135 </div>
173 ) : null}136 ) : null}
174137
175 {!isLoading && todos.length === 0 ? (138 {history.map((entry) => (
176 <p className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-5 text-sm text-slate-500">139 <article
177 No todos yet. Add one above.140 key={entry.id}
178 </p>141 className="overflow-hidden rounded-2xl border border-slate-700 bg-slate-950 shadow-soft"
179 ) : null}142 >
180143 <header className="flex items-center justify-between border-b border-slate-800 px-4 py-3 font-mono text-xs text-slate-300">
181 <ul className="space-y-3">144 <p>$ {entry.command}</p>
182 {todos.map((todo) => (145 <p>{formatTimestamp(entry.createdAt)}</p>
183 <li146 </header>
184 key={todo.id}147
185 className="flex items-center justify-between rounded-2xl border border-slate-200 bg-white px-4 py-3"148 <div className="space-y-3 px-4 py-4 font-mono text-sm">
186 >149 {entry.result.stdout ? (
187 <button150 <div>
188 type="button"151 <p className="mb-1 text-xs uppercase tracking-wide text-emerald-300">
189 onClick={() => {152 stdout
190 void onToggle(todo.id);
191 }}
192 className="flex flex-1 items-center gap-3 text-left"
193 >
194 <span
195 className={[
196 "mt-0.5 inline-block h-5 w-5 rounded-full border transition",
197 todo.completed
198 ? "border-emerald-600 bg-emerald-500"
199 : "border-slate-300 bg-white",
200 ].join(" ")}
201 />
202 <span>
203 <p
204 className={[
205 "text-sm font-medium",
206 todo.completed
207 ? "text-slate-400 line-through"
208 : "text-slate-800",
209 ].join(" ")}
210 >
211 {todo.title}
212 </p>153 </p>
213 <p className="text-xs text-slate-500">154 <pre className="whitespace-pre-wrap break-words text-emerald-100">
214 Created {formatCreatedAt(todo.createdAt)}155 {entry.result.stdout}
156 </pre>
157 </div>
158 ) : null}
159
160 {entry.result.stderr ? (
161 <div>
162 <p className="mb-1 text-xs uppercase tracking-wide text-rose-300">
163 stderr
215 </p>164 </p>
216 </span>165 <pre className="whitespace-pre-wrap break-words text-rose-200">
217 </button>166 {entry.result.stderr}
218167 </pre>
219 <button168 </div>
220 type="button"169 ) : null}
221 onClick={() => {170
222 void onRemove(todo.id);171 {!entry.result.stdout && !entry.result.stderr ? (
223 }}172 <p className="text-slate-400">(no output)</p>
224 className="rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-medium text-rose-600 transition hover:bg-rose-50"173 ) : null}
225 >174 </div>
226 Remove175
227 </button>176 <footer className="border-t border-slate-800 bg-slate-900/60 px-4 py-2 font-mono text-xs text-slate-400">
228 </li>177 exit={entry.result.code ?? "null"} signal=
229 ))}178 {entry.result.signal ?? "null"} timedOut=
230 </ul>179 {entry.result.timedOut ? "yes" : "no"}
180 </footer>
181 </article>
182 ))}
231 </section>183 </section>
232 </main>184 </main>
233 );185 );
apps/renderer/src/index.css+5-4
...@@ -9,9 +9,10 @@...@@ -9,9 +9,10 @@
9}9}
1010
11body {11body {
12 font-family: "Avenir Next", "Avenir", "Segoe UI", sans-serif;12 font-family: "Iowan Old Style", "Georgia", serif;
13 min-height: 100vh;13 min-height: 100vh;
14 background: radial-gradient(circle at top left, #dbeafe 0%, transparent 40%),14 background:
15 radial-gradient(circle at 90% 20%, #e9d5ff 0%, transparent 35%),15 radial-gradient(circle at top left, #d1fae5 0%, transparent 38%),
16 linear-gradient(120deg, #f8fafc 0%, #eef2ff 50%, #f8fafc 100%);16 radial-gradient(circle at 88% 20%, #fef3c7 0%, transparent 28%),
17 linear-gradient(130deg, #f8fafc 0%, #eef6f2 50%, #f8fafc 100%);
17}18}
packages/contracts/src/index.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1export * from "./todo";1export * from "./todo";
2export * from "./ipc";2export * from "./ipc";
3export * from "./agent";3export * from "./agent";
4export * from "./terminal";
packages/contracts/src/ipc.ts+5
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { AgentConfig, AgentExit, OutputChunk } from "./agent";1import type { AgentConfig, AgentExit, OutputChunk } from "./agent";
2import type { TerminalCommandInput, TerminalCommandResult } from "./terminal";
2import type { NewTodoInput, Todo } from "./todo";3import type { NewTodoInput, Todo } from "./todo";
34
4export const IPC_CHANNELS = {5export const IPC_CHANNELS = {
...@@ -6,6 +7,7 @@ export const IPC_CHANNELS = {...@@ -6,6 +7,7 @@ export const IPC_CHANNELS = {
6 todosAdd: "todos:add",7 todosAdd: "todos:add",
7 todosToggle: "todos:toggle",8 todosToggle: "todos:toggle",
8 todosRemove: "todos:remove",9 todosRemove: "todos:remove",
10 terminalRun: "terminal:run",
9 agentSpawn: "agent:spawn",11 agentSpawn: "agent:spawn",
10 agentKill: "agent:kill",12 agentKill: "agent:kill",
11 agentWrite: "agent:write",13 agentWrite: "agent:write",
...@@ -20,6 +22,9 @@ export interface NativeApi {...@@ -20,6 +22,9 @@ export interface NativeApi {
20 toggle: (id: string) => Promise<Todo[]>;22 toggle: (id: string) => Promise<Todo[]>;
21 remove: (id: string) => Promise<Todo[]>;23 remove: (id: string) => Promise<Todo[]>;
22 };24 };
25 terminal: {
26 run: (input: TerminalCommandInput) => Promise<TerminalCommandResult>;
27 };
23 agent: {28 agent: {
24 spawn: (config: AgentConfig) => Promise<string>;29 spawn: (config: AgentConfig) => Promise<string>;
25 kill: (sessionId: string) => Promise<void>;30 kill: (sessionId: string) => Promise<void>;
packages/contracts/src/terminal.test.ts created+24
...@@ -0,0 +1,24 @@
1import { describe, expect, it } from "vitest";
2
3import { terminalCommandInputSchema } from "./terminal";
4
5describe("terminalCommandInputSchema", () => {
6 it("accepts a valid command", () => {
7 const result = terminalCommandInputSchema.safeParse({
8 command: "echo hello",
9 });
10 expect(result.success).toBe(true);
11 });
12
13 it("trims whitespace", () => {
14 const result = terminalCommandInputSchema.parse({
15 command: " echo hello ",
16 });
17 expect(result.command).toBe("echo hello");
18 });
19
20 it("rejects an empty command", () => {
21 const result = terminalCommandInputSchema.safeParse({ command: " " });
22 expect(result.success).toBe(false);
23 });
24});
packages/contracts/src/terminal.ts created+18
...@@ -0,0 +1,18 @@
1import { z } from "zod";
2
3export const terminalCommandInputSchema = z.object({
4 command: z.string().trim().min(1).max(4000),
5 cwd: z.string().min(1).optional(),
6 timeoutMs: z.number().int().positive().max(120_000).optional(),
7});
8
9export const terminalCommandResultSchema = z.object({
10 stdout: z.string(),
11 stderr: z.string(),
12 code: z.number().nullable(),
13 signal: z.string().nullable(),
14 timedOut: z.boolean(),
15});
16
17export type TerminalCommandInput = z.infer<typeof terminalCommandInputSchema>;
18export type TerminalCommandResult = z.infer<typeof terminalCommandResultSchema>;