mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Add folder mentions (#9023)
* feat(vscode): add folder mentions * fix(vscode): harden folder mentions * refactor(vscode): tighten folder mention plumbing * fix(cli): annotate kilo-specific read.ts exports
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Support mentioning folders in the prompt with @ references, including top-level folder file contents.
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
flushPendingSessionRefresh as flushPendingSessionRefreshUtil,
|
||||
resolveContextDirectory,
|
||||
resolveWorkspaceDirectory,
|
||||
mergeFileSearchResults,
|
||||
SessionStreamScheduler,
|
||||
type SessionRefreshContext,
|
||||
} from "./kilo-provider-utils"
|
||||
@@ -47,6 +46,7 @@ import { retry } from "./services/cli-backend/retry"
|
||||
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
|
||||
import { handleContinueInWorktree } from "./kilo-provider/continue-worktree"
|
||||
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
|
||||
import { handleFileSearch } from "./kilo-provider/file-search"
|
||||
import { getTerminalContents } from "./services/terminal/context"
|
||||
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
|
||||
import { childID } from "./kilo-provider/task-session"
|
||||
@@ -815,29 +815,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
)
|
||||
break
|
||||
}
|
||||
case "requestFileSearch": {
|
||||
const sdkClient = this.client
|
||||
if (sdkClient) {
|
||||
const dir = this.getWorkspaceDirectory(this.currentSession?.id)
|
||||
const openPaths = dir ? await this.getOpenTabPaths(dir) : new Set<string>()
|
||||
void sdkClient.find
|
||||
.files({ query: message.query, directory: dir, type: "file", limit: 50 }, { throwOnError: true })
|
||||
.then(({ data: paths }) => {
|
||||
const uri = vscode.window.activeTextEditor?.document.uri
|
||||
const active =
|
||||
uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath).replaceAll("\\", "/") : undefined
|
||||
const result = mergeFileSearchResults({ query: message.query, backend: paths, open: openPaths, active })
|
||||
this.postMessage({ type: "fileSearchResult", paths: result, dir, requestId: message.requestId })
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("[Kilo New] File search failed:", error)
|
||||
this.postMessage({ type: "fileSearchResult", paths: [], dir, requestId: message.requestId })
|
||||
})
|
||||
} else {
|
||||
this.postMessage({ type: "fileSearchResult", paths: [], dir: "", requestId: message.requestId })
|
||||
}
|
||||
case "requestFileSearch":
|
||||
await handleFileSearch({
|
||||
client: this.client,
|
||||
message,
|
||||
current: this.currentSession?.id,
|
||||
context: this.contextSessionID,
|
||||
dir: (id) => this.getWorkspaceDirectory(id),
|
||||
open: (dir) => this.getOpenTabPaths(dir),
|
||||
post: (msg) => this.postMessage(msg),
|
||||
})
|
||||
break
|
||||
}
|
||||
case "requestTerminalContext":
|
||||
void this.handleTerminalContext(message.requestId)
|
||||
break
|
||||
|
||||
@@ -265,6 +265,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
private async onMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
||||
if (this.prBridge.handleMessage(msg)) return null
|
||||
if (msg.type === "requestFileSearch" && typeof msg.sessionID !== "string" && this.activeSessionId) {
|
||||
return { ...msg, sessionID: this.activeSessionId }
|
||||
}
|
||||
const m = msg as unknown as AgentManagerInMessage
|
||||
|
||||
const worktree = await this.onWorktreeMessage(m)
|
||||
|
||||
@@ -98,6 +98,8 @@ function createHarness() {
|
||||
const manager = Object.create(AgentManagerProvider.prototype) as {
|
||||
host: Host
|
||||
panel: { sessions: { registerSession: ReturnType<typeof vi.fn> } } | undefined
|
||||
prBridge: { handleMessage: ReturnType<typeof vi.fn> }
|
||||
activeSessionId: string | undefined
|
||||
stateReady: Promise<void> | undefined
|
||||
createWorktreeOnDisk: ReturnType<typeof vi.fn>
|
||||
runSetupScriptForWorktree: ReturnType<typeof vi.fn>
|
||||
@@ -107,6 +109,7 @@ function createHarness() {
|
||||
notifyWorktreeReady: ReturnType<typeof vi.fn>
|
||||
log: ReturnType<typeof vi.fn>
|
||||
onCreateWorktree: () => Promise<null>
|
||||
onMessage: (msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>
|
||||
}
|
||||
|
||||
manager.host = host
|
||||
@@ -115,6 +118,8 @@ function createHarness() {
|
||||
registerSession: vi.fn(),
|
||||
},
|
||||
}
|
||||
manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) }
|
||||
manager.activeSessionId = undefined
|
||||
manager.stateReady = Promise.resolve()
|
||||
manager.createWorktreeOnDisk = vi.fn()
|
||||
manager.runSetupScriptForWorktree = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -169,4 +174,13 @@ describe("AgentManagerProvider worktree creation", () => {
|
||||
|
||||
expect(manager.createWorktreeOnDisk).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("routes file search through the active worktree session", async () => {
|
||||
const manager = createHarness()
|
||||
manager.activeSessionId = "session-wt"
|
||||
|
||||
const result = await manager.onMessage({ type: "requestFileSearch", query: "src", requestId: "r1" })
|
||||
|
||||
expect(result).toEqual({ type: "requestFileSearch", query: "src", requestId: "r1", sessionID: "session-wt" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -564,12 +564,16 @@ export function mergeFileSearchResults(input: {
|
||||
open: Set<string>
|
||||
active?: string
|
||||
}): string[] {
|
||||
const query = input.query.trim().toLowerCase()
|
||||
const norm = (p: string) => p.replaceAll("\\", "/")
|
||||
const query = norm(input.query).trim().toLowerCase()
|
||||
const open = new Set([...input.open].map(norm))
|
||||
const active = input.active ? norm(input.active) : undefined
|
||||
const backend = input.backend.map(norm)
|
||||
const ok = (p: string) => !query || p.toLowerCase().includes(query)
|
||||
const tabs =
|
||||
input.active && input.open.has(input.active) && ok(input.active)
|
||||
? [input.active, ...[...input.open].filter((p) => p !== input.active && ok(p))]
|
||||
: [...input.open].filter(ok)
|
||||
active && open.has(active) && ok(active)
|
||||
? [active, ...[...open].filter((p) => p !== active && ok(p))]
|
||||
: [...open].filter(ok)
|
||||
const seen = new Set(tabs)
|
||||
return [...tabs, ...input.backend.filter((p) => !seen.has(p))]
|
||||
return [...tabs, ...backend.filter((p) => !seen.has(p))]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export type FileSearchItem = { path: string; type: "file" | "folder" }
|
||||
|
||||
const normalize = (p: string) => p.replaceAll("\\", "/")
|
||||
const trim = (p: string) => normalize(p).replace(/\/+$/, "")
|
||||
|
||||
function base(p: string): string {
|
||||
const clean = trim(p)
|
||||
return clean.split("/").pop() ?? clean
|
||||
}
|
||||
|
||||
function rank(query: string, p: string): number {
|
||||
const clean = trim(p).toLowerCase()
|
||||
const name = base(p).toLowerCase()
|
||||
if (clean === query || name === query) return 0
|
||||
if (name.startsWith(query) || (query.includes("/") && clean.startsWith(query))) return 1
|
||||
if (name.includes(query)) return 2
|
||||
if (clean.includes(query)) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
export function mergeFileSearchItems(input: { query: string; files: string[]; folders: string[] }): FileSearchItem[] {
|
||||
const query = normalize(input.query).trim().toLowerCase()
|
||||
const files = input.files.map((p) => ({ path: normalize(p), type: "file" as const }))
|
||||
// Dedup folders against themselves; a file and a folder that share a stem are distinct entries.
|
||||
const seen = new Set<string>()
|
||||
const folders = input.folders
|
||||
.filter((p) => {
|
||||
const key = trim(p)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.map((p, index) => ({
|
||||
item: { path: normalize(p), type: "folder" as const },
|
||||
index,
|
||||
rank: query ? rank(query, p) : 4,
|
||||
}))
|
||||
|
||||
if (!query) return [...files, ...folders.map((x) => x.item)]
|
||||
|
||||
const sorted = [...folders].sort((a, b) => a.rank - b.rank || a.index - b.index)
|
||||
const boosted = sorted.filter((x) => x.rank <= 1).map((x) => x.item)
|
||||
const rest = sorted.filter((x) => x.rank > 1).map((x) => x.item)
|
||||
return [...boosted, ...files, ...rest]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { mergeFileSearchResults } from "../kilo-provider-utils"
|
||||
import { mergeFileSearchItems } from "./file-search-items"
|
||||
|
||||
type Message = {
|
||||
query: string
|
||||
requestId: string
|
||||
sessionID?: string
|
||||
}
|
||||
|
||||
type Input = {
|
||||
client: KiloClient | null
|
||||
message: Message
|
||||
current?: string
|
||||
context?: string
|
||||
dir: (id?: string) => string
|
||||
open: (dir: string) => Promise<Set<string>>
|
||||
post: (message: unknown) => void
|
||||
}
|
||||
|
||||
export async function handleFileSearch(input: Input): Promise<void> {
|
||||
const client = input.client
|
||||
if (!client) {
|
||||
input.post({ type: "fileSearchResult", paths: [], items: [], dir: "", requestId: input.message.requestId })
|
||||
return
|
||||
}
|
||||
|
||||
const id = input.message.sessionID ?? input.current ?? input.context
|
||||
const dir = input.dir(id)
|
||||
const open = dir ? await input.open(dir) : new Set<string>()
|
||||
|
||||
const query = input.message.query
|
||||
void Promise.allSettled([
|
||||
client.find.files({ query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }),
|
||||
client.find.files({ query, directory: dir, type: "directory", limit: 50 }, { throwOnError: true }),
|
||||
]).then(([fileRes, folderRes]) => {
|
||||
const files = settled(fileRes, "file")
|
||||
const folders = settled(folderRes, "folder")
|
||||
const uri = vscode.window.activeTextEditor?.document.uri
|
||||
const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined
|
||||
const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined
|
||||
const result = mergeFileSearchResults({ query, backend: files, open, active })
|
||||
const items = mergeFileSearchItems({ query, files: result, folders })
|
||||
input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId })
|
||||
})
|
||||
}
|
||||
|
||||
function settled(result: PromiseSettledResult<{ data: string[] }>, kind: "file" | "folder"): string[] {
|
||||
if (result.status === "fulfilled") return result.value.data
|
||||
console.error(`[Kilo New] File search (${kind}) failed:`, result.reason)
|
||||
return []
|
||||
}
|
||||
@@ -50,6 +50,11 @@ describe("buildMentionResults", () => {
|
||||
const result = buildMentionResults("src", ["src/index.ts"])
|
||||
expect(result.map((item) => item.type)).toEqual(["file"])
|
||||
})
|
||||
|
||||
it("includes folder results", () => {
|
||||
const result = buildMentionResults("src", [{ path: "src", type: "folder" }])
|
||||
expect(result).toEqual([{ type: "folder", value: "src" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncMentionedPaths", () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getConfigErrorDetails,
|
||||
type ProviderInfo,
|
||||
} from "../../src/kilo-provider-utils"
|
||||
import { mergeFileSearchItems } from "../../src/kilo-provider/file-search-items"
|
||||
import type { CloudSessionMessage } from "../../src/services/cli-backend/types"
|
||||
import type {
|
||||
Session,
|
||||
@@ -606,6 +607,47 @@ describe("mapCloudSessionMessage", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeFileSearchItems", () => {
|
||||
it("puts exact folder matches before file matches", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "script",
|
||||
files: ["script/hooks", "script/release", "script/beta.ts"],
|
||||
folders: ["script/", "script/run-script/"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "script/", type: "folder" },
|
||||
{ path: "script/hooks", type: "file" },
|
||||
{ path: "script/release", type: "file" },
|
||||
{ path: "script/beta.ts", type: "file" },
|
||||
{ path: "script/run-script/", type: "folder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps file ordering before non-prefix folder matches", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "test",
|
||||
files: ["src/test.ts"],
|
||||
folders: ["src/latest/"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "src/test.ts", type: "file" },
|
||||
{ path: "src/latest/", type: "folder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("normalizes Windows separators for matching and output", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "kilo-vscode",
|
||||
files: ["packages\\kilo-vscode\\src\\KiloProvider.ts"],
|
||||
folders: ["packages\\kilo-vscode\\"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "packages/kilo-vscode/", type: "folder" },
|
||||
{ path: "packages/kilo-vscode/src/KiloProvider.ts", type: "file" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeFileSearchResults", () => {
|
||||
it("returns backend results when no open files", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
@@ -699,6 +741,16 @@ describe("mergeFileSearchResults", () => {
|
||||
})
|
||||
expect(result).toEqual(["src/utils/path.ts", "src/index.ts"])
|
||||
})
|
||||
|
||||
it("normalizes backslash paths before filtering and deduping", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "utils/path",
|
||||
backend: ["src\\utils\\path.ts"],
|
||||
open: new Set(["src/utils/path.ts"]),
|
||||
active: "src\\utils\\path.ts",
|
||||
})
|
||||
expect(result).toEqual(["src/utils/path.ts"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getErrorMessage", () => {
|
||||
|
||||
@@ -65,7 +65,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const vscode = useVSCode()
|
||||
const worktree = useWorktreeMode()
|
||||
const dialog = useDialog()
|
||||
const mention = useFileMention(vscode)
|
||||
const mention = useFileMention(
|
||||
vscode,
|
||||
() => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID(),
|
||||
)
|
||||
const terminal = useTerminalContext(vscode)
|
||||
const excluded = worktree ? new Set(["sessions"]) : undefined
|
||||
const slash = useSlashCommand(vscode, excluded)
|
||||
@@ -715,7 +718,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="file-mention-dropdown" ref={dropdownRef}>
|
||||
<Show
|
||||
when={mention.mentionResults().length > 0}
|
||||
fallback={<div class="file-mention-empty">No files found</div>}
|
||||
fallback={<div class="file-mention-empty">No files or folders found</div>}
|
||||
>
|
||||
<For each={mention.mentionResults()}>
|
||||
{(item, index) => (
|
||||
@@ -736,8 +739,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileIcon node={{ path: item.value, type: "file" }} class="file-mention-icon" />
|
||||
<span class="file-mention-name">{fileName(item.value)}</span>
|
||||
<FileIcon
|
||||
node={{ path: item.value, type: item.type === "folder" ? "directory" : "file" }}
|
||||
class="file-mention-icon"
|
||||
/>
|
||||
<span class="file-mention-name">
|
||||
{item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)}
|
||||
</span>
|
||||
<span class="file-mention-dir">{dirName(item.value)}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileAttachment } from "../types/messages"
|
||||
import type { FileAttachment, FileSearchItem } from "../types/messages"
|
||||
import { TERMINAL_MENTION } from "./terminal-context-utils"
|
||||
|
||||
export const AT_PATTERN = /(?:^|\s)@(\S*)$/
|
||||
@@ -6,6 +6,7 @@ export const AT_PATTERN = /(?:^|\s)@(\S*)$/
|
||||
export type MentionResult =
|
||||
| { type: "terminal"; value: typeof TERMINAL_MENTION; label: string; description: string }
|
||||
| { type: "file"; value: string }
|
||||
| { type: "folder"; value: string }
|
||||
|
||||
export const TERMINAL_RESULT: MentionResult = {
|
||||
type: "terminal",
|
||||
@@ -27,8 +28,13 @@ export function getTerminalMentionResult(query: string): MentionResult[] {
|
||||
return [TERMINAL_RESULT]
|
||||
}
|
||||
|
||||
export function buildMentionResults(query: string, paths: string[]): MentionResult[] {
|
||||
return [...getTerminalMentionResult(query), ...paths.map((path) => ({ type: "file" as const, value: path }))]
|
||||
export function buildMentionResults(query: string, items: Array<FileSearchItem | string>): MentionResult[] {
|
||||
const results: MentionResult[] = items.map((item) => {
|
||||
if (typeof item === "string") return { type: "file", value: item }
|
||||
if (item.type === "folder") return { type: "folder", value: item.path }
|
||||
return { type: "file", value: item.path }
|
||||
})
|
||||
return [...getTerminalMentionResult(query), ...results]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +56,7 @@ export function syncMentionedPaths(prev: Set<string>, text: string): Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the @mention pattern before the cursor with the selected file path.
|
||||
* Replace the @mention pattern before the cursor with the selected path.
|
||||
* Returns the new text string.
|
||||
*/
|
||||
export function buildTextAfterMentionSelect(before: string, after: string, path: string): string {
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface FileMention {
|
||||
addPaths: (paths: string[], cwd: string) => void
|
||||
}
|
||||
|
||||
export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
export function useFileMention(vscode: VSCodeContext, sessionID?: Accessor<string | undefined>): FileMention {
|
||||
const [mentionedPaths, setMentionedPaths] = createSignal<Set<string>>(new Set())
|
||||
const [mentionQuery, setMentionQuery] = createSignal<string | null>(null)
|
||||
const [mentionResults, setMentionResults] = createSignal<MentionResult[]>([])
|
||||
@@ -60,10 +60,10 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
|
||||
const unsubscribe = vscode.onMessage((message) => {
|
||||
if (message.type !== "fileSearchResult") return
|
||||
const result = message as { type: "fileSearchResult"; paths: string[]; dir: string; requestId: string }
|
||||
if (result.requestId === `file-search-${fileSearchCounter}`) {
|
||||
workspaceDir = result.dir
|
||||
setMentionResults(buildMentionResults(mentionQuery() ?? "", result.paths))
|
||||
if (message.requestId === `file-search-${fileSearchCounter}`) {
|
||||
const items = message.items ?? message.paths.map((path) => ({ path, type: "file" as const }))
|
||||
workspaceDir = message.dir
|
||||
setMentionResults(buildMentionResults(mentionQuery() ?? "", items))
|
||||
setMentionIndex(0)
|
||||
}
|
||||
})
|
||||
@@ -77,7 +77,13 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
if (fileSearchTimer) clearTimeout(fileSearchTimer)
|
||||
fileSearchTimer = setTimeout(() => {
|
||||
fileSearchCounter++
|
||||
vscode.postMessage({ type: "requestFileSearch", query, requestId: `file-search-${fileSearchCounter}` })
|
||||
const id = sessionID?.()
|
||||
vscode.postMessage({
|
||||
type: "requestFileSearch",
|
||||
query,
|
||||
requestId: `file-search-${fileSearchCounter}`,
|
||||
...(id ? { sessionID: id } : {}),
|
||||
})
|
||||
}, FILE_SEARCH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
@@ -110,7 +116,8 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
textarea.setSelectionRange(pos, pos)
|
||||
textarea.focus()
|
||||
|
||||
if (result.type === "file") setMentionedPaths((prev) => new Set([...prev, result.value]))
|
||||
if (result.type === "file" || result.type === "folder")
|
||||
setMentionedPaths((prev) => new Set([...prev, result.value]))
|
||||
closeMention()
|
||||
onSelect?.()
|
||||
}
|
||||
|
||||
@@ -749,9 +749,15 @@ export interface ChatCompletionResultMessage {
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface FileSearchItem {
|
||||
path: string
|
||||
type: "file" | "folder"
|
||||
}
|
||||
|
||||
export interface FileSearchResultMessage {
|
||||
type: "fileSearchResult"
|
||||
paths: string[]
|
||||
items?: FileSearchItem[]
|
||||
dir: string
|
||||
requestId: string
|
||||
}
|
||||
@@ -1896,6 +1902,7 @@ export interface RequestFileSearchMessage {
|
||||
type: "requestFileSearch"
|
||||
query: string
|
||||
requestId: string
|
||||
sessionID?: string
|
||||
}
|
||||
|
||||
export interface RequestTerminalContextMessage {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Effect } from "effect"
|
||||
import { lstat } from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { AppFileSystem } from "../../filesystem"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { isBinaryFile, lines } from "../../tool/read"
|
||||
|
||||
const LIMIT = 2000
|
||||
const CONCURRENCY = 8
|
||||
|
||||
export type DirectoryFile = {
|
||||
filepath: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export const readDirectoryFiles = Effect.fn("KiloReadDirectory.files")(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
filepath: string,
|
||||
items: string[],
|
||||
) {
|
||||
const entries = yield* fs.readDirectoryEntries(filepath).pipe(Effect.catch(() => Effect.succeed([])))
|
||||
const types = new Map(entries.map((entry) => [entry.name, entry.type]))
|
||||
const files = yield* Effect.forEach(
|
||||
items.filter((item) => !item.endsWith("/") && types.get(item) === "file"),
|
||||
Effect.fnUntraced(function* (item) {
|
||||
const child = path.join(filepath, item)
|
||||
const info = yield* Effect.promise(() => lstat(child)).pipe(Effect.catch(() => Effect.void))
|
||||
if (!info?.isFile()) return
|
||||
const binary = yield* Effect.promise(() => isBinaryFile(child, info.size)).pipe(
|
||||
Effect.catch(() => Effect.succeed(true)),
|
||||
)
|
||||
if (binary) return
|
||||
const file = yield* Effect.promise(() => lines(child, { limit: LIMIT, offset: 1 })).pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
if (!file) return
|
||||
const rel = path.relative(Instance.directory, child).replaceAll("\\", "/")
|
||||
const note = file.cut || file.more ? "\n\n(File truncated)" : ""
|
||||
return {
|
||||
filepath: child,
|
||||
content: `<file_content path="${rel}">\n${file.raw.join("\n")}${note}\n</file_content>`,
|
||||
}
|
||||
}),
|
||||
{ concurrency: CONCURRENCY },
|
||||
)
|
||||
return files.filter((item): item is DirectoryFile => item !== undefined)
|
||||
})
|
||||
@@ -1142,7 +1142,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
if (part.mime === "application/x-directory") {
|
||||
const args = { filePath: filepath }
|
||||
const exit = yield* execRead(args).pipe(Effect.exit)
|
||||
const exit = yield* execRead(args, { includeDirectoryFiles: true }).pipe(Effect.exit) // kilocode_change inline folder files
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
log.error("failed to read directory", { error })
|
||||
|
||||
@@ -12,6 +12,9 @@ import DESCRIPTION from "./read.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { Instruction } from "../session/instruction"
|
||||
// kilocode_change start
|
||||
import { readDirectoryFiles } from "../kilocode/tool/read-directory"
|
||||
// kilocode_change end
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -124,6 +127,11 @@ export const ReadTool = Tool.defineEffect(
|
||||
const start = offset - 1
|
||||
const sliced = items.slice(start, start + limit)
|
||||
const truncated = start + sliced.length < items.length
|
||||
// kilocode_change start
|
||||
const expand = Boolean(ctx.extra?.["includeDirectoryFiles"])
|
||||
const loaded = expand ? yield* readDirectoryFiles(fs, filepath, sliced) : []
|
||||
const content = loaded.map((item) => item.content).join("\n\n")
|
||||
// kilocode_change end
|
||||
|
||||
return {
|
||||
title,
|
||||
@@ -136,11 +144,16 @@ export const ReadTool = Tool.defineEffect(
|
||||
? `\n(Showing ${sliced.length} of ${items.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
|
||||
: `\n(${items.length} entries)`,
|
||||
`</entries>`,
|
||||
// kilocode_change start
|
||||
...(content ? [`\n${content}`] : []),
|
||||
// kilocode_change end
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
preview: sliced.slice(0, 20).join("\n"),
|
||||
truncated,
|
||||
loaded: [] as string[],
|
||||
// kilocode_change start
|
||||
loaded: loaded.map((item) => item.filepath),
|
||||
// kilocode_change end
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -225,7 +238,9 @@ export const ReadTool = Tool.defineEffect(
|
||||
}),
|
||||
)
|
||||
|
||||
async function lines(filepath: string, opts: { limit: number; offset: number }) {
|
||||
// kilocode_change start
|
||||
export async function lines(filepath: string, opts: { limit: number; offset: number }) {
|
||||
// kilocode_change end
|
||||
const stream = createReadStream(filepath, { encoding: "utf8" })
|
||||
const rl = createInterface({
|
||||
input: stream,
|
||||
@@ -269,7 +284,9 @@ async function lines(filepath: string, opts: { limit: number; offset: number })
|
||||
return { raw, count, cut, more, offset: opts.offset }
|
||||
}
|
||||
|
||||
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
|
||||
// kilocode_change start
|
||||
export async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
|
||||
// kilocode_change end
|
||||
const ext = path.extname(filepath).toLowerCase()
|
||||
// binary check for common non-text extensions
|
||||
switch (ext) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { symlink } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { ReadTool } from "../../src/tool/read"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const baseCtx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
callID: "",
|
||||
agent: "code",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const expandCtx = { ...baseCtx, extra: { includeDirectoryFiles: true } }
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
FileTime.defaultLayer,
|
||||
Instruction.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const init = Effect.fn("ReadDirectoryTest.init")(function* () {
|
||||
const info = yield* ReadTool
|
||||
return yield* Effect.promise(() => info.init())
|
||||
})
|
||||
|
||||
const run = Effect.fn("ReadDirectoryTest.run")(function* (
|
||||
args: Tool.InferParameters<typeof ReadTool>,
|
||||
ctx = expandCtx,
|
||||
) {
|
||||
const tool = yield* init()
|
||||
return yield* Effect.promise(() => tool.execute(args, ctx))
|
||||
})
|
||||
|
||||
const exec = Effect.fn("ReadDirectoryTest.exec")(function* (
|
||||
dir: string,
|
||||
args: Tool.InferParameters<typeof ReadTool>,
|
||||
ctx = expandCtx,
|
||||
) {
|
||||
return yield* provideInstance(dir)(run(args, ctx))
|
||||
})
|
||||
|
||||
const put = Effect.fn("ReadDirectoryTest.put")(function* (p: string, content: string | Uint8Array) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
yield* fs.writeWithDirs(p, content)
|
||||
})
|
||||
|
||||
describe("kilocode directory reads", () => {
|
||||
it.live("includes top-level file contents for directory reads", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* put(path.join(dir, "folder", "a.txt"), "alpha")
|
||||
yield* put(path.join(dir, "folder", "nested", "b.txt"), "beta")
|
||||
yield* put(path.join(dir, "folder", "binary.bin"), new Uint8Array([0, 1, 2]))
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "folder") })
|
||||
|
||||
expect(result.output).toContain("a.txt")
|
||||
expect(result.output).toContain('<file_content path="folder/a.txt">\nalpha\n</file_content>')
|
||||
expect(result.output).not.toContain('<file_content path="folder/nested/b.txt">')
|
||||
expect(result.output).not.toContain('<file_content path="folder/binary.bin">')
|
||||
expect(result.metadata.loaded).toContain(path.join(dir, "folder", "a.txt"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("skips content inlining without the kilo flag", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* put(path.join(dir, "folder", "a.txt"), "alpha")
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "folder") }, baseCtx)
|
||||
|
||||
expect(result.output).toContain("a.txt")
|
||||
expect(result.output).not.toContain('<file_content path="folder/a.txt">')
|
||||
expect(result.metadata.loaded).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
it.live("skips symlinked top-level files", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const outer = yield* tmpdirScoped()
|
||||
yield* put(path.join(dir, "folder", "a.txt"), "alpha")
|
||||
yield* put(path.join(outer, "secret.txt"), "secret")
|
||||
yield* Effect.promise(() => symlink(path.join(outer, "secret.txt"), path.join(dir, "folder", "secret.txt")))
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "folder") })
|
||||
|
||||
expect(result.output).toContain("secret.txt")
|
||||
expect(result.output).not.toContain('<file_content path="folder/secret.txt">')
|
||||
expect(result.output).not.toContain("secret\n</file_content>")
|
||||
expect(result.metadata.loaded).not.toContain(path.join(dir, "folder", "secret.txt"))
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user