From 99e0e81185560bd43b45ca2007c3029acaec5edb Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 19 Aug 2026 12:13:26 +0200 Subject: [PATCH] feat(agent-manager): add terminal context attachments --- .changeset/agent-manager-terminal-context.md | 5 +++ packages/kilo-vscode/src/KiloProvider.ts | 2 +- .../src/agent-manager/AgentManagerProvider.ts | 6 ++-- .../agent-manager/SessionTerminalManager.ts | 16 ++++++--- .../kilo-vscode/src/agent-manager/types.ts | 1 + .../agent-manager-terminal-output.test.ts | 24 +++++++++++++ .../unit/session-terminal-manager.test.ts | 23 +++++++++++- .../tests/unit/terminal-architecture.test.ts | 6 ++++ .../agent-manager/AgentManagerApp.tsx | 19 +++++++++- .../agent-manager/terminal/TerminalTab.tsx | 9 +++++ .../agent-manager/terminal/output.ts | 19 ++++++++++ .../src/components/chat/ChatView.tsx | 4 +++ .../src/components/chat/PromptInput.tsx | 18 +++++++--- .../src/hooks/useTerminalContext.ts | 36 +++++++++++++------ .../src/types/messages/webview-messages.ts | 1 + 15 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 .changeset/agent-manager-terminal-context.md create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-terminal-output.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/terminal/output.ts diff --git a/.changeset/agent-manager-terminal-context.md b/.changeset/agent-manager-terminal-context.md new file mode 100644 index 0000000000..bfc2ad8df8 --- /dev/null +++ b/.changeset/agent-manager-terminal-context.md @@ -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. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 457bdc5355..2b0aac1b57 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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" diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 86e581263f..7ca15a3fea 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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, diff --git a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts index 3fc41c1170..b87db85bc8 100644 --- a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts +++ b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts @@ -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 { diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 6df88ab49f..fbe7cf9153 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -899,6 +899,7 @@ interface RequestTerminalContextIn { type: "requestTerminalContext" requestId: string sessionID?: string + agentManagerContext?: string } interface ClearSessionIn { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-output.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-output.test.ts new file mode 100644 index 0000000000..edc607d48f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-output.test.ts @@ -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") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts index 5669acaeac..2eed4d6f04 100644 --- a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts @@ -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", () => { diff --git a/packages/kilo-vscode/tests/unit/terminal-architecture.test.ts b/packages/kilo-vscode/tests/unit/terminal-architecture.test.ts index b7f760b843..a25bc5f374 100644 --- a/packages/kilo-vscode/tests/unit/terminal-architecture.test.ts +++ b/packages/kilo-vscode/tests/unit/terminal-architecture.test.ts @@ -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") }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 10e72057ad..9cc166d12f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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} />
@@ -2765,7 +2783,6 @@ const AgentManagerContent: Component = () => {
) } - export const AgentManagerApp: Component = () => { return ( diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index bdf6ae8318..3f12558904 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -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) => { 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) => { onCleanup(() => { closed = true + unregisterTerminalOutput(props.terminalId) if (pendingFrame !== null) cancelAnimationFrame(pendingFrame) if (frame !== undefined) cancelAnimationFrame(frame) if (deferred !== undefined) cancelAnimationFrame(deferred) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/output.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/output.ts new file mode 100644 index 0000000000..411b08ff91 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/output.ts @@ -0,0 +1,19 @@ +import { truncateTerminalOutput } from "../../../src/services/terminal/truncate" + +type Reader = () => string + +const readers = new Map() + +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 +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 2d01055f04..24976794c0 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -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 } export const ChatView: Component = (props) => { @@ -387,10 +389,12 @@ export const ChatView: Component = (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} />
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 5bb00170cf..e9dc47d5e5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -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 } function MentionItemContent(props: { item: MentionResult }) { @@ -189,7 +195,7 @@ export const PromptInput: Component = (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 = (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 diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useTerminalContext.ts b/packages/kilo-vscode/webview-ui/src/hooks/useTerminalContext.ts index 7409468198..87869affd1 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useTerminalContext.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useTerminalContext.ts @@ -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 } -interface VSCodeContext { - postMessage: (message: WebviewMessage) => void - onMessage: (handler: (message: ExtensionMessage) => void) => () => void -} +type EmbeddedResolver = (context?: string) => Promise export interface TerminalContext { pending: Accessor - resolveAttachment: (text: string, sessionID?: string) => Promise + resolveAttachment: (text: string, sessionID?: string, context?: string) => Promise } -export function useTerminalContext(vscode: VSCodeContext): TerminalContext { +export function useTerminalContext(embedded?: EmbeddedResolver): TerminalContext { + const vscode = useVSCode() const [pending, setPending] = createSignal(false) const requests = new Map() 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((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) } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index a93d067e3a..fc998944f2 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -442,6 +442,7 @@ export interface RequestTerminalContextMessage { type: "requestTerminalContext" requestId: string sessionID?: string + agentManagerContext?: string } export interface RequestGitChangesContextMessage {