mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
revert: remove suggest tool feature (#8994)
Reverts PR #6404 (feat: suggest code review tool) and PR #8988 (fix: dismiss suggestions on new message).
This commit is contained in:
@@ -85,7 +85,6 @@ import {
|
||||
handleQuestionReject,
|
||||
fetchAndSendPendingQuestions,
|
||||
} from "./kilo-provider/handlers/question"
|
||||
import { fetchAndSendPendingSuggestions, routeSuggestionWebviewMessage } from "./kilo-provider/handlers/suggestion"
|
||||
|
||||
import {
|
||||
buildActionContext,
|
||||
@@ -507,7 +506,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
await Promise.all([
|
||||
fetchAndSendPendingPermissions(this.permissionCtx),
|
||||
fetchAndSendPendingQuestions(this.questionCtx),
|
||||
fetchAndSendPendingSuggestions(this.questionCtx),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -561,7 +559,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
await routeSuggestionWebviewMessage(this.questionCtx, message)
|
||||
switch (message.type) {
|
||||
case "webviewReady":
|
||||
console.log("[Kilo New] KiloProvider: ✅ webviewReady received")
|
||||
|
||||
@@ -307,24 +307,8 @@ export type WebviewMessage =
|
||||
}
|
||||
}
|
||||
| { type: "todoUpdated"; sessionID: string; items: unknown[] }
|
||||
| {
|
||||
type: "questionRequest"
|
||||
question: { id: string; sessionID: string; questions: unknown[]; blocking?: boolean; tool?: unknown }
|
||||
}
|
||||
| { type: "questionRequest"; question: { id: string; sessionID: string; questions: unknown[]; tool?: unknown } }
|
||||
| { type: "questionResolved"; requestID: string }
|
||||
| {
|
||||
type: "suggestionRequest"
|
||||
suggestion: {
|
||||
id: string
|
||||
sessionID: string
|
||||
text: string
|
||||
actions: unknown[]
|
||||
blocking?: boolean
|
||||
tool?: unknown
|
||||
}
|
||||
}
|
||||
| { type: "suggestionResolved"; requestID: string }
|
||||
| { type: "suggestionError"; requestID: string }
|
||||
| { type: "permissionResolved"; permissionID: string }
|
||||
| { type: "permissionError"; permissionID: string }
|
||||
| { type: "sessionCreated"; session: ReturnType<typeof sessionToWebview>; draftID?: string }
|
||||
@@ -428,7 +412,6 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un
|
||||
id: event.properties.id,
|
||||
sessionID: event.properties.sessionID,
|
||||
questions: event.properties.questions,
|
||||
blocking: event.properties.blocking,
|
||||
tool: event.properties.tool,
|
||||
},
|
||||
}
|
||||
@@ -438,24 +421,6 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un
|
||||
type: "questionResolved",
|
||||
requestID: event.properties.requestID,
|
||||
}
|
||||
case "suggestion.shown":
|
||||
return {
|
||||
type: "suggestionRequest",
|
||||
suggestion: {
|
||||
id: event.properties.id,
|
||||
sessionID: event.properties.sessionID,
|
||||
text: event.properties.text,
|
||||
actions: event.properties.actions,
|
||||
blocking: event.properties.blocking,
|
||||
tool: event.properties.tool,
|
||||
},
|
||||
}
|
||||
case "suggestion.accepted":
|
||||
case "suggestion.dismissed":
|
||||
return {
|
||||
type: "suggestionResolved",
|
||||
requestID: event.properties.requestID,
|
||||
}
|
||||
case "session.error": {
|
||||
return {
|
||||
type: "sessionError",
|
||||
|
||||
@@ -41,7 +41,6 @@ export async function fetchAndSendPendingQuestions(ctx: QuestionContext): Promis
|
||||
id: q.id,
|
||||
sessionID: q.sessionID,
|
||||
questions: q.questions,
|
||||
blocking: q.blocking,
|
||||
tool: q.tool,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Suggestion handlers — extracted from KiloProvider.
|
||||
*
|
||||
* Manages suggestion accept and dismiss flows plus recovery after SSE reconnects.
|
||||
* No vscode dependency.
|
||||
*/
|
||||
|
||||
import type { KiloClient, SuggestionRequest } from "@kilocode/sdk/v2/client"
|
||||
import { recoveryDirs } from "./permission-handler"
|
||||
|
||||
export type RecoverableSuggestion = SuggestionRequest
|
||||
|
||||
export interface SuggestionContext {
|
||||
readonly client: KiloClient | null
|
||||
readonly currentSessionId: string | undefined
|
||||
readonly trackedSessionIds: Set<string>
|
||||
readonly sessionDirectories: ReadonlyMap<string, string>
|
||||
postMessage(msg: unknown): void
|
||||
getWorkspaceDirectory(sessionId?: string): string
|
||||
}
|
||||
|
||||
export function recoverableSuggestions(items: RecoverableSuggestion[], tracked: Set<string>, seen: Set<string>) {
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.id)) return false
|
||||
seen.add(item.id)
|
||||
return tracked.has(item.sessionID)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Route suggestion-related webview messages.
|
||||
* Extracted from the main message handler to stay within the complexity limit.
|
||||
*/
|
||||
export async function routeSuggestionWebviewMessage(
|
||||
ctx: SuggestionContext,
|
||||
message: { type: string; requestID?: string; sessionID?: string; index?: number },
|
||||
): Promise<void> {
|
||||
switch (message.type) {
|
||||
case "suggestionAccept":
|
||||
await handleSuggestionAccept(ctx, message.requestID!, message.index!, message.sessionID)
|
||||
break
|
||||
case "suggestionDismiss":
|
||||
await handleSuggestionDismiss(ctx, message.requestID!, message.sessionID)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSuggestionAccept(
|
||||
ctx: SuggestionContext,
|
||||
requestID: string,
|
||||
index: number,
|
||||
sessionID?: string,
|
||||
): Promise<void> {
|
||||
if (!ctx.client) {
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.client.suggestion.accept(
|
||||
{ requestID, index, directory: ctx.getWorkspaceDirectory(sessionID ?? ctx.currentSessionId) },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to accept suggestion:", error)
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSuggestionDismiss(
|
||||
ctx: SuggestionContext,
|
||||
requestID: string,
|
||||
sessionID?: string,
|
||||
): Promise<void> {
|
||||
if (!ctx.client) {
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.client.suggestion.dismiss(
|
||||
{ requestID, directory: ctx.getWorkspaceDirectory(sessionID ?? ctx.currentSessionId) },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to dismiss suggestion:", error)
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAndSendPendingSuggestions(ctx: SuggestionContext): Promise<void> {
|
||||
if (!ctx.client) return
|
||||
try {
|
||||
const dirs = recoveryDirs(ctx.getWorkspaceDirectory(), ctx.sessionDirectories)
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const dir of dirs) {
|
||||
const { data } = await ctx.client.suggestion.list({ directory: dir })
|
||||
if (!data) continue
|
||||
for (const suggestion of recoverableSuggestions(data, ctx.trackedSessionIds, seen)) {
|
||||
ctx.postMessage({
|
||||
type: "suggestionRequest",
|
||||
suggestion,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to fetch pending suggestions:", error)
|
||||
}
|
||||
}
|
||||
@@ -405,7 +405,6 @@ export class KiloConnectionService {
|
||||
if (error) throw new Error(`Failed to reject question ${q.id}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
await drainSuggestions(this.client, dir)
|
||||
await drainNetworkWaits(this.client, dir)
|
||||
}
|
||||
for (const listener of this.clearPendingPromptsListeners) {
|
||||
@@ -634,14 +633,3 @@ export class KiloConnectionService {
|
||||
this.startHealthPoll(config.baseUrl, config.password)
|
||||
}
|
||||
}
|
||||
|
||||
async function drainSuggestions(client: KiloClient, directory: string): Promise<void> {
|
||||
const { data, error: err } = await client.suggestion.list({ directory })
|
||||
if (err) throw new Error(`Failed to list suggestions for ${directory}: ${String(err)}`)
|
||||
if (data) {
|
||||
for (const s of data) {
|
||||
const { error } = await client.suggestion.dismiss({ requestID: s.id, directory })
|
||||
if (error) throw new Error(`Failed to dismiss suggestion ${s.id}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,17 +41,6 @@ export function resolveEventSessionId(
|
||||
case "question.replied":
|
||||
case "question.rejected":
|
||||
return event.properties.sessionID
|
||||
default:
|
||||
return resolveSuggestionSessionId(event)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSuggestionSessionId(event: Event): string | undefined {
|
||||
switch (event.type) {
|
||||
case "suggestion.shown":
|
||||
case "suggestion.accepted":
|
||||
case "suggestion.dismissed":
|
||||
return event.properties.sessionID
|
||||
default:
|
||||
// session.network.* events are not yet in the SDK Event type union
|
||||
// (pending SDK regeneration). Handle them via string comparison.
|
||||
|
||||
@@ -149,30 +149,6 @@ describe("resolveEventSessionId", () => {
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s11")
|
||||
})
|
||||
|
||||
it("returns sessionID from suggestion.shown", () => {
|
||||
const e = event({
|
||||
type: "suggestion.shown",
|
||||
properties: { id: "sug_1", sessionID: "s12", text: "Review?", actions: [] },
|
||||
})
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s12")
|
||||
})
|
||||
|
||||
it("returns sessionID from suggestion.accepted", () => {
|
||||
const e = event({
|
||||
type: "suggestion.accepted",
|
||||
properties: { sessionID: "s13", requestID: "sug_1", index: 0, action: { label: "Start", prompt: "x" } },
|
||||
})
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s13")
|
||||
})
|
||||
|
||||
it("returns sessionID from suggestion.dismissed", () => {
|
||||
const e = event({
|
||||
type: "suggestion.dismissed",
|
||||
properties: { sessionID: "s14", requestID: "sug_2" },
|
||||
})
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s14")
|
||||
})
|
||||
|
||||
it("returns undefined for unknown event types (global events)", () => {
|
||||
const e = event({ type: "server.connected", properties: {} })
|
||||
expect(resolveEventSessionId(e, noLookup)).toBeUndefined()
|
||||
|
||||
@@ -26,9 +26,6 @@ import type {
|
||||
EventQuestionAsked,
|
||||
EventQuestionReplied,
|
||||
EventQuestionRejected,
|
||||
EventSuggestionShown,
|
||||
EventSuggestionAccepted,
|
||||
EventSuggestionDismissed,
|
||||
EventSessionCreated,
|
||||
EventSessionUpdated,
|
||||
EventServerConnected,
|
||||
@@ -447,43 +444,6 @@ describe("mapSSEEventToWebviewMessage", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("maps suggestion.shown to suggestionRequest", () => {
|
||||
const event: EventSuggestionShown = {
|
||||
type: "suggestion.shown",
|
||||
properties: {
|
||||
id: "sug-1",
|
||||
sessionID: "sess-1",
|
||||
text: "Review changes?",
|
||||
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
|
||||
},
|
||||
}
|
||||
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
|
||||
expect(msg?.type).toBe("suggestionRequest")
|
||||
})
|
||||
|
||||
it("maps suggestion.accepted to suggestionResolved", () => {
|
||||
const event: EventSuggestionAccepted = {
|
||||
type: "suggestion.accepted",
|
||||
properties: {
|
||||
sessionID: "sess-1",
|
||||
requestID: "sug-1",
|
||||
index: 0,
|
||||
action: { label: "Start", prompt: "/local-review-uncommitted" },
|
||||
},
|
||||
}
|
||||
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
|
||||
expect(msg?.type).toBe("suggestionResolved")
|
||||
})
|
||||
|
||||
it("maps suggestion.dismissed to suggestionResolved", () => {
|
||||
const event: EventSuggestionDismissed = {
|
||||
type: "suggestion.dismissed",
|
||||
properties: { sessionID: "sess-1", requestID: "sug-2" },
|
||||
}
|
||||
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
|
||||
expect(msg?.type).toBe("suggestionResolved")
|
||||
})
|
||||
|
||||
it("maps session.created to sessionCreated with ISO dates", () => {
|
||||
const event: EventSessionCreated = {
|
||||
type: "session.created",
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
fetchAndSendPendingSuggestions,
|
||||
recoverableSuggestions,
|
||||
type RecoverableSuggestion,
|
||||
type SuggestionContext,
|
||||
} from "../../src/kilo-provider/handlers/suggestion"
|
||||
|
||||
function pending(id: string, sessionID: string): RecoverableSuggestion {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
text: "Review changes?",
|
||||
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
|
||||
}
|
||||
}
|
||||
|
||||
type Items = Record<string, RecoverableSuggestion[]>
|
||||
|
||||
function suggestionClient(itemsPerDir: Items, queries: string[]) {
|
||||
return {
|
||||
suggestion: {
|
||||
list: async (args?: { directory?: string }) => {
|
||||
const dir = args?.directory ?? ""
|
||||
queries.push(dir)
|
||||
return { data: itemsPerDir[dir] ?? [] }
|
||||
},
|
||||
accept: async () => ({ data: true }),
|
||||
dismiss: async () => ({ data: true }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ctx(opts: { tracked: string[]; dirs?: Map<string, string>; itemsPerDir?: Items }) {
|
||||
const messages: unknown[] = []
|
||||
const queries: string[] = []
|
||||
const sdk = suggestionClient(opts.itemsPerDir ?? {}, queries) as unknown as SuggestionContext["client"]
|
||||
|
||||
const fake: SuggestionContext = {
|
||||
client: sdk,
|
||||
currentSessionId: undefined,
|
||||
trackedSessionIds: new Set(opts.tracked),
|
||||
sessionDirectories: opts.dirs ?? new Map(),
|
||||
postMessage: (msg) => messages.push(msg),
|
||||
getWorkspaceDirectory: () => "/workspace",
|
||||
}
|
||||
|
||||
return { fake, messages, queries }
|
||||
}
|
||||
|
||||
describe("recoverableSuggestions", () => {
|
||||
it("filters out untracked suggestions and deduplicates by id", () => {
|
||||
const seen = new Set<string>()
|
||||
const list = [pending("s1", "tracked"), pending("s1", "tracked"), pending("s2", "other")]
|
||||
expect(recoverableSuggestions(list, new Set(["tracked"]), seen)).toEqual([pending("s1", "tracked")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchAndSendPendingSuggestions", () => {
|
||||
it("forwards suggestions from tracked sessions", async () => {
|
||||
const dirs = new Map([["s1", "/wt"]])
|
||||
const { fake, messages, queries } = ctx({
|
||||
tracked: ["s1"],
|
||||
dirs,
|
||||
itemsPerDir: { "/wt": [pending("sug-1", "s1")] },
|
||||
})
|
||||
|
||||
await fetchAndSendPendingSuggestions(fake)
|
||||
|
||||
expect(queries).toContain("/workspace")
|
||||
expect(queries).toContain("/wt")
|
||||
expect(messages).toEqual([{ type: "suggestionRequest", suggestion: pending("sug-1", "s1") }])
|
||||
})
|
||||
|
||||
it("does nothing when client is null", async () => {
|
||||
const messages: unknown[] = []
|
||||
const fake: SuggestionContext = {
|
||||
client: null,
|
||||
currentSessionId: undefined,
|
||||
trackedSessionIds: new Set(["s1"]),
|
||||
sessionDirectories: new Map(),
|
||||
postMessage: (msg) => messages.push(msg),
|
||||
getWorkspaceDirectory: () => "/workspace",
|
||||
}
|
||||
|
||||
await fetchAndSendPendingSuggestions(fake)
|
||||
expect(messages).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eee5f5d61cf2ac9ad0b987a8b220d76c503be0d11447766e6669da4684698c82
|
||||
size 6130
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
import { useData } from "@kilocode/kilo-ui/context/data"
|
||||
import { useSession } from "../../context/session"
|
||||
import { QuestionDock } from "./QuestionDock"
|
||||
import { SuggestBar } from "./SuggestBar"
|
||||
|
||||
// Tools that the upstream message-part renderer suppresses (returns null for).
|
||||
// We render these ourselves via ToolRegistry when they complete,
|
||||
@@ -42,22 +41,6 @@ function isRenderable(part: SDKPart): boolean {
|
||||
return !!PART_MAPPING[part.type]
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a tool part to an active request (question or suggestion) by tool name
|
||||
* and callID/messageID. Returns the matched request or undefined.
|
||||
*/
|
||||
function matchToolRequest<T extends { tool?: { callID: string; messageID: string } }>(
|
||||
part: SDKPart,
|
||||
name: string,
|
||||
requests: T[],
|
||||
): T | undefined {
|
||||
if (part.type !== "tool") return undefined
|
||||
const tp = part as unknown as ToolPart
|
||||
if (tp.tool !== name) return undefined
|
||||
if (tp.state?.status !== "pending" && tp.state?.status !== "running") return undefined
|
||||
return requests.find((r) => r.tool?.callID === tp.callID && r.tool?.messageID === tp.messageID)
|
||||
}
|
||||
|
||||
interface AssistantMessageProps {
|
||||
message: SDKAssistantMessage
|
||||
showAssistantCopyPartID?: string | null
|
||||
@@ -104,40 +87,36 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
part.type === "tool" && UPSTREAM_SUPPRESSED_TOOLS.has((part as SDKPart & { tool: string }).tool)
|
||||
|
||||
// Active question tool parts render the interactive QuestionDock inline
|
||||
const activeQuestion = createMemo(() => matchToolRequest(part, "question", session.questions()))
|
||||
|
||||
// Active suggestion tool parts render the interactive SuggestBar inline
|
||||
const activeSuggestion = createMemo(() => matchToolRequest(part, "suggest", session.suggestions()))
|
||||
const activeQuestion = createMemo(() => {
|
||||
if (part.type !== "tool") return undefined
|
||||
const tp = part as unknown as ToolPart
|
||||
if (tp.tool !== "question") return undefined
|
||||
if (tp.state?.status !== "pending" && tp.state?.status !== "running") return undefined
|
||||
return session.questions().find((q) => q.tool?.callID === tp.callID && q.tool?.messageID === tp.messageID)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isUpstreamSuppressed || activeQuestion() || activeSuggestion() || PART_MAPPING[part.type]}>
|
||||
<Show when={isUpstreamSuppressed || activeQuestion() || PART_MAPPING[part.type]}>
|
||||
<div data-component="tool-part-wrapper" data-part-type={part.type}>
|
||||
<Show
|
||||
when={activeQuestion()}
|
||||
fallback={
|
||||
<Show
|
||||
when={activeSuggestion()}
|
||||
when={isUpstreamSuppressed}
|
||||
fallback={
|
||||
<Show
|
||||
when={isUpstreamSuppressed}
|
||||
fallback={
|
||||
<Part
|
||||
part={part}
|
||||
message={props.message as SDKMessage}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
animate={
|
||||
part.type === "tool" &&
|
||||
((part as unknown as ToolPart).state?.status === "pending" ||
|
||||
(part as unknown as ToolPart).state?.status === "running")
|
||||
}
|
||||
/>
|
||||
<Part
|
||||
part={part}
|
||||
message={props.message as SDKMessage}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
animate={
|
||||
part.type === "tool" &&
|
||||
((part as unknown as ToolPart).state?.status === "pending" ||
|
||||
(part as unknown as ToolPart).state?.status === "running")
|
||||
}
|
||||
>
|
||||
<TodoToolCard part={part as unknown as ToolPart} />
|
||||
</Show>
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(req) => <SuggestBar request={req()} />}
|
||||
<TodoToolCard part={part as unknown as ToolPart} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -55,16 +55,13 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
// not once per accessor call (questionRequest, permissionRequest, blocked all read these).
|
||||
const familyPermissions = createMemo(() => session.scopedPermissions(id()))
|
||||
const familyQuestions = createMemo(() => session.scopedQuestions(id()))
|
||||
const familySuggestions = createMemo(() => session.scopedSuggestions(id()))
|
||||
|
||||
// Non-tool questions (standalone, not from the question tool) render inline in
|
||||
// the message list since they don't have an associated tool part in the conversation.
|
||||
// Tool-linked questions render inline at their tool part position via AssistantMessage.
|
||||
const standaloneQuestions = createMemo(() => familyQuestions().filter((q) => !q.tool))
|
||||
const standaloneSuggestions = createMemo(() => familySuggestions().filter((s) => !s.tool))
|
||||
const permissionRequest = () => familyPermissions().find((p) => p.sessionID === id()) ?? familyPermissions()[0]
|
||||
const blocked = () => familyPermissions().length > 0 || familyQuestions().some((q) => q.blocking !== false)
|
||||
// Session is busy only because a suggestion tool call is pending — prompt should behave as idle
|
||||
const suggesting = () => !blocked() && familySuggestions().length > 0
|
||||
const blocked = () => familyPermissions().length > 0 || familyQuestions().length > 0
|
||||
const dock = () => !props.readonly || !!permissionRequest()
|
||||
|
||||
// When a bottom-dock permission disappears while the session is busy,
|
||||
@@ -133,7 +130,6 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
onSelectSession={props.onSelectSession}
|
||||
onShowHistory={props.onShowHistory}
|
||||
questions={standaloneQuestions}
|
||||
suggestions={standaloneSuggestions}
|
||||
readonly={props.readonly}
|
||||
/>
|
||||
</div>
|
||||
@@ -219,12 +215,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.readonly}>
|
||||
<PromptInput
|
||||
blocked={blocked}
|
||||
suggesting={suggesting}
|
||||
boxId={props.promptBoxId}
|
||||
pendingSessionID={props.pendingSessionID}
|
||||
/>
|
||||
<PromptInput blocked={blocked} boxId={props.promptBoxId} pendingSessionID={props.pendingSessionID} />
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -23,9 +23,8 @@ import { AccountSwitcher } from "../shared/AccountSwitcher"
|
||||
import { KiloNotifications } from "./KiloNotifications"
|
||||
import { WorkingIndicator } from "../shared/WorkingIndicator"
|
||||
import { QuestionDock } from "./QuestionDock"
|
||||
import { SuggestBar } from "./SuggestBar"
|
||||
import { activeUserMessageID as getActiveUserMessageID } from "../../context/session-queue"
|
||||
import type { QuestionRequest, SuggestionRequest } from "../../types/messages"
|
||||
import type { QuestionRequest } from "../../types/messages"
|
||||
|
||||
const KiloLogo = (): JSX.Element => {
|
||||
const iconsBaseUri = (window as { ICONS_BASE_URI?: string }).ICONS_BASE_URI || ""
|
||||
@@ -45,8 +44,6 @@ interface MessageListProps {
|
||||
onShowHistory?: () => void
|
||||
/** Non-tool question requests to render inline at the bottom of the message list */
|
||||
questions?: () => QuestionRequest[]
|
||||
/** Non-tool suggestion requests to render inline at the bottom of the message list */
|
||||
suggestions?: () => SuggestionRequest[]
|
||||
/** When true (subagent viewer), replace the welcome screen with an initializing indicator */
|
||||
readonly?: boolean
|
||||
}
|
||||
@@ -176,7 +173,6 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
</Show>
|
||||
<WorkingIndicator />
|
||||
<For each={props.questions?.()}>{(req) => <QuestionDock request={req} />}</For>
|
||||
<For each={props.suggestions?.()}>{(req) => <SuggestBar request={req} />}</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,8 +50,6 @@ function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]
|
||||
|
||||
interface PromptInputProps {
|
||||
blocked?: () => boolean
|
||||
/** When true, session is busy only because a suggestion is pending — treat as idle for input */
|
||||
suggesting?: () => boolean
|
||||
boxId?: string
|
||||
pendingSessionID?: string
|
||||
}
|
||||
@@ -270,7 +268,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
window.addEventListener("compactSession", onCompact)
|
||||
onCleanup(() => window.removeEventListener("compactSession", onCompact))
|
||||
|
||||
const isBusy = () => session.status() !== "idle" && !props.suggesting?.()
|
||||
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() && !terminal.pending() && !props.blocked?.()
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show } from "solid-js"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useSession } from "../../context/session"
|
||||
import type { SuggestionRequest } from "../../types/messages"
|
||||
|
||||
export const SuggestBar: Component<{ request: SuggestionRequest }> = (props) => {
|
||||
const session = useSession()
|
||||
const language = useLanguage()
|
||||
|
||||
const error = () => session.suggestionErrors().has(props.request.id)
|
||||
const responding = () => session.respondingSuggestions().has(props.request.id)
|
||||
|
||||
const accept = (index: number) => {
|
||||
if (responding()) return
|
||||
session.acceptSuggestion(props.request.id, index)
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
if (responding()) return
|
||||
session.dismissSuggestion(props.request.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="suggest-bar">
|
||||
<div data-slot="suggest-bar-copy">
|
||||
<span data-slot="suggest-bar-icon">
|
||||
<Icon name="brain" size="small" />
|
||||
</span>
|
||||
<span data-slot="suggest-bar-text">{props.request.text}</span>
|
||||
</div>
|
||||
<Show when={error()}>
|
||||
<div data-slot="suggest-bar-error">
|
||||
<Icon name="close" size="small" />
|
||||
<span>Failed — try again</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="suggest-bar-actions">
|
||||
<For each={props.request.actions}>
|
||||
{(action, index) => (
|
||||
<Button
|
||||
variant={index() === 0 ? "secondary" : "ghost"}
|
||||
size="small"
|
||||
disabled={responding()}
|
||||
onClick={() => accept(index())}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
<IconButton
|
||||
icon="close"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={responding()}
|
||||
label={language.t("common.dismiss")}
|
||||
onClick={dismiss}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -83,8 +83,7 @@ export const WorkingIndicator: Component = () => {
|
||||
.permissions()
|
||||
.filter((p) => p.sessionID === id && !(p.tool && ["todowrite", "todoread"].includes(p.toolName)))
|
||||
const questions = session.questions().filter((q) => q.sessionID === id)
|
||||
const suggestions = session.suggestions().filter((s) => s.sessionID === id)
|
||||
return perms.length > 0 || questions.length > 0 || suggestions.length > 0
|
||||
return perms.length > 0 || questions.length > 0
|
||||
}
|
||||
|
||||
const isRetrying = () => session.statusInfo().type === "retry"
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
SessionStatusInfo,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SuggestionRequest,
|
||||
TodoItem,
|
||||
ModelSelection,
|
||||
ContextUsage,
|
||||
@@ -114,14 +113,10 @@ interface SessionContextValue {
|
||||
// Pending question requests (unscoped — all tracked sessions)
|
||||
questions: Accessor<QuestionRequest[]>
|
||||
questionErrors: Accessor<Set<string>>
|
||||
suggestions: Accessor<SuggestionRequest[]>
|
||||
suggestionErrors: Accessor<Set<string>>
|
||||
respondingSuggestions: Accessor<Set<string>>
|
||||
|
||||
// Scoped permissions/questions — filtered to a session's family (self + subagents)
|
||||
scopedPermissions: (sessionID: string | undefined) => PermissionRequest[]
|
||||
scopedQuestions: (sessionID: string | undefined) => QuestionRequest[]
|
||||
scopedSuggestions: (sessionID: string | undefined) => SuggestionRequest[]
|
||||
|
||||
// Model selection (global, extension-lifetime)
|
||||
selected: Accessor<ModelSelection | null>
|
||||
@@ -196,8 +191,6 @@ interface SessionContextValue {
|
||||
) => void
|
||||
replyToQuestion: (requestID: string, answers: string[][]) => void
|
||||
rejectQuestion: (requestID: string) => void
|
||||
acceptSuggestion: (requestID: string, index: number) => void
|
||||
dismissSuggestion: (requestID: string) => void
|
||||
createSession: () => void
|
||||
clearCurrentSession: () => void
|
||||
loadSessions: () => void
|
||||
@@ -257,9 +250,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
// Tracks question IDs that failed so the UI can reset sending state
|
||||
const [questionErrors, setQuestionErrors] = createSignal<Set<string>>(new Set())
|
||||
const [suggestions, setSuggestions] = createSignal<SuggestionRequest[]>([])
|
||||
const [suggestionErrors, setSuggestionErrors] = createSignal<Set<string>>(new Set())
|
||||
const [respondingSuggestions, setRespondingSuggestions] = createSignal<Set<string>>(new Set())
|
||||
|
||||
// Tracks whether the user has explicitly set a model override per agent (to
|
||||
// prevent the default-sync effect from overwriting it).
|
||||
@@ -653,9 +643,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
// Handle messages from extension
|
||||
onMount(() => {
|
||||
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
|
||||
// Route suggestion messages (extracted to stay within complexity limit)
|
||||
routeSuggestionMessage(message)
|
||||
|
||||
switch (message.type) {
|
||||
case "sessionCreated":
|
||||
handleSessionCreated(message.session, message.draftID)
|
||||
@@ -696,10 +683,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
case "clearPendingPrompts":
|
||||
setPermissions([])
|
||||
setQuestions([])
|
||||
setSuggestions([])
|
||||
setRespondingPermissions(new Set<string>())
|
||||
setSuggestionErrors(new Set<string>())
|
||||
setRespondingSuggestions(new Set<string>())
|
||||
break
|
||||
|
||||
case "sessionsLoaded":
|
||||
@@ -1016,60 +1000,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
setQuestionErrors((prev) => new Set(prev).add(requestID))
|
||||
}
|
||||
|
||||
function handleSuggestionRequest(suggestion: SuggestionRequest) {
|
||||
setSuggestions((prev) => {
|
||||
const idx = prev.findIndex((item) => item.id === suggestion.id)
|
||||
if (idx === -1) return [...prev, suggestion]
|
||||
const next = prev.slice()
|
||||
next[idx] = suggestion
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleSuggestionResolved(requestID: string) {
|
||||
setSuggestions((prev) => prev.filter((item) => item.id !== requestID))
|
||||
setRespondingSuggestions((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
setSuggestionErrors((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleSuggestionError(requestID: string) {
|
||||
setRespondingSuggestions((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
setSuggestionErrors((prev) => new Set(prev).add(requestID))
|
||||
}
|
||||
|
||||
/**
|
||||
* Route suggestion-related extension messages.
|
||||
* Extracted from the main message handler to stay within the complexity limit.
|
||||
*/
|
||||
function routeSuggestionMessage(message: ExtensionMessage) {
|
||||
switch (message.type) {
|
||||
case "suggestionRequest":
|
||||
handleSuggestionRequest(message.suggestion)
|
||||
break
|
||||
case "suggestionResolved":
|
||||
handleSuggestionResolved(message.requestID)
|
||||
break
|
||||
case "suggestionError":
|
||||
handleSuggestionError(message.requestID)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed send: remove the optimistic message from the store
|
||||
* and show a toast. The PromptInput restores the draft text separately
|
||||
@@ -1186,12 +1116,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return questions().filter((q) => family.has(q.sessionID))
|
||||
}
|
||||
|
||||
function scopedSuggestions(sessionID: string | undefined): SuggestionRequest[] {
|
||||
if (!sessionID) return []
|
||||
const family = sessionFamily(sessionID)
|
||||
return suggestions().filter((item) => family.has(item.sessionID))
|
||||
}
|
||||
|
||||
function handleTodoUpdated(sessionID: string, items: TodoItem[]) {
|
||||
setStore("todos", sessionID, items)
|
||||
}
|
||||
@@ -1272,24 +1196,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return next
|
||||
})
|
||||
}
|
||||
const gone = suggestions()
|
||||
.filter((item) => item.sessionID === sessionID)
|
||||
.map((item) => item.id)
|
||||
if (gone.length > 0) {
|
||||
setSuggestions((prev) => prev.filter((item) => item.sessionID !== sessionID))
|
||||
setSuggestionErrors((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of gone) next.delete(id)
|
||||
if (next.size === prev.size) return prev
|
||||
return next
|
||||
})
|
||||
setRespondingSuggestions((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of gone) next.delete(id)
|
||||
if (next.size === prev.size) return prev
|
||||
return next
|
||||
})
|
||||
}
|
||||
setPermissions((prev) => removeSessionPermissions(prev, sessionID))
|
||||
setStatusMap(
|
||||
produce((map) => {
|
||||
@@ -1502,8 +1408,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
const sid = currentSessionID()
|
||||
const suggestion = scopedSuggestions(sid)[0]
|
||||
if (suggestion) dismissSuggestion(suggestion.id)
|
||||
if (sid) addOptimistic(sid, messageID, text, files)
|
||||
|
||||
const agent = selectedAgentName() !== defaultAgent() ? selectedAgentName() : undefined
|
||||
@@ -1557,8 +1461,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
const sid = currentSessionID()
|
||||
const suggestion = scopedSuggestions(sid)[0]
|
||||
if (suggestion) dismissSuggestion(suggestion.id)
|
||||
|
||||
if (sid) addOptimistic(sid, messageID, `/${command} ${args}`.trim(), files)
|
||||
|
||||
@@ -1646,15 +1548,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
function clearSuggestionError(requestID: string) {
|
||||
setSuggestionErrors((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function replyToQuestion(requestID: string, answers: string[][]) {
|
||||
clearQuestionError(requestID)
|
||||
const question = questions().find((item) => item.id === requestID)
|
||||
@@ -1678,29 +1571,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
function acceptSuggestion(requestID: string, index: number) {
|
||||
clearSuggestionError(requestID)
|
||||
setRespondingSuggestions((prev) => new Set(prev).add(requestID))
|
||||
const sid = suggestions().find((s) => s.id === requestID)?.sessionID ?? currentSessionID() ?? ""
|
||||
vscode.postMessage({
|
||||
type: "suggestionAccept",
|
||||
requestID,
|
||||
sessionID: sid,
|
||||
index,
|
||||
})
|
||||
}
|
||||
|
||||
function dismissSuggestion(requestID: string) {
|
||||
clearSuggestionError(requestID)
|
||||
setRespondingSuggestions((prev) => new Set(prev).add(requestID))
|
||||
const sid = suggestions().find((s) => s.id === requestID)?.sessionID ?? currentSessionID() ?? ""
|
||||
vscode.postMessage({
|
||||
type: "suggestionDismiss",
|
||||
requestID,
|
||||
sessionID: sid,
|
||||
})
|
||||
}
|
||||
|
||||
function createSession() {
|
||||
if (!server.isConnected()) {
|
||||
console.warn("[Kilo New] Cannot create session: not connected")
|
||||
@@ -1945,12 +1815,8 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
respondingPermissions,
|
||||
questions,
|
||||
questionErrors,
|
||||
suggestions,
|
||||
suggestionErrors,
|
||||
respondingSuggestions,
|
||||
scopedPermissions,
|
||||
scopedQuestions,
|
||||
scopedSuggestions,
|
||||
selected,
|
||||
selectModel,
|
||||
hasModelOverride,
|
||||
@@ -2012,8 +1878,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
respondToPermission,
|
||||
replyToQuestion,
|
||||
rejectQuestion,
|
||||
acceptSuggestion,
|
||||
dismissSuggestion,
|
||||
createSession,
|
||||
clearCurrentSession,
|
||||
loadSessions,
|
||||
|
||||
@@ -33,13 +33,7 @@ import { dict as appEn } from "../i18n/en"
|
||||
import { dict as amEn } from "../../agent-manager/i18n/en"
|
||||
import { dict as kiloEn } from "@kilocode/kilo-i18n/en"
|
||||
import { resolveTemplate } from "../context/language-utils"
|
||||
import type {
|
||||
Config,
|
||||
KilocodeNotification,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SuggestionRequest,
|
||||
} from "../types/messages"
|
||||
import type { Config, KilocodeNotification, PermissionRequest, QuestionRequest } from "../types/messages"
|
||||
|
||||
// Merged English dictionary (same merge order as the real LanguageProvider)
|
||||
const dict: Record<string, string> = { ...appEn, ...amEn, ...uiEn, ...kiloEn }
|
||||
@@ -126,13 +120,11 @@ export function mockSessionValue(overrides?: {
|
||||
id?: string
|
||||
permissions?: PermissionRequest[]
|
||||
questions?: QuestionRequest[]
|
||||
suggestions?: SuggestionRequest[]
|
||||
status?: string
|
||||
}) {
|
||||
const id = overrides?.id ?? "story-session-001"
|
||||
const permissions = overrides?.permissions ?? []
|
||||
const qs = overrides?.questions ?? []
|
||||
const suggestions = overrides?.suggestions ?? []
|
||||
const status = (overrides?.status ?? "idle") as "idle" | "busy"
|
||||
|
||||
return {
|
||||
@@ -162,12 +154,8 @@ export function mockSessionValue(overrides?: {
|
||||
respondingPermissions: () => new Set<string>(),
|
||||
questions: () => qs,
|
||||
questionErrors: () => new Set<string>(),
|
||||
suggestions: () => suggestions,
|
||||
suggestionErrors: () => new Set<string>(),
|
||||
respondingSuggestions: () => new Set<string>(),
|
||||
scopedPermissions: (sid?: string) => (sid ? permissions.filter((p) => p.sessionID === sid) : permissions),
|
||||
scopedQuestions: (sid?: string) => (sid ? qs.filter((q) => q.sessionID === sid) : qs),
|
||||
scopedSuggestions: (sid?: string) => (sid ? suggestions.filter((item) => item.sessionID === sid) : suggestions),
|
||||
selected: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }),
|
||||
selectModel: noop,
|
||||
hasModelOverride: () => false,
|
||||
@@ -198,14 +186,11 @@ export function mockSessionValue(overrides?: {
|
||||
currentVariant: () => undefined,
|
||||
selectVariant: noop,
|
||||
sendMessage: noop,
|
||||
sendCommand: noop,
|
||||
abort: noop,
|
||||
compact: noop,
|
||||
respondToPermission: noop,
|
||||
replyToQuestion: noop,
|
||||
rejectQuestion: noop,
|
||||
acceptSuggestion: noop,
|
||||
dismissSuggestion: noop,
|
||||
createSession: noop,
|
||||
clearCurrentSession: noop,
|
||||
loadSessions: noop,
|
||||
@@ -226,7 +211,6 @@ interface StoryProvidersProps {
|
||||
data?: any
|
||||
permissions?: PermissionRequest[]
|
||||
questions?: QuestionRequest[]
|
||||
suggestions?: SuggestionRequest[]
|
||||
notifications?: KilocodeNotification[]
|
||||
status?: string
|
||||
sessionID?: string
|
||||
@@ -258,7 +242,6 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
|
||||
id: props.sessionID,
|
||||
permissions: props.permissions,
|
||||
questions: props.questions,
|
||||
suggestions: props.suggestions,
|
||||
status: props.status,
|
||||
})
|
||||
const notifications = mockNotificationsValue(props.notifications)
|
||||
|
||||
@@ -12,10 +12,9 @@ import { StoryProviders, mockSessionValue } from "./StoryProviders"
|
||||
import { ChatView } from "../components/chat/ChatView"
|
||||
import { TaskHeader } from "../components/chat/TaskHeader"
|
||||
import { QuestionDock } from "../components/chat/QuestionDock"
|
||||
import { SuggestBar } from "../components/chat/SuggestBar"
|
||||
import { SessionContext } from "../context/session"
|
||||
import { ServerContext } from "../context/server"
|
||||
import type { QuestionRequest, SuggestionRequest, TodoItem } from "../types/messages"
|
||||
import type { QuestionRequest, TodoItem } from "../types/messages"
|
||||
|
||||
const SESSION_ID = "story-session-chat-001"
|
||||
|
||||
@@ -67,14 +66,6 @@ const multiQuestion: QuestionRequest = {
|
||||
tool: { messageID: "asst-msg-001", callID: "call-question-002" },
|
||||
}
|
||||
|
||||
const reviewSuggestion: SuggestionRequest = {
|
||||
id: "s-review-001",
|
||||
sessionID: SESSION_ID,
|
||||
text: "Start a code review of uncommitted changes?",
|
||||
actions: [{ label: "Start review", description: "Run a local review now", prompt: "/local-review-uncommitted" }],
|
||||
tool: { messageID: "asst-msg-002", callID: "call-suggest-001" },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -181,17 +172,6 @@ export const QuestionDockManyOptions: Story = {
|
||||
),
|
||||
}
|
||||
|
||||
export const SuggestBarReview: Story = {
|
||||
name: "SuggestBar — review suggestion",
|
||||
render: () => (
|
||||
<StoryProviders sessionID={SESSION_ID} suggestions={[reviewSuggestion]}>
|
||||
<div style={{ width: "100%" }}>
|
||||
<SuggestBar request={reviewSuggestion} />
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskHeader with todos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -19,6 +19,5 @@
|
||||
@import "./notifications.css";
|
||||
@import "./tool-overrides.css";
|
||||
@import "./question-dock.css";
|
||||
@import "./suggest-bar.css";
|
||||
@import "./settings.css";
|
||||
@import "./high-contrast.css";
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/* ============================================
|
||||
Suggest Bar
|
||||
============================================ */
|
||||
|
||||
/* Strip the card border from tool-part-wrapper when it contains a suggest bar */
|
||||
[data-component="tool-part-wrapper"]:has([data-component="suggest-bar"]) {
|
||||
border: none !important;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
[data-component="suggest-bar"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--background-base) 88%, var(--vscode-textLink-foreground) 12%);
|
||||
|
||||
[data-slot="suggest-bar-copy"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-slot="suggest-bar-icon"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-info, var(--vscode-textLink-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="suggest-bar-text"] {
|
||||
min-width: 0;
|
||||
color: var(--text-base);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
[data-slot="suggest-bar-actions"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
@@ -218,25 +218,6 @@ export interface QuestionRequest {
|
||||
id: string
|
||||
sessionID: string
|
||||
questions: QuestionInfo[]
|
||||
blocking?: boolean
|
||||
tool?: {
|
||||
messageID: string
|
||||
callID: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface SuggestionAction {
|
||||
label: string
|
||||
description?: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export interface SuggestionRequest {
|
||||
id: string
|
||||
sessionID: string
|
||||
text: string
|
||||
actions: SuggestionAction[]
|
||||
blocking?: boolean
|
||||
tool?: {
|
||||
messageID: string
|
||||
callID: string
|
||||
@@ -785,21 +766,6 @@ export interface QuestionErrorMessage {
|
||||
requestID: string
|
||||
}
|
||||
|
||||
export interface SuggestionRequestMessage {
|
||||
type: "suggestionRequest"
|
||||
suggestion: SuggestionRequest
|
||||
}
|
||||
|
||||
export interface SuggestionResolvedMessage {
|
||||
type: "suggestionResolved"
|
||||
requestID: string
|
||||
}
|
||||
|
||||
export interface SuggestionErrorMessage {
|
||||
type: "suggestionError"
|
||||
requestID: string
|
||||
}
|
||||
|
||||
export interface BrowserSettings {
|
||||
enabled: boolean
|
||||
useSystemChrome: boolean
|
||||
@@ -1530,9 +1496,6 @@ export type ExtensionMessage =
|
||||
| QuestionRequestMessage
|
||||
| QuestionResolvedMessage
|
||||
| QuestionErrorMessage
|
||||
| SuggestionRequestMessage
|
||||
| SuggestionResolvedMessage
|
||||
| SuggestionErrorMessage
|
||||
| BrowserSettingsLoadedMessage
|
||||
| ClaudeCompatSettingLoadedMessage
|
||||
| ConfigLoadedMessage
|
||||
@@ -1844,19 +1807,6 @@ export interface QuestionRejectRequest {
|
||||
sessionID?: string
|
||||
}
|
||||
|
||||
export interface SuggestionAcceptRequest {
|
||||
type: "suggestionAccept"
|
||||
requestID: string
|
||||
sessionID: string
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface SuggestionDismissRequest {
|
||||
type: "suggestionDismiss"
|
||||
requestID: string
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
export interface DeleteSessionRequest {
|
||||
type: "deleteSession"
|
||||
sessionID: string
|
||||
@@ -2493,8 +2443,6 @@ export type WebviewMessage =
|
||||
| SetLanguageRequest
|
||||
| QuestionReplyRequest
|
||||
| QuestionRejectRequest
|
||||
| SuggestionAcceptRequest
|
||||
| SuggestionDismissRequest
|
||||
| DeleteSessionRequest
|
||||
| RenameSessionRequest
|
||||
| RequestAutocompleteSettingsMessage
|
||||
|
||||
@@ -102,7 +102,6 @@ export namespace Agent {
|
||||
"*": "ask",
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
},
|
||||
suggest: "deny", // kilocode_change
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
@@ -131,7 +130,6 @@ export namespace Agent {
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
question: "allow",
|
||||
suggest: "allow", // kilocode_change
|
||||
plan_enter: "allow",
|
||||
}),
|
||||
user,
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
Command,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SuggestionRequest, // kilocode_change
|
||||
SessionNetworkWait, // kilocode_change
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
@@ -28,7 +27,6 @@ import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { handleSuggestionEvent } from "@/kilocode/suggestion/tui/sync" // kilocode_change
|
||||
import { Log } from "@/util/log"
|
||||
import { useToast } from "@tui/ui/toast" // kilocode_change
|
||||
import type { Path } from "@kilocode/sdk"
|
||||
@@ -54,9 +52,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
// kilocode_change start
|
||||
suggestion: {
|
||||
[sessionID: string]: SuggestionRequest[]
|
||||
}
|
||||
network: {
|
||||
[sessionID: string]: SessionNetworkWait[]
|
||||
}
|
||||
@@ -103,7 +98,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
permission: {},
|
||||
question: {},
|
||||
// kilocode_change start
|
||||
suggestion: {},
|
||||
network: {},
|
||||
// kilocode_change end
|
||||
command: [],
|
||||
@@ -149,9 +143,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
delete draft.session_diff[sessionID]
|
||||
delete draft.session_status[sessionID]
|
||||
delete draft.todo[sessionID]
|
||||
delete draft.permission[sessionID]
|
||||
delete draft.question[sessionID]
|
||||
delete draft.suggestion[sessionID]
|
||||
delete draft.network[sessionID]
|
||||
}),
|
||||
)
|
||||
@@ -265,15 +256,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
case "suggestion.accepted":
|
||||
case "suggestion.dismissed":
|
||||
case "suggestion.shown": {
|
||||
handleSuggestionEvent(event, store, setStore)
|
||||
break
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
case "session.network.restored": {
|
||||
const requests = store.network[event.properties.sessionID]
|
||||
if (!requests) break
|
||||
@@ -306,6 +288,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
case "todo.updated":
|
||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||
break
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Spinner } from "@tui/component/spinner"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import type { AssistantMessage, Part, Provider, ToolPart, UserMessage, TextPart, ReasoningPart } from "@kilocode/sdk/v2" // kilocode_change
|
||||
import type { AssistantMessage, Part, Provider, ToolPart, UserMessage, TextPart, ReasoningPart } from "@kilocode/sdk/v2" // kilocode_change // kilocode_change
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { Locale } from "@/util/locale"
|
||||
import type { Tool } from "@/tool/tool"
|
||||
@@ -69,8 +69,6 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { Global } from "@/global"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
import { QuestionPrompt } from "./question"
|
||||
import { Suggest } from "@/kilocode/suggestion/tui/render" // kilocode_change
|
||||
import { SuggestPrompt } from "@/kilocode/suggestion/tui/prompt" // kilocode_change
|
||||
import { NetworkPrompt } from "./network" // kilocode_change
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
import * as Model from "../../util/model"
|
||||
@@ -135,42 +133,18 @@ export function Session() {
|
||||
return children().flatMap((x) => sync.data.question[x.id] ?? [])
|
||||
})
|
||||
// kilocode_change start
|
||||
const suggestions = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return children().flatMap((x) => sync.data.suggestion[x.id] ?? [])
|
||||
})
|
||||
const network = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return children().flatMap((x) => sync.data.network[x.id] ?? [])
|
||||
})
|
||||
const blockingQuestions = createMemo(() => questions().filter((q) => q.blocking !== false))
|
||||
const nonBlockingQuestions = createMemo(() => questions().filter((q) => q.blocking === false))
|
||||
const question = createMemo(() => blockingQuestions()[0] ?? nonBlockingQuestions()[0])
|
||||
const blockingSuggestions = createMemo(() => suggestions().filter((s) => s.blocking !== false))
|
||||
const nonBlockingSuggestions = createMemo(() => suggestions().filter((s) => s.blocking === false))
|
||||
const suggestion = createMemo(() => blockingSuggestions()[0] ?? nonBlockingSuggestions()[0])
|
||||
const visible = createMemo(
|
||||
() =>
|
||||
!session()?.parentID &&
|
||||
permissions().length === 0 &&
|
||||
blockingQuestions().length === 0 &&
|
||||
blockingSuggestions().length === 0 &&
|
||||
network().length === 0,
|
||||
() => !session()?.parentID && permissions().length === 0 && questions().length === 0 && network().length === 0,
|
||||
)
|
||||
const networkVisible = createMemo(
|
||||
() =>
|
||||
permissions().length === 0 &&
|
||||
blockingQuestions().length === 0 &&
|
||||
blockingSuggestions().length === 0 &&
|
||||
network().length > 0,
|
||||
)
|
||||
const disabled = createMemo(
|
||||
() =>
|
||||
permissions().length > 0 ||
|
||||
blockingQuestions().length > 0 ||
|
||||
blockingSuggestions().length > 0 ||
|
||||
network().length > 0,
|
||||
() => permissions().length === 0 && questions().length === 0 && network().length > 0,
|
||||
)
|
||||
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0 || network().length > 0)
|
||||
// kilocode_change end
|
||||
|
||||
const pending = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
||||
@@ -179,7 +153,6 @@ export function Session() {
|
||||
const lastAssistant = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant")
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - ring terminal bell on task completion
|
||||
createEffect(
|
||||
@@ -214,7 +187,7 @@ export function Session() {
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => [route.sessionID, suggestions().length + network().length] as const, // kilocode_change
|
||||
() => [route.sessionID, network().length] as const,
|
||||
([id, len], prev) => {
|
||||
if (!prev || prev[0] !== id) return
|
||||
if (len > prev[1] && bellEnabled()) bell()
|
||||
@@ -1258,32 +1231,15 @@ export function Session() {
|
||||
<PermissionPrompt request={permissions()[0]} />
|
||||
</Show>
|
||||
{/* kilocode_change start */}
|
||||
<Show when={permissions().length === 0 && question()} keyed>
|
||||
{(request) => (
|
||||
<QuestionPrompt
|
||||
request={request}
|
||||
nonBlocking={request.blocking === false}
|
||||
inputFocused={() => prompt?.focused ?? false}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={permissions().length === 0 && !question()}>
|
||||
{/* kilocode_change end */}
|
||||
{/* kilocode_change start */}
|
||||
<Show when={suggestion()} keyed>
|
||||
{(request) => (
|
||||
<SuggestPrompt
|
||||
request={request}
|
||||
nonBlocking={request.blocking === false}
|
||||
inputFocused={() => prompt?.focused ?? false}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
{/* kilocode_change start */}
|
||||
<Show when={permissions().length === 0 && questions().length > 0}>
|
||||
<QuestionPrompt request={questions()[0]} />
|
||||
</Show>
|
||||
{/* kilocode_change end */}
|
||||
{/* kilocode_change end */}
|
||||
<Show when={session()?.parentID}>
|
||||
<SubagentFooter />
|
||||
</Show>
|
||||
{/* kilocode_change end */}
|
||||
{/* kilocode_change start */}
|
||||
<Show when={networkVisible()}>
|
||||
<NetworkPrompt request={network()[0]} />
|
||||
@@ -1701,11 +1657,6 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
||||
<Match when={props.part.tool === "question"}>
|
||||
<Question {...toolprops} />
|
||||
</Match>
|
||||
{/* kilocode_change start */}
|
||||
<Match when={props.part.tool === "suggest"}>
|
||||
<Suggest {...toolprops} InlineTool={InlineTool} BlockTool={BlockTool} />
|
||||
</Match>
|
||||
{/* kilocode_change end */}
|
||||
<Match when={props.part.tool === "skill"}>
|
||||
<Skill {...toolprops} />
|
||||
</Match>
|
||||
|
||||
@@ -11,11 +11,7 @@ import { useTextareaKeybindings } from "../../component/textarea-keybindings"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
// kilocode_change start
|
||||
export function QuestionPrompt(props: {
|
||||
request: QuestionRequest
|
||||
nonBlocking?: boolean
|
||||
inputFocused?: () => boolean
|
||||
}) {
|
||||
export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
// kilocode_change end
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
@@ -132,10 +128,6 @@ export function QuestionPrompt(props: {
|
||||
// Skip processing if a dialog (e.g., command palette) is open
|
||||
if (dialog.stack.length > 0) return
|
||||
|
||||
// kilocode_change start - avoid intrusive key capture for non-blocking review suggestions
|
||||
if (props.nonBlocking && props.inputFocused?.()) return
|
||||
// kilocode_change end
|
||||
|
||||
// When editing custom answer textarea
|
||||
if (store.editing && !confirm()) {
|
||||
if (evt.name === "escape") {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// kilocode_change - new file
|
||||
export { SuggestPrompt } from "../../../../../kilocode/suggestion/tui/prompt"
|
||||
@@ -202,10 +202,4 @@ export namespace Command {
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
export async function get(name: string) {
|
||||
return runPromise((svc) => svc.get(name))
|
||||
}
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ export namespace Identifier {
|
||||
message: "msg",
|
||||
permission: "per",
|
||||
question: "que",
|
||||
suggestion: "sug", // kilocode_change
|
||||
user: "usr",
|
||||
part: "prt",
|
||||
pty: "pty",
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Instance } from "@/project/instance"
|
||||
import { Session } from "@/session"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { Question } from "@/question"
|
||||
import { Suggestion } from "@/kilocode/suggestion" // kilocode_change
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { SessionID } from "@/session/schema"
|
||||
@@ -25,11 +24,6 @@ const PermissionData = z.object({
|
||||
message: z.string().optional(),
|
||||
})
|
||||
|
||||
const SuggestionData = z.object({
|
||||
requestID: z.string(),
|
||||
index: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
// kilocode_change start — lazy init to avoid circular dependency
|
||||
// (Server → RemoteRoutes → RemoteSender → SessionPrompt at module load time)
|
||||
let _remotePromptInput: ReturnType<typeof SessionPrompt.PromptInput.extend> | undefined
|
||||
@@ -39,6 +33,7 @@ function getRemotePromptInput() {
|
||||
}))
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
function normalizeModel(model: string | undefined) {
|
||||
if (!model) return undefined
|
||||
return {
|
||||
@@ -119,24 +114,11 @@ export namespace RemoteSender {
|
||||
}
|
||||
}
|
||||
|
||||
// Replay pending suggestions/questions/permissions so a newly-subscribed web client
|
||||
// Replay pending questions/permissions so a newly-subscribed web client
|
||||
// sees state that was asked before it connected — analogous to the Cloud
|
||||
// Agent's `connected` event carrying pending question/permission fields.
|
||||
async function replay(sessionId: string) {
|
||||
const [suggestions, questions, permissions] = await Promise.all([
|
||||
Suggestion.list(),
|
||||
Question.list(),
|
||||
Permission.list(),
|
||||
])
|
||||
for (const suggestion of suggestions) {
|
||||
if (suggestion.sessionID !== sessionId) continue
|
||||
options.conn.send({
|
||||
type: "event",
|
||||
sessionId,
|
||||
event: "suggestion.shown",
|
||||
data: suggestion,
|
||||
})
|
||||
}
|
||||
const [questions, permissions] = await Promise.all([Question.list(), Permission.list()])
|
||||
for (const q of questions) {
|
||||
if (q.sessionID !== sessionId) continue
|
||||
options.conn.send({
|
||||
@@ -307,37 +289,6 @@ export namespace RemoteSender {
|
||||
dispatchQuick(msg, dir, () => Question.reject(QuestionID.make(parsed.data.requestID)))
|
||||
return
|
||||
}
|
||||
if (msg.command === "suggestion_accept") {
|
||||
const parsed = SuggestionData.safeParse(msg.data)
|
||||
if (!parsed.success) {
|
||||
options.conn.send({
|
||||
type: "response",
|
||||
id: msg.id,
|
||||
error: "invalid suggestion_accept data: " + parsed.error.message,
|
||||
})
|
||||
return
|
||||
}
|
||||
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
|
||||
dispatchQuick(msg, dir, async () => {
|
||||
const ok = await Suggestion.accept(parsed.data)
|
||||
if (!ok) throw new Error("suggestion not found or invalid action index")
|
||||
})
|
||||
return
|
||||
}
|
||||
if (msg.command === "suggestion_dismiss") {
|
||||
const parsed = z.object({ requestID: z.string() }).safeParse(msg.data)
|
||||
if (!parsed.success) {
|
||||
options.conn.send({
|
||||
type: "response",
|
||||
id: msg.id,
|
||||
error: "invalid suggestion_dismiss data: " + parsed.error.message,
|
||||
})
|
||||
return
|
||||
}
|
||||
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
|
||||
dispatchQuick(msg, dir, () => Suggestion.dismiss(parsed.data.requestID))
|
||||
return
|
||||
}
|
||||
if (msg.command === "permission_respond") {
|
||||
const parsed = PermissionData.safeParse(msg.data)
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -231,7 +231,6 @@ export function patchAgents(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
question: "allow",
|
||||
suggest: "allow", // kilocode_change
|
||||
plan_exit: "allow",
|
||||
bash: readOnlyBash,
|
||||
...kilo.mcpRules,
|
||||
@@ -290,7 +289,6 @@ export function patchAgents(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
question: "allow",
|
||||
suggest: "allow", // kilocode_change
|
||||
plan_enter: "allow",
|
||||
}),
|
||||
user,
|
||||
@@ -314,7 +312,6 @@ export function patchAgents(
|
||||
glob: "allow",
|
||||
list: "allow",
|
||||
question: "allow",
|
||||
suggest: "allow", // kilocode_change
|
||||
task: "allow",
|
||||
todoread: "allow",
|
||||
todowrite: "allow",
|
||||
|
||||
@@ -115,7 +115,6 @@ export async function generateHandover(input: {
|
||||
export namespace PlanFollowup {
|
||||
const log = Log.create({ service: "plan.followup" })
|
||||
|
||||
export const PLAN_PREFIX = "Implement the following plan:"
|
||||
export const ANSWER_NEW_SESSION = "Start new session"
|
||||
export const ANSWER_CONTINUE = "Continue here"
|
||||
|
||||
|
||||
@@ -351,51 +351,27 @@ export namespace Review {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get uncommitted changes (staged + unstaged + untracked)
|
||||
* Get uncommitted changes (staged + unstaged)
|
||||
* Implements SCOPE-01
|
||||
*
|
||||
* Uses: git diff HEAD for tracked changes, plus git ls-files for untracked files
|
||||
* Uses: git diff HEAD to capture both staged and unstaged changes
|
||||
*/
|
||||
export async function getUncommittedChanges(): Promise<DiffResult> {
|
||||
log.info("getting uncommitted changes")
|
||||
|
||||
// git diff HEAD shows all uncommitted changes (staged + unstaged) for tracked files
|
||||
// git diff HEAD shows all uncommitted changes (staged + unstaged)
|
||||
// Using -c core.quotepath=false to handle unicode filenames
|
||||
const result = await $`git -c core.quotepath=false diff HEAD`.cwd(Instance.directory).quiet().nothrow()
|
||||
|
||||
let raw = result.exitCode === 0 ? result.stdout.toString() : ""
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
log.warn("git diff failed", {
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.toString(),
|
||||
})
|
||||
return { files: [], raw: "" }
|
||||
}
|
||||
|
||||
// Also include untracked files — git diff HEAD misses brand-new files
|
||||
const untracked = await $`git ls-files --others --exclude-standard -z`.cwd(Instance.directory).quiet().nothrow()
|
||||
if (untracked.exitCode === 0) {
|
||||
const paths = untracked.stdout.toString().split("\0").filter(Boolean)
|
||||
// Process in batches to avoid spawning hundreds of git processes
|
||||
const batch = 20
|
||||
for (let i = 0; i < paths.length; i += batch) {
|
||||
const chunk = paths.slice(i, i + batch)
|
||||
const diffs = await Promise.all(
|
||||
chunk.map((p) =>
|
||||
// --no-index exits 1 when files differ, which is expected
|
||||
$`git -c core.quotepath=false diff --no-index -- /dev/null ${p}`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.then((fd) => fd.stdout.toString()),
|
||||
),
|
||||
)
|
||||
for (const out of diffs) {
|
||||
if (out) raw += out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const raw = result.stdout.toString()
|
||||
const parsed = parseDiff(raw)
|
||||
|
||||
log.info("parsed uncommitted changes", {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { KilocodeRoutes } from "../../server/routes/kilocode"
|
||||
import { PermissionKilocodeRoutes } from "../permission/routes"
|
||||
import { RemoteRoutes } from "../../server/routes/remote"
|
||||
import { NetworkRoutes } from "../../server/routes/network"
|
||||
import { SuggestionRoutes } from "../suggestion/routes"
|
||||
import { createKiloRoutes } from "@kilocode/kilo-gateway"
|
||||
import { Auth } from "../../auth"
|
||||
import { errors } from "../../server/error"
|
||||
@@ -28,7 +27,6 @@ export function register(app: Hono): Hono {
|
||||
return app
|
||||
.route("/permission", PermissionKilocodeRoutes())
|
||||
.route("/network", NetworkRoutes())
|
||||
.route("/suggestion", SuggestionRoutes())
|
||||
.route("/telemetry", TelemetryRoutes())
|
||||
.route("/remote", RemoteRoutes())
|
||||
.route("/commit-message", CommitMessageRoutes())
|
||||
|
||||
@@ -12,16 +12,3 @@ You are Kilo, a highly skilled software engineer with extensive knowledge in man
|
||||
# Code
|
||||
|
||||
- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.
|
||||
|
||||
## Suggestions
|
||||
|
||||
- Use the `question` tool only when you need an actual answer from the user.
|
||||
- If the `suggest` tool is available, use it ONLY to offer a local code review — never for other actions like committing, pushing, running tests, or any other next step.
|
||||
- When you have completed implementation work and you are at least 90% confident the task is done, use `suggest` to offer a code review of uncommitted changes.
|
||||
- Only suggest review when the user's request appears fully addressed. Do not suggest it after every edit or partial implementation turn.
|
||||
- Do not repeat a review suggestion that was already dismissed in this conversation.
|
||||
- Keep suggestion text concise, use at most 1-2 actions, and make each accepted action prompt self-contained.
|
||||
- When suggesting a code review, choose the right command for the action prompt:
|
||||
- `/local-review-uncommitted` — for reviewing uncommitted working-tree changes (staged, unstaged, and untracked files).
|
||||
- `/local-review` — for reviewing all committed changes on the current branch vs its base branch.
|
||||
- Prefer `/local-review-uncommitted` when the work you just did has not been committed yet.
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { Bus } from "../../bus"
|
||||
import { BusEvent } from "../../bus/bus-event"
|
||||
import { Identifier } from "../../id/id"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Log } from "../../util/log"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Suggestion {
|
||||
const log = Log.create({ service: "suggestion" })
|
||||
|
||||
export const Action = z
|
||||
.object({
|
||||
label: z.string().describe("Button or option label (1-5 words)"),
|
||||
description: z.string().optional().describe("Brief explanation of what this action does"),
|
||||
prompt: z.string().describe("Synthetic user prompt to inject when this action is accepted"),
|
||||
})
|
||||
.meta({
|
||||
ref: "SuggestionAction",
|
||||
})
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
text: z.string().describe("Suggestion text shown to the user"),
|
||||
actions: z.array(Action).min(1).max(2).describe("Available actions the user can take"),
|
||||
})
|
||||
.meta({
|
||||
ref: "SuggestionInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: Identifier.schema("suggestion"),
|
||||
sessionID: Identifier.schema("session"),
|
||||
text: z.string().describe("Suggestion text shown to the user"),
|
||||
actions: z.array(Action).min(1).max(2).describe("Available actions the user can take"),
|
||||
blocking: z.boolean().optional().describe("Whether this suggestion blocks prompt input (default: true)"),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: z.string(),
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "SuggestionRequest",
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Accept = z.object({
|
||||
index: z.number().int().nonnegative().describe("Zero-based action index to accept"),
|
||||
})
|
||||
export type Accept = z.infer<typeof Accept>
|
||||
|
||||
export const Event = {
|
||||
Shown: BusEvent.define("suggestion.shown", Request),
|
||||
Accepted: BusEvent.define(
|
||||
"suggestion.accepted",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
requestID: z.string(),
|
||||
index: z.number().int().nonnegative(),
|
||||
action: Action,
|
||||
}),
|
||||
),
|
||||
Dismissed: BusEvent.define(
|
||||
"suggestion.dismissed",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const pending: Record<
|
||||
string,
|
||||
{
|
||||
info: Request
|
||||
resolve: (action: Action) => void
|
||||
reject: (error: any) => void
|
||||
}
|
||||
> = {}
|
||||
|
||||
return {
|
||||
pending,
|
||||
}
|
||||
})
|
||||
|
||||
export async function show(input: {
|
||||
sessionID: string
|
||||
text: string
|
||||
actions: Action[]
|
||||
blocking?: boolean
|
||||
tool?: { messageID: string; callID: string }
|
||||
}): Promise<Action> {
|
||||
const s = await state()
|
||||
const id = Identifier.ascending("suggestion")
|
||||
|
||||
log.info("shown", { id, actions: input.actions.length })
|
||||
|
||||
return new Promise<Action>((resolve, reject) => {
|
||||
const info: Request = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
text: input.text,
|
||||
actions: input.actions,
|
||||
blocking: input.blocking,
|
||||
tool: input.tool,
|
||||
}
|
||||
s.pending[id] = {
|
||||
info,
|
||||
resolve,
|
||||
reject,
|
||||
}
|
||||
Bus.publish(Event.Shown, info)
|
||||
})
|
||||
}
|
||||
|
||||
export async function accept(input: { requestID: string; index: number }): Promise<boolean> {
|
||||
const s = await state()
|
||||
const existing = s.pending[input.requestID]
|
||||
if (!existing) {
|
||||
log.warn("accept for unknown request", { requestID: input.requestID })
|
||||
return false
|
||||
}
|
||||
|
||||
const action = existing.info.actions[input.index]
|
||||
if (!action) {
|
||||
log.warn("accept for invalid action index", { requestID: input.requestID, index: input.index })
|
||||
delete s.pending[input.requestID]
|
||||
existing.reject(new Error(`Invalid action index: ${input.index}`))
|
||||
return false
|
||||
}
|
||||
|
||||
delete s.pending[input.requestID]
|
||||
|
||||
log.info("accepted", { requestID: input.requestID, index: input.index, label: action.label })
|
||||
|
||||
Bus.publish(Event.Accepted, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
index: input.index,
|
||||
action,
|
||||
})
|
||||
|
||||
existing.resolve(action)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function dismiss(requestID: string): Promise<void> {
|
||||
const s = await state()
|
||||
const existing = s.pending[requestID]
|
||||
if (!existing) {
|
||||
log.warn("dismiss for unknown request", { requestID })
|
||||
return
|
||||
}
|
||||
delete s.pending[requestID]
|
||||
|
||||
log.info("dismissed", { requestID })
|
||||
|
||||
Bus.publish(Event.Dismissed, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
})
|
||||
|
||||
existing.reject(new DismissedError())
|
||||
}
|
||||
|
||||
export class DismissedError extends Error {
|
||||
constructor() {
|
||||
super("The user dismissed this suggestion")
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissAll(sessionID: string): Promise<void> {
|
||||
const s = await state()
|
||||
for (const [id, entry] of Object.entries(s.pending)) {
|
||||
if (entry.info.sessionID !== sessionID) continue
|
||||
delete s.pending[id]
|
||||
log.info("dismissed", { requestID: id })
|
||||
Bus.publish(Event.Dismissed, {
|
||||
sessionID: entry.info.sessionID,
|
||||
requestID: entry.info.id,
|
||||
})
|
||||
entry.reject(new DismissedError())
|
||||
}
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return state().then((state) => Object.values(state.pending).map((item) => item.info))
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { errors } from "../../server/error"
|
||||
import { NotFoundError } from "../../storage/db"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Suggestion } from "./index"
|
||||
|
||||
export const SuggestionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List pending suggestions",
|
||||
description: "Get all pending suggestion requests across all sessions.",
|
||||
operationId: "suggestion.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of pending suggestions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Suggestion.Request.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const suggestions = await Suggestion.list()
|
||||
return c.json(suggestions)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:requestID/accept",
|
||||
describeRoute({
|
||||
summary: "Accept suggestion request",
|
||||
description: "Accept a suggestion request from the AI assistant.",
|
||||
operationId: "suggestion.accept",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Suggestion accepted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator("json", Suggestion.Accept),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
const ok = await Suggestion.accept({
|
||||
requestID: params.requestID,
|
||||
index: json.index,
|
||||
})
|
||||
if (!ok) throw new NotFoundError({ message: `Suggestion not found: ${params.requestID}` })
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:requestID/dismiss",
|
||||
describeRoute({
|
||||
summary: "Dismiss suggestion request",
|
||||
description: "Dismiss a suggestion request from the AI assistant.",
|
||||
operationId: "suggestion.dismiss",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Suggestion dismissed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await Suggestion.dismiss(params.requestID)
|
||||
return c.json(true)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Command } from "../../command"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { Log } from "../../util/log"
|
||||
import z from "zod"
|
||||
import DESCRIPTION from "./tool.txt"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import { Suggestion } from "./index"
|
||||
|
||||
const log = Log.create({ service: "tool.suggest" })
|
||||
|
||||
const Params = z.object({
|
||||
suggest: z.string().describe("Short suggestion text shown to the user"),
|
||||
actions: z.array(Suggestion.Action).min(1).max(2).describe("Available actions the user can take"),
|
||||
})
|
||||
|
||||
type Meta = {
|
||||
accepted?: Suggestion.Action
|
||||
dismissed: boolean
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* If prompt starts with `/`, treat it as a slash-command reference.
|
||||
* Resolve the command template and return its content so the LLM can
|
||||
* act on it in the current turn — without injecting a synthetic user
|
||||
* message or trying to dispatch a command on the same session (which
|
||||
* would deadlock).
|
||||
*/
|
||||
async function resolve(prompt: string): Promise<string> {
|
||||
if (!prompt.startsWith("/")) return prompt
|
||||
|
||||
const name = prompt.slice(1).split(/\s/, 1)[0]
|
||||
if (!name) return prompt
|
||||
|
||||
const args = prompt.slice(1 + name.length).trim()
|
||||
|
||||
const cmd = await Command.get(name)
|
||||
if (!cmd) {
|
||||
log.warn("unknown command in suggestion action", { name })
|
||||
return prompt
|
||||
}
|
||||
|
||||
try {
|
||||
const template = await cmd.template
|
||||
log.info("resolved command template", { name, length: template.length })
|
||||
return args ? `${template}\n\n${args}` : template
|
||||
} catch (err) {
|
||||
log.warn("failed to resolve command template", { name, err })
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
|
||||
export const SuggestTool = Tool.define<typeof Params, Meta>("suggest", {
|
||||
description: DESCRIPTION,
|
||||
parameters: Params,
|
||||
async execute(params, ctx) {
|
||||
const promise = Suggestion.show({
|
||||
sessionID: ctx.sessionID,
|
||||
text: params.suggest,
|
||||
actions: params.actions,
|
||||
blocking: Flag.KILO_CLIENT !== "vscode",
|
||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||
})
|
||||
|
||||
const listener = () =>
|
||||
Suggestion.list().then((items: Suggestion.Request[]) => {
|
||||
const match = items.find((item: Suggestion.Request) => item.tool?.callID === ctx.callID)
|
||||
if (match) return Suggestion.dismiss(match.id)
|
||||
})
|
||||
ctx.abort.addEventListener("abort", listener, { once: true })
|
||||
|
||||
const action = await promise
|
||||
.catch((error) => {
|
||||
if (error instanceof Suggestion.DismissedError) return undefined
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
ctx.abort.removeEventListener("abort", listener)
|
||||
})
|
||||
|
||||
if (!action) {
|
||||
const metadata: Meta = {
|
||||
accepted: undefined,
|
||||
dismissed: true,
|
||||
truncated: false,
|
||||
}
|
||||
return {
|
||||
title: "Suggestion dismissed",
|
||||
output: "User dismissed the suggestion.",
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = await resolve(action.prompt)
|
||||
|
||||
const metadata: Meta = {
|
||||
accepted: action,
|
||||
dismissed: false,
|
||||
truncated: false,
|
||||
}
|
||||
|
||||
return {
|
||||
title: `User accepted: ${action.label}`,
|
||||
output: `User accepted the suggestion "${action.label}". Carry out the following request now:\n\n${resolved}`,
|
||||
metadata,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
Use this tool to suggest a local code review to the user after completing implementation work.
|
||||
|
||||
This tool is ONLY for suggesting code review. Do NOT use it to suggest running tests, committing, pushing, or any other action.
|
||||
|
||||
Guidelines:
|
||||
- Only suggest review when you are at least 90% confident the user's request is fully addressed
|
||||
- Do not suggest review after every edit or partial implementation turn
|
||||
- Do not repeat a review suggestion that was already dismissed in this conversation
|
||||
- Keep the suggestion text concise and actionable
|
||||
- Provide 1-2 actions maximum
|
||||
- Make each action prompt self-contained so it can be injected as a synthetic user message
|
||||
- If you need a real answer from the user, use the `question` tool instead
|
||||
|
||||
Choosing the right review command for the action prompt:
|
||||
- Use `/local-review-uncommitted` as the action prompt for uncommitted working-tree changes (staged, unstaged, and untracked files)
|
||||
- Use `/local-review` as the action prompt for committed branch-level changes
|
||||
- Prefer `/local-review-uncommitted` when the work you just did has not been committed yet
|
||||
@@ -1,173 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { SuggestionRequest } from "@kilocode/sdk/v2"
|
||||
import { createMemo, createSignal, For } from "solid-js"
|
||||
import { SplitBorder } from "../../../cli/cmd/tui/component/border"
|
||||
import { useKeybind } from "../../../cli/cmd/tui/context/keybind"
|
||||
import { useSDK } from "../../../cli/cmd/tui/context/sdk"
|
||||
import { tint, useTheme } from "../../../cli/cmd/tui/context/theme"
|
||||
import { useDialog } from "../../../cli/cmd/tui/ui/dialog"
|
||||
|
||||
const dismiss = {
|
||||
label: "Dismiss",
|
||||
description: "Dismiss this suggestion and continue",
|
||||
}
|
||||
|
||||
export function SuggestPrompt(props: {
|
||||
request: SuggestionRequest
|
||||
nonBlocking?: boolean
|
||||
inputFocused?: () => boolean
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() => [...props.request.actions, dismiss])
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [busy, setBusy] = createSignal(false)
|
||||
|
||||
function accept(index: number) {
|
||||
if (busy()) return
|
||||
setBusy(true)
|
||||
sdk.client.suggestion
|
||||
.accept({
|
||||
requestID: props.request.id,
|
||||
index,
|
||||
})
|
||||
.catch(() => {
|
||||
setBusy(false)
|
||||
})
|
||||
}
|
||||
|
||||
function reject() {
|
||||
if (busy()) return
|
||||
setBusy(true)
|
||||
sdk.client.suggestion
|
||||
.dismiss({
|
||||
requestID: props.request.id,
|
||||
})
|
||||
.catch(() => {
|
||||
setBusy(false)
|
||||
})
|
||||
}
|
||||
|
||||
function choose(index: number) {
|
||||
if (index >= props.request.actions.length) {
|
||||
reject()
|
||||
return
|
||||
}
|
||||
accept(index)
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (dialog.stack.length > 0) return
|
||||
if (props.nonBlocking && props.inputFocused?.()) return
|
||||
|
||||
const total = options().length
|
||||
const max = Math.min(total, 9)
|
||||
const digit = Number(evt.name)
|
||||
|
||||
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
|
||||
evt.preventDefault()
|
||||
const index = digit - 1
|
||||
setSelected(index)
|
||||
choose(index)
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "up" || evt.name === "k") {
|
||||
evt.preventDefault()
|
||||
setSelected((selected() - 1 + total) % total)
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "down" || evt.name === "j") {
|
||||
evt.preventDefault()
|
||||
setSelected((selected() + 1) % total)
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
choose(selected())
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
evt.preventDefault()
|
||||
reject()
|
||||
}
|
||||
})
|
||||
|
||||
const note = createMemo(() => (busy() ? "Waiting..." : undefined))
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.secondary}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text}>{props.request.text}</text>
|
||||
</box>
|
||||
|
||||
<box>
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const active = () => i() === selected()
|
||||
const muted = () => i() === props.request.actions.length
|
||||
return (
|
||||
<box
|
||||
onMouseOver={() => setSelected(i())}
|
||||
onMouseDown={() => setSelected(i())}
|
||||
onMouseUp={() => choose(i())}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
|
||||
{`${i() + 1}.`}
|
||||
</text>
|
||||
</box>
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
|
||||
<text fg={active() ? theme.secondary : muted() ? theme.textMuted : theme.text}>{opt.label}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{opt.description}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
gap={2}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.text}>
|
||||
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>choose</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>{note()}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
|
||||
import { createMemo, Show, type JSX } from "solid-js"
|
||||
import { useTheme } from "../../../cli/cmd/tui/context/theme"
|
||||
import type { ToolPart as MessageToolPart } from "@kilocode/sdk/v2"
|
||||
|
||||
type InlineProps = {
|
||||
icon: string
|
||||
complete: unknown
|
||||
pending: string
|
||||
part: MessageToolPart
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
type BlockProps = {
|
||||
title: string
|
||||
part?: MessageToolPart
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export function Suggest(props: {
|
||||
input: {
|
||||
suggest?: string
|
||||
}
|
||||
metadata: {
|
||||
accepted?: {
|
||||
label: string
|
||||
}
|
||||
dismissed?: boolean
|
||||
}
|
||||
part: MessageToolPart
|
||||
InlineTool: (props: InlineProps) => JSX.Element
|
||||
BlockTool: (props: BlockProps) => JSX.Element
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const accepted = createMemo(() => props.metadata.accepted)
|
||||
const dismissed = createMemo(() => props.metadata.dismissed === true)
|
||||
|
||||
if (accepted() || dismissed()) {
|
||||
return props.BlockTool({
|
||||
title: "# Suggestion",
|
||||
part: props.part,
|
||||
children: (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>{props.input.suggest}</text>
|
||||
<Show when={accepted()}>
|
||||
<text fg={theme.text}>Accepted: {accepted()?.label}</text>
|
||||
</Show>
|
||||
<Show when={dismissed()}>
|
||||
<text fg={theme.text}>Dismissed</text>
|
||||
</Show>
|
||||
</box>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return props.InlineTool({
|
||||
icon: "→",
|
||||
pending: "Suggesting next step...",
|
||||
complete: props.part.state.status === "completed",
|
||||
part: props.part,
|
||||
children: props.input.suggest ?? "Suggested next step",
|
||||
})
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import type { SuggestionRequest } from "@kilocode/sdk/v2"
|
||||
|
||||
type RemovedEvent = {
|
||||
type: "suggestion.accepted" | "suggestion.dismissed"
|
||||
properties: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
}
|
||||
}
|
||||
|
||||
type ShownEvent = {
|
||||
type: "suggestion.shown"
|
||||
properties: SuggestionRequest
|
||||
}
|
||||
|
||||
type Event = RemovedEvent | ShownEvent
|
||||
|
||||
type Store = {
|
||||
suggestion: {
|
||||
[sessionID: string]: SuggestionRequest[]
|
||||
}
|
||||
}
|
||||
|
||||
type SetStore = {
|
||||
(key: "suggestion", sessionID: string, value: SuggestionRequest[]): void
|
||||
}
|
||||
|
||||
export function handleSuggestionEvent(event: Event, store: Store, setStore: SetStore) {
|
||||
if (event.type !== "suggestion.shown") {
|
||||
const info = event.properties
|
||||
const requests = store.suggestion[info.sessionID]
|
||||
if (!requests) return
|
||||
const match = Binary.search(requests, info.requestID, (r) => r.id)
|
||||
if (!match.found) return
|
||||
setStore("suggestion", info.sessionID, requests.toSpliced(match.index, 1))
|
||||
return
|
||||
}
|
||||
|
||||
const request = event.properties
|
||||
const requests = store.suggestion[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("suggestion", request.sessionID, [request])
|
||||
return
|
||||
}
|
||||
const match = Binary.search(requests, request.id, (r) => r.id)
|
||||
if (match.found) {
|
||||
const next = [...requests]
|
||||
next[match.index] = request
|
||||
setStore("suggestion", request.sessionID, next)
|
||||
return
|
||||
}
|
||||
setStore("suggestion", request.sessionID, [
|
||||
...requests.slice(0, match.index),
|
||||
request,
|
||||
...requests.slice(match.index),
|
||||
])
|
||||
}
|
||||
@@ -26,11 +26,6 @@ export namespace KiloToolRegistry {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Suggest tool is only registered for cli and vscode clients */
|
||||
export function suggest(tool: Tool.Def): Tool.Def[] {
|
||||
return ["cli", "vscode"].includes(Flag.KILO_CLIENT) ? [tool] : []
|
||||
}
|
||||
|
||||
/** Kilo-specific tools to append to the builtin list */
|
||||
export function extra(
|
||||
tools: { codebase: Tool.Def; recall: Tool.Def },
|
||||
|
||||
@@ -37,7 +37,6 @@ export namespace Question {
|
||||
id: QuestionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
questions: z.array(Info).describe("Questions to ask"),
|
||||
blocking: z.boolean().optional().describe("Whether this question blocks prompt input (default: true)"), // kilocode_change
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
@@ -98,7 +97,6 @@ export namespace Question {
|
||||
readonly ask: (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
blocking?: boolean // kilocode_change
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) => Effect.Effect<Answer[], RejectedError>
|
||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||
@@ -134,7 +132,6 @@ export namespace Question {
|
||||
const ask = Effect.fn("Question.ask")(function* (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
blocking?: boolean // kilocode_change
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
@@ -146,7 +143,6 @@ export namespace Question {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
questions: input.questions,
|
||||
blocking: input.blocking, // kilocode_change
|
||||
tool: input.tool,
|
||||
}
|
||||
pending.set(id, { info, deferred })
|
||||
@@ -209,7 +205,6 @@ export namespace Question {
|
||||
export async function ask(input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
blocking?: boolean // kilocode_change
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}): Promise<Answer[]> {
|
||||
return runPromise((s) => s.ask(input))
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// kilocode_change - new file
|
||||
export { SuggestionRoutes } from "../../kilocode/suggestion/routes"
|
||||
@@ -19,7 +19,6 @@ import { SessionSummary } from "./summary"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Question } from "@/question"
|
||||
import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change
|
||||
import { Suggestion } from "@/kilocode/suggestion" // kilocode_change
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
@@ -209,11 +208,7 @@ export namespace SessionProcessor {
|
||||
},
|
||||
})
|
||||
// kilocode_change start
|
||||
if (
|
||||
error instanceof Permission.RejectedError ||
|
||||
error instanceof Question.RejectedError ||
|
||||
error instanceof Suggestion.DismissedError
|
||||
) {
|
||||
if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
|
||||
// kilocode_change end
|
||||
ctx.blocked = ctx.shouldBreak
|
||||
}
|
||||
@@ -362,11 +357,6 @@ export namespace SessionProcessor {
|
||||
|
||||
case "tool-result": {
|
||||
yield* completeToolCall(value.toolCallId, value.output)
|
||||
// kilocode_change start
|
||||
if (value.output.metadata?.dismissed === true) {
|
||||
ctx.blocked = ctx.shouldBreak
|
||||
}
|
||||
// kilocode_change end
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import os from "os"
|
||||
import fs from "fs/promises"
|
||||
import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change
|
||||
import { KiloSession } from "@/kilocode/session" // kilocode_change
|
||||
import { Suggestion } from "@/kilocode/suggestion" // kilocode_change
|
||||
import z from "zod"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
@@ -1288,11 +1287,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
if (input.noReply === true) return message
|
||||
// kilocode_change start — dismiss pending suggestions and cancel the session
|
||||
// before starting a new loop to avoid the runner ignoring the new work
|
||||
yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID))
|
||||
yield* state.cancel(input.sessionID)
|
||||
// kilocode_change end
|
||||
return yield* loop({ sessionID: input.sessionID })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// kilocode_change - new file
|
||||
export { Suggestion } from "../kilocode/suggestion/index"
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PlanExitTool } from "./plan"
|
||||
import { QuestionTool } from "./question"
|
||||
import { SuggestTool } from "../kilocode/suggestion/tool" // kilocode_change
|
||||
import { BashTool } from "./bash"
|
||||
import { EditTool } from "./edit"
|
||||
import { GlobTool } from "./glob"
|
||||
@@ -162,7 +161,6 @@ export namespace ToolRegistry {
|
||||
question: Tool.init(question),
|
||||
lsp: Tool.init(LspTool),
|
||||
plan: Tool.init(PlanExitTool),
|
||||
suggest: Tool.init(SuggestTool), // kilocode_change
|
||||
})
|
||||
|
||||
const kilo = yield* KiloToolRegistry.build() // kilocode_change
|
||||
@@ -187,7 +185,6 @@ export namespace ToolRegistry {
|
||||
tool.patch,
|
||||
...(Flag.KILO_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), // kilocode_change
|
||||
...(KiloToolRegistry.plan() ? [tool.plan] : []), // kilocode_change
|
||||
...KiloToolRegistry.suggest(tool.suggest), // kilocode_change
|
||||
...KiloToolRegistry.extra(kilo, cfg), // kilocode_change
|
||||
],
|
||||
task: tool.task,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// kilocode_change - new file
|
||||
export { SuggestTool } from "../kilocode/suggestion/tool"
|
||||
@@ -6,7 +6,6 @@ import type { RemoteProtocol } from "../../../src/kilo-sessions/remote-protocol"
|
||||
import { SessionPrompt } from "../../../src/session/prompt"
|
||||
import { Question } from "../../../src/question"
|
||||
import { Permission } from "../../../src/permission"
|
||||
import { Suggestion } from "../../../src/kilocode/suggestion" // kilocode_change
|
||||
|
||||
function fakeConn() {
|
||||
const sent: any[] = []
|
||||
@@ -491,51 +490,6 @@ describe("RemoteSender", () => {
|
||||
expect(sent[0].error).toContain("boom")
|
||||
})
|
||||
|
||||
test("suggestion_accept sends response after work completes", async () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const accept = spyOn(Suggestion, "accept").mockResolvedValue(true)
|
||||
const sender = RemoteSender.create({
|
||||
conn,
|
||||
directory: "/tmp/test",
|
||||
log: nolog,
|
||||
subscribe: fakeBus().subscribe,
|
||||
provide: async <R>(input: { directory: string; init?: () => Promise<unknown>; fn: () => R }) => input.fn(),
|
||||
})
|
||||
|
||||
sender.handle({
|
||||
type: "command",
|
||||
id: "req_suggestion_accept",
|
||||
command: "suggestion_accept",
|
||||
data: { requestID: "sug_1", index: 1 },
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
|
||||
expect(accept).toHaveBeenCalledWith({ requestID: "sug_1", index: 1 })
|
||||
expect(sent).toContainEqual({ type: "response", id: "req_suggestion_accept", result: {} })
|
||||
})
|
||||
|
||||
test("suggestion_dismiss with invalid data sends error response", () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const sender = RemoteSender.create({
|
||||
conn,
|
||||
directory: "/tmp/test",
|
||||
log: nolog,
|
||||
subscribe: fakeBus().subscribe,
|
||||
provide: async () => ({}) as any,
|
||||
})
|
||||
|
||||
sender.handle({
|
||||
type: "command",
|
||||
id: "req_suggestion_dismiss_bad",
|
||||
command: "suggestion_dismiss",
|
||||
data: { nope: true },
|
||||
})
|
||||
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0].error).toContain("invalid suggestion_dismiss data")
|
||||
})
|
||||
|
||||
test("question_reject sends response after work completes", async () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
let provideCalled = false
|
||||
@@ -859,7 +813,6 @@ describe("RemoteSender", () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const bus = fakeBus()
|
||||
|
||||
spyOn(Suggestion, "list").mockResolvedValue([])
|
||||
spyOn(Question, "list").mockResolvedValue([
|
||||
{ id: "question_1", sessionID: "ses_target", questions: [{ type: "text", text: "Continue?" }] } as any,
|
||||
{ id: "question_2", sessionID: "ses_other", questions: [{ type: "text", text: "Unrelated?" }] } as any,
|
||||
@@ -891,7 +844,6 @@ describe("RemoteSender", () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const bus = fakeBus()
|
||||
|
||||
spyOn(Suggestion, "list").mockResolvedValue([])
|
||||
spyOn(Question, "list").mockResolvedValue([])
|
||||
spyOn(Permission, "list").mockResolvedValue([
|
||||
{
|
||||
@@ -944,9 +896,6 @@ describe("RemoteSender", () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const bus = fakeBus()
|
||||
|
||||
spyOn(Suggestion, "list").mockResolvedValue([
|
||||
{ id: "sug_1", sessionID: "ses_other", text: "Review?", actions: [] } as any,
|
||||
])
|
||||
spyOn(Question, "list").mockResolvedValue([{ id: "question_1", sessionID: "ses_other", questions: [] } as any])
|
||||
spyOn(Permission, "list").mockResolvedValue([
|
||||
{
|
||||
@@ -974,53 +923,6 @@ describe("RemoteSender", () => {
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("subscribe replays pending suggestion for the subscribed session", async () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const bus = fakeBus()
|
||||
|
||||
spyOn(Suggestion, "list").mockResolvedValue([
|
||||
{
|
||||
id: "sug_1",
|
||||
sessionID: "ses_target",
|
||||
text: "Review?",
|
||||
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
|
||||
} as any,
|
||||
{
|
||||
id: "sug_2",
|
||||
sessionID: "ses_other",
|
||||
text: "Ignore",
|
||||
actions: [{ label: "Skip", prompt: "skip" }],
|
||||
} as any,
|
||||
])
|
||||
spyOn(Question, "list").mockResolvedValue([])
|
||||
spyOn(Permission, "list").mockResolvedValue([])
|
||||
|
||||
const sender = RemoteSender.create({
|
||||
conn,
|
||||
directory: "/tmp/test",
|
||||
log: nolog,
|
||||
subscribe: bus.subscribe,
|
||||
provide: async (input: any) => input.fn(),
|
||||
})
|
||||
|
||||
sender.handle({ type: "subscribe", sessionId: "ses_target" })
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
|
||||
const suggestionEvents = sent.filter((m: any) => m.event === "suggestion.shown")
|
||||
expect(suggestionEvents).toHaveLength(1)
|
||||
expect(suggestionEvents[0]).toEqual({
|
||||
type: "event",
|
||||
sessionId: "ses_target",
|
||||
event: "suggestion.shown",
|
||||
data: {
|
||||
id: "sug_1",
|
||||
sessionID: "ses_target",
|
||||
text: "Review?",
|
||||
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("system message is handled without error", () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const sender = RemoteSender.create({
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../../src/project/instance"
|
||||
import { Suggestion } from "../../../src/kilocode/suggestion"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
describe("suggestion", () => {
|
||||
test("show adds pending request with blocking flag", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const pending = Suggestion.show({
|
||||
sessionID: "ses_test",
|
||||
text: "Run review?",
|
||||
blocking: false,
|
||||
actions: [{ label: "Start", description: "Run it", prompt: "/local-review-uncommitted" }],
|
||||
})
|
||||
|
||||
const list = await Suggestion.list()
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0]?.blocking).toBe(false)
|
||||
expect(list[0]?.text).toBe("Run review?")
|
||||
|
||||
await Suggestion.dismiss(list[0]!.id)
|
||||
await expect(pending).rejects.toBeInstanceOf(Suggestion.DismissedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("accept resolves selected action and removes pending request", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = Suggestion.show({
|
||||
sessionID: "ses_test",
|
||||
text: "Next step?",
|
||||
actions: [
|
||||
{ label: "Review", description: "Start review", prompt: "/local-review-uncommitted" },
|
||||
{ label: "Test", description: "Run tests", prompt: "Run the relevant tests now." },
|
||||
],
|
||||
})
|
||||
|
||||
const list = await Suggestion.list()
|
||||
await Suggestion.accept({ requestID: list[0]!.id, index: 1 })
|
||||
|
||||
await expect(ask).resolves.toEqual({
|
||||
label: "Test",
|
||||
description: "Run tests",
|
||||
prompt: "Run the relevant tests now.",
|
||||
})
|
||||
await expect(Suggestion.list()).resolves.toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("dismiss rejects pending request and removes it", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = Suggestion.show({
|
||||
sessionID: "ses_test",
|
||||
text: "Review changes?",
|
||||
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
|
||||
})
|
||||
|
||||
const list = await Suggestion.list()
|
||||
await Suggestion.dismiss(list[0]!.id)
|
||||
|
||||
await expect(ask).rejects.toBeInstanceOf(Suggestion.DismissedError)
|
||||
await expect(Suggestion.list()).resolves.toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,162 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, spyOn } from "bun:test"
|
||||
import { Command } from "../../../src/command"
|
||||
import { Suggestion } from "../../../src/kilocode/suggestion"
|
||||
import { SuggestTool } from "../../../src/kilocode/suggestion/tool"
|
||||
|
||||
const ctx = {
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_assistant",
|
||||
callID: "call_suggest",
|
||||
agent: "code",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [
|
||||
{
|
||||
info: {
|
||||
id: "msg_user",
|
||||
role: "user",
|
||||
sessionID: "ses_test",
|
||||
time: { created: 1 },
|
||||
agent: "code",
|
||||
model: { providerID: "openai", modelID: "gpt-4" },
|
||||
},
|
||||
parts: [],
|
||||
},
|
||||
],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
describe("tool.suggest", () => {
|
||||
let show: ReturnType<typeof spyOn>
|
||||
let cmdGet: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
show = spyOn(Suggestion, "show")
|
||||
cmdGet = spyOn(Command, "get")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
show.mockRestore()
|
||||
cmdGet.mockRestore()
|
||||
})
|
||||
|
||||
test("returns dismissal result when suggestion is dismissed", async () => {
|
||||
const tool = await SuggestTool.init()
|
||||
show.mockRejectedValueOnce(new Suggestion.DismissedError())
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
suggest: "Run review?",
|
||||
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
|
||||
},
|
||||
ctx as any,
|
||||
)
|
||||
|
||||
expect(result.title).toBe("Suggestion dismissed")
|
||||
expect(result.output).toBe("User dismissed the suggestion.")
|
||||
expect(result.metadata.dismissed).toBe(true)
|
||||
})
|
||||
|
||||
test("resolves command template for slash-command action prompt", async () => {
|
||||
const tool = await SuggestTool.init()
|
||||
show.mockResolvedValueOnce({
|
||||
label: "Start review",
|
||||
description: "Run a local review now",
|
||||
prompt: "/local-review-uncommitted",
|
||||
})
|
||||
cmdGet.mockResolvedValueOnce({
|
||||
name: "local-review-uncommitted",
|
||||
description: "local review (uncommitted changes)",
|
||||
template: Promise.resolve("Review these uncommitted changes:\n\n## Files Changed\n..."),
|
||||
hints: [],
|
||||
})
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
suggest: "Run review?",
|
||||
actions: [{ label: "Start review", prompt: "/local-review-uncommitted" }],
|
||||
},
|
||||
ctx as any,
|
||||
)
|
||||
|
||||
expect(result.title).toBe("User accepted: Start review")
|
||||
expect(result.output).toContain("Review these uncommitted changes:")
|
||||
expect(result.output).toContain("Carry out the following request now")
|
||||
expect(result.metadata.dismissed).toBe(false)
|
||||
expect(result.metadata.accepted).toEqual({
|
||||
label: "Start review",
|
||||
description: "Run a local review now",
|
||||
prompt: "/local-review-uncommitted",
|
||||
})
|
||||
expect(cmdGet).toHaveBeenCalledWith("local-review-uncommitted")
|
||||
})
|
||||
|
||||
test("returns plain-text prompt directly for non-command actions", async () => {
|
||||
const tool = await SuggestTool.init()
|
||||
show.mockResolvedValueOnce({
|
||||
label: "Run tests",
|
||||
prompt: "Run the test suite and fix any failures",
|
||||
})
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
suggest: "Tests might need running",
|
||||
actions: [{ label: "Run tests", prompt: "Run the test suite and fix any failures" }],
|
||||
},
|
||||
ctx as any,
|
||||
)
|
||||
|
||||
expect(result.title).toBe("User accepted: Run tests")
|
||||
expect(result.output).toContain("Run the test suite and fix any failures")
|
||||
expect(result.output).toContain("Carry out the following request now")
|
||||
expect(result.metadata.dismissed).toBe(false)
|
||||
expect(cmdGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("falls back to raw prompt when command is not found", async () => {
|
||||
const tool = await SuggestTool.init()
|
||||
show.mockResolvedValueOnce({
|
||||
label: "Unknown cmd",
|
||||
prompt: "/nonexistent-command",
|
||||
})
|
||||
cmdGet.mockResolvedValueOnce(undefined)
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
suggest: "Try this?",
|
||||
actions: [{ label: "Unknown cmd", prompt: "/nonexistent-command" }],
|
||||
},
|
||||
ctx as any,
|
||||
)
|
||||
|
||||
expect(result.title).toBe("User accepted: Unknown cmd")
|
||||
expect(result.output).toContain("/nonexistent-command")
|
||||
expect(result.metadata.dismissed).toBe(false)
|
||||
})
|
||||
|
||||
test("falls back to raw prompt when template resolution fails", async () => {
|
||||
const tool = await SuggestTool.init()
|
||||
show.mockResolvedValueOnce({
|
||||
label: "Start review",
|
||||
prompt: "/local-review-uncommitted",
|
||||
})
|
||||
cmdGet.mockResolvedValueOnce({
|
||||
name: "local-review-uncommitted",
|
||||
description: "local review (uncommitted changes)",
|
||||
template: Promise.reject(new Error("git not found")),
|
||||
hints: [],
|
||||
})
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
suggest: "Run review?",
|
||||
actions: [{ label: "Start review", prompt: "/local-review-uncommitted" }],
|
||||
},
|
||||
ctx as any,
|
||||
)
|
||||
|
||||
expect(result.title).toBe("User accepted: Start review")
|
||||
expect(result.output).toContain("/local-review-uncommitted")
|
||||
expect(result.metadata.dismissed).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -72,34 +72,6 @@ test("ask - adds to pending list", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change start - review follow-up uses non-blocking question prompts
|
||||
test("ask - preserves blocking flag", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = Question.ask({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
blocking: false,
|
||||
questions: [
|
||||
{
|
||||
question: "Proceed with review suggestion?",
|
||||
header: "Code review",
|
||||
options: [{ label: "Start", description: "Run review" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const pending = await Question.list()
|
||||
expect(pending[0]?.blocking).toBe(false)
|
||||
|
||||
await Question.reject(pending[0].id)
|
||||
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
// reply tests
|
||||
|
||||
test("reply - resolves the pending ask with answers", async () => {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// kilocode_change - new file
|
||||
// Moved to test/kilocode/suggestion/suggestion.test.ts.
|
||||
@@ -32,37 +32,6 @@ describe("tool.registry", () => {
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start
|
||||
test("suggest is registered for cli and vscode only", async () => {
|
||||
const original = process.env["KILO_CLIENT"]
|
||||
const originalQuestion = process.env["KILO_ENABLE_QUESTION_TOOL"]
|
||||
const originalConfig = process.env["KILO_CONFIG_DIR"]
|
||||
try {
|
||||
for (const client of ["cli", "vscode", "desktop", "app"]) {
|
||||
process.env["KILO_CLIENT"] = client
|
||||
process.env["KILO_ENABLE_QUESTION_TOOL"] = client === "vscode" ? "true" : "false"
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
process.env["KILO_CONFIG_DIR"] = tmp.path
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ids = await ToolRegistry.ids()
|
||||
if (client === "cli" || client === "vscode") expect(ids).toContain("suggest")
|
||||
else expect(ids).not.toContain("suggest")
|
||||
},
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (original === undefined) delete process.env["KILO_CLIENT"]
|
||||
else process.env["KILO_CLIENT"] = original
|
||||
if (originalQuestion === undefined) delete process.env["KILO_ENABLE_QUESTION_TOOL"]
|
||||
else process.env["KILO_ENABLE_QUESTION_TOOL"] = originalQuestion
|
||||
if (originalConfig === undefined) delete process.env["KILO_CONFIG_DIR"]
|
||||
else process.env["KILO_CONFIG_DIR"] = originalConfig
|
||||
}
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
test("loads tools from .opencode/tool (singular)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// kilocode_change - new file
|
||||
// Moved to test/kilocode/suggestion/tool.test.ts.
|
||||
@@ -203,11 +203,6 @@ import type {
|
||||
SessionUpdateResponses,
|
||||
SessionViewedResponses,
|
||||
SubtaskPartInput,
|
||||
SuggestionAcceptErrors,
|
||||
SuggestionAcceptResponses,
|
||||
SuggestionDismissErrors,
|
||||
SuggestionDismissResponses,
|
||||
SuggestionListResponses,
|
||||
TelemetryCaptureErrors,
|
||||
TelemetryCaptureResponses,
|
||||
TextPartInput,
|
||||
@@ -4521,109 +4516,6 @@ export class Network extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Suggestion extends HeyApiClient {
|
||||
/**
|
||||
* List pending suggestions
|
||||
*
|
||||
* Get all pending suggestion requests across all sessions.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<SuggestionListResponses, unknown, ThrowOnError>({
|
||||
url: "/suggestion",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept suggestion request
|
||||
*
|
||||
* Accept a suggestion request from the AI assistant.
|
||||
*/
|
||||
public accept<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
requestID: string
|
||||
directory?: string
|
||||
workspace?: string
|
||||
index?: number
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "requestID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "index" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<SuggestionAcceptResponses, SuggestionAcceptErrors, ThrowOnError>({
|
||||
url: "/suggestion/{requestID}/accept",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss suggestion request
|
||||
*
|
||||
* Dismiss a suggestion request from the AI assistant.
|
||||
*/
|
||||
public dismiss<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
requestID: string
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "requestID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<SuggestionDismissResponses, SuggestionDismissErrors, ThrowOnError>({
|
||||
url: "/suggestion/{requestID}/dismiss",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Telemetry extends HeyApiClient {
|
||||
/**
|
||||
* Capture telemetry event
|
||||
@@ -5829,11 +5721,6 @@ export class KiloClient extends HeyApiClient {
|
||||
return (this._network ??= new Network({ client: this.client }))
|
||||
}
|
||||
|
||||
private _suggestion?: Suggestion
|
||||
get suggestion(): Suggestion {
|
||||
return (this._suggestion ??= new Suggestion({ client: this.client }))
|
||||
}
|
||||
|
||||
private _telemetry?: Telemetry
|
||||
get telemetry(): Telemetry {
|
||||
return (this._telemetry ??= new Telemetry({ client: this.client }))
|
||||
|
||||
@@ -418,10 +418,6 @@ export type QuestionRequest = {
|
||||
* Questions to ask
|
||||
*/
|
||||
questions: Array<QuestionInfo>
|
||||
/**
|
||||
* Whether this question blocks prompt input (default: true)
|
||||
*/
|
||||
blocking?: boolean
|
||||
tool?: {
|
||||
messageID: string
|
||||
callID: string
|
||||
@@ -509,65 +505,6 @@ export type EventSessionIdle = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SuggestionAction = {
|
||||
/**
|
||||
* Button or option label (1-5 words)
|
||||
*/
|
||||
label: string
|
||||
/**
|
||||
* Brief explanation of what this action does
|
||||
*/
|
||||
description?: string
|
||||
/**
|
||||
* Synthetic user prompt to inject when this action is accepted
|
||||
*/
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export type SuggestionRequest = {
|
||||
id: string
|
||||
sessionID: string
|
||||
/**
|
||||
* Suggestion text shown to the user
|
||||
*/
|
||||
text: string
|
||||
/**
|
||||
* Available actions the user can take
|
||||
*/
|
||||
actions: Array<SuggestionAction>
|
||||
/**
|
||||
* Whether this suggestion blocks prompt input (default: true)
|
||||
*/
|
||||
blocking?: boolean
|
||||
tool?: {
|
||||
messageID: string
|
||||
callID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSuggestionShown = {
|
||||
type: "suggestion.shown"
|
||||
properties: SuggestionRequest
|
||||
}
|
||||
|
||||
export type EventSuggestionAccepted = {
|
||||
type: "suggestion.accepted"
|
||||
properties: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
index: number
|
||||
action: SuggestionAction
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSuggestionDismissed = {
|
||||
type: "suggestion.dismissed"
|
||||
properties: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionCompacted = {
|
||||
type: "session.compacted"
|
||||
properties: {
|
||||
@@ -1147,9 +1084,6 @@ export type Event =
|
||||
| EventTodoUpdated
|
||||
| EventSessionStatus
|
||||
| EventSessionIdle
|
||||
| EventSuggestionShown
|
||||
| EventSuggestionAccepted
|
||||
| EventSuggestionDismissed
|
||||
| EventSessionCompacted
|
||||
| EventKiloSessionsRemoteStatusChanged
|
||||
| EventWorkspaceReady
|
||||
@@ -5757,98 +5691,6 @@ export type NetworkRejectResponses = {
|
||||
|
||||
export type NetworkRejectResponse = NetworkRejectResponses[keyof NetworkRejectResponses]
|
||||
|
||||
export type SuggestionListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/suggestion"
|
||||
}
|
||||
|
||||
export type SuggestionListResponses = {
|
||||
/**
|
||||
* List of pending suggestions
|
||||
*/
|
||||
200: Array<SuggestionRequest>
|
||||
}
|
||||
|
||||
export type SuggestionListResponse = SuggestionListResponses[keyof SuggestionListResponses]
|
||||
|
||||
export type SuggestionAcceptData = {
|
||||
body?: {
|
||||
/**
|
||||
* Zero-based action index to accept
|
||||
*/
|
||||
index: number
|
||||
}
|
||||
path: {
|
||||
requestID: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/suggestion/{requestID}/accept"
|
||||
}
|
||||
|
||||
export type SuggestionAcceptErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: NotFoundError
|
||||
}
|
||||
|
||||
export type SuggestionAcceptError = SuggestionAcceptErrors[keyof SuggestionAcceptErrors]
|
||||
|
||||
export type SuggestionAcceptResponses = {
|
||||
/**
|
||||
* Suggestion accepted successfully
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type SuggestionAcceptResponse = SuggestionAcceptResponses[keyof SuggestionAcceptResponses]
|
||||
|
||||
export type SuggestionDismissData = {
|
||||
body?: never
|
||||
path: {
|
||||
requestID: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/suggestion/{requestID}/dismiss"
|
||||
}
|
||||
|
||||
export type SuggestionDismissErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: NotFoundError
|
||||
}
|
||||
|
||||
export type SuggestionDismissError = SuggestionDismissErrors[keyof SuggestionDismissErrors]
|
||||
|
||||
export type SuggestionDismissResponses = {
|
||||
/**
|
||||
* Suggestion dismissed successfully
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type SuggestionDismissResponse = SuggestionDismissResponses[keyof SuggestionDismissResponses]
|
||||
|
||||
export type TelemetryCaptureData = {
|
||||
body?: {
|
||||
/**
|
||||
|
||||
@@ -5247,206 +5247,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/suggestion": {
|
||||
"get": {
|
||||
"operationId": "suggestion.list",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "workspace",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": "List pending suggestions",
|
||||
"description": "Get all pending suggestion requests across all sessions.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of pending suggestions",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SuggestionRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.suggestion.list({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/suggestion/{requestID}/accept": {
|
||||
"post": {
|
||||
"operationId": "suggestion.accept",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "workspace",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "requestID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"summary": "Accept suggestion request",
|
||||
"description": "Accept a suggestion request from the AI assistant.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Suggestion accepted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"index": {
|
||||
"description": "Zero-based action index to accept",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": ["index"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.suggestion.accept({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/suggestion/{requestID}/dismiss": {
|
||||
"post": {
|
||||
"operationId": "suggestion.dismiss",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "workspace",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "requestID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"summary": "Dismiss suggestion request",
|
||||
"description": "Dismiss a suggestion request from the AI assistant.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Suggestion dismissed successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.suggestion.dismiss({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/provider": {
|
||||
"get": {
|
||||
"operationId": "provider.list",
|
||||
@@ -11178,7 +10978,6 @@
|
||||
},
|
||||
"required": ["name", "data"]
|
||||
},
|
||||
|
||||
"Event.session.error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13284,18 +13083,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.lsp.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.edited"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.suggestion.shown"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.suggestion.accepted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.suggestion.dismissed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.tui.prompt.append"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user