feat: interactive terminal tool

This commit is contained in:
Catriel Müller
2026-06-18 00:32:13 -03:00
parent 34f16a507f
commit bbf3c5b43d
48 changed files with 3808 additions and 152 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Run commands that require human interaction in an embedded CLI terminal dialog and return their output to the model when complete.
+2 -1
View File
@@ -114,7 +114,6 @@
"@kilocode/kilo-web-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@lottiefiles/dotlottie-web": "0.74.0",
"@opencode-ai/ui": "workspace:*",
"@solidjs/router": "catalog:",
"ghostty-web": "0.4.0",
"solid-js": "catalog:",
@@ -366,6 +365,8 @@
"dependencies": {
"@kilocode/kilo-ui": "workspace:*",
"@kobalte/core": "catalog:",
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
@@ -168,7 +168,8 @@ export function SessionReview(props: SessionReviewProps) {
const expanded = () => open().includes(diff.file)
const expandable = () => diff.additions !== 0 || diff.deletions !== 0
const added = () => diff.status === "added" || (diff.before.length === 0 && diff.after.length > 0)
const deleted = () => diff.status === "deleted" || (diff.after.length === 0 && diff.before.length > 0)
const deleted = () =>
diff.status === "deleted" || (diff.after.length === 0 && diff.before.length > 0)
return (
<Accordion.Item
+2
View File
@@ -117,6 +117,7 @@ export const layer = Layer.effect(
},
suggest: "deny", // kilocode_change
question: "deny",
interactive_terminal: "deny", // kilocode_change - human-driven tools are primary-agent only
plan_enter: "deny",
plan_exit: "deny",
repo_clone: "deny",
@@ -146,6 +147,7 @@ export const layer = Layer.effect(
defaults,
Permission.fromConfig({
question: "allow",
interactive_terminal: "allow", // kilocode_change
suggest: "allow", // kilocode_change
plan_enter: "allow",
}),
+5
View File
@@ -380,6 +380,11 @@ export const RunCommand = effectCmd({
action: "deny",
pattern: "*",
},
{
permission: "interactive_terminal", // kilocode_change - non-interactive runs cannot take over a terminal
action: "deny",
pattern: "*",
},
{
permission: "plan_enter",
action: "deny",
+12 -1
View File
@@ -30,6 +30,7 @@ import { createComponent, createSignal, type Accessor, type Setter } from "solid
import { createStore, reconcile } from "solid-js/store"
import { withRunSpan } from "./otel"
import { RUN_COMMAND_PANEL_ROWS } from "./footer.command"
import { RUN_INTERACTIVE_TERMINAL_ROWS } from "@/kilocode/cli/cmd/run/interactive-terminal" // kilocode_change
import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { printableBinding } from "./prompt.shared"
@@ -85,6 +86,9 @@ type RunFooterOptions = {
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void>
onTerminalWrite: (input: { terminalID: string; data: string }) => Promise<void> // kilocode_change
onTerminalResize: (input: { terminalID: string; cols: number; rows: number }) => Promise<void> // kilocode_change
onTerminalClose: (terminalID: string) => Promise<void> // kilocode_change
onCycleVariant?: () => CycleResult | void
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
@@ -282,6 +286,11 @@ export class RunFooter implements FooterApi {
onPermissionReply: this.handlePermissionReply,
onQuestionReply: this.handleQuestionReply,
onQuestionReject: this.handleQuestionReject,
// kilocode_change start
onTerminalWrite: options.onTerminalWrite,
onTerminalResize: options.onTerminalResize,
onTerminalClose: options.onTerminalClose,
// kilocode_change end
onCycle: this.handleCycle,
onInterrupt: this.handleInterrupt,
onInputClear: this.handleInputClear,
@@ -561,7 +570,9 @@ export class RunFooter implements FooterApi {
? this.base + PERMISSION_ROWS
: type === "question"
? this.base + QUESTION_ROWS
: this.promptRoute.type === "command"
: type === "interactive_terminal" // kilocode_change
? this.base + RUN_INTERACTIVE_TERMINAL_ROWS
: this.promptRoute.type === "command"
? 1 + tabs + COMMAND_ROWS
: this.promptRoute.type === "model"
? 1 + tabs + MODEL_ROWS
@@ -14,6 +14,7 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import "opentui-spinner/solid"
import { createColors, createFrames } from "../tui/ui/spinner"
import { RunInteractiveTerminalBody } from "@/kilocode/cli/cmd/run/interactive-terminal" // kilocode_change
import { RunCommandMenuBody, RunModelSelectBody, RunVariantSelectBody } from "./footer.command"
import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent"
@@ -76,6 +77,9 @@ type RunFooterViewProps = {
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void>
onTerminalWrite: (input: { terminalID: string; data: string }) => Promise<void> // kilocode_change
onTerminalResize: (input: { terminalID: string; cols: number; rows: number }) => Promise<void> // kilocode_change
onTerminalClose: (terminalID: string) => Promise<void> // kilocode_change
onCycle: () => void
onInterrupt: () => boolean
onInputClear: () => void
@@ -179,6 +183,12 @@ export function RunFooterView(props: RunFooterViewProps) {
const view = active()
return view.type === "question" ? view : undefined
})
// kilocode_change start
const terminal = createMemo<Extract<FooterView, { type: "interactive_terminal" }> | undefined>(() => {
const view = active()
return view.type === "interactive_terminal" ? view : undefined
})
// kilocode_change end
const promptView = createMemo(() => {
if (active().type !== "prompt") {
return active().type
@@ -473,6 +483,17 @@ export function RunFooterView(props: RunFooterViewProps) {
onReject={props.onQuestionReject}
/>
</Match>
{/* kilocode_change start */}
<Match when={active().type === "interactive_terminal"}>
<RunInteractiveTerminalBody
terminal={() => terminal()!.terminal}
theme={theme()}
onWrite={props.onTerminalWrite}
onResize={props.onTerminalResize}
onClose={props.onTerminalClose}
/>
</Match>
{/* kilocode_change end */}
</Switch>
</box>
@@ -66,6 +66,9 @@ export type LifecycleInput = {
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void>
onTerminalWrite: (input: { terminalID: string; data: string }) => Promise<void> // kilocode_change
onTerminalResize: (input: { terminalID: string; cols: number; rows: number }) => Promise<void> // kilocode_change
onTerminalClose: (terminalID: string) => Promise<void> // kilocode_change
onCycleVariant?: () => CycleResult | void
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
@@ -175,7 +178,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
stdin: source.stdin,
targetFps: 30,
maxFps: 60,
useMouse: false,
useMouse: true, // kilocode_change - interactive terminal close and scroll controls
autoFocus: false,
openConsoleOnError: false,
exitOnCtrlC: false,
@@ -235,6 +238,11 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onPermissionReply: input.onPermissionReply,
onQuestionReply: input.onQuestionReply,
onQuestionReject: input.onQuestionReject,
// kilocode_change start
onTerminalWrite: input.onTerminalWrite,
onTerminalResize: input.onTerminalResize,
onTerminalClose: input.onTerminalClose,
// kilocode_change end
onCycleVariant: input.onCycleVariant,
onModelSelect: input.onModelSelect,
onVariantSelect: input.onVariantSelect,
@@ -272,6 +272,23 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
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 })
},
// kilocode_change end
onCycleVariant: () => {
if (!state.model || state.variants.length === 0) {
return {
@@ -25,8 +25,10 @@
// event arrives, the queue entry is removed and the footer falls back
// to the next pending request or to the prompt view.
import type { Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2"
import type { RunInteractiveTerminalSnapshot } from "@/kilocode/cli/cmd/run/types" // kilocode_change
import type { Event } from "./event"
import * as Locale from "@/util/locale"
import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output" // kilocode_change
import { toolView } from "./tool"
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
@@ -71,6 +73,7 @@ export type SessionData = {
call: Map<string, Dict>
permissions: PermissionRequest[]
questions: QuestionRequest[]
terminal?: RunInteractiveTerminalSnapshot // kilocode_change
role: Map<string, MessageRole>
msg: Map<string, string>
part: Map<string, PartKind>
@@ -206,7 +209,17 @@ function out(data: SessionData, commits: SessionCommit[], footer?: FooterOutput)
}
}
export function pickBlockerView(input: { permission?: PermissionRequest; question?: QuestionRequest }): FooterView {
export function pickBlockerView(input: {
permission?: PermissionRequest
question?: QuestionRequest
terminal?: RunInteractiveTerminalSnapshot // kilocode_change
}): FooterView {
// kilocode_change start
if (input.terminal) {
return { type: "interactive_terminal", terminal: input.terminal }
}
// kilocode_change end
if (input.permission) {
return { type: "permission", request: input.permission }
}
@@ -219,6 +232,12 @@ export function pickBlockerView(input: { permission?: PermissionRequest; questio
}
export function blockerStatus(view: FooterView) {
// kilocode_change start
if (view.type === "interactive_terminal") {
return "interactive terminal"
}
// kilocode_change end
if (view.type === "permission") {
return "awaiting permission"
}
@@ -232,6 +251,7 @@ export function blockerStatus(view: FooterView) {
function pickSessionView(data: SessionData): FooterView {
return pickBlockerView({
terminal: data.terminal, // kilocode_change
permission: data.permissions[0],
question: data.questions[0],
})
@@ -908,6 +928,44 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
return out(data, commits)
}
// kilocode_change start - direct interactive mode terminal footer
if (event.type === "interactive_terminal.updated") {
if (event.properties.info.sessionID !== input.sessionID) {
return out(data, commits)
}
const current = data.terminal
data.terminal = {
info: event.properties.info,
output: current?.info.id === event.properties.info.id ? current.output : "",
cursor: current?.info.id === event.properties.info.id ? current.cursor : 0,
}
return queueOut(data, commits)
}
if (event.type === "interactive_terminal.data") {
if (event.properties.sessionID !== input.sessionID || data.terminal?.info.id !== event.properties.terminalID) {
return out(data, commits)
}
data.terminal = {
...data.terminal,
output: appendTerminalOutput(data.terminal.output, event.properties.data),
cursor: event.properties.cursor,
}
return queueOut(data, commits)
}
if (event.type === "interactive_terminal.deleted") {
if (event.properties.sessionID !== input.sessionID || data.terminal?.info.id !== event.properties.terminalID) {
return out(data, commits)
}
data.terminal = undefined
return queueOut(data, commits)
}
// kilocode_change end
if (event.type === "permission.asked") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
@@ -129,12 +129,16 @@ function sid(event: Event): string | undefined {
return event.properties.part.sessionID
}
if (event.type === "interactive_terminal.updated") return event.properties.info.sessionID // kilocode_change
if (
event.type === "permission.asked" ||
event.type === "permission.replied" ||
event.type === "question.asked" ||
event.type === "question.replied" ||
event.type === "question.rejected" ||
event.type === "interactive_terminal.data" || // kilocode_change
event.type === "interactive_terminal.deleted" || // kilocode_change
event.type === "session.error" ||
event.type === "session.status"
) {
@@ -261,6 +265,13 @@ function sameView(a: FooterView, b: FooterView) {
return false
}
// kilocode_change start
if (a.type === "interactive_terminal" && b.type === "interactive_terminal") {
return a.terminal === b.terminal
}
if (a.type === "interactive_terminal" || b.type === "interactive_terminal") return false
// kilocode_change end
return a.request === b.request
}
@@ -281,6 +292,7 @@ function firstByOrder<T extends { id: string }>(left: T[], right: T[], order: Ma
function pickView(data: SessionData, subagent: SubagentData, order: Map<string, number>): FooterView {
return pickBlockerView({
terminal: data.terminal, // kilocode_change
permission: firstByOrder(data.permissions, listSubagentPermissions(subagent), order),
question: firstByOrder(data.questions, listSubagentQuestions(subagent), order),
})
+40
View File
@@ -25,6 +25,7 @@ import type { GrepTool } from "@/tool/grep"
import type { InvalidTool } from "@/tool/invalid"
import type { LspTool } from "@/tool/lsp"
import type { PlanExitTool } from "@/tool/plan"
import type { InteractiveTerminalTool } from "@/kilocode/tool/interactive-terminal" // kilocode_change
import type { QuestionTool } from "@/tool/question"
import type { ReadTool } from "@/tool/read"
import type { SkillTool } from "@/tool/skill"
@@ -101,6 +102,7 @@ type ToolDefs = {
task: typeof TaskTool
todowrite: typeof TodoWriteTool
question: typeof QuestionTool
interactive_terminal: typeof InteractiveTerminalTool // kilocode_change
read: typeof ReadTool
glob: typeof GlobTool
grep: typeof GrepTool
@@ -423,6 +425,31 @@ function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline {
}
}
// kilocode_change start
function runInteractiveTerminal(p: ToolProps<typeof InteractiveTerminalTool>): ToolInline {
const command = p.input.command ?? ""
const description = p.input.description || command || "Interactive terminal"
return {
icon: "$",
title: description,
description: command && command !== description ? `$ ${command}` : undefined,
}
}
function scrollInteractiveTerminalStart(p: ToolProps<typeof InteractiveTerminalTool>) {
const command = p.input.command ?? ""
const description = p.input.description || command || "Interactive terminal"
return command && command !== description ? `# Interactive terminal: ${description}\n$ ${command}` : `# ${description}`
}
function scrollInteractiveTerminalFinal(p: ToolProps<typeof InteractiveTerminalTool>) {
if (p.metadata.closedBy === "user") return "interactive terminal closed by user"
if (p.metadata.closedBy === "abort") return "interactive terminal cancelled"
const code = p.metadata.exitCode
return typeof code === "number" ? `interactive terminal completed (exit ${code})` : "interactive terminal completed"
}
// kilocode_change end
function runInvalid(p: ToolProps<typeof InvalidTool>): ToolInline {
return {
icon: "✗",
@@ -1130,6 +1157,19 @@ const TOOL_RULES = {
final: scrollQuestionFinal,
},
},
// kilocode_change start
interactive_terminal: {
view: {
output: false,
final: true,
},
run: runInteractiveTerminal,
scroll: {
start: scrollInteractiveTerminalStart,
final: scrollInteractiveTerminalFinal,
},
},
// kilocode_change end
read: {
view: {
output: false,
@@ -14,6 +14,7 @@
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { KiloClient, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2"
import type { RunInteractiveTerminalSnapshot } from "@/kilocode/cli/cmd/run/types" // kilocode_change
export type RunFilePart = {
type: "file"
@@ -157,6 +158,7 @@ export type FooterView =
| { type: "prompt" }
| { type: "permission"; request: PermissionRequest }
| { type: "question"; request: QuestionRequest }
| { type: "interactive_terminal"; terminal: RunInteractiveTerminalSnapshot } // kilocode_change
export type FooterPromptRoute =
| { type: "composer" }
@@ -20,6 +20,7 @@ import type {
ProviderAuthMethod,
VcsInfo,
BackgroundProcessInfo, // kilocode_change
InteractiveTerminalSnapshot, // kilocode_change
} from "@kilocode/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "@tui/context/project"
@@ -32,6 +33,7 @@ import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, createEffect, on, onMount } from "solid-js" // kilocode_change - add createEffect/on for workspace re-bootstrap
import { handleSuggestionEvent } from "@/kilocode/suggestion/tui/sync" // kilocode_change
import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output" // kilocode_change
import { useToast } from "@tui/ui/toast" // kilocode_change
import * as Log from "@opencode-ai/core/util/log"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
@@ -82,6 +84,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
background_process: {
[sessionID: string]: BackgroundProcessInfo[]
}
interactive_terminal: {
[sessionID: string]: InteractiveTerminalSnapshot[]
}
// kilocode_change end
message: {
[sessionID: string]: Message[]
@@ -126,6 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
session_diff: {},
todo: {},
background_process: {}, // kilocode_change
interactive_terminal: {}, // kilocode_change
message: {},
part: {},
lsp: [],
@@ -157,6 +163,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
delete draft.session_status[sessionID]
delete draft.todo[sessionID]
delete draft.background_process[sessionID] // kilocode_change
delete draft.interactive_terminal[sessionID] // kilocode_change
delete draft.permission[sessionID]
delete draft.question[sessionID]
delete draft.suggestion[sessionID]
@@ -177,6 +184,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const fullSyncedSessions = new Set<string>()
const deleted = new Set<string>() // kilocode_change
const terminalDeleted = new Set<string>() // kilocode_change
let syncedWorkspace = project.workspace.current()
let vcsVersion = 0 // kilocode_change
@@ -201,7 +209,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "server.instance.disposed":
// kilocode_change start
deleted.clear()
terminalDeleted.clear()
setStore("background_process", {})
setStore("interactive_terminal", {})
// kilocode_change end
void bootstrap()
break
@@ -391,6 +401,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
)
break
}
case "interactive_terminal.updated": {
const info = event.properties.info
terminalDeleted.delete(info.id)
const list = store.interactive_terminal[info.sessionID]
if (!list) {
setStore("interactive_terminal", info.sessionID, [{ info, output: "", cursor: 0 }])
break
}
const result = Binary.search(list, info.id, (item) => item.info.id)
if (result.found) {
setStore("interactive_terminal", info.sessionID, result.index, "info", reconcile(info))
break
}
setStore(
"interactive_terminal",
info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, { info, output: "", cursor: 0 })
}),
)
break
}
case "interactive_terminal.data": {
const list = store.interactive_terminal[event.properties.sessionID]
if (!list) break
const result = Binary.search(list, event.properties.terminalID, (item) => item.info.id)
if (!result.found) break
setStore(
"interactive_terminal",
event.properties.sessionID,
result.index,
produce((draft) => {
draft.output = appendTerminalOutput(draft.output, event.properties.data)
draft.cursor = event.properties.cursor
}),
)
break
}
case "interactive_terminal.deleted": {
terminalDeleted.add(event.properties.terminalID)
const list = store.interactive_terminal[event.properties.sessionID]
if (!list) break
const result = Binary.search(list, event.properties.terminalID, (item) => item.info.id)
if (!result.found) break
setStore(
"interactive_terminal",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
// kilocode_change end
case "message.part.delta": {
@@ -587,7 +653,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.clear()
// kilocode_change start
deleted.clear()
terminalDeleted.clear()
setStore("background_process", {})
setStore("interactive_terminal", {})
// kilocode_change end
syncedWorkspace = workspace
}
@@ -709,6 +777,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}),
)
}),
sdk.client.interactiveTerminal.list({ workspace }).then((x) => {
const next: Record<string, InteractiveTerminalSnapshot[]> = {}
for (const item of x.data ?? []) {
if (terminalDeleted.has(item.info.id)) continue
if (!next[item.info.sessionID]) next[item.info.sessionID] = []
next[item.info.sessionID].push(item)
}
for (const list of Object.values(next)) list.sort((a, b) => a.info.id.localeCompare(b.info.id))
setStore(
"interactive_terminal",
produce((draft) => {
for (const [sessionID, list] of Object.entries(next)) {
const items = new Map((draft[sessionID] ?? []).map((item) => [item.info.id, item]))
for (const item of list) {
if (!items.has(item.info.id)) items.set(item.info.id, item)
}
draft[sessionID] = Array.from(items.values()).toSorted((a, b) => a.info.id.localeCompare(b.info.id))
}
}),
)
}),
// kilocode_change end
sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data ?? {}))
@@ -52,6 +52,7 @@ import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill"
// kilocode_change start
import type { BackgroundProcessTool } from "@/kilocode/tool/background-process"
import type { InteractiveTerminalTool } from "@/kilocode/tool/interactive-terminal"
import type { SemanticSearchTool } from "@/kilocode/tool/semantic-search"
// kilocode_change end
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
@@ -86,6 +87,7 @@ import { QuestionPrompt } from "./question"
import { Suggest } from "@/kilocode/suggestion/tui/render"
import { SuggestPrompt } from "@/kilocode/suggestion/tui/prompt"
import { NetworkPrompt } from "./network"
import { TerminalPrompt } from "./terminal"
// kilocode_change end
import { DialogExportOptions } from "../../ui/dialog-export-options"
import * as Model from "../../util/model"
@@ -231,6 +233,11 @@ export function Session() {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.network[x.id] ?? [])
})
const terminals = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.interactive_terminal[x.id] ?? [])
})
const terminal = createMemo(() => terminals()[0])
const blockingQuestions = createMemo(() => questions().filter((q) => q.blocking !== false))
const nonBlockingQuestions = createMemo(() => questions().filter((q) => q.blocking === false))
const question = createMemo(() => blockingQuestions()[0] ?? nonBlockingQuestions()[0])
@@ -243,13 +250,15 @@ export function Session() {
permissions().length === 0 &&
blockingQuestions().length === 0 &&
blockingSuggestions().length === 0 &&
network().length === 0,
network().length === 0 &&
terminals().length === 0,
)
const networkVisible = createMemo(
() =>
permissions().length === 0 &&
blockingQuestions().length === 0 &&
blockingSuggestions().length === 0 &&
terminals().length === 0 &&
network().length > 0,
)
const disabled = createMemo(
@@ -257,7 +266,8 @@ export function Session() {
permissions().length > 0 ||
blockingQuestions().length > 0 ||
blockingSuggestions().length > 0 ||
network().length > 0,
network().length > 0 ||
terminals().length > 0,
)
// kilocode_change end
@@ -1363,11 +1373,15 @@ export function Session() {
</For>
</scrollbox>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
{/* kilocode_change - the terminal owns the input area while active */}
<Show when={!terminal() && permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
{/* kilocode_change start */}
<Show when={permissions().length === 0 && question()} keyed>
<Show when={terminal()} keyed>
{(value) => <TerminalPrompt sessionID={value.info.sessionID} terminalID={value.info.id} />}
</Show>
<Show when={!terminal() && permissions().length === 0 ? question() : undefined} keyed>
{(request) => (
<QuestionPrompt
request={request}
@@ -1376,7 +1390,7 @@ export function Session() {
/>
)}
</Show>
<Show when={permissions().length === 0 && !question()}>
<Show when={!terminal() && permissions().length === 0 && !question()}>
<Show when={blockingSuggestion()} keyed>
{(request) => <SuggestPrompt request={request} />}
</Show>
@@ -1387,7 +1401,7 @@ export function Session() {
<Show when={networkVisible()}>
<NetworkPrompt request={network()[0]} />
</Show>
<Show when={!session()?.parentID}>
<Show when={!terminal() && !session()?.parentID}>
<TuiPluginRuntime.Slot
name="session_prompt"
mode="replace"
@@ -1817,6 +1831,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "background_process"}>
<BackgroundProcess {...toolprops} />
</Match>
<Match when={props.part.tool === "interactive_terminal"}>
<InteractiveTerminal {...toolprops} />
</Match>
<Match when={props.part.tool === "semantic_search"}>
<SemanticSearch {...toolprops} />
</Match>
@@ -2270,6 +2287,44 @@ function BackgroundProcess(props: ToolProps<typeof BackgroundProcessTool>) {
)
}
function InteractiveTerminal(props: ToolProps<typeof InteractiveTerminalTool>) {
const sync = useSync()
const pathFormatter = usePathFormatter()
const running = createMemo(() => props.part.state.status === "running")
const command = createMemo(() => (typeof props.input.command === "string" ? props.input.command : ""))
const description = createMemo(() => props.input.description || command() || "interactive command")
const dir = createMemo(() => {
const raw = props.input.workdir
if (!raw || raw === ".") return undefined
const base = sync.path.directory
if (!base) return pathFormatter.format(raw)
const abs = path.resolve(base, raw)
if (abs === base) return undefined
return pathFormatter.format(abs)
})
const status = createMemo(() => {
if (props.metadata.closedBy === "user") return "closed by user"
if (props.metadata.closedBy === "abort") return "cancelled"
if (props.metadata.closedBy !== "exit") return undefined
return typeof props.metadata.exitCode === "number" ? `exit ${props.metadata.exitCode}` : "completed"
})
return (
<InlineTool
icon="$"
pending="Opening interactive terminal..."
complete={description()}
spinner={running()}
part={props.part}
>
Interactive terminal: {description()}
<Show when={dir()}> in {dir()}</Show>
<Show when={command()}> · $ {command()}</Show>
<Show when={status()}> ({status()})</Show>
</InlineTool>
)
}
function SemanticSearch(props: ToolProps<typeof SemanticSearchTool>) {
const pathFormatter = usePathFormatter()
const meta = createMemo(() => props.metadata as { results?: { length: number }[] })
@@ -0,0 +1,203 @@
// kilocode_change - new file
import { TextAttributes, decodePasteBytes, type MouseEvent, type PasteEvent } from "@opentui/core"
import { useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid"
import type { InteractiveTerminalSnapshot } from "@kilocode/sdk/v2"
import { VtScreen } from "@/kilocode/cli/cmd/tui/vt/vt-screen"
import { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { createEffect, createMemo, createSignal, on, onMount } from "solid-js"
export function TerminalPrompt(props: { sessionID: string; terminalID: string }) {
const sdk = useSDK()
const sync = useSync()
const { theme } = useTheme()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const [snapshot, setSnapshot] = createSignal<InteractiveTerminalSnapshot>()
function terminal() {
const live = sync.data.interactive_terminal[props.sessionID]?.find((item) => item.info.id === props.terminalID)
const polled = snapshot()
if (!live) return polled
if (!polled || live.cursor >= polled.cursor) return live
return polled
}
const cols = createMemo(() => Math.max(20, dimensions().width - 8))
const rows = createMemo(() => Math.max(6, Math.min(18, dimensions().height - 12)))
const state = {
vt: new VtScreen(cols(), rows()),
consumed: 0,
input: Promise.resolve(),
polling: false,
}
const [version, refresh] = createSignal(0)
const [offset, setOffset] = createSignal(0)
const [closing, setClosing] = createSignal(false)
function send(data: string) {
if (!data || closing()) return
state.input = state.input
.then(() =>
sdk.client.interactiveTerminal.write({
terminalID: props.terminalID,
interactiveTerminalWriteInput: { data },
}),
)
.then(() => undefined)
.catch(() => undefined)
}
function close() {
if (closing()) return
setClosing(true)
void sdk.client.interactiveTerminal.close({ terminalID: props.terminalID }).catch(() => {
setClosing(false)
})
}
function scroll(delta: number) {
setOffset((value) => Math.max(0, Math.min(state.vt.scrollbackSize(), value + delta)))
}
function poll() {
if (state.polling || closing()) return
state.polling = true
void sdk.client.interactiveTerminal
.get({ terminalID: props.terminalID })
.then((result) => {
if (result.data) setSnapshot(result.data)
})
.catch(() => undefined)
.finally(() => {
state.polling = false
})
}
onMount(() => {
poll()
})
useKeyboard((event) => {
if (event.eventType === "release") return
event.preventDefault()
event.stopPropagation()
if (event.name === "pageup") {
scroll(Math.max(1, rows() - 1))
return
}
if (event.name === "pagedown") {
scroll(-Math.max(1, rows() - 1))
return
}
send(event.raw || event.sequence)
})
usePaste((event: PasteEvent) => {
event.preventDefault()
event.stopPropagation()
send(decodePasteBytes(event.bytes))
})
createEffect(() => {
const current = terminal()
if (!current) return
const output = current.output
const cursor = current.cursor
const start = cursor - output.length
if (state.consumed < start || state.consumed > cursor) {
state.vt = new VtScreen(cols(), rows())
state.consumed = start
setOffset(0)
}
const data = output.slice(state.consumed - start)
state.consumed = cursor
if (!data) return
const before = state.vt.scrollCount()
state.vt.write(data)
const added = state.vt.scrollCount() - before
setOffset((value) => {
const next = value > 0 ? value + added : 0
return Math.min(state.vt.scrollbackSize(), next)
})
refresh((value) => value + 1)
})
createEffect(
on([cols, rows], ([width, height]) => {
state.vt.resize(width, height)
setOffset((value) => Math.min(state.vt.scrollbackSize(), value))
refresh((value) => value + 1)
void sdk.client.interactiveTerminal
.resize({
terminalID: props.terminalID,
interactiveTerminalResizeInput: { cols: width, rows: height },
})
.catch(() => undefined)
}),
)
const screen = createMemo(() => {
version()
return state.vt.viewText(offset(), rows())
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.accent}
customBorderChars={SplitBorder.customBorderChars}
flexShrink={0}
>
<box
flexDirection="row"
justifyContent="space-between"
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{terminal()?.info.description ?? terminal()?.info.command ?? props.terminalID}
</text>
<box
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
close()
}}
>
<text fg={closing() ? theme.textMuted : theme.error}>{closing() ? "closing" : "x"}</text>
</box>
</box>
<box
paddingLeft={2}
paddingRight={2}
height={rows()}
overflow="hidden"
onMouseScroll={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const amount = Math.max(1, Math.abs(event.scroll?.delta ?? 1)) * 3
if (event.scroll?.direction === "up") scroll(amount)
if (event.scroll?.direction === "down") scroll(-amount)
}}
>
<text fg={theme.text} wrapMode="none">
{screen()}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2} paddingRight={2} paddingBottom={1} paddingTop={1}>
<text fg={theme.text}>
ctrl+c <span style={{ fg: theme.textMuted }}>interrupt</span>
</text>
<text fg={theme.text}>
pgup/pgdn <span style={{ fg: theme.textMuted }}>scroll{offset() > 0 ? ` (${offset()} lines up)` : ""}</span>
</text>
<text fg={theme.text}>
click x <span style={{ fg: theme.textMuted }}>force close</span>
</text>
</box>
</box>
)
}
@@ -0,0 +1,146 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes, decodePasteBytes, type MouseEvent, type PasteEvent } from "@opentui/core"
import { useKeyboard, usePaste, useTerminalDimensions } from "@opentui/solid"
import type { RunInteractiveTerminalSnapshot } from "./types"
import { VtScreen } from "@/kilocode/cli/cmd/tui/vt/vt-screen"
import type { RunFooterTheme } from "@/cli/cmd/run/theme"
import { createEffect, createMemo, createSignal, on } from "solid-js"
export const RUN_INTERACTIVE_TERMINAL_ROWS = 18
const VIEW_ROWS = 14
type Props = {
terminal: () => RunInteractiveTerminalSnapshot
theme: RunFooterTheme
onWrite: (input: { terminalID: string; data: string }) => Promise<void>
onResize: (input: { terminalID: string; cols: number; rows: number }) => Promise<void>
onClose: (terminalID: string) => Promise<void>
}
export function RunInteractiveTerminalBody(props: Props) {
const term = useTerminalDimensions()
const cols = createMemo(() => Math.max(20, term().width - 6))
const state = {
vt: new VtScreen(cols(), VIEW_ROWS),
consumed: 0,
input: Promise.resolve(),
}
const [version, refresh] = createSignal(0)
const [offset, setOffset] = createSignal(0)
const [closing, setClosing] = createSignal(false)
function send(data: string) {
if (!data || closing()) return
state.input = state.input
.then(() => props.onWrite({ terminalID: props.terminal().info.id, data }))
.catch(() => undefined)
}
function scroll(delta: number) {
setOffset((value) => Math.max(0, Math.min(state.vt.scrollbackSize(), value + delta)))
}
function close() {
if (closing()) return
setClosing(true)
void props.onClose(props.terminal().info.id).catch(() => setClosing(false))
}
useKeyboard((event) => {
if (event.eventType === "release") return
event.preventDefault()
event.stopPropagation()
if (event.name === "pageup") {
scroll(VIEW_ROWS - 1)
return
}
if (event.name === "pagedown") {
scroll(-(VIEW_ROWS - 1))
return
}
if (event.name === "escape") {
close()
return
}
send(event.raw || event.sequence)
})
usePaste((event: PasteEvent) => {
event.preventDefault()
event.stopPropagation()
send(decodePasteBytes(event.bytes))
})
createEffect(() => {
const terminal = props.terminal()
const start = terminal.cursor - terminal.output.length
if (state.consumed < start || state.consumed > terminal.cursor) {
state.vt = new VtScreen(cols(), VIEW_ROWS)
state.consumed = start
setOffset(0)
}
const data = terminal.output.slice(state.consumed - start)
state.consumed = terminal.cursor
if (!data) return
const before = state.vt.scrollCount()
state.vt.write(data)
const added = state.vt.scrollCount() - before
setOffset((value) => Math.min(state.vt.scrollbackSize(), value > 0 ? value + added : 0))
refresh((value) => value + 1)
})
createEffect(
on(cols, (width) => {
state.vt.resize(width, VIEW_ROWS)
setOffset((value) => Math.min(state.vt.scrollbackSize(), value))
refresh((value) => value + 1)
void props
.onResize({ terminalID: props.terminal().info.id, cols: width, rows: VIEW_ROWS })
.catch(() => undefined)
}),
)
const screen = createMemo(() => {
version()
return state.vt.viewText(offset(), VIEW_ROWS)
})
return (
<box width="100%" height={RUN_INTERACTIVE_TERMINAL_ROWS} flexDirection="column" paddingLeft={2} paddingRight={2}>
<box height={1} flexDirection="row" justifyContent="space-between" flexShrink={0}>
<text fg={props.theme.text} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
{props.terminal().info.description ?? props.terminal().info.command}
</text>
<box onMouseUp={close}>
<text fg={closing() ? props.theme.muted : props.theme.error} wrapMode="none">
{closing() ? "closing" : "x"}
</text>
</box>
</box>
<box
height={VIEW_ROWS}
overflow="hidden"
flexShrink={0}
onMouseScroll={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const amount = Math.max(1, Math.abs(event.scroll?.delta ?? 1)) * 3
if (event.scroll?.direction === "up") scroll(amount)
if (event.scroll?.direction === "down") scroll(-amount)
}}
>
<text fg={props.theme.text} wrapMode="none">
{screen()}
</text>
</box>
<box height={1} flexDirection="row" gap={2} flexShrink={0}>
<text fg={props.theme.text} wrapMode="none">
ctrl+c <span style={{ fg: props.theme.muted }}>interrupt</span>
</text>
<text fg={props.theme.text} wrapMode="none">
pgup/pgdn <span style={{ fg: props.theme.muted }}>scroll{offset() > 0 ? ` (${offset()} up)` : ""}</span>
</text>
</box>
</box>
)
}
@@ -0,0 +1 @@
export type { InteractiveTerminalSnapshot as RunInteractiveTerminalSnapshot } from "@kilocode/sdk/v2"
@@ -0,0 +1,537 @@
// Minimal VT/ANSI screen emulator for the interactive terminal dialog.
//
// This is intentionally small: it covers the escape sequences that line-oriented
// interactive prompts emit (credential prompts, `gh auth login`'s arrow-key
// survey UI, REPLs, `ssh` passphrase, installers): SGR colors/attrs, cursor
// movement, erase-in-line / erase-in-display, scrolling, save/restore cursor,
// tab/backspace/carriage-return. It deliberately does NOT aim for full
// terminal fidelity (no sixel, no full alt-screen app rendering); unknown
// sequences are dropped without corrupting the grid.
//
// Pure and dependency-free so it can be unit tested by feeding raw bytes and
// asserting the resulting grid. Color is normalized to a transport-neutral
// shape (palette index or rgb) and mapped to OpenTUI/theme colors by the caller.
export type Color = number | { r: number; g: number; b: number }
export interface Cell {
char: string
fg?: Color
bg?: Color
bold?: boolean
dim?: boolean
italic?: boolean
underline?: boolean
inverse?: boolean
}
interface Attrs {
fg?: Color
bg?: Color
bold?: boolean
dim?: boolean
italic?: boolean
underline?: boolean
inverse?: boolean
}
const TAB = 8
const MAX_ROWS = 200 // hard cap so a runaway program can't grow the grid unbounded
export const SCROLLBACK_LINES = 500
function blank(attrs?: Attrs): Cell {
return { char: " ", ...(attrs ?? {}) }
}
export class VtScreen {
cols: number
rows: number
private grid: Cell[][]
private history: Cell[][] = []
private scrolled = 0
private cur = { x: 0, y: 0 }
private saved = { x: 0, y: 0 }
private attrs: Attrs = {}
private top = 0
private bottom: number
private wrapPending = false
cursorVisible = true
// parser state
private state: "ground" | "esc" | "csi" | "osc" | "osc-esc" = "ground"
private params = ""
private intermediate = ""
constructor(cols = 80, rows = 24) {
this.cols = Math.max(1, cols)
this.rows = Math.max(1, Math.min(MAX_ROWS, rows))
this.bottom = this.rows - 1
this.grid = Array.from({ length: this.rows }, () => this.row())
}
private row(): Cell[] {
return Array.from({ length: this.cols }, () => blank())
}
/** Current grid snapshot for rendering (rows of cells). */
cells(): ReadonlyArray<ReadonlyArray<Cell>> {
return this.grid
}
/** Plain text per line, trailing blanks trimmed. Used by tests and fallbacks. */
lines(): string[] {
return this.grid.map((row) => {
let text = row.map((c) => c.char).join("")
return text.replace(/\s+$/u, "")
})
}
/** Whole screen as text with trailing empty lines removed. */
text(): string {
const lines = this.lines()
let end = lines.length
while (end > 0 && lines[end - 1] === "") end--
return lines.slice(0, end).join("\n")
}
scrollbackSize() {
return this.history.length
}
scrollCount() {
return this.scrolled
}
viewLines(offset = 0, height = this.rows) {
const rows = [...this.history, ...this.grid]
const size = Math.max(1, Math.min(rows.length, height))
const distance = Math.max(0, Math.min(this.history.length, offset))
const end = rows.length - distance
const start = Math.max(0, end - size)
return rows.slice(start, end).map((row) => row.map((cell) => cell.char).join("").replace(/\s+$/u, ""))
}
viewText(offset = 0, height = this.rows) {
return this.viewLines(offset, height).join("\n")
}
cursor() {
return { x: this.cur.x, y: this.cur.y }
}
resize(cols: number, rows: number) {
cols = Math.max(1, cols)
rows = Math.max(1, Math.min(MAX_ROWS, rows))
if (cols === this.cols && rows === this.rows) return
const next = Array.from({ length: rows }, (_, y) => {
const old = this.grid[y]
return Array.from({ length: cols }, (_, x) => old?.[x] ?? blank())
})
const history = this.history.map((row) => Array.from({ length: cols }, (_, x) => row[x] ?? blank()))
this.cols = cols
this.rows = rows
this.grid = next
this.history = history
this.top = 0
this.bottom = rows - 1
this.cur.x = Math.min(this.cur.x, cols - 1)
this.cur.y = Math.min(this.cur.y, rows - 1)
this.wrapPending = false
}
write(data: string) {
for (const ch of data) {
const code = ch.codePointAt(0)!
if (this.state === "ground") this.ground(ch, code)
else if (this.state === "esc") this.esc(ch)
else if (this.state === "csi") this.csi(ch, code)
else this.osc(ch, code)
}
}
private ground(ch: string, code: number) {
if (code === 0x1b) {
this.state = "esc"
this.params = ""
this.intermediate = ""
return
}
if (code === 0x0a || code === 0x0b || code === 0x0c) return this.lineFeed() // LF/VT/FF
if (code === 0x0d) {
this.cur.x = 0
this.wrapPending = false
return
}
if (code === 0x08) {
this.cur.x = Math.max(0, this.cur.x - 1)
this.wrapPending = false
return
}
if (code === 0x09) {
const next = Math.min(this.cols - 1, (Math.floor(this.cur.x / TAB) + 1) * TAB)
this.cur.x = next
return
}
if (code === 0x07) return // BEL
if (code < 0x20) return // other C0 controls ignored
this.put(ch)
}
private put(ch: string) {
if (this.wrapPending) {
this.cur.x = 0
this.lineFeed()
this.wrapPending = false
}
const line = this.grid[this.cur.y]
if (!line) return
line[this.cur.x] = { char: ch, ...this.attrs }
if (this.cur.x === this.cols - 1) this.wrapPending = true
else this.cur.x++
}
private lineFeed() {
if (this.cur.y === this.bottom) {
this.scrollUp(1)
return
}
if (this.cur.y < this.rows - 1) this.cur.y++
}
private scrollUp(n: number) {
for (let i = 0; i < n; i++) {
const removed = this.grid.splice(this.top, 1)[0]
if (removed && this.top === 0 && this.bottom === this.rows - 1) {
this.history.push(removed)
this.scrolled++
if (this.history.length > SCROLLBACK_LINES) this.history.splice(0, this.history.length - SCROLLBACK_LINES)
}
this.grid.splice(this.bottom, 0, this.row())
}
}
private scrollDown(n: number) {
for (let i = 0; i < n; i++) {
this.grid.splice(this.bottom, 1)
this.grid.splice(this.top, 0, this.row())
}
}
private esc(ch: string) {
if (ch === "[") {
this.state = "csi"
this.params = ""
this.intermediate = ""
return
}
if (ch === "]") {
this.state = "osc"
return
}
if (ch === "7") {
this.saveCursor()
this.state = "ground"
return
}
if (ch === "8") {
this.restoreCursor()
this.state = "ground"
return
}
if (ch === "M") {
// reverse index: move up, scroll down at top
if (this.cur.y === this.top) this.scrollDown(1)
else this.cur.y = Math.max(0, this.cur.y - 1)
this.state = "ground"
return
}
if (ch === "c") {
this.reset()
this.state = "ground"
return
}
if (ch === "(" || ch === ")" || ch === "#" || ch === "%") {
// charset designators consume one more byte; approximate by staying in
// esc for the next char then dropping it.
this.intermediate = ch
return
}
if (this.intermediate) {
// drop the charset payload byte
this.intermediate = ""
this.state = "ground"
return
}
this.state = "ground"
}
private osc(ch: string, code: number) {
if (code === 0x07) {
this.state = "ground"
return
}
if (this.state === "osc" && code === 0x1b) {
this.state = "osc-esc"
return
}
if (this.state === "osc-esc" && ch === "\\") {
this.state = "ground"
return
}
this.state = "osc"
}
private csi(ch: string, code: number) {
// params: digits, ';', and a leading '?' private marker
if ((code >= 0x30 && code <= 0x3f) || ch === ":") {
this.params += ch
return
}
if (code >= 0x20 && code <= 0x2f) {
this.intermediate += ch
return
}
this.dispatch(ch)
this.state = "ground"
}
private nums(): number[] {
const raw = this.params.startsWith("?") ? this.params.slice(1) : this.params
if (raw === "") return []
return raw.split(";").map((p) => {
const n = parseInt(p, 10)
return Number.isFinite(n) ? n : 0
})
}
private dispatch(ch: string) {
const priv = this.params.startsWith("?")
const p = this.nums()
const n = p[0] && p[0] > 0 ? p[0] : 1
switch (ch) {
case "A":
this.cur.y = Math.max(this.top, this.cur.y - n)
this.wrapPending = false
return
case "B":
this.cur.y = Math.min(this.bottom, this.cur.y + n)
this.wrapPending = false
return
case "C":
this.cur.x = Math.min(this.cols - 1, this.cur.x + n)
this.wrapPending = false
return
case "D":
this.cur.x = Math.max(0, this.cur.x - n)
this.wrapPending = false
return
case "E":
this.cur.y = Math.min(this.bottom, this.cur.y + n)
this.cur.x = 0
this.wrapPending = false
return
case "F":
this.cur.y = Math.max(this.top, this.cur.y - n)
this.cur.x = 0
this.wrapPending = false
return
case "G":
this.cur.x = Math.min(this.cols - 1, Math.max(0, (p[0] ?? 1) - 1))
this.wrapPending = false
return
case "d":
this.cur.y = Math.min(this.rows - 1, Math.max(0, (p[0] ?? 1) - 1))
this.wrapPending = false
return
case "H":
case "f": {
const r = (p[0] ?? 1) - 1
const c = (p[1] ?? 1) - 1
this.cur.y = Math.min(this.rows - 1, Math.max(0, r))
this.cur.x = Math.min(this.cols - 1, Math.max(0, c))
this.wrapPending = false
return
}
case "J":
this.eraseDisplay(p[0] ?? 0)
return
case "K":
this.eraseLine(p[0] ?? 0)
return
case "X":
this.eraseChars(n)
return
case "P":
this.deleteChars(n)
return
case "@":
this.insertChars(n)
return
case "L":
this.insertLines(n)
return
case "M":
this.deleteLines(n)
return
case "S":
this.scrollUp(n)
return
case "T":
this.scrollDown(n)
return
case "m":
this.sgr(p)
return
case "r": {
this.top = Math.max(0, (p[0] ?? 1) - 1)
this.bottom = Math.min(this.rows - 1, (p[1] ?? this.rows) - 1)
if (this.bottom < this.top) {
this.top = 0
this.bottom = this.rows - 1
}
this.cur.x = 0
this.cur.y = this.top
return
}
case "s":
this.saveCursor()
return
case "u":
this.restoreCursor()
return
case "h":
if (priv && p.includes(25)) this.cursorVisible = true
if (priv && (p.includes(1049) || p.includes(47) || p.includes(1047))) this.clearAll()
return
case "l":
if (priv && p.includes(25)) this.cursorVisible = false
if (priv && (p.includes(1049) || p.includes(47) || p.includes(1047))) this.clearAll()
return
default:
return
}
}
private eraseLine(mode: number) {
const line = this.grid[this.cur.y]
if (!line) return
const from = mode === 0 ? this.cur.x : 0
const to = mode === 1 ? this.cur.x : this.cols - 1
for (let x = from; x <= to; x++) line[x] = blank(this.attrs)
}
private eraseDisplay(mode: number) {
if (mode === 2 || mode === 3) {
if (mode === 3) this.history = []
this.clearAll()
return
}
if (mode === 0) {
this.eraseLine(0)
for (let y = this.cur.y + 1; y < this.rows; y++) this.grid[y] = this.row()
return
}
// mode 1: start of screen to cursor
for (let y = 0; y < this.cur.y; y++) this.grid[y] = this.row()
const line = this.grid[this.cur.y]
if (line) for (let x = 0; x <= this.cur.x; x++) line[x] = blank(this.attrs)
}
private eraseChars(n: number) {
const line = this.grid[this.cur.y]
if (!line) return
for (let i = 0; i < n && this.cur.x + i < this.cols; i++) line[this.cur.x + i] = blank(this.attrs)
}
private deleteChars(n: number) {
const line = this.grid[this.cur.y]
if (!line) return
line.splice(this.cur.x, n)
while (line.length < this.cols) line.push(blank(this.attrs))
}
private insertChars(n: number) {
const line = this.grid[this.cur.y]
if (!line) return
for (let i = 0; i < n; i++) line.splice(this.cur.x, 0, blank(this.attrs))
line.length = this.cols
}
private insertLines(n: number) {
if (this.cur.y < this.top || this.cur.y > this.bottom) return
for (let i = 0; i < n; i++) {
this.grid.splice(this.bottom, 1)
this.grid.splice(this.cur.y, 0, this.row())
}
}
private deleteLines(n: number) {
if (this.cur.y < this.top || this.cur.y > this.bottom) return
for (let i = 0; i < n; i++) {
this.grid.splice(this.cur.y, 1)
this.grid.splice(this.bottom, 0, this.row())
}
}
private saveCursor() {
this.saved = { x: this.cur.x, y: this.cur.y }
}
private restoreCursor() {
this.cur = { x: Math.min(this.saved.x, this.cols - 1), y: Math.min(this.saved.y, this.rows - 1) }
this.wrapPending = false
}
private clearAll() {
this.grid = Array.from({ length: this.rows }, () => this.row())
this.cur = { x: 0, y: 0 }
this.wrapPending = false
}
private reset() {
this.attrs = {}
this.history = []
this.top = 0
this.bottom = this.rows - 1
this.clearAll()
}
private sgr(p: number[]) {
if (p.length === 0) {
this.attrs = {}
return
}
for (let i = 0; i < p.length; i++) {
const code = p[i]
if (code === 0) this.attrs = {}
else if (code === 1) this.attrs.bold = true
else if (code === 2) this.attrs.dim = true
else if (code === 3) this.attrs.italic = true
else if (code === 4) this.attrs.underline = true
else if (code === 7) this.attrs.inverse = true
else if (code === 22) {
this.attrs.bold = false
this.attrs.dim = false
} else if (code === 23) this.attrs.italic = false
else if (code === 24) this.attrs.underline = false
else if (code === 27) this.attrs.inverse = false
else if (code >= 30 && code <= 37) this.attrs.fg = code - 30
else if (code === 39) this.attrs.fg = undefined
else if (code >= 40 && code <= 47) this.attrs.bg = code - 40
else if (code === 49) this.attrs.bg = undefined
else if (code >= 90 && code <= 97) this.attrs.fg = code - 90 + 8
else if (code >= 100 && code <= 107) this.attrs.bg = code - 100 + 8
else if (code === 38 || code === 48) {
const target = code === 38 ? "fg" : "bg"
const mode = p[i + 1]
if (mode === 5) {
this.attrs[target] = p[i + 2] ?? 0
i += 2
} else if (mode === 2) {
this.attrs[target] = { r: p[i + 2] ?? 0, g: p[i + 3] ?? 0, b: p[i + 4] ?? 0 }
i += 4
}
}
}
}
}
@@ -24,9 +24,7 @@ export namespace KilocodeDefaultPlugins {
cfg.plugin = plugins
// Built-in plugins are not loaded externally and must not wait for external plugin setup.
const origins = cfg.plugin_origins?.filter(
(item) => !isIndexingPlugin(item.spec) && !isAtomicChatPlugin(item.spec),
)
const origins = cfg.plugin_origins?.filter((item) => !isIndexingPlugin(item.spec) && !isAtomicChatPlugin(item.spec))
if (!origins) return cfg
if (opts.disabled) {
cfg.plugin_origins = origins
@@ -0,0 +1,446 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output"
import { Identifier } from "@/id/id"
import { Instance, type InstanceContext } from "@/kilocode/instance"
import { SessionID } from "@/session/schema"
import { Shell } from "@/shell/shell"
import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import * as Log from "@opencode-ai/core/util/log"
import type { Disp, Proc } from "#pty"
import { Context, Effect, Layer, Schema, Types } from "effect"
import path from "path"
import stripAnsi from "strip-ansi"
import z from "zod"
export namespace InteractiveTerminal {
const log = Log.create({ service: "interactive-terminal" })
const FLUSH_MS = 25
const DEFAULT_COLS = 100
const DEFAULT_ROWS = 18
const idSchema = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("itx") }).pipe(
Schema.brand("InteractiveTerminalID"),
)
export type ID = typeof idSchema.Type
export const ID = idSchema.pipe(
withStatics((schema: typeof idSchema) => ({
ascending: (id?: string) => {
if (id && !id.startsWith("itx")) throw new Error(`Interactive terminal ID must start with itx: ${id}`)
return schema.make(id ?? Identifier.create("itx", "ascending"))
},
zod: zod(schema),
})),
)
export const Status = Schema.Literals(["running", "closed"])
export type Status = Schema.Schema.Type<typeof Status>
export const ClosedBy = Schema.Literals(["exit", "user", "abort"])
export type ClosedBy = Schema.Schema.Type<typeof ClosedBy>
export const Info = Schema.Struct({
id: ID,
sessionID: SessionID,
pid: PositiveInt,
command: Schema.String,
cwd: Schema.String,
description: optionalOmitUndefined(Schema.String),
status: Status,
cols: PositiveInt,
rows: PositiveInt,
exitCode: optionalOmitUndefined(Schema.Number),
signal: optionalOmitUndefined(Schema.String),
closedBy: optionalOmitUndefined(ClosedBy),
time: Schema.Struct({
started: NonNegativeInt,
updated: NonNegativeInt,
ended: optionalOmitUndefined(NonNegativeInt),
}),
})
.annotate({ identifier: "InteractiveTerminalInfo" })
.pipe(withStatics((schema) => ({ zod: zod(schema) })))
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
export const Snapshot = Schema.Struct({
info: Info,
output: Schema.String,
cursor: NonNegativeInt,
})
.annotate({ identifier: "InteractiveTerminalSnapshot" })
.pipe(withStatics((schema) => ({ zod: zod(schema) })))
export type Snapshot = Types.DeepMutable<Schema.Schema.Type<typeof Snapshot>>
export const WriteInput = Schema.Struct({
data: Schema.String,
}).annotate({ identifier: "InteractiveTerminalWriteInput" })
export const ResizeInput = Schema.Struct({
cols: PositiveInt,
rows: PositiveInt,
}).annotate({ identifier: "InteractiveTerminalResizeInput" })
export const Event = {
Updated: BusEvent.define("interactive_terminal.updated", Schema.Struct({ info: Info })),
Data: BusEvent.define(
"interactive_terminal.data",
Schema.Struct({ terminalID: ID, sessionID: SessionID, data: Schema.String, cursor: NonNegativeInt }),
),
Deleted: BusEvent.define("interactive_terminal.deleted", Schema.Struct({ terminalID: ID, sessionID: SessionID })),
}
export interface RunInput {
sessionID: SessionID
command: string
cwd?: string
description?: string
shell: string
env: NodeJS.ProcessEnv
cols?: number
rows?: number
abort?: AbortSignal
}
export interface Result {
id: ID
output: string
exitCode?: number
signal?: string
closedBy: ClosedBy
}
type Active = {
ctx: InstanceContext
info: Info
proc: Proc
output: string
chunk: string
cursor: number
resolve: (result: Result) => void
ready?: Promise<void>
timer?: ReturnType<typeof setTimeout>
data?: Disp
exit?: Disp
abort?: () => void
done: boolean
result?: Result
}
type State = {
ctx: InstanceContext
dir: string
terminals: Map<ID, Active>
}
class StateService extends Context.Service<StateService, { readonly get: () => Effect.Effect<State> }>()(
"@kilocode/InteractiveTerminal.State",
) {}
function clone(info: Info): Info {
return { ...info, time: { ...info.time } }
}
function clean(text: string) {
return stripAnsi(text)
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/[\b\x00-\x07\x0b\x0c\x0e-\x1f\x7f]/g, "")
}
function publish(active: Active, event: typeof Event.Updated, payload: { info: Info }): Promise<void>
function publish(
active: Active,
event: typeof Event.Data,
payload: { terminalID: ID; sessionID: SessionID; data: string; cursor: number },
): Promise<void>
function publish(
active: Active,
event: typeof Event.Deleted,
payload: { terminalID: ID; sessionID: SessionID },
): Promise<void>
function publish(
active: Active,
event: typeof Event.Updated | typeof Event.Data | typeof Event.Deleted,
payload:
| { info: Info }
| { terminalID: ID; sessionID: SessionID; data: string; cursor: number }
| { terminalID: ID; sessionID: SessionID },
) {
return Instance.restore(active.ctx, () =>
Bus.publish(active.ctx, event as never, payload as never).catch((err) => {
log.warn("failed to publish terminal event", { err, id: active.info.id, type: event.type })
}),
)
}
async function flush(active: Active) {
if (active.timer) clearTimeout(active.timer)
active.timer = undefined
const data = active.chunk
const cursor = active.cursor
active.chunk = ""
if (!data) return
await publish(active, Event.Data, {
terminalID: active.info.id,
sessionID: active.info.sessionID,
data,
cursor,
})
}
function schedule(active: Active) {
if (active.done || active.timer) return
active.timer = setTimeout(() => {
active.timer = undefined
void flush(active)
}, FLUSH_MS)
}
function append(active: Active, data: string) {
if (active.done) return
active.output = appendTerminalOutput(active.output, data)
active.chunk += data
active.cursor += data.length
active.info.time.updated = Date.now()
schedule(active)
}
function gate(shell: string, command: string) {
const name = Shell.name(shell)
if (name === "cmd") return `pause >nul & ${command}`
if (Shell.ps(shell)) return `$null = [Console]::ReadKey($true); ${command}`
return `stty -echo; IFS= read -r __kilo_gate; stty echo; ${command}`
}
function release(shell: string) {
if (Shell.ps(shell) || Shell.name(shell) === "cmd") return " "
return "\r"
}
function environment(input: NodeJS.ProcessEnv) {
const env = Object.fromEntries(
Object.entries(input).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
env.TERM = "xterm-256color"
env.KILO_TERMINAL = "1"
env.KILO_INTERACTIVE_TERMINAL = "1"
delete env.KILO_SERVER_PASSWORD
delete env.KILO_SERVER_USERNAME
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
return env
}
async function finish(
state: State,
active: Active,
input: { closedBy: ClosedBy; exitCode?: number; signal?: number | string; kill?: boolean; silent?: boolean },
) {
if (active.done) return active.result
active.done = true
if (active.timer) clearTimeout(active.timer)
active.timer = undefined
active.abort?.()
active.abort = undefined
if (input.kill) {
try {
active.proc.kill()
} catch (err) {
log.warn("failed to kill interactive terminal", { err, id: active.info.id })
}
}
active.data?.dispose()
active.exit?.dispose()
active.data = undefined
active.exit = undefined
await active.ready
await flush(active)
const now = Date.now()
active.info.status = "closed"
active.info.closedBy = input.closedBy
active.info.time.updated = now
active.info.time.ended = now
if (input.exitCode !== undefined) active.info.exitCode = input.exitCode
if (input.signal !== undefined) active.info.signal = String(input.signal)
state.terminals.delete(active.info.id)
const result: Result = {
id: active.info.id,
output: clean(active.output),
exitCode: active.info.exitCode,
signal: active.info.signal,
closedBy: input.closedBy,
}
active.result = result
if (!input.silent) {
await publish(active, Event.Updated, { info: clone(active.info) })
await publish(active, Event.Deleted, {
terminalID: active.info.id,
sessionID: active.info.sessionID,
})
}
active.resolve(result)
return result
}
async function launch(state: State, input: RunInput) {
const existing = Array.from(state.terminals.values()).find((active) => active.info.sessionID === input.sessionID)
if (existing) throw new Error(`An interactive terminal is already active for session ${input.sessionID}`)
const id = ID.ascending()
const cwd = path.resolve(state.dir, input.cwd ?? state.dir)
const cols = Math.max(1, input.cols ?? DEFAULT_COLS)
const rows = Math.max(1, input.rows ?? DEFAULT_ROWS)
const args = Shell.args(input.shell, gate(input.shell, input.command), cwd)
const { spawn } = await import("#pty")
const proc = spawn(input.shell, args, {
name: "xterm-256color",
cols,
rows,
cwd,
env: environment(input.env),
})
const waiter = Promise.withResolvers<Result>()
const now = Date.now()
const active: Active = {
ctx: state.ctx,
info: {
id,
sessionID: input.sessionID,
pid: proc.pid,
command: input.command,
cwd,
description: input.description,
status: "running",
cols,
rows,
time: { started: now, updated: now },
},
proc,
output: "",
chunk: "",
cursor: 0,
resolve: waiter.resolve,
done: false,
}
state.terminals.set(id, active)
const announced = Promise.withResolvers<void>()
active.ready = announced.promise
active.data = proc.onData((data) => append(active, data))
active.exit = proc.onExit((event) => {
void finish(state, active, {
closedBy: "exit",
exitCode: event.exitCode,
signal: event.signal,
})
})
if (input.abort) {
const abort = () => {
input.abort?.removeEventListener("abort", abort)
void finish(state, active, { closedBy: "abort", kill: true })
}
active.abort = () => input.abort?.removeEventListener("abort", abort)
if (input.abort.aborted) abort()
else input.abort.addEventListener("abort", abort, { once: true })
}
if (!active.done) active.proc.write(release(input.shell))
await publish(active, Event.Updated, { info: clone(active.info) })
announced.resolve()
return waiter.promise
}
const stateLayer = Layer.effect(
StateService,
Effect.gen(function* () {
const ref = yield* InstanceState.make(
Effect.fn("InteractiveTerminal.state")(function* (ctx) {
const state: State = { ctx, dir: ctx.directory, terminals: new Map() }
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
await Promise.all(
Array.from(state.terminals.values()).map((active) =>
finish(state, active, { closedBy: "abort", kill: true, silent: true }),
),
)
state.terminals.clear()
}),
)
return state
}),
)
return StateService.of({ get: () => InstanceState.get(ref) })
}),
)
const runtime = makeRuntime(StateService, stateLayer)
function state() {
return runtime.runPromise((service) => service.get())
}
export async function run(input: RunInput) {
return launch(await state(), input)
}
export async function list(input?: { sessionID?: SessionID }) {
const current = await state()
return Array.from(current.terminals.values())
.map((active) => clone(active.info))
.filter((info) => !input?.sessionID || info.sessionID === input.sessionID)
.toSorted((a, b) => a.time.started - b.time.started || a.id.localeCompare(b.id))
}
export async function get(id: ID): Promise<Snapshot | undefined> {
const current = await state()
const active = current.terminals.get(id)
if (!active) return
return { info: clone(active.info), output: active.output, cursor: active.cursor }
}
export async function write(id: ID, data: string) {
const current = await state()
const active = current.terminals.get(id)
if (!active || active.done) return false
active.proc.write(data)
return true
}
export async function resize(id: ID, cols: number, rows: number) {
const current = await state()
const active = current.terminals.get(id)
if (!active || active.done) return false
const width = Math.max(1, cols)
const height = Math.max(1, rows)
active.proc.resize(width, height)
active.info.cols = width
active.info.rows = height
active.info.time.updated = Date.now()
await publish(active, Event.Updated, { info: clone(active.info) })
return true
}
export async function close(id: ID, closedBy: ClosedBy = "user") {
const current = await state()
const active = current.terminals.get(id)
if (!active) return false
await finish(current, active, { closedBy, kill: true })
return true
}
export async function stopSession(sessionID: SessionID) {
const current = await state()
const list = Array.from(current.terminals.values()).filter((active) => active.info.sessionID === sessionID)
await Promise.all(list.map((active) => finish(current, active, { closedBy: "abort", kill: true })))
}
}
@@ -0,0 +1,13 @@
export const TERMINAL_OUTPUT_LIMIT = 2 * 1024 * 1024
export function trimTerminalOutput(output: string) {
const buf = Buffer.from(output, "utf-8")
if (buf.length <= TERMINAL_OUTPUT_LIMIT) return output
let start = buf.length - TERMINAL_OUTPUT_LIMIT
while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++
return buf.subarray(start).toString("utf-8")
}
export function appendTerminalOutput(output: string, data: string) {
return trimTerminalOutput(output + data)
}
@@ -0,0 +1,103 @@
import { InteractiveTerminal } from "@/kilocode/interactive-terminal"
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/interactive-terminal"
export const InteractiveTerminalPaths = {
list: root,
get: `${root}/:terminalID`,
write: `${root}/:terminalID/input`,
resize: `${root}/:terminalID/resize`,
close: `${root}/:terminalID/close`,
} as const
export const InteractiveTerminalApi = HttpApi.make("interactive-terminal")
.add(
HttpApiGroup.make("interactive-terminal")
.add(
HttpApiEndpoint.get("list", InteractiveTerminalPaths.list, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(InteractiveTerminal.Snapshot), "List of interactive terminals"),
}).annotateMerge(
OpenApi.annotations({
identifier: "interactiveTerminal.list",
summary: "List interactive terminals",
description: "List active human-driven terminal sessions for the current instance.",
}),
),
HttpApiEndpoint.get("get", InteractiveTerminalPaths.get, {
params: { terminalID: InteractiveTerminal.ID },
query: WorkspaceRoutingQuery,
success: described(InteractiveTerminal.Snapshot, "Interactive terminal snapshot"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "interactiveTerminal.get",
summary: "Get interactive terminal",
description: "Get metadata and retained output for an active interactive terminal.",
}),
),
HttpApiEndpoint.post("write", InteractiveTerminalPaths.write, {
params: { terminalID: InteractiveTerminal.ID },
query: WorkspaceRoutingQuery,
payload: InteractiveTerminal.WriteInput,
success: described(Schema.Boolean, "Input written"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "interactiveTerminal.write",
summary: "Write interactive terminal input",
description: "Send raw keyboard input to an active interactive terminal.",
}),
),
HttpApiEndpoint.post("resize", InteractiveTerminalPaths.resize, {
params: { terminalID: InteractiveTerminal.ID },
query: WorkspaceRoutingQuery,
payload: InteractiveTerminal.ResizeInput,
success: described(Schema.Boolean, "Terminal resized"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "interactiveTerminal.resize",
summary: "Resize interactive terminal",
description: "Resize an active interactive terminal's PTY.",
}),
),
HttpApiEndpoint.post("close", InteractiveTerminalPaths.close, {
params: { terminalID: InteractiveTerminal.ID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Terminal closed"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "interactiveTerminal.close",
summary: "Close interactive terminal",
description: "Terminate an active interactive terminal and unblock its tool call.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "interactive-terminal",
description: "Kilo human-driven interactive terminal routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "kilo HttpApi",
version: "0.0.1",
description: "Kilo HttpApi surface.",
}),
)
@@ -0,0 +1,62 @@
import { InteractiveTerminal } from "@/kilocode/interactive-terminal"
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
const missing = () => new HttpApiError.NotFound({})
export const interactiveTerminalHandlers = HttpApiBuilder.group(InstanceHttpApi, "interactive-terminal", (handlers) =>
Effect.gen(function* () {
const list = Effect.fn("InteractiveTerminalHttpApi.list")(function* () {
const infos = yield* Effect.promise(() => InteractiveTerminal.list())
return yield* Effect.promise(() =>
Promise.all(infos.map((info) => InteractiveTerminal.get(info.id))).then((items) =>
items.filter((item) => item !== undefined),
),
)
})
const get = Effect.fn("InteractiveTerminalHttpApi.get")(function* (ctx: {
params: { terminalID: InteractiveTerminal.ID }
}) {
const terminal = yield* Effect.promise(() => InteractiveTerminal.get(ctx.params.terminalID))
if (!terminal) return yield* missing()
return terminal
})
const write = Effect.fn("InteractiveTerminalHttpApi.write")(function* (ctx: {
params: { terminalID: InteractiveTerminal.ID }
payload: typeof InteractiveTerminal.WriteInput.Type
}) {
const ok = yield* Effect.promise(() => InteractiveTerminal.write(ctx.params.terminalID, ctx.payload.data))
if (!ok) return yield* missing()
return true
})
const resize = Effect.fn("InteractiveTerminalHttpApi.resize")(function* (ctx: {
params: { terminalID: InteractiveTerminal.ID }
payload: typeof InteractiveTerminal.ResizeInput.Type
}) {
const ok = yield* Effect.promise(() =>
InteractiveTerminal.resize(ctx.params.terminalID, ctx.payload.cols, ctx.payload.rows),
)
if (!ok) return yield* missing()
return true
})
const close = Effect.fn("InteractiveTerminalHttpApi.close")(function* (ctx: {
params: { terminalID: InteractiveTerminal.ID }
}) {
const ok = yield* Effect.promise(() => InteractiveTerminal.close(ctx.params.terminalID))
if (!ok) return yield* missing()
return true
})
return handlers
.handle("list", list)
.handle("get", get)
.handle("write", write)
.handle("resize", resize)
.handle("close", close)
}),
)
@@ -6,6 +6,7 @@ import { commitMessageHandlers } from "./handlers/commit-message"
import { configConsoleHandlers } from "./handlers/config-console"
import { enhancePromptHandlers } from "./handlers/enhance-prompt"
import { indexingHandlers } from "./handlers/indexing"
import { interactiveTerminalHandlers } from "./handlers/interactive-terminal"
import { kiloGatewayHandlers } from "./handlers/kilo-gateway"
import { kilocodeHandlers } from "./handlers/kilocode"
import { networkHandlers } from "./handlers/network"
@@ -21,6 +22,7 @@ export const provide = Layer.provide([
configConsoleHandlers,
enhancePromptHandlers,
indexingHandlers,
interactiveTerminalHandlers,
kiloGatewayHandlers,
kilocodeHandlers,
networkHandlers,
@@ -0,0 +1,94 @@
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { InteractiveTerminal } from "@/kilocode/interactive-terminal"
import { Plugin } from "@/plugin"
import { Shell } from "@/shell/shell"
import { ShellPermission } from "@/tool/shell"
import { Tool } from "@/tool/tool"
import type { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Schema } from "effect"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import path from "path"
import DESCRIPTION from "./interactive-terminal.txt"
export const Params = Schema.Struct({
command: Schema.String.annotate({ description: "Command to run in an interactive terminal" }),
workdir: Schema.optional(Schema.String).annotate({
description: "Working directory. Defaults to the project directory.",
}),
description: Schema.optional(Schema.String).annotate({
description: "Short label shown in the terminal dialog",
}),
}).check(
Schema.makeFilter((params: { command: string }) =>
params.command.trim() ? undefined : "command must contain a non-whitespace character",
),
)
export type Params = Schema.Schema.Type<typeof Params>
type Meta = {
terminalID?: InteractiveTerminal.ID
exitCode?: number
closedBy?: InteractiveTerminal.ClosedBy
}
export const InteractiveTerminalTool = Tool.define<
typeof Params,
Meta,
Config.Service | Plugin.Service | AppFileSystem.Service | ChildProcessSpawner,
"interactive_terminal"
>(
"interactive_terminal",
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
const permission = yield* ShellPermission
return {
description: DESCRIPTION,
parameters: Params,
execute: (params, ctx) =>
Effect.gen(function* () {
const inst = yield* InstanceState.context
const command = params.command.trim()
const cwd = path.resolve(inst.directory, params.workdir ?? inst.directory)
const cfg = yield* config.get()
const shell = Shell.acceptable(cfg.shell)
yield* permission.ask(ctx, { command, cwd, shell, description: params.description })
const extra = yield* plugin.trigger(
"shell.env",
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
{ env: {} },
)
const result = yield* Effect.promise(() =>
InteractiveTerminal.run({
sessionID: ctx.sessionID,
command,
cwd,
description: params.description,
shell,
env: { ...process.env, ...extra.env },
abort: ctx.abort,
}),
)
const reason =
result.closedBy === "exit"
? `Process exited${result.exitCode === undefined ? "" : ` with code ${result.exitCode}`}.`
: result.closedBy === "user"
? "The user closed the interactive terminal before the process exited."
: "The interactive terminal was closed because the tool run was cancelled."
return {
title: params.description ?? command,
output: `${result.output || "(no output)"}\n\n${reason}`,
metadata: {
terminalID: result.id,
exitCode: result.exitCode,
closedBy: result.closedBy,
},
}
}),
}
}),
)
@@ -0,0 +1,25 @@
Run a single command in a real interactive terminal (PTY) that the user can type into directly.
This is the only tool that lets the user interact with a running process. In the CLI it opens a terminal dialog over the session input area, streams the live output, and hands keyboard control to the user. The user can type into the process, press Ctrl+C to send the standard terminal interrupt, or close the terminal to terminate it. The tool blocks until the process exits or the terminal is closed, then returns the captured output together with how it ended (exit code, user close, or cancellation).
Use this only for commands that genuinely need a TTY or private human input:
- Authentication and login flows (`gh auth login`, `vercel login`, cloud CLIs).
- Credential, passphrase, and 2FA prompts (`ssh`, `gpg`, `op`).
- Interactive installers, scaffolders, and wizards (`npm create`, `create-next-app`).
- REPLs and interactive shells the user should drive (`python3`, `node`, `psql`).
Prefer other tools whenever possible:
- Use the bash tool for non-interactive commands, even if they normally prompt — pass flags like `--yes`, `-y`, `--no-input`, or pipe input instead of waiting on a human.
- Use background_process for long-running servers, watchers, and dev processes.
Notes:
- Runs one command per call; it is not a persistent shell, and each call starts a fresh terminal.
- Uses the same permissions as the bash tool, plus a directory permission when `workdir` is outside the project.
- This tool is not available to subagents. Do not delegate steps that require direct user interaction.
- Do not use it for commands that do not need human input — the run will block waiting on a user who has nothing to do.
- Full-screen terminal applications such as vim, htop, and less are not guaranteed to render perfectly in the dialog.
Parameters:
- `command` (required): the command to run.
- `workdir` (optional): working directory. Defaults to the project directory.
- `description` (optional): short label shown in the terminal dialog, e.g. "Log in to GitHub".
@@ -3,6 +3,7 @@ import { CodebaseSearchTool } from "../../tool/warpgrep"
import { RecallTool } from "../../tool/recall"
import { AgentManagerTool } from "./agent-manager"
import { BackgroundProcessTool } from "./background-process"
import { InteractiveTerminalTool } from "./interactive-terminal"
import * as Tool from "../../tool/tool"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect } from "effect"
@@ -29,14 +30,21 @@ export namespace KiloToolRegistry {
const recall = yield* RecallTool
const manager = yield* AgentManagerTool
const process = yield* BackgroundProcessTool
return { codebase, recall, manager, process }
const terminal = yield* InteractiveTerminalTool
return { codebase, recall, manager, process, terminal }
})
}
/** Finalize Kilo-specific tools into Tool.Defs. Call this inside the InstanceState state Effect —
* it has no Service deps beyond what Tool.init itself needs. */
export function build(
tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info; process: Tool.Info },
tools: {
codebase: Tool.Info
recall: Tool.Info
manager: Tool.Info
process: Tool.Info
terminal?: Tool.Info
},
deps: Deps,
loaders: Loaders = {},
) {
@@ -47,8 +55,9 @@ export namespace KiloToolRegistry {
manager: Tool.init(tools.manager),
process: Tool.init(tools.process),
})
const terminal = tools.terminal ? yield* Tool.init(tools.terminal) : undefined
const semantic = yield* semanticTool(deps, loaders)
return { ...base, semantic }
return { ...base, terminal, semantic }
})
}
@@ -85,9 +94,22 @@ export namespace KiloToolRegistry {
})
}
/** Hide human-driven tools from agents that cannot interact with the user directly. */
export function available(tool: Tool.Def, agent: Agent.Info) {
if (tool.id !== "interactive_terminal") return true
return agent.mode === "primary"
}
/** Kilo-specific tools to append to the builtin list */
export function extra(
tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def; manager: Tool.Def; process: Tool.Def },
tools: {
codebase: Tool.Def
semantic?: Tool.Def
recall: Tool.Def
manager: Tool.Def
process: Tool.Def
terminal?: Tool.Def
},
cfg: { experimental?: { codebase_search?: boolean } },
): Tool.Def[] {
return [
@@ -95,6 +117,7 @@ export namespace KiloToolRegistry {
...(tools.semantic ? [tools.semantic] : []),
tools.recall,
...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []),
...(Flag.KILO_CLIENT === "cli" && tools.terminal ? [tools.terminal] : []),
// The extension is the only client that can consume the Agent Manager start event.
...(Flag.KILO_CLIENT === "vscode" ? [tools.manager] : []),
]
@@ -67,6 +67,7 @@ export namespace KiloTask {
return [
{ permission: "task", pattern: "*", action: "deny" },
{ permission: "question", pattern: "*", action: "deny" },
{ permission: "interactive_terminal", pattern: "*", action: "deny" },
...rules,
]
}
@@ -27,6 +27,7 @@ import { BackgroundProcessApi } from "@/kilocode/server/httpapi/groups/backgroun
import { ConfigConsoleApi } from "@/kilocode/server/httpapi/groups/config-console"
import { EnhancePromptApi } from "@/kilocode/server/httpapi/groups/enhance-prompt"
import { IndexingApi } from "@/kilocode/server/httpapi/groups/indexing"
import { InteractiveTerminalApi } from "@/kilocode/server/httpapi/groups/interactive-terminal"
import { KiloGatewayApi } from "@/kilocode/server/httpapi/groups/kilo-gateway"
import { KilocodeApi } from "@/kilocode/server/httpapi/groups/kilocode"
import { NetworkApi } from "@/kilocode/server/httpapi/groups/network"
@@ -71,6 +72,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(ConfigConsoleApi)
.addHttpApi(EnhancePromptApi)
.addHttpApi(IndexingApi)
.addHttpApi(InteractiveTerminalApi)
.addHttpApi(KiloGatewayApi)
.addHttpApi(KilocodeApi)
.addHttpApi(NetworkApi)
+2
View File
@@ -31,6 +31,7 @@ import { Permission } from "@/permission"
import { Global } from "@opencode-ai/core/global"
// kilocode_change start - Kilo session behavior extensions
import { BackgroundProcess } from "@/kilocode/background-process"
import { InteractiveTerminal } from "@/kilocode/interactive-terminal"
import { KiloSession, kiloSessionFork } from "@/kilocode/session"
import { SessionExport } from "@/kilocode/session-export"
import { baseKey, cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff" // kilocode_change
@@ -643,6 +644,7 @@ export const layer: Layer.Layer<
KiloSession.clearPlatformOverride(sessionID)
if (hasInstance) {
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)).pipe(Effect.ignore)
yield* Effect.promise(() => InteractiveTerminal.stopSession(sessionID)).pipe(Effect.ignore)
void Promise.all([import("@/effect/app-runtime"), import("./run-state")]).then(([app, run]) =>
app.AppRuntime.runPromise(run.SessionRunState.Service.use((svc) => svc.cancel(sessionID))).catch(() => {}),
)
+1
View File
@@ -338,6 +338,7 @@ export const layer: Layer.Layer<
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const filtered = (yield* all()).filter((tool) => {
if (!KiloToolRegistry.available(tool, input.agent)) return false // kilocode_change
if (tool.id === WebSearchTool.id) {
return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })
}
+111 -97
View File
@@ -310,6 +310,114 @@ const ask = Effect.fn("ShellTool.ask")(function* (
})
})
// kilocode_change start - share bash permission scanning with Kilo interactive terminal
type PermissionInput = {
command: string
cwd: string
shell: string
description?: string
}
export const ShellPermission = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const fs = yield* AppFileSystem.Service
const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
const lines = yield* spawner
.lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const file = lines[0]?.trim()
if (!file) return
return AppFileSystem.normalizePath(file)
})
const resolve = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
const file = yield* cygpath(shell, text)
if (file) return file
}
return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text)))
}
return path.resolve(root, text)
})
const argpath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
const file = text && prefix(text)
if (!file || dynamic(file, ps)) return
const next = ps ? provider(file) : file
if (!next) return
return yield* resolve(next, cwd, shell)
})
const collect = Effect.fn("ShellTool.collect")(function* (
root: Node,
cwd: string,
ps: boolean,
shell: string,
instance: InstanceContext,
) {
const scan: Scan = {
dirs: new Set<string>(),
patterns: new Set<string>(),
always: new Set<string>(),
access: "read",
}
const kind = ShellID.toKind(Shell.name(shell))
const nodes = commands(root)
if (root.descendantsOfType("file_redirect").length > 0) scan.access = "unknown"
if (nodes.some((node) => !READ.has((ps ? parts(node)[0]?.text.toLowerCase() : parts(node)[0]?.text) ?? ""))) {
scan.access = "unknown"
}
for (const node of nodes) {
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps || kind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && (FILES.has(cmd) || (kind === "cmd" && CMD_FILES.has(cmd)))) {
const accessKind = access(cmd, node)
for (const arg of pathArgs(command, ps, kind === "cmd")) {
const resolved = yield* argpath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || containsPath(resolved, instance)) continue
const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
if (accessKind !== "read") scan.access = "unknown"
}
}
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
}
}
return scan
})
const check = Effect.fn("ShellTool.permission")(function* (ctx: Tool.Context, input: PermissionInput) {
const instance = yield* InstanceState.context
const ps = Shell.ps(input.shell)
yield* Effect.scoped(
Effect.gen(function* () {
const tree = yield* Effect.acquireRelease(parse(input.command, ps), (tree) => Effect.sync(() => tree.delete()))
const scan = yield* collect(tree.rootNode, input.cwd, ps, input.shell, instance)
if (!containsPath(input.cwd, instance)) {
scan.dirs.add(input.cwd)
scan.access = "unknown"
}
yield* ask(ctx, scan, input.command, input.description)
}),
)
})
return { ask: check, resolve }
})
// kilocode_change end
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && Shell.ps(shell)) {
return ChildProcess.make(shell, Shell.args(shell, command, cwd), {
@@ -361,91 +469,12 @@ export const ShellTool = Tool.define(
Effect.gen(function* () {
const config = yield* Config.Service
const spawner = yield* ChildProcessSpawner
const fs = yield* AppFileSystem.Service
const trunc = yield* Truncate.Service
const plugin = yield* Plugin.Service
const flags = yield* RuntimeFlags.Service
const permission = yield* ShellPermission // kilocode_change
const defaultTimeout = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
const lines = yield* spawner
.lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const file = lines[0]?.trim()
if (!file) return
return AppFileSystem.normalizePath(file)
})
const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
const file = yield* cygpath(shell, text)
if (file) return file
}
return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text)))
}
return path.resolve(root, text)
})
const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
const file = text && prefix(text)
if (!file || dynamic(file, ps)) return
const next = ps ? provider(file) : file
if (!next) return
return yield* resolvePath(next, cwd, shell)
})
const collect = Effect.fn("ShellTool.collect")(function* (
root: Node,
cwd: string,
ps: boolean,
shell: string,
instance: InstanceContext,
) {
const scan: Scan = {
dirs: new Set<string>(),
patterns: new Set<string>(),
always: new Set<string>(),
access: "read", // kilocode_change
}
const shellKind = ShellID.toKind(Shell.name(shell))
const nodes = commands(root) // kilocode_change
if (root.descendantsOfType("file_redirect").length > 0) scan.access = "unknown" // kilocode_change
// kilocode_change start
if (nodes.some((node) => !READ.has((ps ? parts(node)[0]?.text.toLowerCase() : parts(node)[0]?.text) ?? ""))) {
scan.access = "unknown"
}
// kilocode_change end
for (const node of nodes) {
// kilocode_change
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) {
const kind = access(cmd, node) // kilocode_change
for (const arg of pathArgs(command, ps, shellKind === "cmd")) {
const resolved = yield* argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || containsPath(resolved, instance)) continue
const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
if (kind !== "read") scan.access = "unknown" // kilocode_change
}
}
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
}
}
return scan
})
const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
const extra = yield* plugin.trigger(
"shell.env",
@@ -651,28 +680,13 @@ export const ShellTool = Tool.define(
Effect.gen(function* () {
const instanceCtx = yield* InstanceState.context
const cwd = params.workdir
? yield* resolvePath(params.workdir, instanceCtx.directory, shell)
? yield* permission.resolve(params.workdir, instanceCtx.directory, shell)
: instanceCtx.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = CommandTimeout.clamp(params.timeout ?? defaultTimeout).timeout // kilocode_change
const ps = Shell.ps(shell)
yield* Effect.scoped(
Effect.gen(function* () {
const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>
Effect.sync(() => tree.delete()),
)
const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx)
// kilocode_change start
if (!containsPath(cwd, instanceCtx)) {
scan.dirs.add(cwd)
scan.access = "unknown"
}
// kilocode_change end
yield* ask(ctx, scan, params.command, params.description) // kilocode_change
}),
)
yield* permission.ask(ctx, { command: params.command, cwd, shell, description: params.description }) // kilocode_change
return yield* run(
{
+1
View File
@@ -257,6 +257,7 @@ export const TaskTool = Tool.define(
agent: next.name,
tools: {
question: false, // kilocode_change - subagents cannot prompt the user directly
interactive_terminal: false, // kilocode_change - subagents cannot take over the user's terminal
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
@@ -72,6 +72,7 @@ it.instance("build agent has correct default properties", () =>
expect(evalPerm(build, "bash")).toBe("ask")
expect(evalPerm(build, "repo_clone")).toBe("deny")
expect(evalPerm(build, "repo_overview")).toBe("deny")
expect(evalPerm(build, "interactive_terminal")).toBe("allow")
}),
)
@@ -81,6 +82,7 @@ it.instance("plan agent denies edits except .opencode/plans/*", () =>
expect(plan).toBeDefined()
// Wildcard is denied
expect(evalPerm(plan, "edit")).toBe("deny")
expect(evalPerm(plan, "interactive_terminal")).toBe("deny")
// But specific path is allowed
expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
}),
@@ -94,6 +96,7 @@ it.instance("explore agent denies edit and write", () =>
expect(evalPerm(explore, "edit")).toBe("deny")
expect(evalPerm(explore, "write")).toBe("deny")
expect(evalPerm(explore, "todowrite")).toBe("deny")
expect(evalPerm(explore, "interactive_terminal")).toBe("deny")
}),
)
+17 -6
View File
@@ -2,10 +2,10 @@
import { describe, expect, test } from "bun:test"
describe("Auto mode flag", () => {
test("auto mode should create session with allow-all permissions except questions", () => {
test("auto mode should create session with allow-all permissions except human-driven tools", () => {
// When --auto flag is set, the session should be created with:
// 1. Wildcard allow rule for all permissions
// 2. Explicit deny rule for questions (to prevent user interaction)
// 2. Explicit deny rules for tools that require live user interaction
const autoPermissions = [
{
@@ -18,19 +18,27 @@ describe("Auto mode flag", () => {
action: "deny" as const,
pattern: "*",
},
{
permission: "interactive_terminal",
action: "deny" as const,
pattern: "*",
},
]
expect(autoPermissions).toHaveLength(2)
expect(autoPermissions).toHaveLength(3)
// First rule: allow all
expect(autoPermissions[0].permission).toBe("*")
expect(autoPermissions[0].action).toBe("allow")
expect(autoPermissions[0].pattern).toBe("*")
// Second rule: deny questions (comes after wildcard to override it)
// Human-driven tools are denied after wildcard allow so they cannot block automation.
expect(autoPermissions[1].permission).toBe("question")
expect(autoPermissions[1].action).toBe("deny")
expect(autoPermissions[1].pattern).toBe("*")
expect(autoPermissions[2].permission).toBe("interactive_terminal")
expect(autoPermissions[2].action).toBe("deny")
expect(autoPermissions[2].pattern).toBe("*")
})
test("non-auto mode should not set allow-all permissions", () => {
@@ -41,12 +49,13 @@ describe("Auto mode flag", () => {
})
test("permission evaluation order matters (findLast behavior)", () => {
// The permission system uses findLast, so the last matching rule wins
// This test verifies that the question deny rule comes AFTER the wildcard
// The permission system uses findLast, so the last matching rule wins.
// Explicit human-interaction denies must come after the wildcard.
const autoPermissions = [
{ permission: "*", action: "allow" as const, pattern: "*" },
{ permission: "question", action: "deny" as const, pattern: "*" },
{ permission: "interactive_terminal", action: "deny" as const, pattern: "*" },
]
// Simulate findLast behavior
@@ -62,6 +71,8 @@ describe("Auto mode flag", () => {
// Test that "question" permission resolves to "deny"
const questionRule = findLastMatch("question")
expect(questionRule?.action).toBe("deny")
const terminalRule = findLastMatch("interactive_terminal")
expect(terminalRule?.action).toBe("deny")
// Test that other permissions resolve to "allow"
const bashRule = findLastMatch("bash")
@@ -0,0 +1,98 @@
import { expect, test } from "bun:test"
import type { Event, ToolPart } from "@kilocode/sdk/v2"
import { createSessionData, reduceSessionData } from "@/cli/cmd/run/session-data"
import { toolInlineInfo } from "@/cli/cmd/run/tool"
function part(): ToolPart {
return {
id: "prt_terminal",
sessionID: "ses_terminal",
messageID: "msg_terminal",
type: "tool",
callID: "call_terminal",
tool: "interactive_terminal",
state: {
status: "completed",
input: {
command: "python3 prompt.py",
description: "Prompt for name interactively",
workdir: "/tmp",
},
output: "Type your name: Ada",
metadata: {
terminalID: "itx_terminal",
exitCode: 0,
closedBy: "exit",
},
title: "Prompt for name interactively",
time: { start: 1, end: 2 },
},
}
}
function reduce(data: ReturnType<typeof createSessionData>, event: unknown) {
return reduceSessionData({
data,
event: event as Event,
sessionID: "session-1",
thinking: true,
limits: {},
})
}
test("formats interactive_terminal without a generic argument dump", () => {
expect(toolInlineInfo(part())).toEqual({
icon: "$",
title: "Prompt for name interactively",
description: "$ python3 prompt.py",
})
})
test("drives the direct interactive terminal footer from terminal events", () => {
const data = createSessionData()
const opened = reduce(data, {
type: "interactive_terminal.updated",
properties: {
info: {
id: "itx_1",
sessionID: "session-1",
pid: 123,
command: "python3 prompt.py",
cwd: "/tmp",
description: "Prompt for input",
status: "running",
cols: 80,
rows: 14,
time: { started: 1, updated: 1 },
},
},
})
expect(opened.footer?.view).toEqual(
expect.objectContaining({
type: "interactive_terminal",
terminal: expect.objectContaining({ output: "", cursor: 0 }),
}),
)
const streamed = reduce(data, {
type: "interactive_terminal.data",
properties: {
terminalID: "itx_1",
sessionID: "session-1",
data: "Type your name: ",
cursor: 16,
},
})
expect(streamed.footer?.view).toEqual(
expect.objectContaining({
type: "interactive_terminal",
terminal: expect.objectContaining({ output: "Type your name: ", cursor: 16 }),
}),
)
const closed = reduce(data, {
type: "interactive_terminal.deleted",
properties: { terminalID: "itx_1", sessionID: "session-1" },
})
expect(closed.footer?.view).toEqual({ type: "prompt" })
})
@@ -0,0 +1,328 @@
import { Bus } from "@/bus"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { VtScreen } from "@/kilocode/cli/cmd/tui/vt/vt-screen"
import { InteractiveTerminal } from "@/kilocode/interactive-terminal"
import { Instance, capture, type InstanceContext } from "@/kilocode/instance"
import { InteractiveTerminalTool } from "@/kilocode/tool/interactive-terminal"
import { Plugin } from "@/plugin"
import type { Permission } from "@/permission"
import { MessageID, SessionID } from "@/session/schema"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncate"
import type { Tool } from "@/tool/tool"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import path from "path"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
import { it, testEffect } from "../lib/effect"
const toolLayer = Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Config.defaultLayer,
Agent.defaultLayer,
RuntimeFlags.defaultLayer,
)
const toolIt = testEffect(toolLayer)
function quote(input: string) {
const value = input.replaceAll("\\", "/")
if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"`
return `'${value.replaceAll("'", "'\\''")}'`
}
async function script(dir: string, name: string, source: string) {
const file = path.join(dir, name)
await Bun.write(file, source)
const bin = quote(process.execPath)
const arg = quote(file)
if (Shell.ps(Shell.acceptable())) return `& ${bin} ${arg}`
return `${bin} ${arg}`
}
function started(sessionID: SessionID) {
const state: { off?: () => void; timer?: ReturnType<typeof setTimeout> } = {}
const promise = new Promise<InteractiveTerminal.Info>((resolve, reject) => {
state.timer = setTimeout(() => {
state.off?.()
reject(new Error("timed out waiting for interactive terminal"))
}, 5_000)
state.off = Bus.subscribe(InteractiveTerminal.Event.Updated, (event) => {
const info = event.properties.info
if (info.sessionID !== sessionID || info.status !== "running") return
state.off?.()
if (state.timer) clearTimeout(state.timer)
resolve(info)
})
})
return {
promise,
dispose() {
state.off?.()
if (state.timer) clearTimeout(state.timer)
},
}
}
function emitted(id: InteractiveTerminal.ID, expected: string) {
const state: { off?: () => void; timer?: ReturnType<typeof setTimeout> } = {}
const promise = new Promise<string>((resolve, reject) => {
state.timer = setTimeout(() => {
state.off?.()
reject(new Error(`timed out waiting for terminal output: ${expected}`))
}, 5_000)
state.off = Bus.subscribe(InteractiveTerminal.Event.Data, (event) => {
if (event.properties.terminalID !== id || !event.properties.data.includes(expected)) return
state.off?.()
if (state.timer) clearTimeout(state.timer)
resolve(event.properties.data)
})
})
return {
promise,
dispose() {
state.off?.()
if (state.timer) clearTimeout(state.timer)
},
}
}
async function snapshot(ctx: InstanceContext, id: InteractiveTerminal.ID, expected: string) {
const deadline = Date.now() + 5_000
while (Date.now() < deadline) {
const value = await Instance.restore(ctx, () => InteractiveTerminal.get(id))
if (value?.output.includes(expected)) return value
await Bun.sleep(10)
}
throw new Error(`timed out waiting for terminal snapshot: ${expected}`)
}
function run(input: { sessionID: SessionID; command: string; cwd: string; abort?: AbortSignal }) {
return InteractiveTerminal.run({
...input,
shell: Shell.acceptable(),
env: { ...process.env },
})
}
function context(
requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>,
stop?: { permission: string; error: Error },
): Tool.Context {
return {
sessionID: SessionID.make("ses_terminal_tool"),
messageID: MessageID.make("msg_terminal_tool"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: (request) =>
Effect.sync(() => {
requests.push(request)
if (stop?.permission === request.permission) throw stop.error
}),
}
}
const initTool = Effect.fn("InteractiveTerminalToolTest.init")(function* () {
const info = yield* InteractiveTerminalTool
return yield* info.init()
})
const failTool = Effect.fn("InteractiveTerminalToolTest.fail")(function* (
args: { command: string; workdir?: string; description?: string },
ctx: Tool.Context,
) {
const tool = yield* initTool()
const exit = yield* tool.execute(args, ctx).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
return err instanceof Error ? err : new Error(String(err))
}
throw new Error("expected terminal tool to stop before launch")
})
describe("InteractiveTerminal", () => {
toolIt.instance("asks for external_directory paths referenced inside the command", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const file = path.join(tmp, "secret.txt")
yield* Effect.promise(() => Bun.write(file, "secret"))
const err = new Error("stop before terminal launch")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* failTool(
{ command: `cat ${quote(file)}` },
context(requests, { permission: "bash", error: err }),
),
).toMatchObject({ message: err.message })
const ext = requests.find((item) => item.permission === "external_directory")
const bash = requests.find((item) => item.permission === "bash")
const want =
process.platform === "win32"
? AppFileSystem.normalizePathPattern(path.join(tmp, "*"))
: path.join(tmp, "*")
expect(ext?.patterns).toContain(want)
expect(bash?.patterns).toContain(`cat ${quote(file)}`)
}),
)
toolIt.instance("uses bash arity for persisted interactive command approvals", () =>
Effect.gen(function* () {
const err = new Error("stop before terminal launch")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
expect(
yield* failTool(
{ command: "gh auth login", description: "Log in to GitHub" },
context(requests, { permission: "bash", error: err }),
),
).toMatchObject({ message: err.message })
const bash = requests.find((item) => item.permission === "bash")
expect(bash?.always).toContain("gh auth login *")
expect(bash?.always).not.toContain("gh *")
}),
)
it.instance("runs commands in a real TTY and captures output", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = SessionID.descending()
const command = yield* Effect.promise(() =>
script(
test.directory,
"tty.mjs",
`console.log(process.stdin.isTTY && process.stdout.isTTY ? "TTY" : "NOTTY")\n`,
),
)
const result = yield* Effect.promise(() => run({ sessionID, command, cwd: test.directory }))
expect(result.closedBy).toBe("exit")
expect(result.exitCode).toBe(0)
expect(result.output).toContain("TTY")
expect(result.output).not.toContain("NOTTY")
}),
)
it.instance("accepts human input and returns the resulting output", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = SessionID.descending()
const command = yield* Effect.promise(() =>
script(
test.directory,
"input.mjs",
`process.stdin.setEncoding("utf8")
console.log("READY")
process.stdin.once("data", (data) => {
console.log("INPUT:" + data.trim())
process.exit(0)
})
`,
),
)
const ready = started(sessionID)
try {
const pending = run({ sessionID, command, cwd: test.directory })
const info = yield* Effect.promise(() => ready.promise)
const wrote = yield* Effect.promise(() => InteractiveTerminal.write(info.id, "hello\r"))
expect(wrote).toBe(true)
const result = yield* Effect.promise(() => pending)
expect(result.closedBy).toBe("exit")
expect(result.output).toContain("READY")
expect(result.output).toContain("INPUT:hello")
} finally {
ready.dispose()
yield* Effect.promise(() => InteractiveTerminal.stopSession(sessionID))
}
}),
)
it.instance("streams terminal echo before enter", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = SessionID.descending()
const command = yield* Effect.promise(() =>
script(
test.directory,
"echo.mjs",
`process.stdin.setEncoding("utf8")
console.log("READY")
process.stdin.once("data", () => process.exit(0))
`,
),
)
const ready = started(sessionID)
const ctx = capture()!
const streams: Array<{ dispose(): void }> = []
try {
const pending = run({ sessionID, command, cwd: test.directory })
const info = yield* Effect.promise(() => ready.promise)
const output = emitted(info.id, "x")
streams.push(output)
yield* Effect.promise(() => InteractiveTerminal.write(info.id, "x"))
expect(yield* Effect.promise(() => output.promise)).toContain("x")
const retained = yield* Effect.promise(() => snapshot(ctx, info.id, "READY"))
const screen = new VtScreen(100, 18)
screen.write(retained.output)
expect(screen.text()).toContain("READY")
expect(screen.text()).toContain("x")
yield* Effect.promise(() => InteractiveTerminal.write(info.id, "\r"))
yield* Effect.promise(() => pending)
} finally {
ready.dispose()
streams.forEach((stream) => stream.dispose())
yield* Effect.promise(() => InteractiveTerminal.stopSession(sessionID))
}
}),
)
it.instance("user close terminates the PTY and unblocks the run", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = SessionID.descending()
const command = yield* Effect.promise(() =>
script(test.directory, "wait.mjs", `console.log("WAITING")\nsetInterval(() => {}, 1_000)\n`),
)
const ready = started(sessionID)
try {
const pending = run({ sessionID, command, cwd: test.directory })
const info = yield* Effect.promise(() => ready.promise)
const closed = yield* Effect.promise(() => InteractiveTerminal.close(info.id))
expect(closed).toBe(true)
const result = yield* Effect.promise(() => pending)
expect(result.closedBy).toBe("user")
const list = yield* Effect.promise(() => InteractiveTerminal.list({ sessionID }))
expect(list).toEqual([])
} finally {
ready.dispose()
yield* Effect.promise(() => InteractiveTerminal.stopSession(sessionID))
}
}),
)
it.instance("abort closes the PTY and unblocks the run", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = SessionID.descending()
const command = yield* Effect.promise(() => script(test.directory, "abort.mjs", `setInterval(() => {}, 1_000)\n`))
const controller = new AbortController()
const ready = started(sessionID)
try {
const pending = run({ sessionID, command, cwd: test.directory, abort: controller.signal })
yield* Effect.promise(() => ready.promise)
controller.abort()
const result = yield* Effect.promise(() => pending)
expect(result.closedBy).toBe("abort")
} finally {
ready.dispose()
yield* Effect.promise(() => InteractiveTerminal.stopSession(sessionID))
}
}),
)
})
@@ -155,7 +155,7 @@ describe("Kilo task nesting", () => {
),
)
it.live("disables nested task and question tools even when global permissions allow them", () =>
it.live("disables nested and human-driven tools even when global permissions allow them", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
@@ -187,6 +187,7 @@ describe("Kilo task nesting", () => {
const child = yield* sessions.get(result.metadata.sessionId)
expect(seen?.tools?.task).toBe(false)
expect(seen?.tools?.question).toBe(false)
expect(seen?.tools?.interactive_terminal).toBe(false)
expect(child.permission).toEqual(
expect.arrayContaining([
{
@@ -199,6 +200,11 @@ describe("Kilo task nesting", () => {
pattern: "*",
action: "deny",
},
{
permission: "interactive_terminal",
pattern: "*",
action: "deny",
},
]),
)
}),
@@ -207,6 +213,7 @@ describe("Kilo task nesting", () => {
permission: {
task: "allow",
question: "allow",
interactive_terminal: "allow",
},
},
},
@@ -177,6 +177,40 @@ describe("kilocode tool registry indexing", () => {
),
)
it.live("omits interactive_terminal from subagent definitions", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const prev = process.env["KILO_CLIENT"]
process.env["KILO_CLIENT"] = "cli"
return prev
}),
() =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const explore = yield* agent.get("explore")
const registry = yield* ToolRegistry.Service
const primary = yield* registry.tools({ ...ref, agent: build })
const subagent = yield* registry.tools({ ...ref, agent: explore })
expect(primary.map((tool) => tool.id)).toContain("interactive_terminal")
expect(subagent.map((tool) => tool.id)).not.toContain("interactive_terminal")
}),
{
git: true,
config: { permission: { interactive_terminal: "allow" } },
},
),
(prev) =>
Effect.sync(() => {
if (prev === undefined) delete process.env["KILO_CLIENT"]
if (prev !== undefined) process.env["KILO_CLIENT"] = prev
}),
),
)
test("conditionally includes Kilo registry extras", () => {
const prev = process.env["KILO_CLIENT"]
const def = (id: string): Tool.Def => ({
@@ -191,6 +225,7 @@ describe("kilocode tool registry indexing", () => {
recall: def("recall"),
manager: def("agent_manager"),
process: def("background_process"),
terminal: def("interactive_terminal"),
}
try {
@@ -199,9 +234,10 @@ describe("kilocode tool registry indexing", () => {
"semantic_search",
"recall",
"background_process",
"interactive_terminal",
])
expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual(
["codebase_search", "semantic_search", "recall", "background_process"],
["codebase_search", "semantic_search", "recall", "background_process", "interactive_terminal"],
)
process.env["KILO_CLIENT"] = "vscode"
@@ -216,6 +252,12 @@ describe("kilocode tool registry indexing", () => {
process.env["KILO_CLIENT"] = "desktop"
expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual(["semantic_search", "recall"])
process.env["KILO_CLIENT"] = "run"
expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual(["semantic_search", "recall"])
process.env["KILO_CLIENT"] = "acp"
expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual(["semantic_search", "recall"])
} finally {
if (prev === undefined) delete process.env["KILO_CLIENT"]
if (prev !== undefined) process.env["KILO_CLIENT"] = prev
@@ -0,0 +1,170 @@
import { describe, expect, test } from "bun:test"
import { VtScreen } from "../../src/kilocode/cli/cmd/tui/vt/vt-screen"
const ESC = "\x1b"
const CSI = ESC + "["
describe("VtScreen", () => {
test("plain text lands on the grid", () => {
const vt = new VtScreen(20, 5)
vt.write("hello")
expect(vt.lines()[0]).toBe("hello")
expect(vt.cursor()).toEqual({ x: 5, y: 0 })
})
test("newline and carriage return", () => {
const vt = new VtScreen(20, 5)
vt.write("ab\r\ncd")
expect(vt.lines()[0]).toBe("ab")
expect(vt.lines()[1]).toBe("cd")
})
test("carriage return overwrites the current line", () => {
const vt = new VtScreen(20, 5)
vt.write("hello\rworld")
expect(vt.lines()[0]).toBe("world")
})
test("backspace moves cursor back", () => {
const vt = new VtScreen(20, 5)
vt.write("abc\b\bX")
expect(vt.lines()[0]).toBe("aXc")
})
test("tab advances to the next tab stop", () => {
const vt = new VtScreen(40, 5)
vt.write("a\tb")
expect(vt.lines()[0]).toBe("a b")
})
test("autowrap to the next line at the right edge", () => {
const vt = new VtScreen(3, 5)
vt.write("abcd")
expect(vt.lines()[0]).toBe("abc")
expect(vt.lines()[1]).toBe("d")
})
test("CUP positions the cursor and writes there", () => {
const vt = new VtScreen(20, 5)
vt.write(CSI + "3;5H" + "X")
expect(vt.cursor()).toEqual({ x: 5, y: 2 })
expect(vt.lines()[2]).toBe(" X")
})
test("cursor up then overwrite line (gh-style redraw)", () => {
const vt = new VtScreen(20, 5)
vt.write("choice: one\r\nchoice: two\r\n")
// move up 2 lines, clear line, rewrite first choice as selected
vt.write(CSI + "2A" + "\r" + CSI + "2K" + "> one")
expect(vt.lines()[0]).toBe("> one")
expect(vt.lines()[1]).toBe("choice: two")
})
test("erase in line (EL 0/1/2)", () => {
const vt = new VtScreen(10, 3)
vt.write("abcdef")
vt.write("\r" + CSI + "3C" + CSI + "0K") // cursor to col 3, clear to end
expect(vt.lines()[0]).toBe("abc")
const vt2 = new VtScreen(10, 3)
vt2.write("abcdef")
vt2.write(CSI + "2K")
expect(vt2.lines()[0]).toBe("")
})
test("erase in display (ED 2) clears everything", () => {
const vt = new VtScreen(10, 3)
vt.write("a\r\nb\r\nc")
vt.write(CSI + "2J")
expect(vt.text()).toBe("")
})
test("scroll up when writing past the bottom", () => {
const vt = new VtScreen(10, 2)
vt.write("one\r\ntwo\r\nthree")
expect(vt.lines()[0]).toBe("two")
expect(vt.lines()[1]).toBe("three")
})
test("SGR sets foreground color and attributes on cells", () => {
const vt = new VtScreen(20, 3)
vt.write(CSI + "1;31m" + "R" + CSI + "0m" + "n")
const row = vt.cells()[0]
expect(row[0].char).toBe("R")
expect(row[0].fg).toBe(1)
expect(row[0].bold).toBe(true)
expect(row[1].char).toBe("n")
expect(row[1].fg).toBeUndefined()
expect(row[1].bold).toBeFalsy()
})
test("SGR 256 and truecolor", () => {
const vt = new VtScreen(20, 3)
vt.write(CSI + "38;5;200m" + "a" + CSI + "38;2;10;20;30m" + "b")
const row = vt.cells()[0]
expect(row[0].fg).toBe(200)
expect(row[1].fg).toEqual({ r: 10, g: 20, b: 30 })
})
test("save and restore cursor", () => {
const vt = new VtScreen(20, 5)
vt.write(CSI + "2;3H") // row 2 col 3
vt.write(ESC + "7") // save
vt.write(CSI + "5;5H" + "X")
vt.write(ESC + "8") // restore
vt.write("Y")
expect(vt.cursor()).toEqual({ x: 3, y: 1 })
expect(vt.lines()[1]).toBe(" Y")
})
test("unknown escape sequences do not corrupt the grid", () => {
const vt = new VtScreen(20, 3)
vt.write("a" + CSI + "99999;1!p" + "b" + ESC + "]0;title\x07" + "c")
expect(vt.lines()[0]).toBe("abc")
})
test("cursor hide/show via private mode", () => {
const vt = new VtScreen(10, 2)
vt.write(CSI + "?25l")
expect(vt.cursorVisible).toBe(false)
vt.write(CSI + "?25h")
expect(vt.cursorVisible).toBe(true)
})
test("resize preserves content within bounds", () => {
const vt = new VtScreen(10, 3)
vt.write("hello")
vt.resize(20, 5)
expect(vt.cols).toBe(20)
expect(vt.rows).toBe(5)
expect(vt.lines()[0]).toBe("hello")
})
test("retains the latest 500 scrolled lines", () => {
const vt = new VtScreen(12, 3)
for (let index = 0; index < 520; index++) vt.write(`line-${index}\r\n`)
expect(vt.scrollbackSize()).toBe(500)
expect(vt.scrollCount()).toBe(518)
expect(vt.viewLines(0, 3)).toEqual(["line-518", "line-519", ""])
expect(vt.viewLines(500, 3)).toEqual(["line-18", "line-19", "line-20"])
})
test("views scrollback using an offset from the bottom", () => {
const vt = new VtScreen(12, 3)
for (let index = 0; index < 10; index++) vt.write(`line-${index}\r\n`)
expect(vt.viewLines(0, 3)).toEqual(["line-8", "line-9", ""])
expect(vt.viewLines(2, 3)).toEqual(["line-6", "line-7", "line-8"])
expect(vt.viewText(2, 3)).toBe("line-6\nline-7\nline-8")
})
test("ED 3 clears scrollback", () => {
const vt = new VtScreen(12, 3)
for (let index = 0; index < 10; index++) vt.write(`line-${index}\r\n`)
expect(vt.scrollbackSize()).toBeGreaterThan(0)
vt.write(CSI + "3J")
expect(vt.scrollbackSize()).toBe(0)
})
})
@@ -2006,7 +2006,10 @@ unix(
.shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
.pipe(Effect.forkChild)
// kilocode_change start - wait for shell to actually be running before exercising the busy guard
yield* waitFor("shell busy", status.get(chat.id).pipe(Effect.map((s) => (s.type === "busy" ? s : undefined))))
yield* waitFor(
"shell busy",
status.get(chat.id).pipe(Effect.map((s) => (s.type === "busy" ? s : undefined))),
)
// kilocode_change end
const exit = yield* prompt
+1
View File
@@ -485,6 +485,7 @@ describe("tool.task", () => {
// kilocode_change end
expect(seen?.tools).toEqual({
question: false, // kilocode_change - subagents cannot prompt the user directly
interactive_terminal: false, // kilocode_change - subagents cannot take over the user's terminal
todowrite: false,
task: false, // kilocode_change - Kilo disallows nested subagents
bash: false,
+206
View File
@@ -89,6 +89,17 @@ import type {
IndexingStatusResponses,
IndexingWarningsResponses,
InstanceDisposeResponses,
InteractiveTerminalCloseErrors,
InteractiveTerminalCloseResponses,
InteractiveTerminalGetErrors,
InteractiveTerminalGetResponses,
InteractiveTerminalListResponses,
InteractiveTerminalResizeErrors,
InteractiveTerminalResizeInput,
InteractiveTerminalResizeResponses,
InteractiveTerminalWriteErrors,
InteractiveTerminalWriteInput,
InteractiveTerminalWriteResponses,
KiloAudioTranscriptionsErrors,
KiloAudioTranscriptionsResponses,
KiloClawChatCredentialsResponses,
@@ -6253,6 +6264,196 @@ export class Indexing extends HeyApiClient {
}
}
export class InteractiveTerminal extends HeyApiClient {
/**
* List interactive terminals
*
* List active human-driven terminal sessions for the current instance.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<InteractiveTerminalListResponses, unknown, ThrowOnError>({
url: "/interactive-terminal",
...options,
...params,
})
}
/**
* Get interactive terminal
*
* Get metadata and retained output for an active interactive terminal.
*/
public get<ThrowOnError extends boolean = false>(
parameters: {
terminalID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "terminalID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<
InteractiveTerminalGetResponses,
InteractiveTerminalGetErrors,
ThrowOnError
>({
url: "/interactive-terminal/{terminalID}",
...options,
...params,
})
}
/**
* Write interactive terminal input
*
* Send raw keyboard input to an active interactive terminal.
*/
public write<ThrowOnError extends boolean = false>(
parameters: {
terminalID: string
directory?: string
workspace?: string
interactiveTerminalWriteInput?: InteractiveTerminalWriteInput
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "terminalID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ key: "interactiveTerminalWriteInput", map: "body" },
],
},
],
)
return (options?.client ?? this.client).post<
InteractiveTerminalWriteResponses,
InteractiveTerminalWriteErrors,
ThrowOnError
>({
url: "/interactive-terminal/{terminalID}/input",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Resize interactive terminal
*
* Resize an active interactive terminal's PTY.
*/
public resize<ThrowOnError extends boolean = false>(
parameters: {
terminalID: string
directory?: string
workspace?: string
interactiveTerminalResizeInput?: InteractiveTerminalResizeInput
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "terminalID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ key: "interactiveTerminalResizeInput", map: "body" },
],
},
],
)
return (options?.client ?? this.client).post<
InteractiveTerminalResizeResponses,
InteractiveTerminalResizeErrors,
ThrowOnError
>({
url: "/interactive-terminal/{terminalID}/resize",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Close interactive terminal
*
* Terminate an active interactive terminal and unblock its tool call.
*/
public close<ThrowOnError extends boolean = false>(
parameters: {
terminalID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "terminalID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<
InteractiveTerminalCloseResponses,
InteractiveTerminalCloseErrors,
ThrowOnError
>({
url: "/interactive-terminal/{terminalID}/close",
...options,
...params,
})
}
}
export class Audio extends HeyApiClient {
/**
* Speech to text transcription
@@ -7798,6 +7999,11 @@ export class KiloClient extends HeyApiClient {
return (this._indexing ??= new Indexing({ client: this.client }))
}
private _interactiveTerminal?: InteractiveTerminal
get interactiveTerminal(): InteractiveTerminal {
return (this._interactiveTerminal ??= new InteractiveTerminal({ client: this.client }))
}
private _kilo?: Kilo
get kilo(): Kilo {
return (this._kilo ??= new Kilo({ client: this.client }))
+211
View File
@@ -34,6 +34,9 @@ export type Event =
| EventPermissionReplied
| EventBackgroundProcessUpdated
| EventBackgroundProcessDeleted
| EventInteractiveTerminalUpdated
| EventInteractiveTerminalData
| EventInteractiveTerminalDeleted
| EventSessionTurnOpen
| EventSessionTurnClose
| EventSessionDiff
@@ -298,6 +301,26 @@ export type BackgroundProcessInfo = {
}
}
export type InteractiveTerminalInfo = {
id: string
sessionID: string
pid: number
command: string
cwd: string
description?: string
status: "running" | "closed"
cols: number
rows: number
exitCode?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
signal?: string
closedBy?: "exit" | "user" | "abort"
time: {
started: number
updated: number
ended?: number
}
}
export type SnapshotFileDiff = {
file?: string
patch?: string
@@ -928,6 +951,9 @@ export type GlobalEvent = {
| EventPermissionReplied
| EventBackgroundProcessUpdated
| EventBackgroundProcessDeleted
| EventInteractiveTerminalUpdated
| EventInteractiveTerminalData
| EventInteractiveTerminalDeleted
| EventSessionTurnOpen
| EventSessionTurnClose
| EventSessionDiff
@@ -2283,6 +2309,21 @@ export type KiloEmbeddingModelCatalog = {
}
}
export type InteractiveTerminalSnapshot = {
info: InteractiveTerminalInfo
output: string
cursor: number
}
export type InteractiveTerminalWriteInput = {
data: string
}
export type InteractiveTerminalResizeInput = {
cols: number
rows: number
}
export type EffectHttpApiErrorUnauthorized = {
_tag: "Unauthorized"
}
@@ -3039,6 +3080,34 @@ export type EventBackgroundProcessDeleted = {
}
}
export type EventInteractiveTerminalUpdated = {
id: string
type: "interactive_terminal.updated"
properties: {
info: InteractiveTerminalInfo
}
}
export type EventInteractiveTerminalData = {
id: string
type: "interactive_terminal.data"
properties: {
terminalID: string
sessionID: string
data: string
cursor: number
}
}
export type EventInteractiveTerminalDeleted = {
id: string
type: "interactive_terminal.deleted"
properties: {
terminalID: string
sessionID: string
}
}
export type EventSessionTurnOpen = {
id: string
type: "session.turn.open"
@@ -8795,6 +8864,148 @@ export type IndexingModelsResponses = {
export type IndexingModelsResponse = IndexingModelsResponses[keyof IndexingModelsResponses]
export type InteractiveTerminalListData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/interactive-terminal"
}
export type InteractiveTerminalListResponses = {
/**
* List of interactive terminals
*/
200: Array<InteractiveTerminalSnapshot>
}
export type InteractiveTerminalListResponse = InteractiveTerminalListResponses[keyof InteractiveTerminalListResponses]
export type InteractiveTerminalGetData = {
body?: never
path: {
terminalID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/interactive-terminal/{terminalID}"
}
export type InteractiveTerminalGetErrors = {
/**
* Not found
*/
404: NotFoundError
}
export type InteractiveTerminalGetError = InteractiveTerminalGetErrors[keyof InteractiveTerminalGetErrors]
export type InteractiveTerminalGetResponses = {
/**
* Interactive terminal snapshot
*/
200: InteractiveTerminalSnapshot
}
export type InteractiveTerminalGetResponse = InteractiveTerminalGetResponses[keyof InteractiveTerminalGetResponses]
export type InteractiveTerminalWriteData = {
body?: InteractiveTerminalWriteInput
path: {
terminalID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/interactive-terminal/{terminalID}/input"
}
export type InteractiveTerminalWriteErrors = {
/**
* Not found
*/
404: NotFoundError
}
export type InteractiveTerminalWriteError = InteractiveTerminalWriteErrors[keyof InteractiveTerminalWriteErrors]
export type InteractiveTerminalWriteResponses = {
/**
* Input written
*/
200: boolean
}
export type InteractiveTerminalWriteResponse =
InteractiveTerminalWriteResponses[keyof InteractiveTerminalWriteResponses]
export type InteractiveTerminalResizeData = {
body?: InteractiveTerminalResizeInput
path: {
terminalID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/interactive-terminal/{terminalID}/resize"
}
export type InteractiveTerminalResizeErrors = {
/**
* Not found
*/
404: NotFoundError
}
export type InteractiveTerminalResizeError = InteractiveTerminalResizeErrors[keyof InteractiveTerminalResizeErrors]
export type InteractiveTerminalResizeResponses = {
/**
* Terminal resized
*/
200: boolean
}
export type InteractiveTerminalResizeResponse =
InteractiveTerminalResizeResponses[keyof InteractiveTerminalResizeResponses]
export type InteractiveTerminalCloseData = {
body?: never
path: {
terminalID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/interactive-terminal/{terminalID}/close"
}
export type InteractiveTerminalCloseErrors = {
/**
* Not found
*/
404: NotFoundError
}
export type InteractiveTerminalCloseError = InteractiveTerminalCloseErrors[keyof InteractiveTerminalCloseErrors]
export type InteractiveTerminalCloseResponses = {
/**
* Terminal closed
*/
200: boolean
}
export type InteractiveTerminalCloseResponse =
InteractiveTerminalCloseResponses[keyof InteractiveTerminalCloseResponses]
export type KiloProfileData = {
body?: never
path?: never
+556
View File
@@ -11264,6 +11264,323 @@
]
}
},
"/interactive-terminal": {
"get": {
"tags": ["interactive-terminal"],
"operationId": "interactiveTerminal.list",
"parameters": [
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "List of interactive terminals",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InteractiveTerminalSnapshot"
},
"description": "List of interactive terminals"
}
}
}
}
},
"description": "List active human-driven terminal sessions for the current instance.",
"summary": "List interactive terminals",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.interactiveTerminal.list({\n ...\n})"
}
]
}
},
"/interactive-terminal/{terminalID}": {
"get": {
"tags": ["interactive-terminal"],
"operationId": "interactiveTerminal.get",
"parameters": [
{
"name": "terminalID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Interactive terminal snapshot",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InteractiveTerminalSnapshot"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Get metadata and retained output for an active interactive terminal.",
"summary": "Get interactive terminal",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.interactiveTerminal.get({\n ...\n})"
}
]
}
},
"/interactive-terminal/{terminalID}/input": {
"post": {
"tags": ["interactive-terminal"],
"operationId": "interactiveTerminal.write",
"parameters": [
{
"name": "terminalID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Input written",
"content": {
"application/json": {
"schema": {
"type": "boolean",
"description": "Input written"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Send raw keyboard input to an active interactive terminal.",
"summary": "Write interactive terminal input",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InteractiveTerminalWriteInput"
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.interactiveTerminal.write({\n ...\n})"
}
]
}
},
"/interactive-terminal/{terminalID}/resize": {
"post": {
"tags": ["interactive-terminal"],
"operationId": "interactiveTerminal.resize",
"parameters": [
{
"name": "terminalID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Terminal resized",
"content": {
"application/json": {
"schema": {
"type": "boolean",
"description": "Terminal resized"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Resize an active interactive terminal's PTY.",
"summary": "Resize interactive terminal",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InteractiveTerminalResizeInput"
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.interactiveTerminal.resize({\n ...\n})"
}
]
}
},
"/interactive-terminal/{terminalID}/close": {
"post": {
"tags": ["interactive-terminal"],
"operationId": "interactiveTerminal.close",
"parameters": [
{
"name": "terminalID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Terminal closed",
"content": {
"application/json": {
"schema": {
"type": "boolean",
"description": "Terminal closed"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Terminate an active interactive terminal and unblock its tool call.",
"summary": "Close interactive terminal",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.interactiveTerminal.close({\n ...\n})"
}
]
}
},
"/kilo/profile": {
"get": {
"tags": ["kilo"],
@@ -14532,6 +14849,15 @@
{
"$ref": "#/components/schemas/EventBackground_processDeleted"
},
{
"$ref": "#/components/schemas/EventInteractive_terminalUpdated"
},
{
"$ref": "#/components/schemas/EventInteractive_terminalData"
},
{
"$ref": "#/components/schemas/EventInteractive_terminalDeleted"
},
{
"$ref": "#/components/schemas/EventSessionTurnOpen"
},
@@ -15340,6 +15666,94 @@
"required": ["id", "sessionID", "command", "cwd", "ports", "status", "ready", "output", "time"],
"additionalProperties": false
},
"InteractiveTerminalInfo": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"pid": {
"type": "integer",
"exclusiveMinimum": 0
},
"command": {
"type": "string"
},
"cwd": {
"type": "string"
},
"description": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["running", "closed"]
},
"cols": {
"type": "integer",
"exclusiveMinimum": 0
},
"rows": {
"type": "integer",
"exclusiveMinimum": 0
},
"exitCode": {
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"enum": ["NaN"]
},
{
"type": "string",
"enum": ["Infinity"]
},
{
"type": "string",
"enum": ["-Infinity"]
},
{
"type": "string",
"enum": ["Infinity", "-Infinity", "NaN"]
}
]
},
"signal": {
"type": "string"
},
"closedBy": {
"type": "string",
"enum": ["exit", "user", "abort"]
},
"time": {
"type": "object",
"properties": {
"started": {
"type": "integer",
"minimum": 0
},
"updated": {
"type": "integer",
"minimum": 0
},
"ended": {
"type": "integer",
"minimum": 0
}
},
"required": ["started", "updated"],
"additionalProperties": false
}
},
"required": ["id", "sessionID", "pid", "command", "cwd", "status", "cols", "rows", "time"],
"additionalProperties": false
},
"SnapshotFileDiff": {
"type": "object",
"properties": {
@@ -17256,6 +17670,15 @@
{
"$ref": "#/components/schemas/EventBackground_processDeleted"
},
{
"$ref": "#/components/schemas/EventInteractive_terminalUpdated"
},
{
"$ref": "#/components/schemas/EventInteractive_terminalData"
},
{
"$ref": "#/components/schemas/EventInteractive_terminalDeleted"
},
{
"$ref": "#/components/schemas/EventSessionTurnOpen"
},
@@ -21311,6 +21734,48 @@
"required": ["defaultModel", "models", "aliases"],
"additionalProperties": false
},
"InteractiveTerminalSnapshot": {
"type": "object",
"properties": {
"info": {
"$ref": "#/components/schemas/InteractiveTerminalInfo"
},
"output": {
"type": "string"
},
"cursor": {
"type": "integer",
"minimum": 0
}
},
"required": ["info", "output", "cursor"],
"additionalProperties": false
},
"InteractiveTerminalWriteInput": {
"type": "object",
"properties": {
"data": {
"type": "string"
}
},
"required": ["data"],
"additionalProperties": false
},
"InteractiveTerminalResizeInput": {
"type": "object",
"properties": {
"cols": {
"type": "integer",
"exclusiveMinimum": 0
},
"rows": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["cols", "rows"],
"additionalProperties": false
},
"effect_HttpApiError_Unauthorized": {
"type": "object",
"properties": {
@@ -23824,6 +24289,93 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventInteractive_terminalUpdated": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["interactive_terminal.updated"]
},
"properties": {
"type": "object",
"properties": {
"info": {
"$ref": "#/components/schemas/InteractiveTerminalInfo"
}
},
"required": ["info"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventInteractive_terminalData": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["interactive_terminal.data"]
},
"properties": {
"type": "object",
"properties": {
"terminalID": {
"type": "string"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"data": {
"type": "string"
},
"cursor": {
"type": "integer",
"minimum": 0
}
},
"required": ["terminalID", "sessionID", "data", "cursor"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventInteractive_terminalDeleted": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["interactive_terminal.deleted"]
},
"properties": {
"type": "object",
"properties": {
"terminalID": {
"type": "string"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
}
},
"required": ["terminalID", "sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSessionTurnOpen": {
"type": "object",
"properties": {
@@ -27611,6 +28163,10 @@
"name": "indexing",
"description": "Kilo indexing routes."
},
{
"name": "interactive-terminal",
"description": "Kilo human-driven interactive terminal routes."
},
{
"name": "kilo",
"description": "Kilo Gateway routes."
+35 -25
View File
@@ -33,9 +33,7 @@ describe("opencode changesets", () => {
})
test("formats changeset markdown", () => {
expect(
changeset([{ tag_name: "v1.2.2", body: "\r\n## Core\r\n\r\n- Fix issue\r\n" }], "1.2.1", "1.2.2"),
).toBe(`---
expect(changeset([{ tag_name: "v1.2.2", body: "\r\n## Core\r\n\r\n- Fix issue\r\n" }], "1.2.1", "1.2.2")).toBe(`---
"@kilocode/cli": patch
"kilo-code": patch
---
@@ -48,10 +46,11 @@ Changes from opencode v1.2.1 to v1.2.2 upstream:
test("filters ignored sections and contributor thanks", () => {
expect(
changeset([
{
tag_name: "v1.2.2",
body: `## Core
changeset(
[
{
tag_name: "v1.2.2",
body: `## Core
- Keep this
@@ -68,8 +67,11 @@ Changes from opencode v1.2.1 to v1.2.2 upstream:
- @user:
- Helped
`,
},
], "1.2.1", "1.2.2"),
},
],
"1.2.1",
"1.2.2",
),
).toBe(`---
"@kilocode/cli": patch
"kilo-code": patch
@@ -83,10 +85,11 @@ Changes from opencode v1.2.1 to v1.2.2 upstream:
test("bundles release notes into shared sections", () => {
expect(
changeset([
{
tag_name: "v1.2.1",
body: `## Core
changeset(
[
{
tag_name: "v1.2.1",
body: `## Core
### Bugfixes
@@ -98,10 +101,10 @@ Changes from opencode v1.2.1 to v1.2.2 upstream:
- Improve first
`,
},
{
tag_name: "v1.2.2",
body: `## Core
},
{
tag_name: "v1.2.2",
body: `## Core
### Bugfixes
@@ -117,8 +120,11 @@ Changes from opencode v1.2.1 to v1.2.2 upstream:
- Improve second
`,
},
], "1.2.0", "1.2.2"),
},
],
"1.2.0",
"1.2.2",
),
).toBe(`---
"@kilocode/cli": patch
"kilo-code": patch
@@ -136,10 +142,11 @@ Changes from opencode v1.2.0 to v1.2.2 upstream:
test("preserves multiline markdown blocks", () => {
expect(
changeset([
{
tag_name: "v1.2.2",
body: `## Core
changeset(
[
{
tag_name: "v1.2.2",
body: `## Core
### Improvements
@@ -149,8 +156,11 @@ Changes from opencode v1.2.0 to v1.2.2 upstream:
Continuation paragraph
- Second item
`,
},
], "1.2.1", "1.2.2"),
},
],
"1.2.1",
"1.2.2",
),
).toBe(`---
"@kilocode/cli": patch
"kilo-code": patch
+12 -3
View File
@@ -53,8 +53,14 @@ function tag(input: string) {
}
function slug(from: string, to: string) {
const base = tag(from).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase()
const head = tag(to).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase()
const base = tag(from)
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase()
const head = tag(to)
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase()
return `opencode-${base}-to-${head}.md`
}
@@ -99,7 +105,10 @@ function filter(input: string, sections: string[]) {
if (!skip) out.push(line)
}
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim()
return out
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim()
}
function add(groups: Group, section: string, category: string, lines: string[]) {