mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge remote-tracking branch 'origin/main' into fix-abort-error-flash
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -461,7 +461,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",
|
||||
@@ -2795,8 +2794,6 @@
|
||||
|
||||
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
|
||||
|
||||
"@xterm/addon-webgl": ["@xterm/addon-webgl@0.19.0", "", {}, "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="],
|
||||
|
||||
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
|
||||
|
||||
"@xyflow/react": ["@xyflow/react@12.10.2", "", { "dependencies": { "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
@@ -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"')
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")
|
||||
@@ -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", () => {
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -154,7 +154,7 @@ import {
|
||||
createSideTerminal,
|
||||
createAmbientSetup,
|
||||
hasSetupTerminal,
|
||||
showTerminalStack,
|
||||
keepTerminalStack,
|
||||
readSavedDestination,
|
||||
resolveRunScriptRequest,
|
||||
resolveVscodeTerminalRequest,
|
||||
@@ -827,7 +827,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()
|
||||
@@ -2503,7 +2505,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
|
||||
|
||||
@@ -4773,7 +4773,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
|
||||
@@ -4801,6 +4801,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;
|
||||
@@ -4821,7 +4832,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
|
||||
@@ -4975,6 +4986,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
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import { Component, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { Terminal } from "@xterm/xterm"
|
||||
import { FitAddon } from "@xterm/addon-fit"
|
||||
import { WebglAddon } from "@xterm/addon-webgl"
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links"
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard"
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"
|
||||
@@ -168,28 +167,12 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(host)
|
||||
// GPU renderer: the DOM renderer creates one element per cell per row,
|
||||
// which dominates CPU under sustained PTY output (see profiling of
|
||||
// streaming script output). WebGL paints the whole viewport in one
|
||||
// draw batched by first glyph. Falls back to the DOM renderer when
|
||||
// WebGL2 is unavailable; the catch logs without breaking the session.
|
||||
try {
|
||||
const webgl = new WebglAddon()
|
||||
term.loadAddon(webgl)
|
||||
// Browsers hand out a limited number of WebGL contexts and evict
|
||||
// old ones; when a context is lost beyond recovery, dispose the
|
||||
// addon so xterm re-installs its DOM renderer for this terminal
|
||||
// instead of leaving a dead canvas.
|
||||
webgl.onContextLoss(() => {
|
||||
try {
|
||||
webgl.dispose()
|
||||
} catch (err) {
|
||||
log("webgl dispose after context loss failed", err)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
log("webgl renderer unavailable, using DOM renderer", err)
|
||||
}
|
||||
// Keep xterm's DOM renderer. Every mounted WebGL addon owns a scarce
|
||||
// browser context, including when xterm pauses an off-screen terminal.
|
||||
// Chromium can evict a live renderer once enough Agent Manager terminals
|
||||
// exist, and xterm 6.0's WebGL addon also has unresolved shared-atlas
|
||||
// corruption. Frame batching below removes the per-message render churn
|
||||
// without making terminal correctness depend on GPU resources.
|
||||
registerTerminalOutput(props.terminalId, () => {
|
||||
const buffer = term.buffer.active
|
||||
return Array.from(
|
||||
@@ -243,6 +226,8 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
let socketEnded = false
|
||||
let frame: number | undefined
|
||||
let deferred: number | undefined
|
||||
const batcher = createWriteBatcher((data, callback) => term.write(data, callback))
|
||||
const writeLine = (data: string) => batcher.write(`${data}\r\n`)
|
||||
// The failure line must not depend on event ordering: the stream can
|
||||
// close before the exited snapshot lands (fast failures), or stay open
|
||||
// when a background child outlives the script. Write it exactly once,
|
||||
@@ -254,12 +239,12 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
if (status?.kind !== "setup") return
|
||||
if (status.state === "failed") {
|
||||
failureWritten = true
|
||||
term.writeln(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailed")}]\x1b[0m`)
|
||||
writeLine(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailed")}]\x1b[0m`)
|
||||
return
|
||||
}
|
||||
if (status.state === "exited" && status.exitCode !== 0) {
|
||||
failureWritten = true
|
||||
term.writeln(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailedCode")} ${status.exitCode ?? "?"}]\x1b[0m`)
|
||||
writeLine(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailedCode")} ${status.exitCode ?? "?"}]\x1b[0m`)
|
||||
}
|
||||
}
|
||||
createEffect(() => {
|
||||
@@ -318,7 +303,6 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
flush()
|
||||
}, 100)
|
||||
}
|
||||
const batcher = createWriteBatcher((data, callback) => term.write(data, callback))
|
||||
const replay = createReplayGate({
|
||||
write: (data, callback) => batcher.write(data, callback),
|
||||
flush: () => flush(true),
|
||||
@@ -329,6 +313,7 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
}
|
||||
const open = (url: string) => {
|
||||
if (closed || !url) return
|
||||
if (ws && ws.readyState !== WebSocket.CLOSING && ws.readyState !== WebSocket.CLOSED) return
|
||||
replay.attach(disconnected)
|
||||
const next = new WebSocket(url)
|
||||
next.binaryType = "arraybuffer"
|
||||
@@ -347,20 +332,28 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
if (closed || ws !== next) return
|
||||
streamed = true
|
||||
if (typeof event.data === "string") {
|
||||
replay.output(event.data)
|
||||
if (!replay.output(event.data)) {
|
||||
input.clear()
|
||||
next.close(1009, "terminal replay exceeded limit")
|
||||
return
|
||||
}
|
||||
scheduleFlush()
|
||||
return
|
||||
}
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(event.data)
|
||||
if (replay.frame(bytes)) return
|
||||
replay.output(bytes)
|
||||
if (!replay.output(bytes)) {
|
||||
input.clear()
|
||||
next.close(1009, "terminal replay exceeded limit")
|
||||
return
|
||||
}
|
||||
scheduleFlush()
|
||||
}
|
||||
}
|
||||
next.onerror = () => {
|
||||
if (closed || ws !== next) return
|
||||
term.writeln(`\r\n\x1b[90m[${t("agentManager.terminal.connectionError")}]\x1b[0m`)
|
||||
writeLine(`\r\n\x1b[90m[${t("agentManager.terminal.connectionError")}]\x1b[0m`)
|
||||
}
|
||||
next.onclose = () => {
|
||||
if (closed || ws !== next) return
|
||||
@@ -380,7 +373,7 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
restartRequested = false
|
||||
}
|
||||
const key = props.restartable ? "agentManager.terminal.endedRestartable" : "agentManager.terminal.ended"
|
||||
term.writeln(`\r\n\x1b[90m[${t(key)}]\x1b[0m`)
|
||||
writeLine(`\r\n\x1b[90m[${t(key)}]\x1b[0m`)
|
||||
}
|
||||
}
|
||||
const disposeData = term.onData(send)
|
||||
@@ -448,8 +441,6 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
return
|
||||
}
|
||||
clearTimeout(resizeTimer)
|
||||
if (readyTimer) clearTimeout(readyTimer)
|
||||
if (fallbackTimer) clearTimeout(fallbackTimer)
|
||||
resizeTimer = setTimeout(syncSize, RESIZE_DEBOUNCE_MS)
|
||||
})
|
||||
ro.observe(host)
|
||||
@@ -460,7 +451,7 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
frame = undefined
|
||||
if (closed) return
|
||||
fitNow()
|
||||
open(props.wsUrl)
|
||||
if (!ws) open(props.wsUrl)
|
||||
deferred = requestAnimationFrame(loadAddons)
|
||||
})
|
||||
|
||||
@@ -480,6 +471,7 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
// resizes or font changes must not yank the cursor out of the chat
|
||||
// input, only explicit activation / focus requests may.
|
||||
let pendingFrame: number | null = null
|
||||
let repaintTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let shouldFocus = false
|
||||
const isRenderable = () => {
|
||||
if (!host.isConnected) return false
|
||||
@@ -488,6 +480,8 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
}
|
||||
const runRepaint = () => {
|
||||
pendingFrame = null
|
||||
clearTimeout(repaintTimer)
|
||||
repaintTimer = undefined
|
||||
if (!props.active) return
|
||||
if (!isRenderable()) return
|
||||
try {
|
||||
@@ -505,6 +499,11 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
shouldFocus ||= focus
|
||||
if (pendingFrame !== null) return
|
||||
pendingFrame = requestAnimationFrame(runRepaint)
|
||||
repaintTimer = setTimeout(() => {
|
||||
if (pendingFrame === null) return
|
||||
cancelAnimationFrame(pendingFrame)
|
||||
runRepaint()
|
||||
}, 250)
|
||||
}
|
||||
const fontSub = vscode.onMessage((message) => {
|
||||
if (message.type === "appendReviewCommentsToTerminal") {
|
||||
@@ -587,12 +586,12 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
const ownsFocus = () => host.contains(document.activeElement)
|
||||
const onVisibilityChange = () => {
|
||||
if (document.hidden) return
|
||||
if (!props.active || !ownsFocus()) return
|
||||
scheduleRepaint(true)
|
||||
if (!props.active) return
|
||||
scheduleRepaint(ownsFocus())
|
||||
}
|
||||
const onWindowFocus = () => {
|
||||
if (!props.active || !ownsFocus()) return
|
||||
scheduleRepaint(true)
|
||||
if (!props.active) return
|
||||
scheduleRepaint(ownsFocus())
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisibilityChange)
|
||||
window.addEventListener("focus", onWindowFocus)
|
||||
@@ -613,8 +612,10 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
onCleanup(() => {
|
||||
closed = true
|
||||
batcher.cancel()
|
||||
replay.cancel()
|
||||
unregisterTerminalOutput(props.terminalId)
|
||||
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame)
|
||||
clearTimeout(repaintTimer)
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
if (deferred !== undefined) cancelAnimationFrame(deferred)
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
@@ -628,6 +629,8 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
fontSub()
|
||||
themeObserver.disconnect()
|
||||
clearTimeout(resizeTimer)
|
||||
clearTimeout(readyTimer)
|
||||
clearTimeout(fallbackTimer)
|
||||
ro.disconnect()
|
||||
disposeData.dispose()
|
||||
disposeBinary.dispose()
|
||||
|
||||
@@ -27,11 +27,21 @@ export type AmbientDecision = "wait" | "hide" | "keep"
|
||||
/** The detail stack hosts chat, terminals, and the read-only banner.
|
||||
* It shows for any selected context (local/worktree) and for an
|
||||
* unassigned session, where `selection` is null but the context is not
|
||||
* empty (a live session is showing). The history view is exclusive. */
|
||||
* empty (a live session is showing). Keep it mounted under history when
|
||||
* terminals exist; the caller hides it without disposing xterm sockets. */
|
||||
export function showTerminalStack(history: boolean, selection: string | null, contextEmpty: boolean): boolean {
|
||||
return !history && (selection !== null || !contextEmpty)
|
||||
}
|
||||
|
||||
export function keepTerminalStack(
|
||||
history: boolean,
|
||||
selection: string | null,
|
||||
contextEmpty: boolean,
|
||||
terminals: number,
|
||||
): boolean {
|
||||
return showTerminalStack(history, selection, contextEmpty) || terminals > 0
|
||||
}
|
||||
|
||||
/** Setup output owns progress/error presentation when its terminal exists. */
|
||||
export function hasSetupTerminal(selection: string | null, terminals: TerminalTabStateWithContext[]): boolean {
|
||||
return selection !== null && terminals.some((term) => term.contextKey === selection && term.kind === "setup")
|
||||
|
||||
@@ -26,4 +26,4 @@ export { TerminalDestinationButton } from "./TerminalDestinationButton"
|
||||
export { createSideTerminal, readSavedDestination, resolveRunScriptRequest, resolveVscodeTerminalRequest } from "./side"
|
||||
export { TerminalTab } from "./TerminalTab"
|
||||
export { SortableTerminalTab } from "./SortableTerminalTab"
|
||||
export { createAmbientSetup, hasSetupTerminal, showTerminalStack } from "./ambient"
|
||||
export { createAmbientSetup, hasSetupTerminal, keepTerminalStack, showTerminalStack } from "./ambient"
|
||||
|
||||
@@ -73,8 +73,8 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
|
||||
* **Once an xterm instance is mounted, its DOM subtree is only ever
|
||||
* hidden, never unmounted.** Inactive slots keep their layout box but
|
||||
* are translated one viewport off-screen; xterm 6's render service
|
||||
* observes the screen element and pauses the render loop (rAF, model
|
||||
* updates, GPU draws) for non-intersecting terminals, then replays a
|
||||
* observes the screen element and pauses the render loop (rAF and model
|
||||
* updates) for non-intersecting terminals, then replays a
|
||||
* full refresh when the slot slides back in. Keeping the box (unlike
|
||||
* `display: none`) also lets FitAddon measure the real panel size while
|
||||
* hidden, so background-created terminals such as setup scripts wrap
|
||||
|
||||
@@ -5,6 +5,21 @@ interface ReplayGateDeps {
|
||||
flush(): void
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
export function byteLength(data: string | Uint8Array) {
|
||||
return typeof data === "string" ? encoder.encode(data).byteLength : data.byteLength
|
||||
}
|
||||
|
||||
function tail(data: string, limit: number) {
|
||||
const bytes = encoder.encode(data)
|
||||
if (bytes.byteLength <= limit) return data
|
||||
let start = bytes.byteLength - limit
|
||||
while (start < bytes.byteLength && (bytes[start]! & 0xc0) === 0x80) start++
|
||||
return decoder.decode(bytes.subarray(start))
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -14,11 +29,11 @@ export function createInputBuffer(limit = 256 * 1024) {
|
||||
const add = (data: string, reply = false) => {
|
||||
if (reply) {
|
||||
replies += data
|
||||
if (replies.length > limit) replies = replies.slice(-limit)
|
||||
replies = tail(replies, limit)
|
||||
return
|
||||
}
|
||||
input += data
|
||||
if (input.length > limit) input = input.slice(-limit)
|
||||
input = tail(input, limit)
|
||||
}
|
||||
|
||||
const take = () => {
|
||||
@@ -28,7 +43,12 @@ export function createInputBuffer(limit = 256 * 1024) {
|
||||
return data
|
||||
}
|
||||
|
||||
return { add, take }
|
||||
const clear = () => {
|
||||
replies = ""
|
||||
input = ""
|
||||
}
|
||||
|
||||
return { add, clear, take }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,49 +79,45 @@ export function createWriteBatcher(
|
||||
let callbacks: Array<() => void> = []
|
||||
let pendingBytes = 0
|
||||
let scheduled = false
|
||||
let raf = 0
|
||||
let watchdog: ReturnType<typeof setTimeout> | 0 = 0
|
||||
let raf: number | undefined
|
||||
let watchdog: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const drain = () => {
|
||||
if (raf !== 0) unschedule(raf)
|
||||
if (watchdog !== 0) clearDelay(watchdog)
|
||||
raf = 0
|
||||
watchdog = 0
|
||||
if (raf !== undefined) unschedule(raf)
|
||||
if (watchdog !== undefined) clearDelay(watchdog)
|
||||
raf = undefined
|
||||
watchdog = undefined
|
||||
scheduled = false
|
||||
const data = chunks
|
||||
const cbs = callbacks
|
||||
chunks = []
|
||||
callbacks = []
|
||||
pendingBytes = 0
|
||||
let joined: string | Uint8Array = ""
|
||||
if (data.length === 1) {
|
||||
joined = data[0]!
|
||||
} else {
|
||||
let text = true
|
||||
for (const chunk of data) {
|
||||
if (typeof chunk !== "string") {
|
||||
text = false
|
||||
break
|
||||
}
|
||||
if (data.length === 0 && cbs.length === 0) return
|
||||
const groups: Array<string | Uint8Array> = []
|
||||
for (const chunk of data) {
|
||||
const prior = groups.at(-1)
|
||||
if (typeof chunk === "string") {
|
||||
if (typeof prior === "string") groups[groups.length - 1] = prior + chunk
|
||||
else groups.push(chunk)
|
||||
continue
|
||||
}
|
||||
if (text) {
|
||||
joined = data.join("")
|
||||
} else {
|
||||
const encoder = new TextEncoder()
|
||||
const parts = data.map((chunk) => (typeof chunk === "string" ? encoder.encode(chunk) : chunk))
|
||||
const length = parts.reduce((sum, part) => sum + part.byteLength, 0)
|
||||
const merged = new Uint8Array(length)
|
||||
let offset = 0
|
||||
for (const part of parts) {
|
||||
merged.set(part, offset)
|
||||
offset += part.byteLength
|
||||
}
|
||||
joined = merged
|
||||
if (!(prior instanceof Uint8Array)) {
|
||||
groups.push(chunk)
|
||||
continue
|
||||
}
|
||||
const merged = new Uint8Array(prior.byteLength + chunk.byteLength)
|
||||
merged.set(prior)
|
||||
merged.set(chunk, prior.byteLength)
|
||||
groups[groups.length - 1] = merged
|
||||
}
|
||||
write(joined, () => {
|
||||
if (groups.length === 0) groups.push("")
|
||||
const complete = () => {
|
||||
for (const cb of cbs) cb()
|
||||
})
|
||||
}
|
||||
for (let index = 0; index < groups.length; index++) {
|
||||
write(groups[index]!, index === groups.length - 1 ? complete : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const kick = () => {
|
||||
@@ -112,8 +128,11 @@ export function createWriteBatcher(
|
||||
}
|
||||
|
||||
const writeChunk = (data: string | Uint8Array, callback?: () => void) => {
|
||||
if ((typeof data === "string" ? data.length : data.byteLength) === 0 && !callback) return
|
||||
const bytes = byteLength(data)
|
||||
if (pendingBytes > 0 && pendingBytes + bytes > maxBytes) drain()
|
||||
chunks.push(data)
|
||||
pendingBytes += typeof data === "string" ? data.length : data.byteLength
|
||||
pendingBytes += bytes
|
||||
if (callback) callbacks.push(callback)
|
||||
if (pendingBytes >= maxBytes) {
|
||||
drain()
|
||||
@@ -123,10 +142,10 @@ export function createWriteBatcher(
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (raf !== 0) unschedule(raf)
|
||||
if (watchdog !== 0) clearDelay(watchdog)
|
||||
raf = 0
|
||||
watchdog = 0
|
||||
if (raf !== undefined) unschedule(raf)
|
||||
if (watchdog !== undefined) clearDelay(watchdog)
|
||||
raf = undefined
|
||||
watchdog = undefined
|
||||
scheduled = false
|
||||
chunks = []
|
||||
callbacks = []
|
||||
@@ -150,7 +169,12 @@ export function createReplayGate(deps: ReplayGateDeps) {
|
||||
let boundary = false
|
||||
let draining = false
|
||||
let serial = 0
|
||||
let pending: Array<string | Uint8Array> = []
|
||||
let pending: Array<{ data: string | Uint8Array; callback?: () => void }> = []
|
||||
let bytes = 0
|
||||
// The server retains at most 2 Mi UTF-16 code units. Eight MiB covers
|
||||
// their maximum UTF-8 expansion while still staying far below xterm's
|
||||
// 50 MiB discard watermark if a boundary frame never arrives.
|
||||
const limit = 8 * 1024 * 1024
|
||||
|
||||
const attach = (reconnecting: boolean) => {
|
||||
serial++
|
||||
@@ -158,14 +182,24 @@ export function createReplayGate(deps: ReplayGateDeps) {
|
||||
boundary = false
|
||||
draining = false
|
||||
pending = []
|
||||
bytes = 0
|
||||
}
|
||||
|
||||
const output = (data: string | Uint8Array) => {
|
||||
const output = (data: string | Uint8Array, callback?: () => void) => {
|
||||
if (blocked && !boundary) {
|
||||
pending.push(data)
|
||||
return
|
||||
bytes += byteLength(data)
|
||||
if (bytes > limit) {
|
||||
serial++
|
||||
blocked = false
|
||||
pending = []
|
||||
bytes = 0
|
||||
return false
|
||||
}
|
||||
pending.push({ data, callback })
|
||||
return true
|
||||
}
|
||||
deps.write(data)
|
||||
deps.write(data, callback)
|
||||
return true
|
||||
}
|
||||
|
||||
const frame = (data: Uint8Array) => {
|
||||
@@ -179,8 +213,9 @@ export function createReplayGate(deps: ReplayGateDeps) {
|
||||
// parser-generated replies in its separate priority buffer meanwhile.
|
||||
draining = true
|
||||
const current = serial
|
||||
for (const chunk of pending) deps.write(chunk)
|
||||
for (const chunk of pending) deps.write(chunk.data, chunk.callback)
|
||||
pending = []
|
||||
bytes = 0
|
||||
deps.write("", () => {
|
||||
if (serial !== current) return
|
||||
draining = false
|
||||
@@ -191,5 +226,14 @@ export function createReplayGate(deps: ReplayGateDeps) {
|
||||
return true
|
||||
}
|
||||
|
||||
return { attach, blocked: () => blocked, draining: () => draining, frame, output }
|
||||
const cancel = () => {
|
||||
serial++
|
||||
blocked = false
|
||||
boundary = false
|
||||
draining = false
|
||||
pending = []
|
||||
bytes = 0
|
||||
}
|
||||
|
||||
return { attach, blocked: () => blocked, cancel, draining: () => draining, frame, output }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user