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
2 changes: 1 addition & 1 deletion .changeset/agent-manager-terminal-render-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"kilo-code": patch
---

Render Agent Manager terminal output with the WebGL renderer instead of the DOM renderer, and pause rendering for terminals hidden in the background
Use the DOM renderer for Agent Manager terminals to avoid WebGL context failures, while batching output and pausing hidden-terminal rendering
3 changes: 0 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion packages/kilo-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1327,7 +1327,6 @@
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-unicode-graphemes": "0.4.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"diff": "8.0.4",
"fastest-levenshtein": "^1.0.16",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, expect, it } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { LOCAL } from "../../webview-ui/agent-manager/navigate"
import { ambientDecision, createAmbientSetup, showTerminalStack } from "../../webview-ui/agent-manager/terminal/ambient"
import {
ambientDecision,
createAmbientSetup,
keepTerminalStack,
showTerminalStack,
} from "../../webview-ui/agent-manager/terminal/ambient"
import { createTerminalState } from "../../webview-ui/agent-manager/terminal/state"

describe("showTerminalStack", () => {
Expand Down Expand Up @@ -33,6 +38,14 @@ describe("showTerminalStack", () => {
})
})

describe("keepTerminalStack", () => {
it("keeps live terminals mounted under history", () => {
expect(keepTerminalStack(true, "wt-1", false, 1)).toBe(true)
expect(keepTerminalStack(true, null, true, 1)).toBe(true)
expect(keepTerminalStack(true, "wt-1", false, 0)).toBe(false)
})
})

describe("ambientDecision", () => {
it("waits while setup is still running", () => {
expect(ambientDecision(undefined, "wt-1", "wt-1")).toBe("wait")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const terminal = readFileSync(
resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/TerminalTab.tsx"),
"utf8",
)
const pkg = readFileSync(resolve(import.meta.dir, "../../package.json"), "utf8")

test("xterm owns the padding used by FitAddon", () => {
const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1]
Expand Down Expand Up @@ -74,6 +75,16 @@ test("does not refit hidden terminal buffers during resize", () => {
expect(callback!.indexOf("if (!props.active) return")).toBeLessThan(callback!.indexOf("fit.fit()"))
})

test("uses the scalable DOM renderer for concurrent terminals", () => {
expect(terminal).not.toContain("WebglAddon")
expect(pkg).not.toContain("@xterm/addon-webgl")
})

test("orders local terminal status lines through the output batcher", () => {
expect(terminal).toContain("const writeLine =")
expect(terminal).not.toContain("term.writeln(")
})

test("keeps raw PTY line endings and initializes Unicode widths before attaching", () => {
expect(terminal).toContain("convertEol: false")
expect(terminal).toContain('term.unicode.activeVersion = "15-graphemes"')
Expand All @@ -85,7 +96,17 @@ test("keeps raw PTY line endings and initializes Unicode widths before attaching
test("fits and forces the initial PTY dimensions before socket attach", () => {
expect(terminal).toContain("const syncSize = (force = false)")
expect(terminal).toContain("if (props.active) syncSize(true)")
expect(terminal.indexOf("fitNow()\n open(props.wsUrl)")).toBeGreaterThan(-1)
expect(terminal.indexOf("fitNow()\n if (!ws) open(props.wsUrl)")).toBeGreaterThan(-1)
})

test("keeps terminal sockets mounted while history is open", () => {
expect(css).toContain(".am-detail-stack-hidden")
expect(css).toMatch(/\.am-detail-stack-hidden[^}]*top: 36px/s)
expect(css).toMatch(/\.am-detail-stack-hidden[^}]*transform: translate\(-100vw, 0\)/s)
})

test("moves a closed side panel outside xterm's intersection area", () => {
expect(css).toMatch(/\.am-side-host-hidden[^}]*transform: translate\(-100vw, 0\)/s)
})

test("re-sends dimensions when an optimistic terminal receives its PTY", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ describe("Agent Manager terminal write batcher", () => {
expect(h.writes).toEqual(["abc"])
})

it("keeps local status output after pending PTY output", () => {
const h = harness()
h.batcher.write("last output")
h.batcher.write("\r\n[terminal ended]\r\n")
h.run()
expect(h.writes).toEqual(["last output\r\n[terminal ended]\r\n"])
})

it("coalesces many frames into separate writes", () => {
const h = harness()
h.batcher.write("1")
Expand All @@ -65,9 +73,9 @@ describe("Agent Manager terminal write batcher", () => {
h.batcher.write("txt")
h.batcher.write(new Uint8Array([1, 2]))
h.run()
expect(h.writes).toHaveLength(1)
const merged = h.writes[0] as Uint8Array
expect(Array.from(merged)).toEqual([116, 120, 116, 1, 2])
expect(h.writes).toHaveLength(2)
expect(h.writes[0]).toBe("txt")
expect(Array.from(h.writes[1] as Uint8Array)).toEqual([1, 2])
})

it("fires chunk callbacks after the batch write completes", () => {
Expand Down Expand Up @@ -138,6 +146,22 @@ describe("Agent Manager terminal input buffer", () => {

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

it("clears buffered input after a failed replay", () => {
const input = createInputBuffer()
input.add("command\r")
input.add("reply", true)
input.clear()
expect(input.take()).toBe("")
})

it("does not flush input when replay exceeds its limit", () => {
let flushed = 0
const gate = createReplayGate({ write: () => undefined, flush: () => flushed++ })
gate.attach(false)
expect(gate.output("x".repeat(8 * 1024 * 1024 + 1))).toBe(false)
expect(flushed).toBe(0)
})
})

describe("Agent Manager terminal replay gate", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ import {
createSideTerminal,
createAmbientSetup,
hasSetupTerminal,
showTerminalStack,
keepTerminalStack,
readSavedDestination,
resolveRunScriptRequest,
resolveVscodeTerminalRequest,
Expand Down Expand Up @@ -812,7 +812,9 @@ const AgentManagerContent: Component = () => {
return false
})

const showDetailStack = createMemo(() => showTerminalStack(history(), selection(), contextEmpty()))
const showDetailStack = createMemo(() =>
keepTerminalStack(history(), selection(), contextEmpty(), terms.all().length + terms.sides().length),
)

const overlay = createMemo((): SetupState | null => {
const state = setup()
Expand Down Expand Up @@ -2489,7 +2491,7 @@ const AgentManagerContent: Component = () => {
</Show>
<Show when={showDetailStack()}>
{/* Terminal overlay is scoped to the main pane so it does not cover the tab bar or side panel. */}
<div class="am-detail-stack">
<div class={`am-detail-stack ${history() ? "am-detail-stack-hidden" : ""}`} inert={history()}>
{/* Chat/terminal + side diff panel. Keep it mounted under the
review tab so live xterm canvases never leave the paint tree. */}
<div
Expand Down
16 changes: 14 additions & 2 deletions packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css
Original file line number Diff line number Diff line change
Expand Up @@ -4881,7 +4881,7 @@ body.vscode-high-contrast-light {
* Terminals are never unmounted once mounted; inactive slots hide one
* viewport to the left while keeping their layout box. xterm 6's render
* service observes the screen element and pauses hidden terminals'
* render loops (rAF, model updates, GPU draws), then replays a full
* render loops (rAF and model updates), then replays a full
* refresh when a slot becomes visible again — the activation fit +
* refresh in the TerminalTab component is insurance on top of that. The
* historical `display: none` avoidance was for the "press Enter to see
Expand Down Expand Up @@ -4909,6 +4909,17 @@ body.vscode-high-contrast-light {
flex-direction: column;
}

.am-detail-stack-hidden {
position: absolute;
top: 36px;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
pointer-events: none;
transform: translate(-100vw, 0);
}

.am-terminal-layer {
position: absolute;
inset: 0;
Expand All @@ -4929,7 +4940,7 @@ body.vscode-high-contrast-light {
.am-terminal-slot {
/* Hidden slots stay in layout but are translated one viewport to the
left, so xterm's render observer sees no intersection and pauses the
render loop (rAF loop, model updates, WebGL draws), then replays a
render loop (rAF loop and model updates), then replays a
full refresh when the slot slides back in. Keeping the layout box
(unlike display:none) lets FitAddon measure the real panel size
while hidden — background-created terminals such as setup scripts
Expand Down Expand Up @@ -5083,6 +5094,7 @@ body.vscode-high-contrast-light {
bottom: 0;
opacity: 0;
pointer-events: none;
transform: translate(-100vw, 0);
}

/* Subagent inspector panel. It remains mounted while another inspector mode
Expand Down
Loading
Loading