From ee1ac30e464b9f40b6362d276ec1dc059e1bd5e3 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 17:13:36 +0200 Subject: [PATCH 1/2] fix(vscode): start agent manager terminals instantly --- .changeset/instant-agent-manager-terminal.md | 5 + .../src/agent-manager/AgentManagerProvider.ts | 2 + .../src/agent-manager/terminal-manager.ts | 19 +-- .../src/agent-manager/terminal-routing.ts | 2 +- .../kilo-vscode/src/agent-manager/types.ts | 3 +- .../agent-manager-terminal-replay.test.ts | 146 ++++++++++++++++++ .../agent-manager-terminal-routing.test.ts | 1 + .../unit/agent-manager-terminal-state.test.ts | 43 +++--- .../agent-manager/AgentManagerApp.tsx | 17 +- .../agent-manager/terminal/TerminalTab.tsx | 68 ++++++-- .../agent-manager/terminal/replay.ts | 90 +++++++++++ .../agent-manager/terminal/state.ts | 57 +++++-- .../src/types/messages/extension-messages.ts | 7 +- .../src/types/messages/webview-messages.ts | 2 +- 14 files changed, 386 insertions(+), 76 deletions(-) create mode 100644 .changeset/instant-agent-manager-terminal.md create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/terminal/replay.ts diff --git a/.changeset/instant-agent-manager-terminal.md b/.changeset/instant-agent-manager-terminal.md new file mode 100644 index 00000000000..4893a22b6d4 --- /dev/null +++ b/.changeset/instant-agent-manager-terminal.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Accept terminal input immediately while the Agent Manager shell starts. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 39b844f9894..15e762d9c45 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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(), @@ -1404,6 +1405,7 @@ export class AgentManagerProvider implements Disposable { reviewDiffStyle: "unified", reviewMarkdownRender: getDiffMarkdownRender(), terminalDestination: this.destination.value(), + terminalFont: readTerminalFont(), isGitRepo: false, runStatuses: [], runScriptConfigured: false, diff --git a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts index bcdef484dc2..24f916542e5 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts @@ -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() private readonly restarts = new Map>() @@ -70,6 +61,7 @@ export class TerminalManager { * tab back into the correct sidebar context. */ async create(params: { + terminalId: string worktreeId: string | null cwd: string title: string @@ -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. */ diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index b58e25b8a9f..95fe5c86328 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -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 diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 3a2b555e355..aa80d246884 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -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. */ @@ -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 */ diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts new file mode 100644 index 00000000000..2ab5221ae27 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts @@ -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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts index 78672282041..348a632a48e 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -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", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts index 92b0efd7c1f..ea0fc92ca79 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -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({ @@ -39,6 +38,7 @@ function scene(initial: string | null = LOCAL) { getSelection: selection, LOCAL, REVIEW_TAB_ID: "review", + getFont: () => font, }) const dispatch = createTerminalMessageHandler({ state, @@ -50,7 +50,6 @@ function scene(initial: string | null = LOCAL) { }, showError: () => events.errors++, postMessage: (message) => posted.push(message as Record), - onSideCreated: (_contextKey, _terminalId, focus) => events.sideFocus.push(focus), onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }), }) return { state, selection, setSelection, posted, events, handlers, dispatch } @@ -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() }) }) @@ -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() }) }) @@ -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() }) }) @@ -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() }) }) @@ -606,6 +609,7 @@ describe("Agent Manager terminal state", () => { getSelection: selection, LOCAL, REVIEW_TAB_ID: "review", + getFont: () => font, }) const dispatch = createTerminalMessageHandler({ state, @@ -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") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 2362d705b1e..88adae2d676 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -45,7 +45,9 @@ import type { SessionCreatedMessage, BranchInfo, TerminalDestination, + TerminalFont, } from "../src/types/messages" +import { readFontSize } from "../src/font-size" import { IndexingProvider } from "../src/context/indexing" import {} from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" @@ -370,6 +372,11 @@ const AgentManagerContent: Component = () => { const PENDING_PREFIX = "pending:" const closedDrafts = new Set() const [activePendingId, setActivePendingId] = createSignal() + const [terminalFont, setTerminalFont] = createSignal({ + fontFamily: + getComputedStyle(document.documentElement).getPropertyValue("--vscode-editor-font-family") || "monospace", + fontSize: readFontSize(), + }) /** Namespace key so worktree/local ids from different projects never collide. */ const nsKey = (sel: string) => `${currentProjectId() ?? "single"}:${sel}` @@ -1065,6 +1072,7 @@ const AgentManagerContent: Component = () => { const applyState = (msg: ExtensionMessage) => { if (msg.type !== "agentManager.state") return const state = msg as AgentManagerStateMessage + if (state.terminalFont) setTerminalFont(state.terminalFont) const pid = state.projectId if (pid) setProjectStates((prev) => ({ ...prev, [pid]: state })) const store = pid ? registry.ensure(pid) : registry.active() @@ -1331,13 +1339,6 @@ const AgentManagerContent: Component = () => { showToast({ variant: "error", title: t("agentManager.terminal.errorTitle"), description: message }), postMessage: (message) => vscode.postMessage(message as never), onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId), - onSideCreated: (contextKey, terminalId, focus) => { - // Focus only when the user is still looking at this panel — - // a slow create landing after a mode switch must not steal it. - if (focus && sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) { - terms.requestFocus(terminalId) - } - }, onSideClosed: (_contextKey, terminalId) => forgetTerminalFocus(terminalId), onScriptRunning: (contextKey, terminalId) => { if (terms.sideKey() !== contextKey) return @@ -1356,6 +1357,7 @@ const AgentManagerContent: Component = () => { onDestinationChanged: (destination) => sideCtl.syncDefault(destination), }) const unsubTerminals = vscode.onMessage((msg) => { + if (msg.type === "agentManager.terminal.fontChanged") setTerminalFont(msg.font) terminalDispatch(msg) }) @@ -2077,6 +2079,7 @@ const AgentManagerContent: Component = () => { getSelection: selection, LOCAL, REVIEW_TAB_ID, + getFont: terminalFont, }) const sideCtl = createSideTerminal({ diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index 606c8fa47ec..8b6e2956b78 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -21,6 +21,7 @@ import { useVSCode } from "../../src/context/vscode" import { useLanguage } from "../../src/context/language" import { formatReviewCommentsMarkdown } from "../../src/utils/review-comment-markdown" import type { ScriptTerminalStatus, TerminalFont } from "./state" +import { createInputBuffer, createReplayGate } from "./replay" interface Props { terminalId: string @@ -192,7 +193,8 @@ export const TerminalTab: Component = (props) => { let ws: WebSocket | undefined let closed = false - let pending = "" + const input = createInputBuffer() + let user = false let restartRequested = false let disconnected = false let readyTimer: ReturnType | undefined @@ -234,11 +236,18 @@ export const TerminalTab: Component = (props) => { rows: term.rows, }) } + const markUser = () => { + user = true + queueMicrotask(() => { + user = false + }) + } const send = (data: string) => { - if (disconnected && props.restartable) { - pending += data - if (pending.length > 256 * 1024) pending = pending.slice(-256 * 1024) - requestRestart() + const reply = replay.draining() && !user + user = false + if (props.restartable && (replay.blocked() || disconnected || ws?.readyState !== WebSocket.OPEN)) { + input.add(data, reply) + if (disconnected) requestRestart() return } if (ws?.readyState === WebSocket.OPEN) { @@ -246,7 +255,7 @@ export const TerminalTab: Component = (props) => { return } } - const flush = () => { + const flush = (all = false) => { if (ws?.readyState !== WebSocket.OPEN) return if (readyTimer) { clearTimeout(readyTimer) @@ -256,9 +265,8 @@ export const TerminalTab: Component = (props) => { clearTimeout(fallbackTimer) fallbackTimer = undefined } - const data = pending - pending = "" - if (data && /[^\r\n]/.test(data)) ws.send(data) + const data = input.take() + if (data && (all || /[^\r\n]/.test(data))) ws.send(data) disconnected = false restartRequested = false } @@ -270,8 +278,17 @@ export const TerminalTab: Component = (props) => { flush() }, 100) } + const replay = createReplayGate({ + write: (data, callback) => term.write(data, callback), + flush: () => flush(true), + }) + const disposeKey = term.onKey(markUser) + for (const event of ["input", "paste", "compositionend", "mousedown", "wheel"]) { + host.addEventListener(event, markUser, true) + } const open = (url: string) => { if (closed || !url) return + replay.attach(disconnected) const next = new WebSocket(url) next.binaryType = "arraybuffer" ws = next @@ -289,14 +306,14 @@ export const TerminalTab: Component = (props) => { if (closed || ws !== next) return streamed = true if (typeof event.data === "string") { - term.write(event.data) + replay.output(event.data) scheduleFlush() return } if (event.data instanceof ArrayBuffer) { const bytes = new Uint8Array(event.data) - if (bytes.length > 0 && bytes[0] === 0x00) return - term.write(bytes) + if (replay.frame(bytes)) return + replay.output(bytes) scheduleFlush() } } @@ -326,6 +343,7 @@ export const TerminalTab: Component = (props) => { } } const disposeData = term.onData(send) + const disposeBinary = term.onBinary(send) open(props.wsUrl) // These addons are not needed to paint the initial prompt. Defer them @@ -440,6 +458,7 @@ export const TerminalTab: Component = (props) => { if (message.targetTerminalId !== props.terminalId) return const comments = message.comments if (!Array.isArray(comments) || comments.length === 0) return + markUser() term.paste(`${formatReviewCommentsMarkdown(comments)}\n`) return } @@ -449,6 +468,16 @@ export const TerminalTab: Component = (props) => { return } + if (message.type === "agentManager.terminal.created") { + if (message.terminalId === props.terminalId && !ws) { + term.options.fontFamily = message.font.fontFamily + term.options.fontSize = message.font.fontSize + scheduleRepaint() + open(message.wsUrl) + } + return + } + if (message.type === "agentManager.terminal.error" && message.terminalId === props.terminalId) { restartRequested = false return @@ -479,8 +508,14 @@ export const TerminalTab: Component = (props) => { createEffect(() => { const now = props.active const serial = props.focusSerial ?? 0 - if (now && (!wasActive || serial !== focusSerial)) - scheduleRepaint((serial > 0 && serial !== focusSerial) || props.focusOnActivate !== false) + if (now && (!wasActive || serial !== focusSerial)) { + const focus = (serial > 0 && serial !== focusSerial) || props.focusOnActivate !== false + // xterm creates its textarea synchronously in term.open(). Focus it + // now so a freshly revealed terminal accepts input in this event + // turn; the queued repaint below still refits and retries next frame. + if (focus && document.hasFocus()) term.focus() + scheduleRepaint(focus) + } if (!now && wasActive) term.blur() wasActive = now focusSerial = serial @@ -528,11 +563,16 @@ export const TerminalTab: Component = (props) => { window.removeEventListener("focus", onWindowFocus) host.removeEventListener("focusin", onFocusIn) host.removeEventListener("focusout", onFocusOut) + for (const event of ["input", "paste", "compositionend", "mousedown", "wheel"]) { + host.removeEventListener(event, markUser, true) + } + disposeKey.dispose() fontSub() themeObserver.disconnect() clearTimeout(resizeTimer) ro.disconnect() disposeData.dispose() + disposeBinary.dispose() disposeTitle.dispose() try { ws?.close() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/replay.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/replay.ts new file mode 100644 index 00000000000..f953a41ffc0 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/replay.ts @@ -0,0 +1,90 @@ +interface ReplayGateDeps { + /** Write one output chunk to xterm, optionally observing parser completion. */ + write(data: string | Uint8Array, callback?: () => void): void + /** Release input buffered before the initial PTY attachment. */ + flush(): void +} + +/** Keep terminal protocol replies ahead of user input without reordering the + * user's bytes when both arrive while initial replay is being parsed. */ +export function createInputBuffer(limit = 256 * 1024) { + let input = "" + let replies = "" + + const add = (data: string, reply = false) => { + if (reply) { + replies += data + if (replies.length > limit) replies = replies.slice(-limit) + return + } + input += data + if (input.length > limit) input = input.slice(-limit) + } + + const take = () => { + const data = replies + input + replies = "" + input = "" + return data + } + + return { add, take } +} + +/** + * Gate initial user input on the PTY replay boundary. The backend sends a + * binary 0x00 metadata frame after retained output; waiting for xterm to parse + * everything queued before that frame keeps shell capability replies ahead of + * the command the user typed while the PTY was starting. + * + * Reconnects keep their existing output-settle timer instead. Their buffered + * input belongs to an exited shell recovery flow, not the initial attachment. + */ +export function createReplayGate(deps: ReplayGateDeps) { + let blocked = false + let boundary = false + let draining = false + let serial = 0 + let pending: Array = [] + + const attach = (reconnecting: boolean) => { + serial++ + blocked = !reconnecting + boundary = false + draining = false + pending = [] + } + + const output = (data: string | Uint8Array) => { + if (blocked && !boundary) { + pending.push(data) + return + } + deps.write(data) + } + + const frame = (data: Uint8Array) => { + if (data.length === 0 || data[0] !== 0x00) return false + if (blocked && !boundary) { + boundary = true + // Match OpenCode's transport ordering: once the server says replay is + // complete, xterm-generated replies from parsing those queued chunks + // must precede the command typed while the PTY was starting. Keep user + // input blocked until the parser-drain callback below; TerminalTab puts + // parser-generated replies in its separate priority buffer meanwhile. + draining = true + const current = serial + for (const chunk of pending) deps.write(chunk) + pending = [] + deps.write("", () => { + if (serial !== current) return + draining = false + blocked = false + deps.flush() + }) + } + return true + } + + return { attach, blocked: () => blocked, draining: () => draining, frame, output } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts index c14f02c1153..2702421223c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -64,12 +64,13 @@ export interface TerminalFocusRequest { * Multiple creates can be in flight for the same context at once. */ interface SideRequest { contextKey: string - focus: boolean } export interface TerminalStateControls { - /** Record received from `terminal.created`. */ + /** Add a terminal record to one context. */ add(worktreeId: string | null, term: TerminalTabState): void + /** Fill an optimistic terminal record without replacing its xterm-owning object. */ + attach(terminalId: string, input: Pick): boolean /** Drop a terminal from its context (location resolved automatically). * Returns the removed record so callers can react to placement. */ remove(terminalId: string): TerminalTabStateWithContext | undefined @@ -145,7 +146,7 @@ export interface TerminalStateControls { /** Request ids of the in-flight side-terminal creates for a context. */ pendingSide(contextKey: string): boolean /** Mark a side-terminal create as in flight for a context. */ - beginSide(contextKey: string, createId: string, focus?: boolean): void + beginSide(contextKey: string, createId: string): void /** Settle a create request; returns it so the caller can validate. */ completeSide(createId: string): SideRequest | undefined } @@ -299,6 +300,19 @@ export function createTerminalState(selection: Accessor): Termina }) } + const attach = (terminalId: string, input: Pick) => { + const key = contextFor(terminalId) + const term = terminalsByContext()[key ?? ""]?.find((item) => item.id === terminalId) + if (!term) return false + // Keep the record reference stable so Solid's never remounts the + // xterm that already owns focus and buffered input. + term.title = input.title + term.wsUrl = input.wsUrl + term.font = input.font + setTitle(terminalId, input.title) + return true + } + const remove = (terminalId: string): TerminalTabStateWithContext | undefined => { const key = contextFor(terminalId) if (!key) return undefined @@ -528,8 +542,8 @@ export function createTerminalState(selection: Accessor): Termina const pendingSide = (key: string) => (pending()[key]?.length ?? 0) > 0 - const beginSide = (key: string, createId: string, focus = false) => { - requests.set(createId, { contextKey: key, focus }) + const beginSide = (key: string, createId: string) => { + requests.set(createId, { contextKey: key }) setPending((prev) => ({ ...prev, [key]: [...(prev[key] ?? []), createId] })) } @@ -550,6 +564,7 @@ export function createTerminalState(selection: Accessor): Termina return { add, + attach, remove, contextFor, isScript, @@ -604,12 +619,12 @@ export interface TerminalHandlerDeps { /** Sentinel value for the LOCAL sidebar selection. */ LOCAL: string REVIEW_TAB_ID: string + getFont: () => TerminalFont } /** Correlation ids for terminal create requests. */ function newId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID() - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + return `${TERMINAL_PREFIX}${crypto.randomUUID()}` } /** @@ -644,7 +659,16 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { // project-namespaced state key and must not leak into the message. const sel = deps.getSelection() const id = newId() - deps.state.beginSide(key, id, focus) + deps.state.beginSide(key, id) + deps.state.add(key === deps.LOCAL ? null : key, { + id, + title: "Terminal", + wsUrl: "", + font: deps.getFont(), + placement: "side", + }) + deps.state.setSideActive(key, id) + if (focus) deps.state.requestFocus(id) deps.postMessage({ type: "agentManager.terminal.create", createId: id, @@ -744,6 +768,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { deps.postMessage({ type: "agentManager.terminal.close", terminalId }) return true } + deps.state.completeSide(terminalId) deps.state.remove(terminalId) deps.postMessage({ type: "agentManager.terminal.close", terminalId }) return true @@ -827,8 +852,6 @@ export interface TerminalMessageHandlerDeps { * than wherever `tabIds()`'s base composition happens to put it. */ onCreated?: (contextKey: string, terminalId: string) => void - /** Side terminal for a context finished creating. */ - onSideCreated?: (contextKey: string, terminalId: string, focus: boolean) => void /** Side terminal create failed for a context. */ onSideError?: (contextKey: string) => void /** Side terminal was closed (locally or by the extension). */ @@ -860,14 +883,16 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { // reloaded (or the context is gone) — close the PTY again instead // of leaking it. const request = deps.state.completeSide(msg.createId) - if (!request || request.contextKey !== key) { + if (!request || request.contextKey !== key || msg.terminalId !== msg.createId) { + deps.state.remove(msg.createId) deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId }) return } - deps.state.add(key === LOCAL ? null : key, term) - // The newest terminal becomes the visible one in its panel. - deps.state.setSideActive(key, msg.terminalId) - deps.onSideCreated?.(key, msg.terminalId, request.focus) + // The user may have closed the optimistic terminal while the PTY was + // starting. The request was completed above, but its record is gone. + if (!deps.state.attach(msg.terminalId, term)) { + deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId }) + } return } deps.state.add(key === LOCAL ? null : key, term) @@ -907,7 +932,9 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { return true } if (msg.type === "agentManager.terminal.error") { + const context = msg.createId ? deps.state.contextFor(msg.createId) : undefined const request = msg.createId ? deps.state.completeSide(msg.createId) : undefined + if (msg.createId && context) deps.state.remove(msg.createId) if (request) deps.onSideError?.(request.contextKey) deps.showError(msg.message) return true diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 034337503f8..59d67adafad 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -745,6 +745,7 @@ export interface AgentManagerStateMessage { /** Last selected sidebar target for seamless project-switch restore. */ activeTarget?: AgentManagerSidebarTarget terminalDestination?: TerminalDestination + terminalFont?: TerminalFont } // A registered Agent Manager project as shown in the sidebar @@ -784,9 +785,9 @@ export interface AgentManagerProjectSessionsMessage { export interface AgentManagerTerminalCreatedMessage { type: "agentManager.terminal.created" - /** Correlates with the create request; lets the webview spot stale - * creates. Deliberately not named `requestId`: that field name is the - * generic webview request/response correlation channel. */ + /** Logical terminal id selected by the webview before PTY startup. + * Deliberately not named `requestId`: that field name is the generic + * webview request/response correlation channel. */ createId: string placement: TerminalPlacement /** null for LOCAL, worktree id otherwise */ diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 257e27e4ce5..f8ab26eee91 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -800,7 +800,7 @@ export interface ShowExistingLocalTerminalRequest { // Create a new xterm terminal in the given worktree context (null = workspace root) export interface AgentManagerTerminalCreateRequest { 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 worktreeId: string | null From a436f452cf871928f33beb25dd4bb6b576e58503 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 17:24:05 +0200 Subject: [PATCH 2/2] fix(vscode): avoid hardcoded terminal font fallback --- .../kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 88adae2d676..a18b251e17b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -373,8 +373,7 @@ const AgentManagerContent: Component = () => { const closedDrafts = new Set() const [activePendingId, setActivePendingId] = createSignal() const [terminalFont, setTerminalFont] = createSignal({ - fontFamily: - getComputedStyle(document.documentElement).getPropertyValue("--vscode-editor-font-family") || "monospace", + fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--vscode-editor-font-family").trim(), fontSize: readFontSize(), })