mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
feat: interactive terminal tool
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {}),
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
Reference in New Issue
Block a user