From 3917ed1f9bd50232b311efc47974e4df0a30ef6c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 10:53:23 +0200 Subject: [PATCH 1/2] fix(cli): route interactive terminal through workspace --- .changeset/fix-interactive-terminal-input.md | 5 + package.json | 2 +- packages/opencode/src/cli/cmd/run/runtime.ts | 21 +--- .../src/kilocode/cli/cmd/run-terminal.ts | 47 +++++++ .../kilocode/cli/cmd/run-terminal.test.ts | 42 +++++++ packages/tui/src/routes/session/terminal.tsx | 7 +- .../kilocode/interactive-terminal.test.tsx | 117 ++++++++++++++++++ 7 files changed, 223 insertions(+), 18 deletions(-) create mode 100644 .changeset/fix-interactive-terminal-input.md create mode 100644 packages/opencode/src/kilocode/cli/cmd/run-terminal.ts create mode 100644 packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts create mode 100644 packages/tui/test/kilocode/interactive-terminal.test.tsx diff --git a/.changeset/fix-interactive-terminal-input.md b/.changeset/fix-interactive-terminal-input.md new file mode 100644 index 0000000000..b9d5391548 --- /dev/null +++ b/.changeset/fix-interactive-terminal-input.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Restore keyboard input for interactive terminal prompts when the CLI session uses a workspace. diff --git a/package.json b/package.json index 7d22cb0b8e..518deb2f56 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "packageManager": "bun@1.3.14", "scripts": { - "dev": "bun run --cwd packages/opencode --conditions=node src/index.ts", + "dev": "KILO_CLIENT=cli bun run --cwd packages/opencode --conditions=node src/index.ts", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 27361a6727..f459fdea8b 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -15,6 +15,7 @@ import { createKiloClient } from "@kilocode/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" import { MessageID } from "@/session/schema" +import { KiloRunTerminal } from "@/kilocode/cli/cmd/run-terminal" // kilocode_change import { createRunDemo } from "./demo" import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" @@ -225,6 +226,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return state.session } + const terminal = KiloRunTerminal.create(ctx.sdk, () => state.sessionID) // kilocode_change + const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({ directory: ctx.directory, findFiles: (query) => @@ -267,21 +270,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep await ctx.sdk.question.reject(next) }, // kilocode_change start - human-driven terminal in direct interactive mode - onTerminalWrite: async (next) => { - await ctx.sdk.interactiveTerminal.write({ - terminalID: next.terminalID, - interactiveTerminalWriteInput: { data: next.data }, - }) - }, - onTerminalResize: async (next) => { - await ctx.sdk.interactiveTerminal.resize({ - terminalID: next.terminalID, - interactiveTerminalResizeInput: { cols: next.cols, rows: next.rows }, - }) - }, - onTerminalClose: async (terminalID) => { - await ctx.sdk.interactiveTerminal.close({ terminalID }) - }, + onTerminalWrite: terminal.write, + onTerminalResize: terminal.resize, + onTerminalClose: terminal.close, // kilocode_change end onCycleVariant: () => { if (!state.model || state.variants.length === 0) { diff --git a/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts b/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts new file mode 100644 index 0000000000..e7b3af89a9 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts @@ -0,0 +1,47 @@ +import type { KiloClient } from "@kilocode/sdk/v2" + +type Client = { + session: Pick + terminal: Pick +} + +export namespace KiloRunTerminal { + export function create(sdk: KiloClient, session: () => string) { + const client: Client = { session: sdk.session, terminal: sdk.interactiveTerminal } + const state = { + id: "", + workspace: undefined as Promise | undefined, + } + + function workspace() { + const id = session() + if (state.id === id && state.workspace) return state.workspace + state.id = id + state.workspace = client.session + .get({ sessionID: id }) + .then((result) => result.data?.workspaceID) + .catch(() => undefined) + return state.workspace + } + + return { + write: async (input: { terminalID: string; data: string }) => { + await client.terminal.write({ + terminalID: input.terminalID, + workspace: await workspace(), + interactiveTerminalWriteInput: { data: input.data }, + }) + }, + resize: async (input: { terminalID: string; cols: number; rows: number }) => { + await client.terminal.resize({ + terminalID: input.terminalID, + workspace: await workspace(), + interactiveTerminalResizeInput: { cols: input.cols, rows: input.rows }, + }) + }, + close: async (terminalID: string) => { + await client.terminal.close({ terminalID, workspace: await workspace() }) + }, + } + } +} diff --git a/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts b/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts new file mode 100644 index 0000000000..f56f7eec0a --- /dev/null +++ b/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import { createKiloClient } from "@kilocode/sdk/v2" +import { KiloRunTerminal } from "@/kilocode/cli/cmd/run-terminal" + +test("routes direct interactive terminal requests through the session workspace", async () => { + const seen: URL[] = [] + const fetch = Object.assign( + async (input: URL | RequestInfo, init?: RequestInit) => { + const request = new Request(input, init) + const url = new URL(request.url) + seen.push(url) + if (url.pathname === "/session/ses_terminal") { + return Response.json({ + id: "ses_terminal", + slug: "terminal", + projectID: "proj_test", + workspaceID: "ws_terminal", + directory: "/tmp", + title: "Terminal", + version: "7.4.20", + time: { created: 1, updated: 1 }, + }) + } + return Response.json(true) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const sdk = createKiloClient({ + baseUrl: "http://test", + fetch, + }) + const terminal = KiloRunTerminal.create(sdk, () => "ses_terminal") + + await terminal.write({ terminalID: "itx_terminal", data: "Ada\r" }) + await terminal.resize({ terminalID: "itx_terminal", cols: 80, rows: 14 }) + await terminal.close("itx_terminal") + + const requests = seen.filter((url) => url.pathname.startsWith("/interactive-terminal/")) + expect(requests).toHaveLength(3) + expect(requests.every((url) => url.searchParams.get("workspace") === "ws_terminal")).toBe(true) + expect(seen.filter((url) => url.pathname === "/session/ses_terminal")).toHaveLength(1) +}) diff --git a/packages/tui/src/routes/session/terminal.tsx b/packages/tui/src/routes/session/terminal.tsx index 75a02f1bd2..2b4ab8376f 100644 --- a/packages/tui/src/routes/session/terminal.tsx +++ b/packages/tui/src/routes/session/terminal.tsx @@ -16,6 +16,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) const renderer = useRenderer() const dimensions = useTerminalDimensions() const [snapshot, setSnapshot] = createSignal() + const workspace = () => sync.session.get(props.sessionID)?.workspaceID function terminal() { const live = sync.data.interactive_terminal[props.sessionID]?.find((item) => item.info.id === props.terminalID) const polled = snapshot() @@ -41,6 +42,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) .then(() => sdk.client.interactiveTerminal.write({ terminalID: props.terminalID, + workspace: workspace(), interactiveTerminalWriteInput: { data }, }), ) @@ -51,7 +53,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) function close() { if (closing()) return setClosing(true) - void sdk.client.interactiveTerminal.close({ terminalID: props.terminalID }).catch(() => { + void sdk.client.interactiveTerminal.close({ terminalID: props.terminalID, workspace: workspace() }).catch(() => { setClosing(false) }) } @@ -64,7 +66,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) if (state.polling || closing()) return state.polling = true void sdk.client.interactiveTerminal - .get({ terminalID: props.terminalID }) + .get({ terminalID: props.terminalID, workspace: workspace() }) .then((result) => { if (result.data) setSnapshot(result.data) }) @@ -131,6 +133,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) void sdk.client.interactiveTerminal .resize({ terminalID: props.terminalID, + workspace: workspace(), interactiveTerminalResizeInput: { cols: width, rows: height }, }) .catch(() => undefined) diff --git a/packages/tui/test/kilocode/interactive-terminal.test.tsx b/packages/tui/test/kilocode/interactive-terminal.test.tsx new file mode 100644 index 0000000000..5f66c2653b --- /dev/null +++ b/packages/tui/test/kilocode/interactive-terminal.test.tsx @@ -0,0 +1,117 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { Show } from "solid-js" +import type { InteractiveTerminalSnapshot, Session } from "@kilocode/sdk/v2" +import { testRender } from "@opentui/solid" +import path from "node:path" +import { ArgsProvider } from "../../src/context/args" +import { ExitProvider } from "../../src/context/exit" +import { KVProvider } from "../../src/context/kv" +import { PermissionProvider } from "../../src/context/permission" +import { ProjectProvider } from "../../src/context/project" +import { SDKProvider } from "../../src/context/sdk" +import { SyncProvider, useSync } from "../../src/context/sync" +import { ThemeProvider } from "../../src/context/theme" +import { TuiConfigProvider } from "../../src/config" +import { TerminalPrompt } from "../../src/routes/session/terminal" +import { ToastProvider } from "../../src/ui/toast" +import { createFetch, directory, eventSource, json } from "../fixture/tui-sdk" +import { tmpdir } from "../fixture/fixture" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" + +const session: Session = { + id: "ses_terminal", + slug: "terminal", + projectID: "proj_test", + workspaceID: "ws_terminal", + directory, + title: "Terminal", + version: "7.4.20", + time: { created: 1, updated: 1 }, +} + +const snapshot: InteractiveTerminalSnapshot = { + info: { + id: "itx_terminal", + sessionID: session.id, + pid: 123, + command: "prompt", + cwd: directory, + status: "running", + cols: 80, + rows: 14, + time: { started: 1, updated: 1 }, + }, + output: "READY", + cursor: 5, +} + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for terminal request") + await Bun.sleep(10) + } +} + +test("routes interactive terminal input through the session workspace", async () => { + await using tmp = await tmpdir() + await Bun.write(path.join(tmp.path, "kv.json"), "{}") + const seen: URL[] = [] + const calls = createFetch((url) => { + seen.push(url) + if (url.pathname === "/session") return json([session]) + if (url.pathname === "/interactive-terminal") return json([snapshot]) + if (url.pathname === "/interactive-terminal/itx_terminal") return json(snapshot) + if (url.pathname.startsWith("/interactive-terminal/itx_terminal/")) return json(true) + return undefined + }) + const config = createTuiResolvedConfig() + const app = await testRender(() => ( + + + + + + + + + {}}> + + + + + + + + + + + + + )) + + try { + await wait(() => seen.some((url) => url.pathname === "/interactive-terminal/itx_terminal")) + app.mockInput.pressKey("x") + await wait(() => seen.some((url) => url.pathname === "/interactive-terminal/itx_terminal/input")) + + const terminal = seen.filter((url) => url.pathname.startsWith("/interactive-terminal/itx_terminal")) + expect(terminal.length).toBeGreaterThan(0) + expect(terminal.every((url) => url.searchParams.get("workspace") === session.workspaceID)).toBe(true) + } finally { + app.renderer.destroy() + } +}) + +function Ready() { + const sync = useSync() + return ( + + ({}) }}> + + + + ) +} From 7c1023da524ad7cd36ccff620cc0362a8945dbb7 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 11:05:28 +0200 Subject: [PATCH 2/2] fix(cli): retry workspace lookup for terminal input --- .../src/kilocode/cli/cmd/run-terminal.ts | 11 +++++-- .../kilocode/cli/cmd/run-terminal.test.ts | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts b/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts index e7b3af89a9..18b87fb254 100644 --- a/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts +++ b/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts @@ -19,8 +19,15 @@ export namespace KiloRunTerminal { state.id = id state.workspace = client.session .get({ sessionID: id }) - .then((result) => result.data?.workspaceID) - .catch(() => undefined) + .then((result) => { + if (result.error) throw result.error + return result.data?.workspaceID + }) + .catch(() => { + state.id = "" + state.workspace = undefined + return undefined + }) return state.workspace } diff --git a/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts b/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts index f56f7eec0a..60e30d4171 100644 --- a/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts @@ -40,3 +40,33 @@ test("routes direct interactive terminal requests through the session workspace" expect(requests.every((url) => url.searchParams.get("workspace") === "ws_terminal")).toBe(true) expect(seen.filter((url) => url.pathname === "/session/ses_terminal")).toHaveLength(1) }) + +test("retries workspace lookup after a failed request", async () => { + const seen: URL[] = [] + let sessions = 0 + const fetch = Object.assign( + async (input: URL | RequestInfo, init?: RequestInit) => { + const request = new Request(input, init) + const url = new URL(request.url) + seen.push(url) + if (url.pathname === "/session/ses_terminal") { + sessions += 1 + if (sessions === 1) return new Response("busy", { status: 503 }) + return Response.json({ workspaceID: "ws_terminal" }) + } + return Response.json(true) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const sdk = createKiloClient({ baseUrl: "http://test", fetch }) + const terminal = KiloRunTerminal.create(sdk, () => "ses_terminal") + + await terminal.write({ terminalID: "itx_terminal", data: "Ada\r" }) + await terminal.write({ terminalID: "itx_terminal", data: "Grace\r" }) + + expect(sessions).toBe(2) + const inputs = seen.filter((url) => url.pathname === "/interactive-terminal/itx_terminal/input") + expect(inputs).toHaveLength(2) + expect(inputs[0]?.searchParams.get("workspace")).toBeNull() + expect(inputs[1]?.searchParams.get("workspace")).toBe("ws_terminal") +})