mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #12935 from Kilo-Org/fix-cli-interactive-terminal
fix(cli): restore interactive terminal input for workspaces
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Restore keyboard input for interactive terminal prompts when the CLI session uses a workspace.
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user