Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/session-mentions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": minor
"@kilocode/cli": minor
---

Reference past chats inline with `@` in the prompt. Typing `@` now surfaces a "Past chats" option that opens a searchable picker of previous sessions (scoped to the current workspace/worktree, searched like the Agent Manager session search); selecting one attaches that session's transcript as context so the model can build on a prior conversation. Clicking the mention opens that session. Available in the CLI TUI and the VS Code extension.
20 changes: 18 additions & 2 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -950,9 +950,23 @@ function HighlightedText(props: { text: string; references: FilePart[]; agents:

const data = useData()

const session = (segment: HighlightSegment) => {
const ref = props.references.find((ref) => ref.source?.text?.value === segment.text)
const url = (ref as { url?: unknown } | undefined)?.url
if (typeof url !== "string" || !url.startsWith("session:")) return
return url.slice("session:".length)
}

const click = (segment: HighlightSegment, e: MouseEvent) => {
if (segment.type !== "file" || !data.openFile) return
if (segment.type !== "file") return
Comment thread
marius-kilocode marked this conversation as resolved.
e.preventDefault()
// Past-chat mentions carry a session: URL — open that session instead of a file.
const id = session(segment)
if (id) {
data.navigateToSession?.(id)
return
}
if (!data.openFile) return
const path = segment.text.replace(/^@/, "")
if (path) data.openFile(path)
}
Expand All @@ -962,7 +976,9 @@ function HighlightedText(props: { text: string; references: FilePart[]; agents:
{(segment) => (
<span
data-highlight={segment.type}
data-clickable={segment.type === "file" && data.openFile ? "" : undefined}
data-clickable={
segment.type === "file" && (session(segment) ? data.navigateToSession : data.openFile) ? "" : undefined
}
onClick={[click, segment]}
>
{segment.text}
Expand Down
56 changes: 43 additions & 13 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree"
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
import { renameSession } from "./kilo-provider/rename-session"
import { handleFileSearch } from "./kilo-provider/file-search"
import { handleSessionSearch } from "./kilo-provider/session-search"
import { handleFilePicker } from "./kilo-provider/file-picker"
import { watchFontSizeConfig } from "./kilo-provider/font-size"
import { getTerminalContents } from "./services/terminal/context"
Expand Down Expand Up @@ -289,6 +290,12 @@ export function unwrapSyncEvent(event: SSEPayload | RawSyncPayload): ProviderEve
}
}

type ContextRequestMessage =
| { type: "requestFileSearch"; query: string; requestId: string; sessionID?: string }
| { type: "requestSessionSearch"; requestId: string; sessionID?: string }
| { type: "requestFilePicker"; requestId: string }
| { type: "requestTerminalContext"; requestId: string; sessionID?: string }

export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider {
public static readonly viewType = "kilo-code.SidebarProvider"
private readonly instanceId = crypto.randomUUID()
Expand Down Expand Up @@ -1298,21 +1305,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
}
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 "requestSessionSearch":
case "requestFilePicker":
await handleFilePicker({ requestId: message.requestId, post: (msg) => this.postMessage(msg) })
break
case "requestTerminalContext":
void this.handleTerminalContext(message.requestId)
await this.handleContextRequest(message)
break
case "chatCompletionAccepted":
this.chatAutocomplete?.telemetry.captureAcceptSuggestion(message.suggestionLength)
Expand Down Expand Up @@ -2041,6 +2037,40 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.pendingSessionRefresh = ctx.pendingSessionRefresh
}

private async handleContextRequest(message: ContextRequestMessage): Promise<void> {
if (message.type === "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),
})
return
}
if (message.type === "requestSessionSearch") {
await handleSessionSearch({
client: this.client,
message,
current: this.currentSession?.id,
context: this.contextSessionID,
dir: (id) => this.getWorkspaceDirectory(id),
exclude: this.currentSession?.id,
post: (msg) => this.postMessage(msg),
})
return
}
if (message.type === "requestFilePicker") {
await handleFilePicker({ requestId: message.requestId, post: (msg) => this.postMessage(msg) })
return
}
if (message.type === "requestTerminalContext") {
void this.handleTerminalContext(message.requestId)
}
}

private async handleTerminalContext(requestId: string): Promise<void> {
try {
const output = await getTerminalContents(-1)
Expand Down
3 changes: 2 additions & 1 deletion packages/kilo-vscode/src/kilo-provider/message-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const source = z.object({

const file = z.object({
mime: z.string(),
url: z.string().refine((url) => url.startsWith("file://") || url.startsWith("data:")),
// session: URLs reference a past chat; the backend resolves them into transcript context
url: z.string().refine((url) => url.startsWith("file://") || url.startsWith("data:") || url.startsWith("session:")),
filename: z.string().optional(),
source: source.optional(),
})
Expand Down
51 changes: 51 additions & 0 deletions packages/kilo-vscode/src/kilo-provider/session-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { KiloClient } from "@kilocode/sdk/v2/client"

type Item = {
id: string
title: string
updated: number
}

type Message = {
requestId: string
sessionID?: string
}

type Input = {
client: KiloClient | null
message: Message
current?: string
context?: string
dir: (id?: string) => string
exclude?: string
post: (message: unknown) => void
}

/**
* Past-chat mention search. Lists root sessions for the directory the current
* chat runs in (workspace root for the sidebar, the worktree for Agent Manager
* sessions) — the same directory-scoped `session.list` the session history and
* Agent Manager search are built on. Fuzzy title filtering happens in the
* webview (same mechanism as the Agent Manager sidebar search).
*/
export async function handleSessionSearch(input: Input): Promise<void> {
const client = input.client
if (!client) {
input.post({ type: "sessionSearchResult", sessions: [], requestId: input.message.requestId })
return
}

const id = input.message.sessionID ?? input.current ?? input.context
const dir = input.dir(id)

try {
const res = await client.session.list({ directory: dir, roots: true, limit: 50 }, { throwOnError: true })
Comment thread
marius-kilocode marked this conversation as resolved.
const sessions: Item[] = res.data
.filter((session) => session.id !== input.exclude && session.title)
.map((session) => ({ id: session.id, title: session.title, updated: session.time.updated }))
input.post({ type: "sessionSearchResult", sessions, requestId: input.message.requestId })
} catch (err) {
console.error("[Kilo New] Session search failed:", err)
input.post({ type: "sessionSearchResult", sessions: [], requestId: input.message.requestId })
}
}
116 changes: 116 additions & 0 deletions packages/kilo-vscode/tests/unit/file-mention-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ import {
buildTextAfterMentionSelect,
buildFileAttachments,
buildMentionResults,
buildSessionAttachments,
filterMentionResults,
getMentionRemovalRange,
getPastChatsMentionResult,
isCursorAtMentionEnd,
findMentionRange,
sessionMentionFilename,
sessionMentionText,
sessionMentionToken,
syncMentionedSessions,
FILE_PICKER_RESULT,
PAST_CHATS_RESULT,
TERMINAL_RESULT,
GIT_CHANGES_RESULT,
} from "../../webview-ui/src/hooks/file-mention-utils"
Expand Down Expand Up @@ -89,6 +96,7 @@ describe("buildMentionResults", () => {
expect(result).toEqual([
TERMINAL_RESULT,
GIT_CHANGES_RESULT,
PAST_CHATS_RESULT,
{ type: "file", value: "src/index.ts" },
FILE_PICKER_RESULT,
])
Expand Down Expand Up @@ -552,3 +560,111 @@ describe("findMentionRange", () => {
expect(findMentionRange(text, 4, paths)).toEqual({ start: 3, end: 6 })
})
})

describe("session mentions", () => {
const now = Date.now()
const sessions = [
{ id: "ses_a", title: "Fix auth bug", updated: now },
{ id: "ses_b", title: "Rotate signing keys", updated: now - 1000 },
{ id: "ses_c", title: "Refactor cache layer", updated: now - 2000 },
]

describe("getPastChatsMentionResult", () => {
it("offers the past-chats picker for an empty query", () => {
expect(getPastChatsMentionResult("")).toEqual([PAST_CHATS_RESULT])
})

it("offers the picker for alias prefixes", () => {
expect(getPastChatsMentionResult("pas")).toEqual([PAST_CHATS_RESULT])
expect(getPastChatsMentionResult("sess")).toEqual([PAST_CHATS_RESULT])
expect(getPastChatsMentionResult("hist")).toEqual([PAST_CHATS_RESULT])
})

it("hides the picker for unrelated queries", () => {
expect(getPastChatsMentionResult("index")).toEqual([])
})
})

describe("sessionMentionText / filename", () => {
it("collapses whitespace in titles", () => {
expect(sessionMentionText("Fix\nauth bug")).toBe("Fix auth bug")
})

it("slugifies titles for the attachment filename", () => {
expect(sessionMentionFilename("Fix auth bug", "ses_a")).toBe("Fix-auth-bug.md")
})

it("falls back to the session id when the slug is empty", () => {
expect(sessionMentionFilename("???", "ses_a")).toBe("ses_a.md")
})

it("disambiguates sessions with the same title", () => {
const known = new Map([["Fix auth bug", sessions[0]!]])
expect(sessionMentionToken({ ...sessions[1]!, title: "Fix auth bug" }, known)).toBe("Fix auth bug (2)")
})

it("reuses the token already assigned to a session", () => {
const known = new Map([["Fix auth bug (2)", sessions[1]!]])
expect(sessionMentionToken(sessions[1]!, known)).toBe("Fix auth bug (2)")
})
})

describe("buildMentionResults", () => {
it("offers the past-chats picker alongside the other special mentions", () => {
const result = buildMentionResults("", [])
expect(result[0]).toEqual(TERMINAL_RESULT)
expect(result).toContainEqual(PAST_CHATS_RESULT)
expect(result[result.length - 1]).toEqual(FILE_PICKER_RESULT)
})
})

describe("filterMentionResults", () => {
it("keeps the past-chats picker for alias queries", () => {
const filtered = filterMentionResults("sess", buildMentionResults("", []))
expect(filtered).toContainEqual(PAST_CHATS_RESULT)
})
})

describe("syncMentionedSessions", () => {
it("drops sessions whose token is no longer present in the text", () => {
const prev = new Map([
["Fix auth bug", sessions[0]!],
["Rotate signing keys", sessions[1]!],
])
const kept = syncMentionedSessions(prev, "see @Fix auth bug here")
expect(kept.has("Fix auth bug")).toBe(true)
expect(kept.has("Rotate signing keys")).toBe(false)
})
})

describe("buildSessionAttachments", () => {
it("builds a session: attachment with span offsets and a readable filename", () => {
const mentioned = new Map([["Fix auth bug", sessions[0]!]])
const attachments = buildSessionAttachments("check @Fix auth bug out", mentioned)
expect(attachments).toHaveLength(1)
const att = attachments[0]!
expect(att.mime).toBe("text/plain")
expect(att.url).toBe("session:ses_a")
expect(att.filename).toBe("Fix-auth-bug.md")
expect(att.source?.type).toBe("file")
expect(att.source?.text.value).toBe("@Fix auth bug")
expect(att.source?.text.start).toBe(6)
expect(att.source?.text.end).toBe(19)
})

it("skips sessions whose token is not present in the text", () => {
const mentioned = new Map([["Fix auth bug", sessions[0]!]])
expect(buildSessionAttachments("nothing here", mentioned)).toEqual([])
})

it("attaches distinct sessions whose titles collide", () => {
const mentioned = new Map([
["Fix auth bug", sessions[0]!],
["Fix auth bug (2)", { ...sessions[1]!, title: "Fix auth bug" }],
])
const attachments = buildSessionAttachments("compare @Fix auth bug with @Fix auth bug (2)", mentioned)
expect(attachments.map((item) => item.url)).toEqual(["session:ses_a", "session:ses_b"])
expect(attachments.map((item) => item.source?.text.value)).toEqual(["@Fix auth bug", "@Fix auth bug (2)"])
})
})
})
18 changes: 18 additions & 0 deletions packages/kilo-vscode/tests/unit/message-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,22 @@ describe("parseMessageFiles", () => {
it("rejects unsupported URLs", () => {
expect(parseMessageFiles([{ mime: "text/plain", url: "https://example.com/file.txt" }])).toBeUndefined()
})

it("accepts past-chat session attachments", () => {
const files = parseMessageFiles([
{
mime: "text/plain",
url: "session:ses_07c08a2ddffeXample",
filename: "fix-auth-bug.md",
source: {
type: "file",
path: "session:ses_07c08a2ddffeXample",
text: { value: "@Fix auth bug", start: 0, end: 13 },
},
},
])

expect(files?.[0]?.url).toBe("session:ses_07c08a2ddffeXample")
expect(files?.[0]?.filename).toBe("fix-auth-bug.md")
})
})
1 change: 1 addition & 0 deletions packages/kilo-vscode/webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export const DataBridge: Component<{ children: any }> = (props) => {
onOpenUrl={openUrl}
onOpenContent={openContent}
onValidateFiles={validateFiles}
onNavigateToSession={(id) => session.selectSession(id)}
>
{props.children}
</DataProvider>
Expand Down
Loading
Loading