mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
fix(vscode): start agent manager terminals instantly
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Accept terminal input immediately while the Agent Manager shell starts.
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>()
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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 }
|
||||
@@ -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")
|
||||
|
||||
@@ -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<string>()
|
||||
const [activePendingId, setActivePendingId] = createSignal<string | undefined>()
|
||||
const [terminalFont, setTerminalFont] = createSignal<TerminalFont>({
|
||||
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({
|
||||
|
||||
@@ -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> = (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<typeof setTimeout> | undefined
|
||||
@@ -234,11 +236,18 @@ export const TerminalTab: Component<Props> = (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> = (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> = (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> = (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> = (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> = (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> = (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> = (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> = (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> = (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()
|
||||
|
||||
@@ -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<string | Uint8Array> = []
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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<TerminalTabState, "title" | "wsUrl" | "font">): 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<string | null>): Termina
|
||||
})
|
||||
}
|
||||
|
||||
const attach = (terminalId: string, input: Pick<TerminalTabState, "title" | "wsUrl" | "font">) => {
|
||||
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 <For> 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<string | null>): 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<string | null>): 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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user