mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
feat(agent-manager): add terminal context attachments
This commit is contained in:
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user