mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
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:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user