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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/kilo-vscode/src/kilo-provider-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ export type WebviewMessage =
}
}
| { type: "todoUpdated"; sessionID: string; items: unknown[] }
| { type: "questionRequest"; question: { id: string; sessionID: string; questions: unknown[]; tool?: unknown } }
| {
type: "questionRequest"
question: { id: string; sessionID: string; questions: unknown[]; blocking?: boolean; tool?: unknown }
}
| { type: "questionResolved"; requestID: string }
| { type: "sessionCreated"; session: ReturnType<typeof sessionToWebview> }
| { type: "sessionUpdated"; session: ReturnType<typeof sessionToWebview> }
Expand Down Expand Up @@ -241,6 +244,7 @@ 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,
},
}
Expand Down
166 changes: 165 additions & 1 deletion packages/kilo-vscode/src/services/cli-backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,171 @@
// equivalents in @kilocode/sdk. All API types (Session, Event, Agent,
// McpStatus, Config, etc.) should be imported from "@kilocode/sdk/v2/client".

/** Connection config used by the extension to reach the local CLI server */
// Session status from SessionStatus.Info
export type SessionStatusInfo =
| { type: "idle" }
| { type: "retry"; attempt: number; message: string; next: number }
| { type: "busy" }

// Token usage shape returned by the server on assistant messages
export interface TokenUsage {
input: number
output: number
reasoning?: number
cache?: { read: number; write: number }
}

// Message types from MessageV2
export interface MessageInfo {
id: string
sessionID: string
role: "user" | "assistant"
time: {
created: number
completed?: number
}
agent?: string
providerID?: string
modelID?: string
model?: { providerID: string; modelID: string }
mode?: string
parentID?: string
path?: { cwd: string; root: string }
error?: { name: string; data?: Record<string, unknown> }
summary?: { title?: string; body?: string; diffs?: unknown[] } | boolean
cost?: number
tokens?: TokenUsage
}

// Part types - simplified for UI display
export type MessagePart =
| { type: "text"; id: string; text: string }
| { type: "tool"; id: string; tool: string; state: ToolState }
| { type: "reasoning"; id: string; text: string }

export type ToolState =
| { status: "pending"; input: Record<string, unknown> }
| { status: "running"; input: Record<string, unknown>; title?: string }
| { status: "completed"; input: Record<string, unknown>; output: string; title: string }
| { status: "error"; input: Record<string, unknown>; error: string }

// Permission request from PermissionNext.Request
export interface PermissionRequest {
id: string
sessionID: string
permission: string
patterns: string[]
metadata: Record<string, unknown>
always: string[]
tool?: {
messageID: string
callID: string
}
}

// SSE Event types - based on BusEvent definitions
export type SSEEvent =
| { type: "server.connected"; properties: Record<string, never> }
| { type: "server.heartbeat"; properties: Record<string, never> }
| { type: "session.created"; properties: { info: SessionInfo } }
| { type: "session.updated"; properties: { info: SessionInfo } }
| { type: "session.status"; properties: { sessionID: string; status: SessionStatusInfo } }
| { type: "session.idle"; properties: { sessionID: string } }
| { type: "message.updated"; properties: { info: MessageInfo } }
| { type: "message.part.updated"; properties: { part: MessagePart; delta?: string } }
| {
type: "message.part.delta"
properties: { sessionID: string; messageID: string; partID: string; field: string; delta: string }
}
| { type: "permission.asked"; properties: PermissionRequest }
| {
type: "permission.replied"
properties: { sessionID: string; requestID: string; reply: "once" | "always" | "reject" }
}
| { type: "todo.updated"; properties: { sessionID: string; items: TodoItem[] } }
| { type: "question.asked"; properties: QuestionRequest }
| { type: "question.replied"; properties: { sessionID: string; requestID: string; answers: string[][] } }
| { type: "question.rejected"; properties: { sessionID: string; requestID: string } }

export interface TodoItem {
id: string
content: string
status: "pending" | "in_progress" | "completed"
}

// Question types from Question module
export interface QuestionOption {
label: string
description: string
}

export interface QuestionInfo {
question: string
header: string
options: QuestionOption[]
multiple?: boolean
custom?: boolean
}

export interface QuestionRequest {
id: string
sessionID: string
questions: QuestionInfo[]
blocking?: boolean
tool?: {
messageID: string
callID: string
}
}

// Agent/mode info from the CLI /agent endpoint
export interface AgentInfo {
name: string
description?: string
mode: "subagent" | "primary" | "all"
native?: boolean
hidden?: boolean
color?: string
}

// Provider/model types from provider catalog

// Model definition from provider catalog
export interface ProviderModel {
id: string
name: string
inputPrice?: number
outputPrice?: number
contextLength?: number
releaseDate?: string
latest?: boolean
// Actual shape returned by the server (Provider.Model)
limit?: { context: number; input?: number; output: number }
variants?: Record<string, Record<string, unknown>>
capabilities?: { reasoning: boolean }
}

// Provider definition
export interface Provider {
id: string
name: string
models: Record<string, ProviderModel>
}

// Response from provider list endpoint
export interface ProviderListResponse {
all: Record<string, Provider>
connected: string[]
default: Record<string, string> // providerID → default modelID
}

// Model selection (providerID + modelID pair)
export interface ModelSelection {
providerID: string
modelID: string
}

// Server connection config
export interface ServerConfig {
baseUrl: string
password: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const hasMessages = () => session.messages().length > 0
const idle = () => session.status() !== "busy"
const sessionQuestions = () => session.questions().filter((q) => q.sessionID === id())
const blockingQuestions = () => sessionQuestions().filter((q) => q.blocking !== false)
const nonBlockingQuestions = () => sessionQuestions().filter((q) => q.blocking === false)
const sessionPermissions = () => session.permissions().filter((p) => p.sessionID === id())

const questionRequest = () => sessionQuestions().find((q) => !q.tool)
const questionRequest = () => blockingQuestions()[0] ?? nonBlockingQuestions()[0]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Tool-attached questions will now render twice

AssistantMessage already renders questions with a tool context inline next to the originating tool call. Because this selector no longer keeps the !q.tool guard, those same questions can also appear in the bottom dock, which duplicates the UI and can surface the wrong prompt when both tool and session questions are pending.

const permissionRequest = () => sessionPermissions().find((p) => !p.tool)
const blocked = () => sessionPermissions().length > 0 || sessionQuestions().length > 0
const blocked = () => sessionPermissions().length > 0 || blockingQuestions().length > 0

// When a bottom-dock permission/question disappears while the session is busy,
// the scroll container grows taller. Dispatch a custom event so MessageList can
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/webview-ui/src/types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export interface QuestionRequest {
id: string
sessionID: string
questions: QuestionInfo[]
blocking?: boolean
tool?: {
messageID: string
callID: string
Expand Down
17 changes: 13 additions & 4 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ export function Session() {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.question[x.id] ?? [])
})
const blockingQuestions = createMemo(() => questions().filter((q) => q.blocking !== false)) // kilocode_change
const nonBlockingQuestions = createMemo(() => questions().filter((q) => q.blocking === false)) // kilocode_change
const question = createMemo(() => blockingQuestions()[0] ?? nonBlockingQuestions()[0]) // kilocode_change

const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
Expand Down Expand Up @@ -1123,11 +1126,17 @@ export function Session() {
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
<Show when={permissions().length === 0 && question()} keyed>
{(request) => (
<QuestionPrompt
request={request}
nonBlocking={request.blocking === false}
inputFocused={() => prompt?.focused ?? false}
/>
)}
</Show>
<Prompt
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
visible={!session()?.parentID && permissions().length === 0 && blockingQuestions().length === 0}
ref={(r) => {
prompt = r
promptRef.set(r)
Expand All @@ -1136,7 +1145,7 @@ export function Session() {
r.set(route.initialPrompt)
}
}}
disabled={permissions().length > 0 || questions().length > 0}
disabled={permissions().length > 0 || blockingQuestions().length > 0}
onSubmit={() => {
toBottom()
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { SplitBorder } from "../../component/border"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useDialog } from "../../ui/dialog"

export function QuestionPrompt(props: { request: QuestionRequest }) {
export function QuestionPrompt(props: {
request: QuestionRequest
nonBlocking?: boolean // kilocode_change
inputFocused?: () => boolean // kilocode_change
}) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
Expand Down Expand Up @@ -126,6 +130,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
// Skip processing if a dialog (e.g., command palette) is open
if (dialog.stack.length > 0) return

// kilocode_change - avoid intrusive key capture for non-blocking review suggestions
if (props.nonBlocking && props.inputFocused?.()) return

// When editing custom answer textarea
if (store.editing && !confirm()) {
if (evt.name === "escape") {
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/kilocode/plan-followup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ 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"

Expand Down Expand Up @@ -216,7 +217,7 @@ export namespace PlanFollowup {
Todo.get(input.sessionID),
])

const sections = [`Implement the following plan:\n\n${input.plan}`]
const sections = [`${PLAN_PREFIX}\n\n${input.plan}`]

if (handover) {
sections.push(`## Handover from Planning Session\n\n${handover}`)
Expand Down
99 changes: 99 additions & 0 deletions packages/opencode/src/kilocode/review-followup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Flag } from "@/flag/flag"
import { Identifier } from "@/id/id"
import { Question } from "@/question"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
import { Review } from "@/kilocode/review/review"

export namespace ReviewFollowup {
export const ANSWER_START = "Start code review"
export const ANSWER_SKIP = "Continue without review"

async function inject(input: { sessionID: string; model: MessageV2.User["model"]; text: string }) {
const msg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: input.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "code",
model: input.model,
}
await Session.updateMessage(msg)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: msg.id,
sessionID: input.sessionID,
type: "text",
text: input.text,
synthetic: true,
} satisfies MessageV2.TextPart)
}

function prompt(input: { sessionID: string; abort: AbortSignal }) {
const promise = Question.ask({
sessionID: input.sessionID,
blocking: Flag.KILO_CLIENT !== "vscode",
questions: [
{
question: "Start an immediate review of uncommitted changes?",
header: "Code review",
custom: false,
options: [
{
label: ANSWER_START,
description: "Run a local review for current uncommitted changes",
},
{
label: ANSWER_SKIP,
description: "Dismiss the review suggestion and continue",
},
],
},
],
})

const listener = () =>
Question.list().then((qs) => {
const match = qs.find((q) => q.sessionID === input.sessionID)
if (match) Question.reject(match.id)
})
input.abort.addEventListener("abort", listener, { once: true })

return promise
.catch((error) => {
if (error instanceof Question.RejectedError) return undefined
throw error
})
.finally(() => {
input.abort.removeEventListener("abort", listener)
})
}

export async function ask(input: {
sessionID: string
messages: MessageV2.WithParts[]
abort: AbortSignal
}): Promise<"continue" | "break"> {
if (input.abort.aborted) return "break"

const user = input.messages
.slice()
.reverse()
.find((msg) => msg.info.role === "user")?.info
if (!user || user.role !== "user" || !user.model) return "break"

const answers = await prompt({ sessionID: input.sessionID, abort: input.abort })
const answer = answers?.[0]?.[0]?.trim()
if (answer !== ANSWER_START) return "break"

const text = await Review.buildReviewPromptUncommitted()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: New-file-only changes are missed by the auto-review

Review.buildReviewPromptUncommitted() is backed by git diff HEAD, which ignores untracked files. If the implementation creates a brand-new file and nothing else, accepting this follow-up produces a "No changes detected" review even though the worktree still has uncommitted changes.

await inject({
sessionID: input.sessionID,
model: user.model,
text,
})
return "continue"
}
}
Loading