Merge pull request #12935 from Kilo-Org/fix-cli-interactive-terminal

fix(cli): restore interactive terminal input for workspaces
This commit is contained in:
Marius
2026-08-06 13:36:42 +02:00
committed by GitHub
7 changed files with 260 additions and 18 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Restore keyboard input for interactive terminal prompts when the CLI session uses a workspace.
+1 -1
View File
@@ -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",
+6 -15
View File
@@ -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) {
@@ -0,0 +1,54 @@
import type { KiloClient } from "@kilocode/sdk/v2"
type Client = {
session: Pick<KiloClient["session"], "get">
terminal: Pick<KiloClient["interactiveTerminal"], "write" | "resize" | "close">
}
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<string | undefined> | 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) => {
if (result.error) throw result.error
return result.data?.workspaceID
})
.catch(() => {
state.id = ""
state.workspace = undefined
return 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() })
},
}
}
}
@@ -0,0 +1,72 @@
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)
})
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")
})
+5 -2
View File
@@ -16,6 +16,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string })
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const [snapshot, setSnapshot] = createSignal<InteractiveTerminalSnapshot>()
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)
@@ -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(() => (
<TestTuiContexts paths={{ state: tmp.path }}>
<ArgsProvider>
<KVProvider>
<TuiConfigProvider config={config}>
<ToastProvider>
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={eventSource()}>
<PermissionProvider>
<ProjectProvider>
<ExitProvider exit={() => {}}>
<SyncProvider>
<Ready />
</SyncProvider>
</ExitProvider>
</ProjectProvider>
</PermissionProvider>
</SDKProvider>
</ToastProvider>
</TuiConfigProvider>
</KVProvider>
</ArgsProvider>
</TestTuiContexts>
))
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 (
<Show when={sync.status === "complete"}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<TerminalPrompt sessionID={session.id} terminalID={snapshot.info.id} />
</ThemeProvider>
</Show>
)
}