fix(vscode): start agent manager terminals instantly

This commit is contained in:
marius-kilocode
2026-08-04 17:13:36 +02:00
parent 849f92ecd6
commit ee1ac30e46
14 changed files with 386 additions and 76 deletions
@@ -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