| ... | @@ -1,19 +1,19 @@ | ... | @@ -1,19 +1,19 @@ |
| 1 | import { | 1 | import { type FormEvent, useMemo, useState } from "react"; |
| 2 | type FormEvent, | | |
| 3 | useCallback, | | |
| 4 | useEffect, | | |
| 5 | useMemo, | | |
| 6 | useState, | | |
| 7 | } from "react"; | | |
| 8 | | 2 | |
| 9 | import type { Todo } from "@acme/contracts"; | 3 | import type { TerminalCommandResult } from "@acme/contracts"; |
| 10 | | 4 | |
| 11 | function formatCreatedAt(isoDate: string): string { | 5 | interface TerminalEntry { |
| | 6 | id: string; |
| | 7 | command: string; |
| | 8 | result: TerminalCommandResult; |
| | 9 | createdAt: string; |
| | 10 | } |
| | 11 | |
| | 12 | function 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 | } |
| 19 | | 19 | |
| ... | @@ -27,102 +27,52 @@ function readNativeApi() { | ... | @@ -27,102 +27,52 @@ function readNativeApi() { |
| 27 | | 27 | |
| 28 | export default function App() { | 28 | export 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); |
| 34 | | 34 | |
| 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(); |
| 62 | | 37 | |
| 63 | if (!api) { | 38 | if (!api || isRunning) { |
| 64 | return; | 39 | return; |
| 65 | } | 40 | } |
| 66 | | 41 | |
| 67 | const trimmed = title.trim(); | 42 | const trimmed = command.trim(); |
| 68 | if (!trimmed) { | 43 | if (!trimmed) { |
| 69 | return; | 44 | return; |
| 70 | } | 45 | } |
| 71 | | 46 | |
| 72 | setError(null); | 47 | setError(null); |
| | 48 | setIsRunning(true); |
| 73 | | 49 | |
| 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 | }, |
| 84 | | 60 | ]); |
| 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 Error | 64 | runError instanceof Error |
| 98 | ? toggleError.message | 65 | ? 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 | }; |
| 122 | | 72 | |
| 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 | } |
| 135 | | 85 | |
| 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 TODOs | 90 | 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 typed | 96 | Runs one command at a time in your shell and prints stdout/stderr. |
| 144 | preload API. | | |
| 145 | </p> | 97 | </p> |
| 146 | | 98 | |
| 147 | <form className="mt-6 flex gap-3" onSubmit={onSubmit}> | 99 | <form className="mt-6 flex gap-3" onSubmit={onSubmit}> |
| 148 | <input | 100 | <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 | <button | 107 | <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 | Add | 120 | Clear |
| 160 | </button> | 121 | </button> |
| 161 | </form> | 122 | </form> |
| 162 | </header> | 123 | </header> |
| 163 | | 124 | |
| 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} |
| 168 | | 130 | |
| 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} |
| 174 | | 137 | |
| 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 | > |
| 180 | | 143 | <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 | <li | 146 | </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 | <button | 150 | <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} |
| 218 | | 167 | </pre> |
| 219 | <button | 168 | </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 | Remove | 175 | |
| 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 | ); |