Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/instant-agent-manager-terminal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Accept terminal input immediately while the Agent Manager shell starts.
Original file line number Diff line number Diff line change
Expand Up @@ -1378,6 +1378,7 @@ export class AgentManagerProvider implements Disposable {
reviewDiffStyle: state.getReviewDiffStyle(),
reviewMarkdownRender: getDiffMarkdownRender(),
terminalDestination: this.destination.value(),
terminalFont: readTerminalFont(),
isGitRepo: true,
defaultBaseBranch: state.getDefaultBaseBranch(),
activeTarget: state.getActiveTarget(),
Expand All @@ -1404,6 +1405,7 @@ export class AgentManagerProvider implements Disposable {
reviewDiffStyle: "unified",
reviewMarkdownRender: getDiffMarkdownRender(),
terminalDestination: this.destination.value(),
terminalFont: readTerminalFont(),
isGitRepo: false,
runStatuses: [],
runScriptConfigured: false,
Expand Down
19 changes: 5 additions & 14 deletions packages/kilo-vscode/src/agent-manager/terminal-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,6 @@ interface Entry {
title: string
}

/** Stable prefix used for terminal tab IDs in the webview (e.g. `terminal:abc123`). */
export const TERMINAL_PREFIX = "terminal:"

/** Generate a reasonably unique terminal ID without bringing in a uuid dep. */
function makeTerminalId(): string {
const rand = Math.random().toString(36).slice(2, 8)
return `${TERMINAL_PREFIX}${Date.now().toString(36)}-${rand}`
}

export class TerminalManager {
private readonly entries = new Map<string, Entry>()
private readonly restarts = new Map<string, Promise<void>>()
Expand All @@ -70,6 +61,7 @@ export class TerminalManager {
* tab back into the correct sidebar context.
*/
async create(params: {
terminalId: string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The create docstring is now stale

With this new parameter, the terminal ID is generated by the webview and echoed back, but the docstring above still says "Returns the attach info the webview needs: our synthetic terminal ID, …". Suggest updating it to note the ID is caller-supplied (e.g. "the webview-supplied logical terminal ID, echoed back").


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

worktreeId: string | null
cwd: string
title: string
Expand All @@ -84,18 +76,17 @@ export class TerminalManager {
const err = error instanceof Error ? error.message : String(error ?? "unknown error")
throw new Error(`Failed to create PTY: ${err}`)
}
const terminalId = makeTerminalId()
const entry: Entry = {
terminalId,
terminalId: params.terminalId,
ptyID: data.id,
worktreeId: params.worktreeId,
cwd: params.cwd,
title: data.title ?? params.title,
}
this.entries.set(terminalId, entry)
this.entries.set(params.terminalId, entry)
const wsUrl = this.deps.buildWsUrl(entry.ptyID, entry.cwd)
this.deps.log(`Terminal created: ${terminalId} -> pty ${entry.ptyID} cwd=${entry.cwd}`)
return { terminalId, worktreeId: entry.worktreeId, title: entry.title, wsUrl }
this.deps.log(`Terminal created: ${params.terminalId} -> pty ${entry.ptyID} cwd=${entry.cwd}`)
return { terminalId: params.terminalId, worktreeId: entry.worktreeId, title: entry.title, wsUrl }
}

/** Forward a resize event to the backend PTY. Missing terminals are a no-op. */
Expand Down
2 changes: 1 addition & 1 deletion packages/kilo-vscode/src/agent-manager/terminal-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export class TerminalRouter {
// Join the shared backend connection instead of racing its synchronous
// client accessor when this is the first Kilo action in the window.
await this.deps.getClientAsync()
const created = await manager.create({ worktreeId, cwd, title })
const created = await manager.create({ terminalId: createId, worktreeId, cwd, title })
if (generation !== this.generation) {
await manager.close(created.terminalId)
return
Expand Down
3 changes: 2 additions & 1 deletion packages/kilo-vscode/src/agent-manager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ interface StateMessage {
/** Last selected sidebar target for seamless project-switch restore. */
activeTarget?: SidebarTarget
terminalDestination?: TerminalDestination
terminalFont?: TerminalFont
}

/** Project catalog pushed to the webview after registry or context changes. */
Expand Down Expand Up @@ -932,7 +933,7 @@ interface MoveSectionIn {

interface TerminalCreateIn {
type: "agentManager.terminal.create"
/** Webview-generated correlation id, echoed back in created/error. */
/** Webview-generated logical terminal id, echoed back in created/error. */
createId: string
placement: TerminalPlacement
/** null for LOCAL, worktree id otherwise */
Expand Down
146 changes: 146 additions & 0 deletions packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, expect, it } from "bun:test"
import { createInputBuffer, createReplayGate } from "../../webview-ui/agent-manager/terminal/replay"

describe("Agent Manager terminal input buffer", () => {
it("sends parser replies first while preserving user input order", () => {
const input = createInputBuffer()
input.add("early ")
input.add("reply", true)
input.add("command\r")

expect(input.take()).toBe("replyearly command\r")
expect(input.take()).toBe("")
})

it("caps user input and protocol replies independently", () => {
const input = createInputBuffer(4)
input.add("12345")
input.add("abcde", true)

expect(input.take()).toBe("bcde2345")
})
})

describe("Agent Manager terminal replay gate", () => {
it("flushes initial input only after replay parsing completes", () => {
const events: string[] = []
let complete: (() => void) | undefined
const gate = createReplayGate({
write: (data, callback) => {
events.push(typeof data === "string" ? data : `bytes:${data.join(",")}`)
if (callback) complete = callback
},
flush: () => events.push("flush"),
})

gate.attach(false)
expect(gate.blocked()).toBe(true)
gate.output("replay")
gate.output(new Uint8Array([1, 2, 3]))
expect(events).toEqual([])
expect(gate.frame(new Uint8Array([0, 123, 125]))).toBe(true)
expect(gate.blocked()).toBe(true)
expect(gate.draining()).toBe(true)
expect(events).toEqual(["replay", "bytes:1,2,3", ""])

complete?.()
expect(gate.blocked()).toBe(false)
expect(gate.draining()).toBe(false)
expect(events).toEqual(["replay", "bytes:1,2,3", "", "flush"])
})

it("leaves reconnect input on the output-settle path", () => {
const events: string[] = []
const gate = createReplayGate({
write: () => events.push("write"),
flush: () => events.push("flush"),
})

gate.attach(true)
expect(gate.blocked()).toBe(false)
expect(gate.draining()).toBe(false)
gate.output("live")
expect(gate.frame(new Uint8Array([0]))).toBe(true)
expect(events).toEqual(["write"])
})

it("consumes only one initial replay boundary", () => {
let drains = 0
const gate = createReplayGate({
write: (_data, callback) => {
if (callback) drains++
},
flush: () => undefined,
})

gate.attach(false)
expect(gate.frame(new Uint8Array())).toBe(false)
expect(gate.frame(new Uint8Array([0]))).toBe(true)
expect(gate.frame(new Uint8Array([0]))).toBe(true)
expect(drains).toBe(1)
})

it("ignores an initial parse callback after reconnect starts", () => {
let complete: (() => void) | undefined
let flushed = 0
const gate = createReplayGate({
write: (_data, callback) => {
if (callback) complete = callback
},
flush: () => flushed++,
})

gate.attach(false)
gate.frame(new Uint8Array([0]))
gate.attach(true)
complete?.()

expect(gate.blocked()).toBe(false)
expect(flushed).toBe(0)
})

it("lets terminal replies pass while queued replay parses before user input flushes", () => {
const events: string[] = []
let complete: (() => void) | undefined
const gate = createReplayGate({
write: (data, callback) => {
events.push(String(data))
if (callback) complete = callback
},
flush: () => events.push("flush"),
})

gate.attach(false)
expect(gate.blocked()).toBe(true)
gate.output("replay")
gate.frame(new Uint8Array([0]))
expect(gate.blocked()).toBe(true)
expect(gate.draining()).toBe(true)
gate.output("terminal-reply")
expect(events).toEqual(["replay", "", "terminal-reply"])
expect(complete).toBeFunction()
complete?.()
expect(gate.blocked()).toBe(false)
expect(gate.draining()).toBe(false)
expect(events).toEqual(["replay", "", "terminal-reply", "flush"])
})

it("keeps user input blocked for the complete parser-drain window", () => {
let complete: (() => void) | undefined
const gate = createReplayGate({
write: (_data, callback) => {
if (callback) complete = callback
},
flush: () => undefined,
})

gate.attach(false)
gate.frame(new Uint8Array([0]))
expect(gate.blocked()).toBe(true)
expect(gate.draining()).toBe(true)

complete?.()
expect(gate.blocked()).toBe(false)
expect(gate.draining()).toBe(false)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe("Agent Manager terminal routing", () => {
expect(messages[0]).toMatchObject({
type: "agentManager.terminal.created",
createId: "side-1",
terminalId: "side-1",
placement: "side",
worktreeId: "wt-1",
projectId: "prj-1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ function scene(initial: string | null = LOCAL) {
shown: [] as string[],
errors: 0,
running: [] as Array<{ contextKey: string; terminalId: string }>,
sideFocus: [] as boolean[],
}
const tabs = () => state.current().map((term) => term.id)
const handlers = createTerminalHandlers({
Expand All @@ -39,6 +38,7 @@ function scene(initial: string | null = LOCAL) {
getSelection: selection,
LOCAL,
REVIEW_TAB_ID: "review",
getFont: () => font,
})
const dispatch = createTerminalMessageHandler({
state,
Expand All @@ -50,7 +50,6 @@ function scene(initial: string | null = LOCAL) {
},
showError: () => events.errors++,
postMessage: (message) => posted.push(message as Record<string, unknown>),
onSideCreated: (_contextKey, _terminalId, focus) => events.sideFocus.push(focus),
onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }),
})
return { state, selection, setSelection, posted, events, handlers, dispatch }
Expand Down Expand Up @@ -317,18 +316,23 @@ describe("Agent Manager terminal state", () => {
item.handlers.requestSide()

expect(item.posted).toHaveLength(1)
expect(item.state.sidesForContext(LOCAL)).toHaveLength(1)
const request = item.posted[0]!
expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null })
const createId = String(request.createId)
expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side")
expect(createId).toStartWith("terminal:")
const optimistic = item.state.sidesForContext(LOCAL)[0]
expect(item.dispatch(createdSide(createId, createId))).toBe(true)
expect(item.state.sidesForContext(LOCAL)[0]).toBe(optimistic)
expect(optimistic?.wsUrl).toBe(`ws://${createId}`)
expect(item.state.sideActiveFor(LOCAL)).toBe(createId)
expect(item.events.activated).toEqual([])
expect(item.events.selected).toEqual([])
expect(item.events.saved).toBe(0)

item.handlers.requestSide()
expect(item.posted).toHaveLength(1)
expect(item.state.focusRequest()?.id).toBe("terminal:side")
expect(item.state.focusRequest()?.id).toBe(createId)
dispose()
})
})
Expand All @@ -347,8 +351,8 @@ describe("Agent Manager terminal state", () => {
worktreeId: "wt-1",
})
const createId = String(item.posted[0]!.createId)
expect(item.dispatch(createdSide(createId, "terminal:side", "Terminal 1", "wt-1"))).toBe(true)
expect(item.events.sideFocus).toEqual([false])
expect(item.dispatch(createdSide(createId, createId, "Terminal 1", "wt-1"))).toBe(true)
expect(item.state.sidesForContext("wt-1")[0]).toMatchObject({ id: createId, title: "Terminal 1" })
dispose()
})
})
Expand All @@ -358,8 +362,8 @@ describe("Agent Manager terminal state", () => {
const item = scene()
item.handlers.addSide()
const createId = String(item.posted[0]!.createId)
expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true)
expect(item.events.sideFocus).toEqual([true])
expect(item.dispatch(createdSide(createId, createId))).toBe(true)
expect(item.state.focusRequest()?.id).toBe(createId)
dispose()
})
})
Expand All @@ -373,13 +377,12 @@ describe("Agent Manager terminal state", () => {
const first = String(item.posted[0]!.createId)
const second = String(item.posted[1]!.createId)

item.dispatch(createdSide(first, "terminal:one", "Terminal 1"))
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one"])
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
item.dispatch(createdSide(first, first, "Terminal 1"))
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([first, second])

item.dispatch(createdSide(second, "terminal:two", "Terminal 2"))
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one", "terminal:two"])
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
item.dispatch(createdSide(second, second, "Terminal 2"))
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([first, second])
expect(item.state.sideActiveFor(LOCAL)).toBe(second)
dispose()
})
})
Expand Down Expand Up @@ -606,6 +609,7 @@ describe("Agent Manager terminal state", () => {
getSelection: selection,
LOCAL,
REVIEW_TAB_ID: "review",
getFont: () => font,
})
const dispatch = createTerminalMessageHandler({
state,
Expand All @@ -627,12 +631,11 @@ describe("Agent Manager terminal state", () => {
expect(item.posted).toHaveLength(1)
const request = item.posted[0]!
expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null })
expect(item.dispatch({ ...createdSide(String(request.createId), "terminal:side"), projectId: "prj-1" })).toBe(
true,
)
const id = String(request.createId)
expect(item.dispatch({ ...createdSide(id, id), projectId: "prj-1" })).toBe(true)
expect(item.state.sideKey()).toBe("prj-1:local")
expect(item.state.sides().map((term) => term.id)).toEqual(["terminal:side"])
expect(item.state.sideActiveFor("prj-1:local")).toBe("terminal:side")
expect(item.state.sides().map((term) => term.id)).toEqual([id])
expect(item.state.sideActiveFor("prj-1:local")).toBe(id)

// A worktree context sends its plain worktree id, not "prj-1:wt-1".
const wt = nsScene("wt-1")
Expand Down
Loading
Loading