From 9fa90ee6389a608242a41da4ba6b8d8ce2f35f7d Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 14 Apr 2026 12:10:42 +0200 Subject: [PATCH] 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 --- .changeset/terminal-context-mention.md | 5 ++ AGENTS.md | 4 + .../code-with-ai/agents/chat-interface.md | 2 +- .../code-with-ai/agents/context-mentions.md | 9 ++- packages/kilo-vscode/src/KiloProvider.ts | 52 +++++++++--- .../src/agent-manager/AgentManagerProvider.ts | 5 ++ .../kilo-vscode/src/agent-manager/types.ts | 21 ++++- .../kilo-provider/handlers/cloud-session.ts | 13 ++- .../src/kilo-provider/message-files.ts | 13 +++ .../code-actions/register-terminal-actions.ts | 53 +----------- .../src/services/terminal/context.ts | 39 +++++++++ .../src/services/terminal/truncate.ts | 39 +++++++++ .../tests/unit/file-mention-utils.test.ts | 23 ++++++ .../tests/unit/message-files.test.ts | 26 ++++++ .../tests/unit/terminal-architecture.test.ts | 41 ++++++++++ .../tests/unit/terminal-context-utils.test.ts | 26 ++++++ .../tests/unit/terminal-truncate.test.ts | 24 ++++++ .../src/components/chat/PromptInput.tsx | 55 +++++++++---- .../webview-ui/src/context/session.tsx | 1 + .../src/hooks/file-mention-utils.ts | 22 +++++ .../src/hooks/terminal-context-utils.ts | 42 ++++++++++ .../webview-ui/src/hooks/useFileMention.ts | 44 +++++----- .../src/hooks/useTerminalContext.ts | 81 +++++++++++++++++++ .../webview-ui/src/types/messages.ts | 34 ++++++++ 24 files changed, 573 insertions(+), 101 deletions(-) create mode 100644 .changeset/terminal-context-mention.md create mode 100644 packages/kilo-vscode/src/services/terminal/context.ts create mode 100644 packages/kilo-vscode/src/services/terminal/truncate.ts create mode 100644 packages/kilo-vscode/tests/unit/message-files.test.ts create mode 100644 packages/kilo-vscode/tests/unit/terminal-architecture.test.ts create mode 100644 packages/kilo-vscode/tests/unit/terminal-context-utils.test.ts create mode 100644 packages/kilo-vscode/tests/unit/terminal-truncate.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/hooks/terminal-context-utils.ts create mode 100644 packages/kilo-vscode/webview-ui/src/hooks/useTerminalContext.ts diff --git a/.changeset/terminal-context-mention.md b/.changeset/terminal-context-mention.md new file mode 100644 index 0000000000..3228399c01 --- /dev/null +++ b/.changeset/terminal-context-mention.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index dc8bc73271..9b92e02e9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.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. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md index 7ae2bc19c1..38cab73d5b 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md @@ -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" %} diff --git a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index 1510f12222..886e84dbb8 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md @@ -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 diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 0981324b07..d72c3302f5 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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 { + 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 { if (!this.client) { this.postMessage({ @@ -2405,7 +2429,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const parts: Array = [] 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 { 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 diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 3b9cb463b2..b58b38de0e 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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) diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 43b7d19943..2ea0840bbd 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -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 diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts index acb0365991..29ce097953 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts @@ -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 { @@ -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 = [] 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 }) diff --git a/packages/kilo-vscode/src/kilo-provider/message-files.ts b/packages/kilo-vscode/src/kilo-provider/message-files.ts index 35b419d419..108fd3ef80 100644 --- a/packages/kilo-vscode/src/kilo-provider/message-files.ts +++ b/packages/kilo-vscode/src/kilo-provider/message-files.ts @@ -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 + export function parseMessageFiles(value: unknown) { return z.array(file).optional().catch(undefined).parse(value) } diff --git a/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts index 1f64de8553..b04a7db5a9 100644 --- a/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts +++ b/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts @@ -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 { - 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.") diff --git a/packages/kilo-vscode/src/services/terminal/context.ts b/packages/kilo-vscode/src/services/terminal/context.ts new file mode 100644 index 0000000000..854835172d --- /dev/null +++ b/packages/kilo-vscode/src/services/terminal/context.ts @@ -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 { + 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 { + 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) + } +} diff --git a/packages/kilo-vscode/src/services/terminal/truncate.ts b/packages/kilo-vscode/src/services/terminal/truncate.ts new file mode 100644 index 0000000000..643b7a6567 --- /dev/null +++ b/packages/kilo-vscode/src/services/terminal/truncate.ts @@ -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, + } +} diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index 597290f519..56e7d31d0c 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -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"]) diff --git a/packages/kilo-vscode/tests/unit/message-files.test.ts b/packages/kilo-vscode/tests/unit/message-files.test.ts new file mode 100644 index 0000000000..106008353d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/message-files.test.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() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/terminal-architecture.test.ts b/packages/kilo-vscode/tests/unit/terminal-architecture.test.ts new file mode 100644 index 0000000000..b7f760b843 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/terminal-architecture.test.ts @@ -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") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/terminal-context-utils.test.ts b/packages/kilo-vscode/tests/unit/terminal-context-utils.test.ts new file mode 100644 index 0000000000..1b40840980 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/terminal-context-utils.test.ts @@ -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 }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/terminal-truncate.test.ts b/packages/kilo-vscode/tests/unit/terminal-truncate.test.ts new file mode 100644 index 0000000000..95fdf4dd91 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/terminal-truncate.test.ts @@ -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) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 3b5738cf61..b5a2ff3218 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -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 = (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 = (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 = (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 = (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 = (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 = (props) => { fallback={
No files found
} > - {(path, index) => ( + {(item, index) => (
{ e.preventDefault() - if (textareaRef) mention.selectFile(path, textareaRef, setText, adjustHeight) + if (textareaRef) mention.selectMention(item, textareaRef, setText, adjustHeight) }} onMouseEnter={() => mention.setMentionIndex(index())} > - - {fileName(path)} - {dirName(path)} + {item.type === "terminal" ? ( + <> + + {item.label} + {item.description} + + ) : ( + <> + + {fileName(item.value)} + {dirName(item.value)} + + )}
)}
@@ -806,7 +833,7 @@ export const PromptInput: Component = (props) => {