feat(agent-manager): add terminal context attachments

This commit is contained in:
marius-kilocode
2026-08-19 12:13:26 +02:00
parent 97abdab66c
commit 99e0e81185
15 changed files with 164 additions and 25 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Capture `@terminal` from the focused Agent Manager terminal, including embedded Run and Setup terminals, instead of using an unrelated active VS Code terminal.
+1 -1
View File
@@ -319,7 +319,7 @@ type ContextRequestMessage =
| { type: "requestFileSearch"; query: string; requestId: string; sessionID?: string }
| { type: "requestSessionSearch"; requestId: string; sessionID?: string }
| { type: "requestFilePicker"; requestId: string }
| { type: "requestTerminalContext"; requestId: string; sessionID?: string }
| { type: "requestTerminalContext"; requestId: string; sessionID?: string; agentManagerContext?: string }
export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider {
public static readonly viewType = "kilo-code.SidebarProvider"
@@ -665,9 +665,11 @@ export class AgentManagerProvider implements Disposable {
this.activeSessionId = m.draftID
return msg
}
if (m.type === "requestTerminalContext") {
if (!m.sessionID || this.terminalManager.prepareContext(m.sessionID)) return msg
const ready = m.agentManagerContext
? this.terminalManager.prepareContext(m.sessionID, m.agentManagerContext)
: this.terminalManager.prepareContext(m.sessionID)
if (ready) return msg
this.panel?.postMessage({
type: "terminalContextError",
requestId: m.requestId,
@@ -180,7 +180,7 @@ export class SessionTerminalManager {
/**
* Show the terminal for a session if it already exists (used when switching sessions).
* Returns true if the terminal was shown, false if no terminal exists for the session.
* Pass preserveFocus=true to keep focus on the current editor (default for session switching).
* Pass preserveFocus=true to keep focus on the current editor (the default).
*/
showExisting(sessionId: string, preserveFocus = true): boolean {
return this.showExistingKey(SessionTerminalManager.sessionKey(sessionId), preserveFocus)
@@ -211,10 +211,18 @@ export class SessionTerminalManager {
return undefined
}
prepareContext(sessionId: string): boolean {
if (this.showExisting(sessionId)) return true
prepareContext(sessionId?: string, context?: string): boolean {
const key =
context === undefined
? undefined
: context === "local"
? SessionTerminalManager.LOCAL_KEY
: SessionTerminalManager.worktreeKey(context)
if (sessionId && this.showExisting(sessionId, false)) return true
if (key && this.showExistingKey(key, false)) return true
const active = this.activeKey()
return !active || active === SessionTerminalManager.sessionKey(sessionId)
if (active === undefined) return this.host.activeTerminal() !== undefined
return (sessionId !== undefined && active === SessionTerminalManager.sessionKey(sessionId)) || active === key
}
dispose(): void {
@@ -899,6 +899,7 @@ interface RequestTerminalContextIn {
type: "requestTerminalContext"
requestId: string
sessionID?: string
agentManagerContext?: string
}
interface ClearSessionIn {
@@ -0,0 +1,24 @@
import { describe, expect, it } from "bun:test"
import {
readTerminalOutput,
registerTerminalOutput,
unregisterTerminalOutput,
} from "../../webview-ui/agent-manager/terminal/output"
describe("Agent Manager terminal output", () => {
it("reads and unregisters a terminal buffer", () => {
registerTerminalOutput("term-1", () => "embedded output")
expect(readTerminalOutput("term-1")).toBe("embedded output")
unregisterTerminalOutput("term-1")
expect(readTerminalOutput("term-1")).toBeUndefined()
})
it("truncates large terminal buffers", () => {
registerTerminalOutput("term-2", () => Array.from({ length: 501 }, (_, index) => `line-${index}`).join("\n"))
const output = readTerminalOutput("term-2")
expect(output).toContain("[...1 lines omitted...]")
expect(output).toContain("line-0")
expect(output).toContain("line-500")
unregisterTerminalOutput("term-2")
})
})
@@ -139,10 +139,31 @@ describe("SessionTerminalManager structure", () => {
it("rejects context capture from another managed session", () => {
const text = body("prepareContext")
expect(text).toContain("this.showExisting(sessionId)")
expect(text).toContain("this.showExisting(sessionId, false)")
expect(text).toContain("this.activeKey()")
expect(text).toContain("SessionTerminalManager.sessionKey(sessionId)")
})
it("allows a focused legacy Run terminal when no managed terminal exists", () => {
const active = {}
const host: TerminalHost = {
createTerminal() {
throw new Error("not used")
},
activeTerminal: () => active,
repoPath: () => undefined,
showWarning() {},
setContext() {},
onTerminalClosed: () => ({ dispose() {} }),
onActiveTerminalChanged: () => ({ dispose() {} }),
registerCommand: () => ({ dispose() {} }),
executeCommand: () => Promise.resolve(),
}
const manager = new SessionTerminalManager(() => {}, host)
expect(manager.prepareContext("session-1", "local")).toBe(true)
manager.dispose()
})
})
describe("SessionTerminalManager command restoration", () => {
@@ -22,19 +22,25 @@ describe("terminal context architecture", () => {
const util = src("webview-ui/src/hooks/terminal-context-utils.ts")
expect(prompt).toContain("useTerminalContext")
expect(prompt).toContain("terminal.resolveAttachment(message, id, readTerminalContext(props.terminalContext))")
expect(prompt).not.toContain("requestTerminalContext")
expect(prompt).not.toContain("data:text/plain")
expect(hook).toContain("requestTerminalContext")
expect(hook).toContain("useVSCode()")
expect(util).toContain("data:text/plain")
})
it("keeps terminal output limits in the shared truncation helper", () => {
const helper = src("src/services/terminal/truncate.ts")
const output = src("webview-ui/agent-manager/terminal/output.ts")
const provider = src("src/KiloProvider.ts")
const prompt = src("webview-ui/src/components/chat/PromptInput.tsx")
expect(helper).toContain("TERMINAL_OUTPUT_LINE_LIMIT = 500")
expect(helper).toContain("TERMINAL_OUTPUT_CHARACTER_LIMIT = 50_000")
expect(output).toContain('from "../../../src/services/terminal/truncate"')
expect(output).not.toContain("LINE_LIMIT = 500")
expect(output).not.toContain("CHAR_LIMIT = 50_000")
expect(provider).not.toContain("TERMINAL_OUTPUT_LINE_LIMIT")
expect(prompt).not.toContain("TERMINAL_OUTPUT_LINE_LIMIT")
})
@@ -142,6 +142,7 @@ import {
resolveRunScriptRequest,
resolveVscodeTerminalRequest,
} from "./terminal"
import { readTerminalOutput } from "./terminal/output"
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
import { useTabScroll } from "./tab-scroll"
import { DiffPanel } from "./DiffPanel"
@@ -372,6 +373,21 @@ const AgentManagerContent: Component = () => {
const sel = selection()
return sel === null ? null : nsKey(sel)
})
const resolveEmbeddedTerminal = async (context?: string) => {
const key = nsKey(context ?? LOCAL)
const side = terms.sidesForContext(key)
const tabs = terms.forSelection(key)
const focused = terms.focusedId()
const focusedTerm = side.find((term) => term.id === focused) ?? tabs.find((term) => term.id === focused)
const sideTerm = side.find((term) => term.id === terms.sideActiveFor(key))
const tab = tabs.find((term) => term.id === terms.activeId())
const id = focusedTerm?.id ?? sideTerm?.id
const target = id ?? tab?.id
if (!target) return undefined
const term = side.find((item) => item.id === target) ?? tabs.find((item) => item.id === target)
if (!term) return undefined
return readTerminalOutput(term.id)
}
const requestChatFocus = createChatFocus({
term: () => terms.activeId(),
history,
@@ -2572,10 +2588,12 @@ const AgentManagerContent: Component = () => {
readonly={readOnly()}
continueInWorktree={selection() === LOCAL}
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
terminalContext={() => selection() ?? undefined}
deferFocusToQuestion={hasQuestionOption}
pendingSessionID={selection() === LOCAL ? activePendingId() : undefined}
focusOnDraftChange={focusOnDraftChange}
onFocusChange={rememberPromptFocus}
resolveEmbeddedTerminal={resolveEmbeddedTerminal}
/>
<Show when={readOnly()}>
<div class="am-readonly-banner">
@@ -2765,7 +2783,6 @@ const AgentManagerContent: Component = () => {
</div>
)
}
export const AgentManagerApp: Component = () => {
return (
<ProviderShell.Root>
@@ -22,6 +22,7 @@ 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"
import { registerTerminalOutput, unregisterTerminalOutput } from "./output"
interface Props {
terminalId: string
@@ -167,6 +168,13 @@ export const TerminalTab: Component<Props> = (props) => {
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
registerTerminalOutput(props.terminalId, () => {
const buffer = term.buffer.active
return Array.from(
{ length: buffer.length },
(_, index) => buffer.getLine(index)?.translateToString(true) ?? "",
).join("\n")
})
// Unicode width must be configured before the first PTY bytes are parsed.
// Loading it later can leave already-wrapped graphemes with stale cell
// widths, which moves the cursor in narrow terminals.
@@ -581,6 +589,7 @@ export const TerminalTab: Component<Props> = (props) => {
onCleanup(() => {
closed = true
unregisterTerminalOutput(props.terminalId)
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame)
if (frame !== undefined) cancelAnimationFrame(frame)
if (deferred !== undefined) cancelAnimationFrame(deferred)
@@ -0,0 +1,19 @@
import { truncateTerminalOutput } from "../../../src/services/terminal/truncate"
type Reader = () => string
const readers = new Map<string, Reader>()
export function registerTerminalOutput(id: string, read: Reader): void {
readers.set(id, read)
}
export function unregisterTerminalOutput(id: string): void {
readers.delete(id)
}
export function readTerminalOutput(id: string): string | undefined {
const content = readers.get(id)?.()
if (content === undefined) return undefined
return truncateTerminalOutput(content).content
}
@@ -39,11 +39,13 @@ interface ChatViewProps {
/** When true, show the "Continue in Worktree" button. Defaults to true in the sidebar. */
continueInWorktree?: boolean
promptBoxId?: string
terminalContext?: () => string | undefined
deferFocusToQuestion?: () => boolean
pendingSessionID?: string
focusOnDraftChange?: () => boolean
onFocusChange?: (focused: boolean) => void
emptyState?: () => JSX.Element
resolveEmbeddedTerminal?: (context?: string) => Promise<string | undefined>
}
export const ChatView: Component<ChatViewProps> = (props) => {
@@ -387,10 +389,12 @@ export const ChatView: Component<ChatViewProps> = (props) => {
suggesting={suggesting}
questioning={questioning}
boxId={props.promptBoxId}
terminalContext={props.terminalContext}
deferFocusToQuestion={props.deferFocusToQuestion}
pendingSessionID={pendingSessionID()}
focusOnDraftChange={props.focusOnDraftChange}
onFocusChange={props.onFocusChange}
resolveEmbeddedTerminal={props.resolveEmbeddedTerminal}
/>
</Show>
</div>
@@ -105,6 +105,10 @@ function beginPending(id: string | undefined) {
if (id) beginPendingSend(id)
}
function readTerminalContext(read: (() => string | undefined) | undefined): string | undefined {
return read?.()
}
interface PromptInputProps {
blocked?: () => boolean
blockedReason?: () => string | undefined
@@ -115,11 +119,13 @@ interface PromptInputProps {
/** When true, defer prompt focus while switching to a pending question */
deferFocusToQuestion?: () => boolean
boxId?: string
terminalContext?: () => string | undefined
pendingSessionID?: string
/** Agent Manager can suppress automatic prompt focus when this session last
* used its side terminal instead. Other callers retain the old behavior. */
focusOnDraftChange?: () => boolean
onFocusChange?: (focused: boolean) => void
resolveEmbeddedTerminal?: (context?: string) => Promise<string | undefined>
}
function MentionItemContent(props: { item: MentionResult }) {
@@ -189,7 +195,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const hasGit = () => server.gitInstalled()
const mention = useFileMention(vscode, sid, hasGit)
const terminal = useTerminalContext(vscode)
const terminal = useTerminalContext(props.resolveEmbeddedTerminal)
const git = useGitChangesContext(vscode, ctx, hasGit)
const imageAttach = useImageAttachments()
imageAttach.setFilePathDropHandler((paths) => {
@@ -1214,10 +1220,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const context = ctx()
const key = draftKey()
const terminalFile = await terminal.resolveAttachment(message, id).catch((err: Error) => {
showToast({ variant: "error", title: "Terminal context unavailable", description: err.message })
return undefined
})
const terminalFile = await terminal
.resolveAttachment(message, id, readTerminalContext(props.terminalContext))
.catch((err: Error) => {
showToast({ variant: "error", title: "Terminal context unavailable", description: err.message })
return undefined
})
if (hasTerminalMention(message) && !terminalFile) {
finishPending(pendingId)
return
@@ -1,6 +1,7 @@
import { createSignal, onCleanup } from "solid-js"
import type { Accessor } from "solid-js"
import type { ExtensionMessage, FileAttachment, WebviewMessage } from "../types/messages"
import type { FileAttachment } from "../types/messages"
import { useVSCode } from "../context/vscode"
import { buildTerminalAttachment, hasTerminalMention } from "./terminal-context-utils"
const TERMINAL_CONTEXT_TIMEOUT_MS = 10_000
@@ -11,17 +12,15 @@ type Pending = {
timer: ReturnType<typeof setTimeout>
}
interface VSCodeContext {
postMessage: (message: WebviewMessage) => void
onMessage: (handler: (message: ExtensionMessage) => void) => () => void
}
type EmbeddedResolver = (context?: string) => Promise<string | undefined>
export interface TerminalContext {
pending: Accessor<boolean>
resolveAttachment: (text: string, sessionID?: string) => Promise<FileAttachment | undefined>
resolveAttachment: (text: string, sessionID?: string, context?: string) => Promise<FileAttachment | undefined>
}
export function useTerminalContext(vscode: VSCodeContext): TerminalContext {
export function useTerminalContext(embedded?: EmbeddedResolver): TerminalContext {
const vscode = useVSCode()
const [pending, setPending] = createSignal(false)
const requests = new Map<string, Pending>()
let counter = 0
@@ -56,7 +55,7 @@ export function useTerminalContext(vscode: VSCodeContext): TerminalContext {
requests.clear()
})
const request = (sessionID?: string) =>
const request = (sessionID?: string, context?: string) =>
new Promise<string>((resolve, reject) => {
counter++
const requestId = `terminal-context-${counter}`
@@ -66,13 +65,28 @@ export function useTerminalContext(vscode: VSCodeContext): TerminalContext {
requests.set(requestId, { resolve, reject, timer })
setPending(true)
vscode.postMessage({ type: "requestTerminalContext", requestId, sessionID })
if (!embedded) {
vscode.postMessage({ type: "requestTerminalContext", requestId, sessionID, agentManagerContext: context })
return
}
void embedded(context).then(
(content) => {
if (content === undefined) {
vscode.postMessage({ type: "requestTerminalContext", requestId, sessionID, agentManagerContext: context })
return
}
settle(requestId, (req) => req.resolve(content))
},
(error: unknown) => {
settle(requestId, (req) => req.reject(error instanceof Error ? error : new Error(String(error))))
},
)
})
const resolveAttachment = async (text: string, sessionID?: string) => {
const resolveAttachment = async (text: string, sessionID?: string, context?: string) => {
if (!hasTerminalMention(text)) return undefined
const content = await request(sessionID)
const content = await request(sessionID, context)
if (!content.trim()) throw new Error("No terminal content available")
return buildTerminalAttachment(text, content)
}
@@ -442,6 +442,7 @@ export interface RequestTerminalContextMessage {
type: "requestTerminalContext"
requestId: string
sessionID?: string
agentManagerContext?: string
}
export interface RequestGitChangesContextMessage {