From 1cd34b2809569180fa408bc4420ce900046f0571 Mon Sep 17 00:00:00 2001 From: Inzimam Date: Mon, 15 Jun 2026 06:53:23 +0000 Subject: [PATCH 1/2] Wire agent runner into the product: nudge endpoint, activity feed, agent catalog Make the Tier 2/3 agent-runner capabilities reachable end-to-end. 1) Steering over HTTP. Thread onSteer through createRoles -> RunnerActor/ RunnerCritic, which pass it as req.steering on every runner call so a runner's live steer handle bubbles up. session forwards it (via the existing RunGoalOptions/CreateRolesOptions spread); the server keeps a per-session latest steer handle and adds POST /api/runs/:id/nudge to inject guidance into the in-flight agent (202), with 400 (no text) / 409 (nothing steerable) and the same token guard as the rest of /api. The handle is cleared on settle. 2) Desktop live tool feed. The run view renders runner_activity events (which arrive via the existing store-event SSE channel) as a concise per-tool feed in the engine log, so you can watch the actor read/edit files in real time. 3) Desktop native-agent catalog. settings.ts gains kind "agent" providers (Anthropic/OpenAI/Google) that emit { kind:"agent", options:{ provider, apiKeyEnv } } runner profiles with pi-ai model ids; they edit files directly. main.ts note/label logic updated so agent providers show their API-key affordance and "edits files" note correctly. 4) Desktop nudge control. api.nudgeRun + a Nudge input/button in the run view post to the new endpoint (Enter to send), surfacing 409 when the active backend isn't steerable. Tests: server nudge (inject / 400 / 409 / 401) and the role-level steer registration. Engine suite 201 green; engine + desktop typecheck clean. --- desktop/src/api.ts | 14 ++++++ desktop/src/main.ts | 44 ++++++++++++++++-- desktop/src/settings.ts | 61 +++++++++++++++++++++++- src/adapters/roleBindings.ts | 4 ++ src/adapters/runnerRoles.ts | 18 ++++++++ src/server/server.ts | 37 +++++++++++++++ test/runnerRoles.test.ts | 19 ++++++++ test/server.test.ts | 90 ++++++++++++++++++++++++++++++++++++ 8 files changed, 282 insertions(+), 5 deletions(-) diff --git a/desktop/src/api.ts b/desktop/src/api.ts index decb8e9..d6770ad 100644 --- a/desktop/src/api.ts +++ b/desktop/src/api.ts @@ -101,6 +101,20 @@ export async function cancelRun(sessionId: string): Promise { if (!res.ok && res.status !== 404) throw new Error(`${res.status} ${await res.text()}`); } +/** + * Injects a steering "nudge" into an in-flight run. Only runners with an inner + * loop (the native agent runner) are steerable; otherwise the server replies + * 409, surfaced here as an Error the caller can show. + */ +export async function nudgeRun(sessionId: string, text: string): Promise { + const res = await fetch((await apiBase()) + `/api/runs/${encodeURIComponent(sessionId)}/nudge`, { + method: "POST", + headers: await authHeaders({ "content-type": "application/json" }), + body: JSON.stringify({ text }), + }); + if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); +} + export async function listSessions(): Promise { const { sessions } = await getJson<{ sessions: SessionRecord[] }>("/api/sessions"); return sessions; diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 20a5a4d..ed58f6b 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -11,6 +11,7 @@ import { isTauri, listSecretKeys, listSessions, + nudgeRun, openStream, pickDirectory, restartEngine, @@ -401,7 +402,7 @@ async function renderModels(): Promise { const found = findModel(choice); if (!found) return note; const { provider } = found; - if (provider.kind === "http" && knowKeys && !storedKeys.has(provider.apiKeyEnv ?? "")) { + if (provider.kind !== "cli" && knowKeys && !storedKeys.has(provider.apiKeyEnv ?? "")) { note.className = "model-note warn"; const link = h("button", { type: "button", class: "linklike" }, [`Add ${provider.apiKeyEnv}`]); link.addEventListener("click", () => navigate("secrets")); @@ -419,11 +420,11 @@ async function renderModels(): Promise { note.append(h("span", {}, ["Edits files directly — runs can produce real, committable changes."])); } else { note.className = "model-note warn"; - note.append(h("span", {}, ["HTTP models return a diff but don't edit files. Pick a CLI writer (Codex / Kiro) to change a repo."])); + note.append(h("span", {}, ["HTTP models return a diff but don't edit files. Pick a CLI or agent writer to change a repo."])); } return note; } - note.append(h("span", {}, [provider.kind === "http" ? `Uses ${provider.apiKeyEnv}` : `Local command: ${provider.command}`])); + note.append(h("span", {}, [provider.kind === "cli" ? `Local command: ${provider.command}` : `Uses ${provider.apiKeyEnv}`])); return note; } @@ -701,10 +702,19 @@ function renderMonitor(sessionId: string, goal: string): void { const phase = h("div", { class: "phase running", id: "phase" }, ["running…"]); const plan = h("div", { class: "plan", id: "plan" }); const stopBtn = h("button", { class: "danger small" }, ["Stop run"]); + // Steering: nudge an in-flight agent run with extra guidance. Only effective + // for steerable backends (the native agent runner); other backends reply 409. + const nudgeInput = h("input", { + type: "text", + class: "nudge-input", + placeholder: "Nudge the agent…", + }) as HTMLInputElement; + const nudgeBtn = h("button", { class: "small" }, ["Nudge"]); const header = h("div", { class: "card run-head" }, [ h("div", { class: "goal" }, [goal]), h("div", { class: "meta-row" }, [phase, stopBtn, h("span", { class: "session-id" }, [`session ${sessionId}`])]), + h("div", { class: "meta-row nudge-row" }, [nudgeInput, nudgeBtn]), plan, ]); @@ -721,6 +731,26 @@ function renderMonitor(sessionId: string, goal: string): void { } }); + // Nudge control: inject steering guidance into the running agent. + const sendNudge = async (): Promise => { + const text = nudgeInput.value.trim(); + if (!text) return; + nudgeBtn.setAttribute("disabled", "true"); + try { + await nudgeRun(sessionId, text); + nudgeInput.value = ""; + appendLog(` · you → nudge: ${text}`); + } catch (err) { + plan.textContent = `Nudge failed: ${(err as Error).message}`; + } finally { + nudgeBtn.removeAttribute("disabled"); + } + }; + nudgeBtn.addEventListener("click", () => void sendNudge()); + nudgeInput.addEventListener("keydown", (ev) => { + if ((ev as KeyboardEvent).key === "Enter") void sendNudge(); + }); + const usage = h("div", { class: "card" }, [ h("h3", {}, ["Usage"]), h("div", { class: "usage-body", id: "usage-body" }, ["No runner calls yet."]), @@ -787,6 +817,14 @@ function renderMonitor(sessionId: string, goal: string): void { } else if (ev.type === "plan_reviewed") { (document.getElementById("plan") as HTMLElement).textContent = `Plan: approved=${ev.data.approved} · revisions=${ev.data.revisions} · open items=${ev.data.openItems}`; + } else if (ev.type === "runner_activity") { + // Live sub-step feed from an agent runner's inner loop (tool calls). + const d = ev.data ?? {}; + if (d.phase === "tool_start") { + appendLog(` · ${d.role} → ${d.toolName}`); + } else if (d.phase === "tool_end") { + appendLog(` · ${d.role} ✓ ${d.toolName}${d.isError ? " (error)" : ""}`); + } } } else if (msg.type === "status") { const phaseEl = document.getElementById("phase") as HTMLElement; diff --git a/desktop/src/settings.ts b/desktop/src/settings.ts index 7f1c2d6..b9c2a8e 100644 --- a/desktop/src/settings.ts +++ b/desktop/src/settings.ts @@ -14,7 +14,7 @@ * HTTP runners only return a diff the engine does not apply. */ -export type ProviderKind = "http" | "cli"; +export type ProviderKind = "http" | "cli" | "agent"; export interface CatalogModel { /** model id sent to the provider (the runner profile's `model`) */ @@ -35,7 +35,12 @@ export interface Provider { apiKeyEnv?: string; /** cli: the command that must be on PATH (used for install detection) */ command?: string; - /** true for CLI actors that edit files directly (can produce real changes) */ + /** + * agent: the pi-ai provider id used to resolve the model (e.g. "anthropic"). + * Agent providers run a real in-process tool-calling loop and edit files. + */ + agentProvider?: string; + /** true for actors that edit files directly (CLI agents and native agents) */ editsFiles?: boolean; /** models this provider offers */ models: CatalogModel[]; @@ -103,6 +108,47 @@ export const MODEL_CATALOG: Provider[] = [ editsFiles: true, models: [{ id: "kiro", label: "Kiro (edits files)" }], }, + // Native agent runners: a real in-process tool-calling loop (pi agent-core + + // ai) that edits files directly. Grouped by the same API key as the matching + // HTTP provider; model ids must be ones pi-ai knows for that provider. + { + id: "anthropic-agent", + label: "Anthropic (agent)", + kind: "agent", + agentProvider: "anthropic", + apiKeyEnv: "ANTHROPIC_API_KEY", + editsFiles: true, + models: [ + { id: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet (edits files)" }, + { id: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet (edits files)" }, + { id: "claude-3-5-haiku-latest", label: "Claude 3.5 Haiku (edits files)" }, + ], + }, + { + id: "openai-agent", + label: "OpenAI (agent)", + kind: "agent", + agentProvider: "openai", + apiKeyEnv: "OPENAI_API_KEY", + editsFiles: true, + models: [ + { id: "gpt-4.1", label: "GPT-4.1 (edits files)" }, + { id: "gpt-4o", label: "GPT-4o (edits files)" }, + { id: "o4-mini", label: "o4-mini (edits files)" }, + ], + }, + { + id: "google-agent", + label: "Google (agent)", + kind: "agent", + agentProvider: "google", + apiKeyEnv: "GEMINI_API_KEY", + editsFiles: true, + models: [ + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro (edits files)" }, + { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash (edits files)" }, + ], + }, ]; /** A concrete model choice: which provider + which model id. */ @@ -276,6 +322,17 @@ interface RunnerProfile { function profileFor(id: string, choice: ModelChoice): RunnerProfile { const provider = getProvider(choice.provider) ?? firstProvider; + if (provider.kind === "agent") { + return { + id, + kind: "agent", + model: choice.model, + options: { + provider: provider.agentProvider ?? provider.id, + ...(provider.apiKeyEnv ? { apiKeyEnv: provider.apiKeyEnv } : {}), + }, + }; + } if (provider.kind === "cli") { if (provider.id === "kiro") { return { diff --git a/src/adapters/roleBindings.ts b/src/adapters/roleBindings.ts index 08740a4..3b4bc03 100644 --- a/src/adapters/roleBindings.ts +++ b/src/adapters/roleBindings.ts @@ -41,6 +41,8 @@ export interface CreateRolesOptions { onRunnerCall?: RunnerCallSink; /** when set, mid-call runner activity (tool calls) streams to this sink */ onActivity?: (e: RunnerActivityEvent) => void; + /** when set, each runner call registers its live steer handle here */ + onSteer?: (steer: (text: string) => void) => void; /** cooperative cancellation, threaded into the actor/critic runner calls */ signal?: AbortSignal; } @@ -103,6 +105,7 @@ export function createRoles( ...(opts.log ? { log: opts.log } : {}), ...(opts.signal ? { signal: opts.signal } : {}), ...(opts.onActivity ? { onActivity: opts.onActivity } : {}), + ...(opts.onSteer ? { onSteer: opts.onSteer } : {}), }); const critic = new RunnerCritic(instrumentedCritic, { @@ -111,6 +114,7 @@ export function createRoles( ...(opts.log ? { log: opts.log } : {}), ...(opts.signal ? { signal: opts.signal } : {}), ...(opts.onActivity ? { onActivity: opts.onActivity } : {}), + ...(opts.onSteer ? { onSteer: opts.onSteer } : {}), }); return { actor, critic }; diff --git a/src/adapters/runnerRoles.ts b/src/adapters/runnerRoles.ts index 8389a06..dc9fcc5 100644 --- a/src/adapters/runnerRoles.ts +++ b/src/adapters/runnerRoles.ts @@ -69,6 +69,12 @@ export interface RunnerActorOptions { signal?: AbortSignal; /** sink for mid-call runner activity (sub-step streaming), if any */ onActivity?: (e: RunnerActivityEvent) => void; + /** + * Steering registration. Threaded into each runner call as `req.steering`; + * a runner with an inner loop invokes it with a `steer(text)` bound to that + * live call, so a supervisor can nudge the in-flight work. + */ + onSteer?: (steer: (text: string) => void) => void; } export interface RunnerCriticOptions { @@ -79,6 +85,8 @@ export interface RunnerCriticOptions { signal?: AbortSignal; /** sink for mid-call runner activity (sub-step streaming), if any */ onActivity?: (e: RunnerActivityEvent) => void; + /** steering registration, threaded into each runner call as req.steering */ + onSteer?: (steer: (text: string) => void) => void; } /** @@ -117,6 +125,8 @@ export class RunnerActor implements Actor { private readonly signal: AbortSignal | undefined; /** per-call activity sink, enriched with role identity (undefined = off) */ private readonly onEvent: ((a: RunnerActivity) => void) | undefined; + /** steering registration passed as req.steering on every run (undefined = off) */ + private readonly steerRegister: ((steer: (text: string) => void) => void) | undefined; constructor(runner: AgentRunner, opts: RunnerActorOptions = {}) { this.runner = runner; @@ -126,6 +136,7 @@ export class RunnerActor implements Actor { this.log = opts.log; this.signal = opts.signal; this.onEvent = activityForwarder(runner, "actor", opts.onActivity); + this.steerRegister = opts.onSteer; } async draftPlan(goal: string, feedback?: Finding[]): Promise { @@ -163,6 +174,7 @@ export class RunnerActor implements Actor { system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), ...(this.onEvent ? { onEvent: this.onEvent } : {}), + ...(this.steerRegister ? { steering: this.steerRegister } : {}), }); return { text: res.text, quotaExhausted: res.quotaExhausted }; } @@ -180,6 +192,7 @@ export class RunnerActor implements Actor { system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), ...(this.onEvent ? { onEvent: this.onEvent } : {}), + ...(this.steerRegister ? { steering: this.steerRegister } : {}), }); if (res.quotaExhausted) { throw new RunnerRoleError(`Actor ${what} failed: runner quota exhausted.`, { @@ -196,6 +209,7 @@ export class RunnerActor implements Actor { system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), ...(this.onEvent ? { onEvent: this.onEvent } : {}), + ...(this.steerRegister ? { steering: this.steerRegister } : {}), }); if (res.quotaExhausted) { throw new RunnerRoleError(`Actor ${what} failed: runner quota exhausted.`, { @@ -220,6 +234,8 @@ export class RunnerCritic implements Critic { private readonly signal: AbortSignal | undefined; /** per-call activity sink, enriched with role identity (undefined = off) */ private readonly onEvent: ((a: RunnerActivity) => void) | undefined; + /** steering registration passed as req.steering on every run (undefined = off) */ + private readonly steerRegister: ((steer: (text: string) => void) => void) | undefined; constructor(runner: AgentRunner, opts: RunnerCriticOptions = {}) { this.runner = runner; @@ -227,6 +243,7 @@ export class RunnerCritic implements Critic { this.cwd = opts.cwd ?? "."; this.signal = opts.signal; this.onEvent = activityForwarder(runner, "critic", opts.onActivity); + this.steerRegister = opts.onSteer; } /** @@ -246,6 +263,7 @@ export class RunnerCritic implements Critic { system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), ...(this.onEvent ? { onEvent: this.onEvent } : {}), + ...(this.steerRegister ? { steering: this.steerRegister } : {}), }); return { text: res.text, quotaExhausted: res.quotaExhausted }; } diff --git a/src/server/server.ts b/src/server/server.ts index 163b63e..cc05e0b 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -214,6 +214,13 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { let activeRuns = 0; /** abort controllers for in-flight runs, keyed by session id (for cancel) */ const controllers = new Map(); + /** + * Live steer handle for each in-flight run, keyed by session id. Updated each + * time a runner call (with an inner loop) starts; POST /api/runs/:id/nudge + * invokes it to inject guidance into the running agent. Latest-wins, so a + * nudge targets whatever runner call is currently active. + */ + const steerers = new Map void>(); /** open SSE responses, so a graceful shutdown can end them deterministically */ const sseClients = new Set(); /** @@ -297,6 +304,34 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { return send(res, 202, { cancelling: true }, cors); } + // Steer (nudge) an in-flight run: inject a user message into the currently + // active agent runner call, taking effect after its current turn. Only + // runners with an inner loop (the native agent runner) register a steer + // handle; for others this reports that there's nothing steerable. + const nudgeMatch = pathname.match(/^\/api\/runs\/([^/]+)\/nudge$/); + if (nudgeMatch && method === "POST") { + const id = decodeURIComponent(nudgeMatch[1] as string); + let nudgeBody: { text?: unknown }; + try { + nudgeBody = ((await readJsonBody(req)) ?? {}) as { text?: unknown }; + } catch (err) { + return send(res, 400, { error: `invalid JSON body: ${String((err as Error).message)}` }, cors); + } + const text = typeof nudgeBody.text === "string" ? nudgeBody.text.trim() : ""; + if (!text) return send(res, 400, { error: "text is required" }, cors); + const steer = steerers.get(id); + if (!steer) { + return send( + res, + 409, + { error: `no steerable runner active for session "${id}" (the active backend may not support steering)` }, + cors, + ); + } + steer(text); + return send(res, 202, { nudged: true }, cors); + } + // Graceful shutdown: the desktop shell calls this before killing the // sidecar so in-flight runs are cancelled (which kills their detached // subprocess trees) and SSE clients are closed cleanly, rather than being @@ -441,6 +476,7 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { const settle = (): void => { activeRuns = Math.max(0, activeRuns - 1); controllers.delete(sessionId); + steerers.delete(sessionId); const timer = setTimeout(() => hub.forget(sessionId, generation), retainMs); if (typeof timer.unref === "function") timer.unref(); }; @@ -457,6 +493,7 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { observer, log: (line) => void hub.publish(sessionId, "log", { line }), signal: controller.signal, + onSteer: (steer) => steerers.set(sessionId, steer), ...(repoDir ? { repoDir } : {}), }) .then((result) => { diff --git a/test/runnerRoles.test.ts b/test/runnerRoles.test.ts index 788f220..de947a0 100644 --- a/test/runnerRoles.test.ts +++ b/test/runnerRoles.test.ts @@ -280,4 +280,23 @@ describe("runner activity streaming (sub-step events)", () => { await actor.build(sampleTask, undefined, "."); expect(called).toBe(false); }); + + it("registers the runner's live steer handle via onSteer", async () => { + const steered: string[] = []; + const runner: AgentRunner = { + profile, + async run(req: RunRequest): Promise { + req.steering?.((text) => steered.push(text)); // runner exposes its bound steer + return { text: buildJson("t1") }; + }, + }; + let captured: ((t: string) => void) | undefined; + const actor = new RunnerActor(runner, { onSteer: (s) => (captured = s) }); + + await actor.build(sampleTask, undefined, "."); + + expect(typeof captured).toBe("function"); + captured?.("focus on edge cases"); + expect(steered).toEqual(["focus on edge cases"]); + }); }); diff --git a/test/server.test.ts b/test/server.test.ts index 99e2dbe..6ae868e 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -241,6 +241,96 @@ describe("server: run cancellation", () => { }); }); +describe("server: steering (nudge)", () => { + /** Builds a run that registers a steer handle (optionally) and stays active + * until its AbortSignal fires, so the test can nudge it mid-flight. */ + function pendingRun(opts: { registersSteer: boolean }): RunGoalImpl { + return (_g, _c, o = {}) => + new Promise((_resolve, reject) => { + if (opts.registersSteer) o.onSteer?.((text) => nudges.push(text)); + o.signal?.addEventListener("abort", () => { + const e = new Error("run cancelled"); + e.name = "AbortError"; + reject(e); + }); + }); + } + let nudges: string[]; + beforeEach(() => { + nudges = []; + }); + + it("injects a nudge into a steerable in-flight run", async () => { + await server?.stop(); + server = createServer({ + store: new MemoryStore(), + config: baseConfig, + baseEnv: {}, + token: TOKEN, + runGoalImpl: pendingRun({ registersSteer: true }), + }); + base = `http://127.0.0.1:${await server.start(0)}`; + + const id = await startRun("long task"); + const res = await fetch(`${base}/api/runs/${id}/nudge`, { + method: "POST", + headers: auth({ "content-type": "application/json" }), + body: JSON.stringify({ text: "focus on the failing test" }), + }); + expect(res.status).toBe(202); + expect(nudges).toEqual(["focus on the failing test"]); + }); + + it("rejects a nudge with no text (400) and an unknown/non-steerable run (409/404)", async () => { + await server?.stop(); + server = createServer({ + store: new MemoryStore(), + config: baseConfig, + baseEnv: {}, + token: TOKEN, + runGoalImpl: pendingRun({ registersSteer: false }), + }); + base = `http://127.0.0.1:${await server.start(0)}`; + + const id = await startRun("long task"); + + // No text -> 400. + const noText = await fetch(`${base}/api/runs/${id}/nudge`, { + method: "POST", + headers: auth({ "content-type": "application/json" }), + body: JSON.stringify({ text: " " }), + }); + expect(noText.status).toBe(400); + + // Active run that didn't register a steer handle -> 409. + const notSteerable = await fetch(`${base}/api/runs/${id}/nudge`, { + method: "POST", + headers: auth({ "content-type": "application/json" }), + body: JSON.stringify({ text: "hi" }), + }); + expect(notSteerable.status).toBe(409); + + // Unknown session -> 409 (no steerer registered for it). + const ghost = await fetch(`${base}/api/runs/ghost/nudge`, { + method: "POST", + headers: auth({ "content-type": "application/json" }), + body: JSON.stringify({ text: "hi" }), + }); + expect(ghost.status).toBe(409); + }); + + it("requires the auth token", async () => { + await startServer(pendingRun({ registersSteer: true })); + const id = await startRun("t"); + const res = await fetch(`${base}/api/runs/${id}/nudge`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "hi" }), + }); + expect(res.status).toBe(401); + }); +}); + describe("server: admission control", () => { it("rejects new runs past the active cap with 429", async () => { let release: () => void = () => {}; From a853ada3bc6e146d04e76f52cfef7d08be72372c Mon Sep 17 00:00:00 2001 From: Inzimam Date: Mon, 15 Jun 2026 10:59:53 +0000 Subject: [PATCH 2/2] Address PR review: aria-label on nudge input, guard sendNudge re-entrancy --- desktop/src/main.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/desktop/src/main.ts b/desktop/src/main.ts index ed58f6b..058a38e 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -708,6 +708,7 @@ function renderMonitor(sessionId: string, goal: string): void { type: "text", class: "nudge-input", placeholder: "Nudge the agent…", + "aria-label": "Nudge the in-flight run", }) as HTMLInputElement; const nudgeBtn = h("button", { class: "small" }, ["Nudge"]); @@ -732,10 +733,14 @@ function renderMonitor(sessionId: string, goal: string): void { }); // Nudge control: inject steering guidance into the running agent. + let nudging = false; const sendNudge = async (): Promise => { + if (nudging) return; const text = nudgeInput.value.trim(); if (!text) return; + nudging = true; nudgeBtn.setAttribute("disabled", "true"); + nudgeInput.setAttribute("disabled", "true"); try { await nudgeRun(sessionId, text); nudgeInput.value = ""; @@ -743,12 +748,17 @@ function renderMonitor(sessionId: string, goal: string): void { } catch (err) { plan.textContent = `Nudge failed: ${(err as Error).message}`; } finally { + nudging = false; nudgeBtn.removeAttribute("disabled"); + nudgeInput.removeAttribute("disabled"); } }; nudgeBtn.addEventListener("click", () => void sendNudge()); nudgeInput.addEventListener("keydown", (ev) => { - if ((ev as KeyboardEvent).key === "Enter") void sendNudge(); + if ((ev as KeyboardEvent).key === "Enter") { + ev.preventDefault(); + void sendNudge(); + } }); const usage = h("div", { class: "card" }, [