feat(vscode): support @terminal context mention in chat and agent manager (#8894)

* feat(vscode): support @terminal context mention in chat and agent manager

* docs: add @terminal to new extension docs tabs

* fix(vscode): revert toggleRemote extraction, remove plan file, extract terminal handler method

* chore: add changeset for @terminal feature and document changeset process in AGENTS.md
This commit is contained in:
Marius
2026-04-14 12:10:42 +02:00
committed by GitHub
parent b3af983439
commit 9fa90ee638
24 changed files with 573 additions and 101 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add @terminal context mention support to the chat input. Type @terminal to include your active VS Code terminal output as context, with output safety limits (500 lines / 50K chars) and truncation. Works in both the sidebar chat and Agent Manager.
+4
View File
@@ -171,6 +171,10 @@ Tests MUST test actual implementation, do not duplicate logic into a test.
[Conventional Commits](https://www.conventionalcommits.org/) with scopes matching packages: `vscode`, `cli`, `agent-manager`, `sdk`, `ui`, `i18n`, `kilo-docs`, `gateway`, `telemetry`, `desktop`. Omit scope when spanning multiple packages.
## Changesets
User-facing changes (features, fixes, breaking changes) require a changeset file for release notes. Run `bunx changeset add` or manually create `.changeset/<slug>.md`. Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes. See `.changeset/README.md` for details.
## Pull Requests
PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus on intent and context a reviewer can't get from the diff — skip file-by-file inventories, test result summaries, and anything obvious from the code itself.
@@ -74,7 +74,7 @@ Find the Kilo Code icon ({% kiloCodeIcon /%}) in VS Code's Primary Side Bar. Cli
**Providing context:**
The extension automatically passes context from your editor, including your open tabs and active file. You can type `@` in the chat input to get file autocomplete suggestions, or mention file paths naturally in your message (e.g., "update src/utils.ts to add a helper function"). The agent can also discover files on its own using its built-in tools.
The extension automatically passes context from your editor, including your open tabs and active file. You can type `@` in the chat input to get file and terminal autocomplete suggestions — use `@filename` to attach a file or `@terminal` to include your active terminal output. You can also mention file paths naturally in your message (e.g., "update src/utils.ts to add a helper function"). The agent can also discover files on its own using its built-in tools.
{% /tab %}
{% tab label="CLI" %}
@@ -18,7 +18,14 @@ When you describe a task, the agent uses its tools — `read`, `grep`, `glob`, a
### @-Mention Autocomplete
Type `@` in the chat input followed by a filename to get autocomplete suggestions. Selecting a file attaches its contents to your message. This is the quickest way to reference a specific file.
Type `@` in the chat input to get autocomplete suggestions. You can mention:
| Mention | Description | Example |
| ------------ | ------------------------------------------- | --------------- |
| **File** | Attach a file's contents to your message | `@src/utils.ts` |
| **Terminal** | Include your active VS Code terminal output | `@terminal` |
Selecting a suggestion inserts the mention and highlights it in the input. File contents and terminal output are attached as context when you send the message.
### Automatic Editor Context
+41 -11
View File
@@ -44,7 +44,8 @@ import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { retry } from "./services/cli-backend/retry"
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
import { handleContinueInWorktree } from "./kilo-provider/continue-worktree"
import { parseMessageFiles } from "./kilo-provider/message-files"
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
import { getTerminalContents } from "./services/terminal/context"
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
import { childID } from "./kilo-provider/task-session"
import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network"
@@ -116,6 +117,7 @@ const mapAgent = (a: Agent) => ({
color: a.color,
deprecated: a.deprecated,
permission: a.permission,
model: a.model,
})
export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider {
@@ -823,15 +825,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
break
}
case "requestTerminalContext":
void this.handleTerminalContext(message.requestId)
break
case "chatCompletionAccepted":
this.chatAutocomplete?.telemetry.captureAcceptSuggestion(message.suggestionLength)
break
case "deleteSession":
await this.handleDeleteSession(message.sessionID)
break
case "renameSession":
await this.handleRenameSession(message.sessionID, message.title)
break
case "toggleRemote":
case "setRemoteEnabled":
case "requestRemoteStatus":
@@ -842,6 +841,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
.catch((err) => console.error("[Kilo New] remote message failed:", err))
break
case "deleteSession":
await this.handleDeleteSession(message.sessionID)
break
case "renameSession":
await this.handleRenameSession(message.sessionID, message.title)
break
case "updateSetting":
await this.handleUpdateSetting(message.key, message.value)
break
@@ -1477,6 +1482,25 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.pendingSessionRefresh = ctx.pendingSessionRefresh
}
private async handleTerminalContext(requestId: string): Promise<void> {
try {
const output = await getTerminalContents(-1)
this.postMessage({
type: "terminalContextResult",
requestId,
content: output.content,
truncated: output.truncated,
})
} catch (error) {
console.error("[Kilo New] Failed to capture terminal context:", error)
this.postMessage({
type: "terminalContextError",
requestId,
error: getErrorMessage(error) || "Failed to capture terminal output",
})
}
}
/**
* Handle deleting a session.
*/
@@ -2383,7 +2407,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
modelID?: string,
agent?: string,
variant?: string,
files?: Array<{ mime: string; url: string }>,
files?: MessageFile[],
): Promise<void> {
if (!this.client) {
this.postMessage({
@@ -2405,7 +2429,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const parts: Array<TextPartInput | FilePartInput> = []
if (files) {
for (const f of files) {
parts.push({ type: "file", mime: f.mime, url: f.url })
parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source })
}
}
parts.push({ type: "text", text })
@@ -2459,7 +2483,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
modelID?: string,
agent?: string,
variant?: string,
files?: Array<{ mime: string; url: string }>,
files?: MessageFile[],
): Promise<void> {
if (!this.client) {
this.postMessage({
@@ -2482,7 +2506,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.connectionService.recordMessageSessionId(messageID, resolved!.sid)
}
const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url }))
const parts = files?.map((f) => ({
type: "file" as const,
mime: f.mime,
url: f.url,
filename: f.filename,
source: f.source,
}))
const sid = resolved!.sid
const dir = resolved!.dir
@@ -326,6 +326,11 @@ export class AgentManagerProvider implements Disposable {
return msg
}
if (m.type === "requestTerminalContext") {
if (m.sessionID) this.terminalManager.showExisting(m.sessionID)
return msg
}
if (m.type === "loadMessages") {
this.activeSessionId = m.sessionID
this.connectionService.registerFocused("agent-manager", m.sessionID)
@@ -534,6 +534,16 @@ interface LoadMessagesIn {
sessionID: string
}
interface FileSourceIn {
type: "file"
path: string
text: {
value: string
start: number
end: number
}
}
interface SendMessageIn {
type: "sendMessage"
text: string
@@ -544,7 +554,7 @@ interface SendMessageIn {
modelID?: string
agent?: string
variant?: string
files?: Array<{ mime: string; url: string; filename?: string }>
files?: Array<{ mime: string; url: string; filename?: string; source?: FileSourceIn }>
}
interface SendCommandIn {
@@ -558,7 +568,13 @@ interface SendCommandIn {
modelID?: string
agent?: string
variant?: string
files?: Array<{ mime: string; url: string; filename?: string }>
files?: Array<{ mime: string; url: string; filename?: string; source?: FileSourceIn }>
}
interface RequestTerminalContextIn {
type: "requestTerminalContext"
requestId: string
sessionID?: string
}
interface ClearSessionIn {
@@ -673,6 +689,7 @@ export type AgentManagerInMessage =
| LoadMessagesIn
| SendMessageIn
| SendCommandIn
| RequestTerminalContextIn
| ClearSessionIn
| AbortIn
| ContinueInWorktreeIn
@@ -8,6 +8,7 @@
import type { KiloClient, Session, TextPartInput, FilePartInput } from "@kilocode/sdk/v2/client"
import type { CloudSessionData, EditorContext } from "../../services/cli-backend/types"
import { getErrorMessage, sessionToWebview, mapCloudSessionMessageToWebviewMessage } from "../../kilo-provider-utils"
import type { MessageFile } from "../message-files"
export interface CloudSessionContext {
readonly client: KiloClient | null
@@ -115,7 +116,7 @@ export async function handleImportAndSend(
modelID?: string,
agent?: string,
variant?: string,
files?: Array<{ mime: string; url: string }>,
files?: MessageFile[],
command?: string,
commandArgs?: string,
): Promise<void> {
@@ -177,7 +178,13 @@ export async function handleImportAndSend(
}
if (command) {
const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url }))
const parts = files?.map((f) => ({
type: "file" as const,
mime: f.mime,
url: f.url,
filename: f.filename,
source: f.source,
}))
await client.session.command(
{
sessionID: session.id,
@@ -198,7 +205,7 @@ export async function handleImportAndSend(
const parts: Array<TextPartInput | FilePartInput> = []
if (files) {
for (const f of files) {
parts.push({ type: "file", mime: f.mime, url: f.url })
parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source })
}
}
parts.push({ type: "text", text })
@@ -1,11 +1,24 @@
import { z } from "zod"
const source = z.object({
type: z.literal("file"),
path: z.string(),
text: z.object({
value: z.string(),
start: z.number(),
end: z.number(),
}),
})
const file = z.object({
mime: z.string(),
url: z.string().refine((url) => url.startsWith("file://") || url.startsWith("data:")),
filename: z.string().optional(),
source: source.optional(),
})
export type MessageFile = z.infer<typeof file>
export function parseMessageFiles(value: unknown) {
return z.array(file).optional().catch(undefined).parse(value)
}
@@ -2,52 +2,7 @@ import * as vscode from "vscode"
import type { KiloProvider } from "../../KiloProvider"
import type { AgentManagerProvider } from "../../agent-manager/AgentManagerProvider"
import { createPrompt } from "./support-prompt"
/**
* Read terminal content via clipboard.
* When `commands` is negative, selects all terminal content.
* When positive, selects the last N commands.
*/
async function getTerminalContents(commands = -1): Promise<string> {
const saved = await vscode.env.clipboard.readText()
try {
if (commands < 0) {
await vscode.commands.executeCommand("workbench.action.terminal.selectAll")
} else {
for (let i = 0; i < commands; i++) {
await vscode.commands.executeCommand("workbench.action.terminal.selectToPreviousCommand")
}
}
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
await vscode.commands.executeCommand("workbench.action.terminal.clearSelection")
let content = (await vscode.env.clipboard.readText()).trim()
await vscode.env.clipboard.writeText(saved)
if (saved === content) {
return ""
}
// Trim duplicate trailing prompt line
const lines = content.split("\n")
const last = lines.pop()?.trim()
if (last) {
let i = lines.length - 1
while (i >= 0 && !lines[i].trim().startsWith(last)) {
i--
}
content = lines.slice(Math.max(i, 0)).join("\n")
}
return content
} catch (err) {
await vscode.env.clipboard.writeText(saved)
throw err
}
}
import { getTerminalContents } from "../terminal/context"
export function registerTerminalActions(
context: vscode.ExtensionContext,
@@ -60,7 +15,7 @@ export function registerTerminalActions(
vscode.commands.registerCommand("kilo-code.new.terminalAddToContext", async (args: any) => {
let content = args?.selection as string | undefined
if (!content) {
content = await getTerminalContents(-1)
content = (await getTerminalContents(-1)).content
}
if (!content) {
vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.")
@@ -77,7 +32,7 @@ export function registerTerminalActions(
vscode.commands.registerCommand("kilo-code.new.terminalFixCommand", async (args: any) => {
let content = args?.selection as string | undefined
if (!content) {
content = await getTerminalContents(1)
content = (await getTerminalContents(1)).content
}
if (!content) {
vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.")
@@ -93,7 +48,7 @@ export function registerTerminalActions(
vscode.commands.registerCommand("kilo-code.new.terminalExplainCommand", async (args: any) => {
let content = args?.selection as string | undefined
if (!content) {
content = await getTerminalContents(1)
content = (await getTerminalContents(1)).content
}
if (!content) {
vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.")
@@ -0,0 +1,39 @@
import * as vscode from "vscode"
import { truncateTerminalOutput, type TerminalLimitOptions, type TerminalOutput } from "./truncate"
function trimPrompt(content: string) {
const lines = content.split("\n")
const last = lines.pop()?.trim()
if (!last) return content
const idx = lines.reduce((found, line, index) => (line.trim().startsWith(last) ? index : found), -1)
return lines.slice(Math.max(idx, 0)).join("\n")
}
async function selectPrevious(count: number): Promise<void> {
if (count <= 0) return
await vscode.commands.executeCommand("workbench.action.terminal.selectToPreviousCommand")
await selectPrevious(count - 1)
}
export async function getTerminalContents(commands = -1, opts?: TerminalLimitOptions): Promise<TerminalOutput> {
const saved = await vscode.env.clipboard.readText()
try {
if (commands < 0) {
await vscode.commands.executeCommand("workbench.action.terminal.selectAll")
} else {
await selectPrevious(commands)
}
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
await vscode.commands.executeCommand("workbench.action.terminal.clearSelection")
const copied = (await vscode.env.clipboard.readText()).trim()
if (saved === copied) return { content: "", truncated: false }
return truncateTerminalOutput(trimPrompt(copied), opts)
} finally {
await vscode.env.clipboard.writeText(saved)
}
}
@@ -0,0 +1,39 @@
export const TERMINAL_OUTPUT_LINE_LIMIT = 500
export const TERMINAL_OUTPUT_CHARACTER_LIMIT = 50_000
export type TerminalLimitOptions = {
lineLimit?: number
characterLimit?: number
}
export type TerminalOutput = {
content: string
truncated: boolean
}
export function truncateTerminalOutput(content: string, opts: TerminalLimitOptions = {}): TerminalOutput {
const chars = opts.characterLimit ?? TERMINAL_OUTPUT_CHARACTER_LIMIT
if (chars > 0 && content.length > chars) {
const before = Math.floor(chars * 0.2)
const after = chars - before
const omitted = content.length - chars
return {
content: `${content.slice(0, before)}\n[...${omitted} characters omitted...]\n${content.slice(-after)}`,
truncated: true,
}
}
const limit = opts.lineLimit ?? TERMINAL_OUTPUT_LINE_LIMIT
if (limit <= 0) return { content, truncated: false }
const lines = content.split("\n")
if (lines.length <= limit) return { content, truncated: false }
const before = Math.floor(limit * 0.2)
const after = limit - before
const omitted = lines.length - limit
return {
content: `${lines.slice(0, before).join("\n")}\n\n[...${omitted} lines omitted...]\n\n${lines.slice(-after).join("\n")}`,
truncated: true,
}
}
@@ -4,6 +4,7 @@ import {
syncMentionedPaths,
buildTextAfterMentionSelect,
buildFileAttachments,
buildMentionResults,
} from "../../webview-ui/src/hooks/file-mention-utils"
describe("AT_PATTERN", () => {
@@ -29,6 +30,28 @@ describe("AT_PATTERN", () => {
})
})
describe("buildMentionResults", () => {
it("includes terminal for empty mention query", () => {
const result = buildMentionResults("", [])
expect(result[0]).toEqual({
type: "terminal",
value: "terminal",
label: "Terminal",
description: "Active terminal output",
})
})
it("includes terminal for matching prefix", () => {
const result = buildMentionResults("term", ["src/terminal.ts"])
expect(result.map((item) => item.type)).toEqual(["terminal", "file"])
})
it("omits terminal for unrelated query", () => {
const result = buildMentionResults("src", ["src/index.ts"])
expect(result.map((item) => item.type)).toEqual(["file"])
})
})
describe("syncMentionedPaths", () => {
it("keeps paths still referenced in text", () => {
const paths = new Set(["foo.ts", "bar.ts"])
@@ -0,0 +1,26 @@
import { describe, expect, it } from "bun:test"
import { parseMessageFiles } from "../../src/kilo-provider/message-files"
describe("parseMessageFiles", () => {
it("accepts terminal text attachments with source metadata", () => {
const files = parseMessageFiles([
{
mime: "text/plain",
url: "data:text/plain;charset=utf-8,terminal%20output",
filename: "terminal-output.txt",
source: {
type: "file",
path: "terminal-output.txt",
text: { value: "@terminal", start: 0, end: 9 },
},
},
])
expect(files?.[0]?.filename).toBe("terminal-output.txt")
expect(files?.[0]?.source?.text.value).toBe("@terminal")
})
it("rejects unsupported URLs", () => {
expect(parseMessageFiles([{ mime: "text/plain", url: "https://example.com/file.txt" }])).toBeUndefined()
})
})
@@ -0,0 +1,41 @@
import { describe, expect, it } from "bun:test"
import fs from "node:fs"
import path from "node:path"
const ROOT = path.resolve(import.meta.dir, "../..")
const src = (file: string) => fs.readFileSync(path.join(ROOT, file), "utf-8")
describe("terminal context architecture", () => {
it("keeps VS Code terminal command capture in the terminal service", () => {
const helper = src("src/services/terminal/context.ts")
const provider = src("src/KiloProvider.ts")
const actions = src("src/services/code-actions/register-terminal-actions.ts")
expect(helper).toContain("workbench.action.terminal.selectAll")
expect(provider).not.toContain("workbench.action.terminal.selectAll")
expect(actions).not.toContain("workbench.action.terminal.selectAll")
})
it("keeps webview terminal attachment logic outside PromptInput", () => {
const prompt = src("webview-ui/src/components/chat/PromptInput.tsx")
const hook = src("webview-ui/src/hooks/useTerminalContext.ts")
const util = src("webview-ui/src/hooks/terminal-context-utils.ts")
expect(prompt).toContain("useTerminalContext")
expect(prompt).not.toContain("requestTerminalContext")
expect(prompt).not.toContain("data:text/plain")
expect(hook).toContain("requestTerminalContext")
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 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(provider).not.toContain("TERMINAL_OUTPUT_LINE_LIMIT")
expect(prompt).not.toContain("TERMINAL_OUTPUT_LINE_LIMIT")
})
})
@@ -0,0 +1,26 @@
import { describe, expect, it } from "bun:test"
import {
buildTerminalAttachment,
findTerminalMention,
hasTerminalMention,
} from "../../webview-ui/src/hooks/terminal-context-utils"
describe("terminal context utils", () => {
it("detects standalone terminal mentions", () => {
expect(hasTerminalMention("see @terminal output")).toBe(true)
expect(hasTerminalMention("see foo@terminal output")).toBe(false)
expect(hasTerminalMention("see @terminal-output")).toBe(false)
})
it("returns mention source range", () => {
expect(findTerminalMention("hello @terminal")!).toEqual({ value: "@terminal", start: 6, end: 15 })
})
it("builds a text attachment with source metadata", () => {
const attachment = buildTerminalAttachment("check @terminal", "npm failed")!
expect(attachment.mime).toBe("text/plain")
expect(attachment.filename).toBe("terminal-output.txt")
expect(attachment.url).toBe("data:text/plain;charset=utf-8,npm%20failed")
expect(attachment.source?.text).toEqual({ value: "@terminal", start: 6, end: 15 })
})
})
@@ -0,0 +1,24 @@
import { describe, expect, it } from "bun:test"
import { truncateTerminalOutput } from "../../src/services/terminal/truncate"
describe("truncateTerminalOutput", () => {
it("returns content within limits", () => {
expect(truncateTerminalOutput("one\ntwo", { lineLimit: 5, characterLimit: 100 })).toEqual({
content: "one\ntwo",
truncated: false,
})
})
it("truncates by character limit first", () => {
const result = truncateTerminalOutput("a".repeat(20), { lineLimit: 1, characterLimit: 10 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("[...10 characters omitted...]")
})
it("truncates by line limit", () => {
const result = truncateTerminalOutput("1\n2\n3\n4\n5", { lineLimit: 3, characterLimit: 100 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("[...2 lines omitted...]")
expect(result.content.endsWith("3\n4\n5")).toBe(true)
})
})
@@ -9,6 +9,8 @@ import { Dialog } from "@kilocode/kilo-ui/dialog"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { FileIcon } from "@kilocode/kilo-ui/file-icon"
import { Icon } from "@kilocode/kilo-ui/icon"
import { showToast } from "@kilocode/kilo-ui/toast"
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
import { useSession } from "../../context/session"
import { useServer } from "../../context/server"
@@ -19,6 +21,8 @@ import { ModelSelector } from "../shared/ModelSelector"
import { ModeSwitcher } from "../shared/ModeSwitcher"
import { ThinkingSelector } from "../shared/ThinkingSelector"
import { useFileMention } from "../../hooks/useFileMention"
import { useTerminalContext } from "../../hooks/useTerminalContext"
import { hasTerminalMention } from "../../hooks/terminal-context-utils"
import { useSlashCommand } from "../../hooks/useSlashCommand"
import { useGhostText } from "../../hooks/useGhostText"
import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments"
@@ -58,6 +62,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const worktree = useWorktreeMode()
const dialog = useDialog()
const mention = useFileMention(vscode)
const terminal = useTerminalContext(vscode)
const excluded = worktree ? new Set(["sessions"]) : undefined
const slash = useSlashCommand(vscode, excluded)
const imageAttach = useImageAttachments()
@@ -266,10 +271,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isBusy = () => session.status() !== "idle"
const isDisabled = () => !server.isConnected()
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
const canSend = () => hasInput() && !isDisabled() && !props.blocked?.()
const canSend = () => hasInput() && !isDisabled() && !terminal.pending() && !props.blocked?.()
const showStop = () => isBusy() && !hasInput()
const isAtEnd = () =>
textareaRef ? atEnd(textareaRef.selectionStart, textareaRef.selectionEnd, textareaRef.value.length) : false
const highlightMentions = () => {
const paths = new Set(mention.mentionedPaths())
if (hasTerminalMention(text())) paths.add("terminal")
return paths
}
const placeholder = () => {
switch (server.connectionState()) {
case "connecting":
@@ -309,7 +319,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const merged = mergeReviewComments(reviewComments(), message.comments)
replaceReviewComments(merged)
if (message.autoSend && empty && !isDisabled() && !props.blocked?.()) {
handleSend()
void handleSend()
} else {
textareaRef?.focus()
}
@@ -567,7 +577,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
vscode.postMessage({ type: "enhancePrompt", text: draft, requestId: `enhance-${draftKey()}-${enhanceCounter}` })
}
const handleSend = () => {
const handleSend = async () => {
const draft = text().trim()
// Detect slash command (hoisted for both client and server command checks).
@@ -598,17 +608,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pending = reviewComments()
const review = pending.length > 0 ? formatReviewCommentsMarkdown(pending) : ""
const message = draft && review ? `${review}\n\n${draft}` : draft || review
if ((!message && imgs.length === 0) || isDisabled() || props.blocked?.()) return
if ((!message && imgs.length === 0) || isDisabled() || terminal.pending() || props.blocked?.()) return
const mentionFiles = mention.parseFileAttachments(draft)
const imgFiles = imgs.map((img) => ({ mime: img.mime, url: img.dataUrl, filename: img.filename }))
const allFiles = [...mentionFiles, ...imgFiles]
const sel = session.selected()
const pendingId = props.pendingSessionID ?? session.draftSessionID()
const sid = session.currentSessionID()
const terminalFile = await terminal.resolveAttachment(message, sid).catch((err: Error) => {
showToast({ variant: "error", title: "Terminal context unavailable", description: err.message })
return undefined
})
if (hasTerminalMention(message) && !terminalFile) return
const allFiles = [...mentionFiles, ...imgFiles, ...(terminalFile ? [terminalFile] : [])]
const attachments = allFiles.length > 0 ? allFiles : undefined
const key = draftKey()
const pendingId = props.pendingSessionID ?? session.draftSessionID()
// Server-side slash command (cmdMatch/matched already computed above)
if (matched) {
const rest = draft.slice(cmdMatch![0].length).trim()
@@ -697,19 +714,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
fallback={<div class="file-mention-empty">No files found</div>}
>
<For each={mention.mentionResults()}>
{(path, index) => (
{(item, index) => (
<div
class="file-mention-item"
classList={{ "file-mention-item--active": index() === mention.mentionIndex() }}
onMouseDown={(e) => {
e.preventDefault()
if (textareaRef) mention.selectFile(path, textareaRef, setText, adjustHeight)
if (textareaRef) mention.selectMention(item, textareaRef, setText, adjustHeight)
}}
onMouseEnter={() => mention.setMentionIndex(index())}
>
<FileIcon node={{ path, type: "file" }} class="file-mention-icon" />
<span class="file-mention-name">{fileName(path)}</span>
<span class="file-mention-dir">{dirName(path)}</span>
{item.type === "terminal" ? (
<>
<Icon name="console" class="file-mention-icon" />
<span class="file-mention-name">{item.label}</span>
<span class="file-mention-dir">{item.description}</span>
</>
) : (
<>
<FileIcon node={{ path: item.value, type: "file" }} class="file-mention-icon" />
<span class="file-mention-name">{fileName(item.value)}</span>
<span class="file-mention-dir">{dirName(item.value)}</span>
</>
)}
</div>
)}
</For>
@@ -806,7 +833,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="prompt-input-wrapper">
<div class="prompt-input-ghost-wrapper">
<div class="prompt-input-highlight-overlay" ref={highlightRef} aria-hidden="true">
<Index each={buildHighlightSegments(text(), mention.mentionedPaths())}>
<Index each={buildHighlightSegments(text(), highlightMentions())}>
{(seg) => (
<Show when={seg().highlight} fallback={<span>{seg().text}</span>}>
<span class="prompt-input-file-mention">{seg().text}</span>
@@ -879,7 +906,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Button
variant="ghost"
size="small"
onClick={handleSend}
onClick={() => void handleSend()}
disabled={!canSend()}
aria-label={language.t("prompt.action.send")}
>
@@ -1363,6 +1363,7 @@ export const SessionProvider: ParentComponent = (props) => {
mime: file.mime,
url: file.url,
filename: file.filename,
source: file.source,
})
}
@@ -1,7 +1,19 @@
import type { FileAttachment } from "../types/messages"
import { TERMINAL_MENTION } from "./terminal-context-utils"
export const AT_PATTERN = /(?:^|\s)@(\S*)$/
export type MentionResult =
| { type: "terminal"; value: typeof TERMINAL_MENTION; label: string; description: string }
| { type: "file"; value: string }
export const TERMINAL_RESULT: MentionResult = {
type: "terminal",
value: TERMINAL_MENTION,
label: "Terminal",
description: "Active terminal output",
}
/**
* Escape special regex characters in a string so it can be used in a RegExp.
*/
@@ -9,6 +21,16 @@ function escape(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
export function getTerminalMentionResult(query: string): MentionResult[] {
const normalized = query.toLowerCase()
if (!TERMINAL_MENTION.startsWith(normalized)) return []
return [TERMINAL_RESULT]
}
export function buildMentionResults(query: string, paths: string[]): MentionResult[] {
return [...getTerminalMentionResult(query), ...paths.map((path) => ({ type: "file" as const, value: path }))]
}
/**
* Sync the set of mentioned paths against the current text.
* Removes any paths that are no longer present in the text as @path mentions.
@@ -0,0 +1,42 @@
import type { FileAttachment } from "../types/messages"
export const TERMINAL_MENTION = "terminal"
export const TERMINAL_FILENAME = "terminal-output.txt"
export const TERMINAL_PATTERN = /(^|\s)@terminal(?=\s|$)/g
export type TerminalMention = {
value: string
start: number
end: number
}
export function findTerminalMention(text: string): TerminalMention | undefined {
TERMINAL_PATTERN.lastIndex = 0
const match = TERMINAL_PATTERN.exec(text)
if (!match) return undefined
const prefix = match[1] ?? ""
const start = match.index + prefix.length
const value = `@${TERMINAL_MENTION}`
return { value, start, end: start + value.length }
}
export function hasTerminalMention(text: string): boolean {
return findTerminalMention(text) !== undefined
}
export function buildTerminalAttachment(text: string, content: string): FileAttachment | undefined {
const mention = findTerminalMention(text)
if (!mention) return undefined
return {
mime: "text/plain",
url: `data:text/plain;charset=utf-8,${encodeURIComponent(content)}`,
filename: TERMINAL_FILENAME,
source: {
type: "file",
path: TERMINAL_FILENAME,
text: mention,
},
}
}
@@ -6,6 +6,8 @@ import {
syncMentionedPaths as _syncMentionedPaths,
buildTextAfterMentionSelect,
buildFileAttachments,
buildMentionResults,
type MentionResult,
} from "./file-mention-utils"
const FILE_SEARCH_DEBOUNCE_MS = 150
@@ -17,7 +19,7 @@ interface VSCodeContext {
export interface FileMention {
mentionedPaths: Accessor<Set<string>>
mentionResults: Accessor<string[]>
mentionResults: Accessor<MentionResult[]>
mentionIndex: Accessor<number>
showMention: Accessor<boolean>
onInput: (val: string, cursor: number) => void
@@ -27,8 +29,8 @@ export interface FileMention {
setText: (text: string) => void,
onSelect?: () => void,
) => boolean
selectFile: (
path: string,
selectMention: (
result: MentionResult,
textarea: HTMLTextAreaElement,
setText: (text: string) => void,
onSelect?: () => void,
@@ -43,7 +45,7 @@ export interface FileMention {
export function useFileMention(vscode: VSCodeContext): FileMention {
const [mentionedPaths, setMentionedPaths] = createSignal<Set<string>>(new Set())
const [mentionQuery, setMentionQuery] = createSignal<string | null>(null)
const [mentionResults, setMentionResults] = createSignal<string[]>([])
const [mentionResults, setMentionResults] = createSignal<MentionResult[]>([])
const [mentionIndex, setMentionIndex] = createSignal(0)
let workspaceDir = ""
@@ -61,7 +63,7 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
const result = message as { type: "fileSearchResult"; paths: string[]; dir: string; requestId: string }
if (result.requestId === `file-search-${fileSearchCounter}`) {
workspaceDir = result.dir
setMentionResults(result.paths)
setMentionResults(buildMentionResults(mentionQuery() ?? "", result.paths))
setMentionIndex(0)
}
})
@@ -88,8 +90,8 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
setMentionedPaths((prev) => _syncMentionedPaths(prev, text))
}
const selectMentionFile = (
path: string,
const selectMention = (
result: MentionResult,
textarea: HTMLTextAreaElement,
setText: (text: string) => void,
onSelect?: () => void,
@@ -99,16 +101,16 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
const before = val.substring(0, cursor)
const after = val.substring(cursor)
const result = buildTextAfterMentionSelect(before, after, path)
textarea.value = result
setText(result)
const text = buildTextAfterMentionSelect(before, after, result.value)
textarea.value = text
setText(text)
// Position cursor right after the inserted @path
const pos = result.length - after.length
// Position cursor right after the inserted @mention
const pos = text.length - after.length
textarea.setSelectionRange(pos, pos)
textarea.focus()
setMentionedPaths((prev) => new Set([...prev, path]))
if (result.type === "file") setMentionedPaths((prev) => new Set([...prev, result.value]))
closeMention()
onSelect?.()
}
@@ -118,8 +120,10 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
const before = val.substring(0, cursor)
const match = before.match(AT_PATTERN)
if (match) {
setMentionQuery(match[1])
requestFileSearch(match[1])
const query = match[1] ?? ""
setMentionQuery(query)
setMentionResults(buildMentionResults(query, []))
requestFileSearch(query)
} else {
closeMention()
}
@@ -136,7 +140,7 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
if (e.key === "ArrowDown") {
e.preventDefault()
setMentionIndex((i) => Math.min(i + 1, mentionResults().length - 1))
setMentionIndex((i) => Math.min(i + 1, Math.max(mentionResults().length - 1, 0)))
return true
}
if (e.key === "ArrowUp") {
@@ -145,10 +149,10 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
return true
}
if (e.key === "Enter" || e.key === "Tab") {
const path = mentionResults()[mentionIndex()]
if (!path) return false
const result = mentionResults()[mentionIndex()]
if (!result) return false
e.preventDefault()
if (textarea) selectMentionFile(path, textarea, setText, onSelect)
if (textarea) selectMention(result, textarea, setText, onSelect)
return true
}
if (e.key === "Escape") {
@@ -180,7 +184,7 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
showMention,
onInput,
onKeyDown,
selectFile: selectMentionFile,
selectMention,
setMentionIndex,
closeMention,
parseFileAttachments,
@@ -0,0 +1,81 @@
import { createSignal, onCleanup } from "solid-js"
import type { Accessor } from "solid-js"
import type { ExtensionMessage, FileAttachment, WebviewMessage } from "../types/messages"
import { buildTerminalAttachment, hasTerminalMention } from "./terminal-context-utils"
const TERMINAL_CONTEXT_TIMEOUT_MS = 10_000
type Pending = {
resolve: (content: string) => void
reject: (err: Error) => void
timer: ReturnType<typeof setTimeout>
}
interface VSCodeContext {
postMessage: (message: WebviewMessage) => void
onMessage: (handler: (message: ExtensionMessage) => void) => () => void
}
export interface TerminalContext {
pending: Accessor<boolean>
resolveAttachment: (text: string, sessionID?: string) => Promise<FileAttachment | undefined>
}
export function useTerminalContext(vscode: VSCodeContext): TerminalContext {
const [pending, setPending] = createSignal(false)
const requests = new Map<string, Pending>()
let counter = 0
const settle = (requestId: string, run: (req: Pending) => void) => {
const req = requests.get(requestId)
if (!req) return
clearTimeout(req.timer)
requests.delete(requestId)
setPending(requests.size > 0)
run(req)
}
const unsubscribe = vscode.onMessage((message) => {
if (message.type === "terminalContextResult") {
settle(message.requestId, (req) => req.resolve(message.content))
return
}
if (message.type === "terminalContextError") {
settle(message.requestId, (req) => req.reject(new Error(message.error)))
}
})
onCleanup(() => {
unsubscribe()
for (const req of requests.values()) {
clearTimeout(req.timer)
req.reject(new Error("Terminal context request cancelled"))
}
requests.clear()
})
const request = (sessionID?: string) =>
new Promise<string>((resolve, reject) => {
counter++
const requestId = `terminal-context-${counter}`
const timer = setTimeout(() => {
settle(requestId, (req) => req.reject(new Error("Timed out while reading terminal output")))
}, TERMINAL_CONTEXT_TIMEOUT_MS)
requests.set(requestId, { resolve, reject, timer })
setPending(true)
vscode.postMessage({ type: "requestTerminalContext", requestId, sessionID })
})
const resolveAttachment = async (text: string, sessionID?: string) => {
if (!hasTerminalMention(text)) return undefined
const content = await request(sessionID)
if (!content.trim()) throw new Error("No terminal content available")
return buildTerminalAttachment(text, content)
}
return { pending, resolveAttachment }
}
@@ -37,11 +37,22 @@ export interface TextPart extends BasePart {
text: string
}
export interface FilePartSource {
type: "file"
path: string
text: {
value: string
start: number
end: number
}
}
export interface FilePart extends BasePart {
type: "file"
mime: string
url: string
filename?: string
source?: FilePartSource
}
export interface ToolPart extends BasePart {
@@ -720,6 +731,19 @@ export interface FileSearchResultMessage {
requestId: string
}
export interface TerminalContextResultMessage {
type: "terminalContextResult"
requestId: string
content: string
truncated?: boolean
}
export interface TerminalContextErrorMessage {
type: "terminalContextError"
requestId: string
error: string
}
export interface QuestionRequestMessage {
type: "questionRequest"
question: QuestionRequest
@@ -1460,6 +1484,8 @@ export type ExtensionMessage =
| AutocompleteSettingsLoadedMessage
| ChatCompletionResultMessage
| FileSearchResultMessage
| TerminalContextResultMessage
| TerminalContextErrorMessage
| QuestionRequestMessage
| QuestionResolvedMessage
| QuestionErrorMessage
@@ -1541,6 +1567,7 @@ export interface FileAttachment {
mime: string
url: string
filename?: string
source?: FilePartSource
}
export interface SendMessageRequest {
@@ -1806,6 +1833,12 @@ export interface RequestFileSearchMessage {
requestId: string
}
export interface RequestTerminalContextMessage {
type: "requestTerminalContext"
requestId: string
sessionID?: string
}
export interface ChatCompletionAcceptedMessage {
type: "chatCompletionAccepted"
suggestionLength?: number
@@ -2409,6 +2442,7 @@ export type WebviewMessage =
| UpdateAutocompleteSettingMessage
| RequestChatCompletionMessage
| RequestFileSearchMessage
| RequestTerminalContextMessage
| ChatCompletionAcceptedMessage
| UpdateSettingRequest
| RequestTimelineSettingMessage