diff --git a/node/agent_sdk/index.ts b/node/agent_sdk/index.ts index d941398..73a9657 100644 --- a/node/agent_sdk/index.ts +++ b/node/agent_sdk/index.ts @@ -115,6 +115,8 @@ export type { QuestionResponseSchema, ClientInfo, ServerInfo, + SteerInput, + SetPlanModeResult, } from "./schema"; // Schemas @@ -129,6 +131,8 @@ export { SlashCommandInfoSchema, parseEventPayload, parseRequestPayload, + SteerInputSchema, + SetPlanModeResultSchema, } from "./schema"; // Protocol diff --git a/node/agent_sdk/package.json b/node/agent_sdk/package.json index 4c7a04d..c2dc1ff 100644 --- a/node/agent_sdk/package.json +++ b/node/agent_sdk/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-agent-sdk", - "version": "0.1.5", + "version": "0.1.6", "description": "SDK for interacting with Kimi Code CLI", "license": "MIT", "repository": { diff --git a/node/agent_sdk/protocol.ts b/node/agent_sdk/protocol.ts index 53db334..b19630b 100644 --- a/node/agent_sdk/protocol.ts +++ b/node/agent_sdk/protocol.ts @@ -16,11 +16,13 @@ import { type ExternalTool, type ToolCallRequest, type ToolReturnValue, + SetPlanModeResultSchema, + type SetPlanModeResult, } from "./schema"; import { TransportError, ProtocolError, CliError } from "./errors"; import { log } from "./logger"; -const PROTOCOL_VERSION = "1.4"; +const PROTOCOL_VERSION = "1.5"; const SDK_NAME = "kimi-agent-sdk"; declare const __SDK_VERSION__: string; @@ -272,6 +274,21 @@ export class ProtocolClient { return Promise.resolve(); } + sendSetPlanMode(enabled: boolean): Promise { + return this.sendRequest("set_plan_mode", { enabled }) + .then((res) => { + const parsed = SetPlanModeResultSchema.safeParse(res); + if (!parsed.success) { + throw new ProtocolError("SCHEMA_MISMATCH", `Invalid set_plan_mode response: ${parsed.error.message}`); + } + return parsed.data; + }); + } + + sendSteer(content: string | ContentPart[]): Promise { + return this.sendRequest("steer", { user_input: content }).then(() => {}); + } + private async sendInitialize(externalTools?: ExternalTool[], clientInfo?: ClientInfo): Promise { let clientName = `${SDK_NAME}/${SDK_VERSION}`; if (clientInfo?.name && clientInfo?.version) { @@ -286,6 +303,7 @@ export class ProtocolClient { }, capabilities: { supports_question: true, + supports_plan_mode: true, }, }; diff --git a/node/agent_sdk/schema.ts b/node/agent_sdk/schema.ts index d2b935d..8ee5411 100644 --- a/node/agent_sdk/schema.ts +++ b/node/agent_sdk/schema.ts @@ -261,10 +261,12 @@ export const ExternalToolsResultSchema = z.object({ }); export type ExternalToolsResult = z.infer; -// Client capabilities (Wire 1.4) +// Client capabilities (Wire 1.4+) export const ClientCapabilitiesSchema = z.object({ // Whether the client supports handling QuestionRequest messages supports_question: z.boolean().optional(), + // Whether the client supports plan mode (Wire 1.5) + supports_plan_mode: z.boolean().optional(), }); export type ClientCapabilities = z.infer; @@ -345,6 +347,28 @@ export const QuestionResponseSchema = z.object({ }); export type QuestionResponse = z.infer; +// ============================================================================ +// Steer Input (Wire 1.5) +// ============================================================================ + +// Server→client echo event emitted after consuming each steer +export const SteerInputSchema = z.object({ + // User steer input, can be plain text or array of content parts + user_input: z.union([z.string(), z.array(ContentPartSchema)]), +}); +export type SteerInput = z.infer; + +// ============================================================================ +// SetPlanModeResult (Wire 1.5) +// ============================================================================ + +// Result of a SetPlanMode request (status: "ok" only; failures use JSON-RPC error) +export const SetPlanModeResultSchema = z.object({ + status: z.literal("ok"), + plan_mode: z.boolean(), +}); +export type SetPlanModeResult = z.infer; + // ============================================================================ // Wire Events // ============================================================================ @@ -383,6 +407,8 @@ export const StatusUpdateSchema = z.object({ token_usage: TokenUsageSchema.nullable().optional(), // Message ID for the current step message_id: z.string().nullable().optional(), + // Whether plan mode is active (null = unchanged, undefined = not sent) + plan_mode: z.boolean().nullable().optional(), }); export type StatusUpdate = z.infer; @@ -445,6 +471,7 @@ export type WireEvent = | { type: "ToolCall"; payload: ToolCall } | { type: "ToolCallPart"; payload: ToolCallPart } | { type: "ToolResult"; payload: ToolResult } + | { type: "SteerInput"; payload: SteerInput } | { type: "SubagentEvent"; payload: SubagentEvent } | { type: "ApprovalResponse"; payload: ApprovalResponseEvent } | { type: "ParseError"; payload: ParseErrorPayload }; @@ -468,6 +495,7 @@ export const EventSchemas: Record = { ToolCallPart: ToolCallPartSchema, ToolResult: ToolResultSchema, ApprovalResponse: ApprovalResponseEventSchema, + SteerInput: SteerInputSchema, }; // Request type -> schema mapping diff --git a/node/agent_sdk/session.ts b/node/agent_sdk/session.ts index 5491a78..5c87c88 100644 --- a/node/agent_sdk/session.ts +++ b/node/agent_sdk/session.ts @@ -27,6 +27,8 @@ export interface Turn { approve(requestId: string, response: ApprovalResponse): Promise; /** Respond to question request (Wire 1.4) */ respondQuestion(rpcRequestId: string, questionRequestId: string, answers: Record): Promise; + /** Steer the current turn with additional user input (Wire 1.5) */ + steer(content: string | ContentPart[]): Promise; /** Promise of the result after the turn is completed */ readonly result: Promise; } @@ -53,6 +55,10 @@ export interface Session { env: Record; // Exported external tools externalTools: ExternalTool[]; + /** Whether plan mode is currently enabled */ + readonly planMode: boolean; + /** Toggle plan mode on/off */ + setPlanMode(enabled: boolean): Promise; /** Send a message, returns a Turn object */ prompt(content: string | ContentPart[]): Turn; /** Close the session, release resources */ @@ -135,6 +141,14 @@ class TurnImpl implements Turn { } return client.sendQuestionResponse(rpcRequestId, questionRequestId, answers); } + + async steer(content: string | ContentPart[]): Promise { + const client = this.getCurrentClient(); + if (!client?.isRunning) { + throw new SessionError("SESSION_CLOSED", "Cannot steer: no active client"); + } + return client.sendSteer(content); + } } class SessionImpl implements Session { @@ -152,6 +166,7 @@ class SessionImpl implements Session { private _skillsDir?: string; private _shareDir?: string; private _slashCommands: SlashCommandInfo[] = []; + private _planMode: boolean = false; private _state: SessionState = "idle"; @@ -225,6 +240,21 @@ class SessionImpl implements Session { set externalTools(v: ExternalTool[]) { this._externalTools = v; } + get planMode(): boolean { + return this._planMode; + } + + async setPlanMode(enabled: boolean): Promise { + if (this._state === "closed") { + throw new SessionError("SESSION_CLOSED", "Session is closed"); + } + if (!this.client?.isRunning) { + throw new SessionError("SESSION_CLOSED", "Cannot set plan mode: no active client"); + } + const result = await this.client.sendSetPlanMode(enabled); + this._planMode = result.plan_mode; + return this._planMode; + } prompt(content: string | ContentPart[]): Turn { if (this._state === "closed") { diff --git a/node/agent_sdk/tests/schema.test.ts b/node/agent_sdk/tests/schema.test.ts index 52e72c1..1a23364 100644 --- a/node/agent_sdk/tests/schema.test.ts +++ b/node/agent_sdk/tests/schema.test.ts @@ -16,6 +16,9 @@ import { RpcMessageSchema, parseEventPayload, parseRequestPayload, + SteerInputSchema, + SetPlanModeResultSchema, + ClientCapabilitiesSchema, type ContentPart, type DisplayBlock, type UnknownBlock, @@ -619,6 +622,89 @@ describe("SubagentEvent parsing", () => { }); }); +// ============================================================================ +// SteerInput Tests +// ============================================================================ +describe("SteerInputSchema", () => { + it("parses string user_input", () => { + const input = { user_input: "change the approach" }; + const result = SteerInputSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data).toEqual(input); + }); + it("parses ContentPart[] user_input", () => { + const input = { user_input: [{ type: "text", text: "hello" }] }; + const result = SteerInputSchema.safeParse(input); + expect(result.success).toBe(true); + }); +}); + +// ============================================================================ +// SetPlanModeResult Tests +// ============================================================================ +describe("SetPlanModeResultSchema", () => { + it("parses valid result", () => { + const input = { status: "ok", plan_mode: true }; + const result = SetPlanModeResultSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data).toEqual(input); + }); + it("rejects invalid status", () => { + const input = { status: "error", plan_mode: true }; + const result = SetPlanModeResultSchema.safeParse(input); + expect(result.success).toBe(false); + }); +}); + +// ============================================================================ +// StatusUpdate plan_mode Tests +// ============================================================================ +describe("StatusUpdateSchema plan_mode", () => { + it("parses with plan_mode true", () => { + const input = { plan_mode: true }; + const result = StatusUpdateSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.plan_mode).toBe(true); + }); + it("parses with plan_mode null (unchanged)", () => { + const input = { plan_mode: null }; + const result = StatusUpdateSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.plan_mode).toBeNull(); + }); + it("parses without plan_mode (backwards compat)", () => { + const input = { context_usage: 0.5 }; + const result = StatusUpdateSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.plan_mode).toBeUndefined(); + }); +}); + +// ============================================================================ +// parseEventPayload SteerInput Tests +// ============================================================================ +describe("parseEventPayload SteerInput", () => { + it("parses SteerInput event", () => { + const result = parseEventPayload("SteerInput", { user_input: "redirect" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.type).toBe("SteerInput"); + } + }); +}); + +// ============================================================================ +// ClientCapabilities Tests +// ============================================================================ +describe("ClientCapabilitiesSchema", () => { + it("parses with supports_plan_mode", () => { + const input = { supports_question: true, supports_plan_mode: true }; + const result = ClientCapabilitiesSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.supports_plan_mode).toBe(true); + }); +}); + // ============================================================================ // JSON Round-trip Tests // ============================================================================ diff --git a/node/vscode_extension/package.json b/node/vscode_extension/package.json index 5d54cea..9893b4f 100644 --- a/node/vscode_extension/package.json +++ b/node/vscode_extension/package.json @@ -3,7 +3,7 @@ "publisher": "moonshot-ai", "displayName": "Kimi Code (Technical Preview)", "description": "Official Kimi Code plugin for VS Code", - "version": "0.4.4", + "version": "0.4.5", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/node/vscode_extension/scripts/download-cli.js b/node/vscode_extension/scripts/download-cli.js index 41552ae..92c6df1 100644 --- a/node/vscode_extension/scripts/download-cli.js +++ b/node/vscode_extension/scripts/download-cli.js @@ -11,6 +11,8 @@ const PLATFORMS = { "darwin-x64": { target: "x86_64-apple-darwin-onedir", ext: "tar.gz" }, "linux-arm64": { target: "aarch64-unknown-linux-gnu-onedir", ext: "tar.gz" }, "linux-x64": { target: "x86_64-unknown-linux-gnu-onedir", ext: "tar.gz" }, + "alpine-x64": { target: null, ext: null }, // No native CLI; UV fallback at runtime + "alpine-arm64": { target: null, ext: null }, // No native CLI; UV fallback at runtime "win32-x64": { target: "x86_64-pc-windows-msvc-onedir", ext: "zip" }, "win32-arm64": { target: "aarch64-pc-windows-msvc-onedir", ext: "zip" }, }; @@ -45,6 +47,7 @@ async function buildManifest(release, bundledPlatform) { const platforms = {}; for (const [key, info] of Object.entries(PLATFORMS)) { + if (!info.target) continue; // No native binary for this platform (e.g. Alpine) const filename = `kimi-${version}-${info.target}.${info.ext}`; const asset = release.assets.find((a) => a.name === filename); const sha256Asset = release.assets.find((a) => a.name === `${filename}.sha256`); diff --git a/node/vscode_extension/shared/bridge.ts b/node/vscode_extension/shared/bridge.ts index 5d9c4a7..9e6776b 100644 --- a/node/vscode_extension/shared/bridge.ts +++ b/node/vscode_extension/shared/bridge.ts @@ -36,6 +36,8 @@ export const Methods = { StreamChat: "streamChat", AbortChat: "abortChat", ResetSession: "resetSession", + SetPlanMode: "setPlanMode", + SteerChat: "steerChat", RespondApproval: "respondApproval", GetKimiSessions: "getKimiSessions", diff --git a/node/vscode_extension/src/handlers/chat.handler.ts b/node/vscode_extension/src/handlers/chat.handler.ts index 83b9e01..9bad988 100644 --- a/node/vscode_extension/src/handlers/chat.handler.ts +++ b/node/vscode_extension/src/handlers/chat.handler.ts @@ -283,6 +283,32 @@ const respondQuestion: Handler = async ( return { ok: true }; }; +interface SetPlanModeParams { + enabled: boolean; +} + +const setPlanMode: Handler = async (params, ctx) => { + const session = ctx.getSession(); + if (!session) { + return { ok: false, planMode: false }; + } + const planMode = await session.setPlanMode(params.enabled); + return { ok: true, planMode }; +}; + +interface SteerChatParams { + content: string | ContentPart[]; +} + +const steerChat: Handler = async (params, ctx) => { + const turn = ctx.getTurn(); + if (!turn) { + return { ok: false }; + } + await turn.steer(params.content); + return { ok: true }; +}; + const resetSession: Handler = async (_, ctx) => { const session = ctx.getSession(); if (session) { @@ -298,5 +324,7 @@ export const chatHandlers: Record> = { [Methods.AbortChat]: abortChat, [Methods.RespondApproval]: respondApproval, [Methods.RespondQuestion]: respondQuestion, + [Methods.SetPlanMode]: setPlanMode, + [Methods.SteerChat]: steerChat, [Methods.ResetSession]: resetSession, }; diff --git a/node/vscode_extension/src/managers/cli-downloader.ts b/node/vscode_extension/src/managers/cli-downloader.ts index ffe6f08..49059f4 100644 --- a/node/vscode_extension/src/managers/cli-downloader.ts +++ b/node/vscode_extension/src/managers/cli-downloader.ts @@ -37,16 +37,29 @@ const PLATFORMS: Record = { "darwin-x64": { uv: { target: "x86_64-apple-darwin", ext: "tar.gz" }, exe: "kimi", wrapper: "kimi" }, "linux-arm64": { uv: { target: "aarch64-unknown-linux-gnu", ext: "tar.gz" }, exe: "kimi", wrapper: "kimi" }, "linux-x64": { uv: { target: "x86_64-unknown-linux-gnu", ext: "tar.gz" }, exe: "kimi", wrapper: "kimi" }, + "alpine-arm64": { uv: { target: "aarch64-unknown-linux-musl", ext: "tar.gz" }, exe: "kimi", wrapper: "kimi" }, + "alpine-x64": { uv: { target: "x86_64-unknown-linux-musl", ext: "tar.gz" }, exe: "kimi", wrapper: "kimi" }, "win32-x64": { uv: { target: "x86_64-pc-windows-msvc", ext: "zip" }, exe: "kimi.exe", wrapper: "kimi.bat" }, }; +function isMusl(): boolean { + try { + // Alpine/musl: ldd --version writes to stderr and contains "musl" + const output = execSync("ldd --version 2>&1 || true", { encoding: "utf-8" }); + return output.toLowerCase().includes("musl"); + } catch { + return false; + } +} + export function getPlatformKey(): string { const { platform, arch } = process; if (platform === "darwin") { return arch === "arm64" ? "darwin-arm64" : "darwin-x64"; } if (platform === "linux") { - return arch === "arm64" ? "linux-arm64" : "linux-x64"; + const prefix = isMusl() ? "alpine" : "linux"; + return arch === "arm64" ? `${prefix}-arm64` : `${prefix}-x64`; } if (platform === "win32") { return "win32-x64"; diff --git a/node/vscode_extension/webview-ui/src/components/ChatMessage.tsx b/node/vscode_extension/webview-ui/src/components/ChatMessage.tsx index 580c8f3..ae10368 100644 --- a/node/vscode_extension/webview-ui/src/components/ChatMessage.tsx +++ b/node/vscode_extension/webview-ui/src/components/ChatMessage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, Fragment } from "react"; import { IconLoader3, IconGitFork } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { Content } from "@/lib/content"; @@ -10,6 +10,7 @@ import { CompactionCard } from "./CompactionCard"; import { MediaThumbnail } from "./MediaThumbnail"; import { MediaPreviewModal } from "./MediaPreviewModal"; import { InlineError } from "./InlineError"; +import { PlanCard } from "./PlanCard"; import { StreamingConfirmDialog } from "./StreamingConfirmDialog"; import { Button } from "@/components/ui/button"; import { useChatStore } from "@/stores"; @@ -32,6 +33,17 @@ function ThinkingIndicator() { ); } +function SteerBubble({ content }: { content: string | import("@moonshot-ai/kimi-agent-sdk/schema").ContentPart[] }) { + const text = typeof content === "string" ? content : Content.getText(content); + return ( +
+
+

{text}

+
+
+ ); +} + function StepItemRenderer({ item }: { item: UIStepItem }) { switch (item.type) { case "thinking": @@ -42,6 +54,8 @@ function StepItemRenderer({ item }: { item: UIStepItem }) { return ; case "compaction": return ; + case "steer": + return ; default: return null; } @@ -102,6 +116,26 @@ function MessageMedia({ images, videos, onPreview }: { images: string[]; videos: ); } +interface StepGroup { + planMode: boolean; + steps: UIStep[]; + startIndex: number; +} + +function groupStepsByPlanMode(steps: UIStep[]): StepGroup[] { + const groups: StepGroup[] = []; + for (let i = 0; i < steps.length; i++) { + const isPlan = steps[i].planMode === true; + const last = groups.at(-1); + if (last && last.planMode === isPlan) { + last.steps.push(steps[i]); + } else { + groups.push({ planMode: isPlan, steps: [steps[i]], startIndex: i }); + } + } + return groups; +} + interface ForkButtonProps { turnIndex: number; className?: string; @@ -245,10 +279,22 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe
{hasSteps && - steps.map((step, idx) => { - const hasNextIndicator = stepHasIndicator.slice(idx + 1).some(Boolean); - const showConnector = stepHasIndicator[idx] && hasNextIndicator; - return ; + groupStepsByPlanMode(steps).map((group, gi) => { + const totalSteps = steps.length; + const stepsContent = group.steps.map((step, i) => { + const globalIndex = group.startIndex + i; + const isLastInGroup = i === group.steps.length - 1; + const isLastOverall = globalIndex === totalSteps - 1; + const hasIndicator = stepHasIndicator[globalIndex]; + const hasNextIndicator = stepHasIndicator.slice(globalIndex + 1).some(Boolean); + const showConnector = hasIndicator && hasNextIndicator && !isLastInGroup && !isLastOverall; + return ; + }); + + if (group.planMode) { + return {stepsContent}; + } + return {stepsContent}; })} {!hasSteps && displayContent && } {(images.length > 0 || videos.length > 0) && ( diff --git a/node/vscode_extension/webview-ui/src/components/PlanCard.tsx b/node/vscode_extension/webview-ui/src/components/PlanCard.tsx new file mode 100644 index 0000000..df0decf --- /dev/null +++ b/node/vscode_extension/webview-ui/src/components/PlanCard.tsx @@ -0,0 +1,30 @@ +import { type ReactNode, useState } from "react"; +import { IconClipboardList, IconChevronDown } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; + +interface PlanCardProps { + children: ReactNode; +} + +export function PlanCard({ children }: PlanCardProps) { + const [collapsed, setCollapsed] = useState(false); + + return ( +
+ + {!collapsed && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/node/vscode_extension/webview-ui/src/components/PlanModeButton.tsx b/node/vscode_extension/webview-ui/src/components/PlanModeButton.tsx new file mode 100644 index 0000000..be2d0c3 --- /dev/null +++ b/node/vscode_extension/webview-ui/src/components/PlanModeButton.tsx @@ -0,0 +1,32 @@ +import { IconClipboardList } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface PlanModeButtonProps { + active: boolean; + onToggle: () => void; +} + +export function PlanModeButton({ active, onToggle }: PlanModeButtonProps) { + const tooltipText = active ? "Plan mode active (click to exit)" : "Enter plan mode"; + + return ( + + + + + {tooltipText} + + ); +} diff --git a/node/vscode_extension/webview-ui/src/components/QueuedMessagesPanel.tsx b/node/vscode_extension/webview-ui/src/components/QueuedMessagesPanel.tsx index bb663b2..616f62c 100644 --- a/node/vscode_extension/webview-ui/src/components/QueuedMessagesPanel.tsx +++ b/node/vscode_extension/webview-ui/src/components/QueuedMessagesPanel.tsx @@ -1,15 +1,23 @@ import { useState } from "react"; -import { IconTrash, IconArrowUp, IconPencil, IconCheck, IconX } from "@tabler/icons-react"; +import { IconTrash, IconArrowUp, IconPencil, IconCheck, IconX, IconBolt } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { useChatStore } from "@/stores"; +import { bridge } from "@/services"; import { Content } from "@/lib/content"; -function QueueItem({ id, content, onEdit }: { id: string; content: string | import("@moonshot-ai/kimi-agent-sdk/schema").ContentPart[]; onEdit: (id: string) => void }) { +function QueueItem({ id, content, isStreaming, onEdit }: { id: string; content: string | import("@moonshot-ai/kimi-agent-sdk/schema").ContentPart[]; isStreaming: boolean; onEdit: (id: string) => void }) { const { removeFromQueue, moveQueueItemUp, queue } = useChatStore(); const text = Content.getText(content); const hasMedia = Content.hasMedia(content); const isFirst = queue[0]?.id === id; + const handleSteer = async () => { + const result = await bridge.steerChat(content); + if (result.ok) { + removeFromQueue(id); + } + }; + return (
@@ -17,6 +25,11 @@ function QueueItem({ id, content, onEdit }: { id: string; content: string | impo {hasMedia && text && + media}
+ {isStreaming && ( + + )} @@ -67,7 +80,7 @@ function EditingItem({ id, initialContent, onDone }: { id: string; initialConten } export function QueuedMessagesPanel() { - const { queue } = useChatStore(); + const { queue, isStreaming } = useChatStore(); const [editingId, setEditingId] = useState(null); if (queue.length === 0) return null; @@ -78,7 +91,7 @@ export function QueuedMessagesPanel() { editingId === item.id ? ( setEditingId(null)} /> ) : ( - + ), )}
diff --git a/node/vscode_extension/webview-ui/src/components/index.ts b/node/vscode_extension/webview-ui/src/components/index.ts index 62888a4..4167156 100644 --- a/node/vscode_extension/webview-ui/src/components/index.ts +++ b/node/vscode_extension/webview-ui/src/components/index.ts @@ -22,3 +22,5 @@ export { MediaThumbnail } from "./MediaThumbnail"; export { BottomToolbar } from "./BottomToolbar"; export { InlineError } from "./InlineError"; export { QuestionDialog } from "./QuestionDialog"; +export { PlanCard } from "./PlanCard"; +export { PlanModeButton } from "./PlanModeButton"; diff --git a/node/vscode_extension/webview-ui/src/components/inputarea/InputArea.tsx b/node/vscode_extension/webview-ui/src/components/inputarea/InputArea.tsx index f16e472..317185a 100644 --- a/node/vscode_extension/webview-ui/src/components/inputarea/InputArea.tsx +++ b/node/vscode_extension/webview-ui/src/components/inputarea/InputArea.tsx @@ -10,7 +10,9 @@ import { FilePickerMenu } from "../FilePickerMenu"; import { MediaThumbnail } from "../MediaThumbnail"; import { MediaPreviewModal } from "../MediaPreviewModal"; import { BottomToolbar } from "../BottomToolbar"; +import { StreamingConfirmDialog } from "../StreamingConfirmDialog"; import { ThinkingButton } from "../ThinkingButton"; +import { PlanModeButton } from "../PlanModeButton"; import { useChatStore, useSettingsStore, getModelById, getModelsForMedia } from "@/stores"; import { bridge, Events } from "@/services"; import { Content } from "@/lib/content"; @@ -33,12 +35,31 @@ export function InputArea({ onAuthAction }: InputAreaProps) { const [cursorPos, setCursorPos] = useState(0); const [previewMedia, setPreviewMedia] = useState(null); - const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, queue } = useChatStore(); + const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, queue, planMode } = useChatStore(); const { currentModel, thinkingEnabled, updateModel, toggleThinking, models, extensionConfig, getCurrentThinkingMode } = useSettingsStore(); const isProcessing = hasProcessingMedia(); const thinkingMode = getCurrentThinkingMode(); + const [showPlanModeConfirm, setShowPlanModeConfirm] = useState(false); + + const handleTogglePlanMode = () => { + // Turning OFF during streaming needs confirmation — user may want next turn, not current + if (planMode && isStreaming) { + setShowPlanModeConfirm(true); + return; + } + const newState = !planMode; + useChatStore.setState({ planMode: newState }); // optimistic + bridge.setPlanMode(newState); + }; + + const handleConfirmExitPlanMode = () => { + useChatStore.setState({ planMode: false }); + bridge.setPlanMode(false); + setShowPlanModeConfirm(false); + }; + const mediaReq = useMemo(() => { const media = getMediaInConversation(); return { image: media.hasImage, video: media.hasVideo }; @@ -374,6 +395,7 @@ export function InputArea({ onAuthAction }: InputAreaProps) { +
@@ -402,6 +424,14 @@ export function InputArea({ onAuthAction }: InputAreaProps) {
setPreviewMedia(null)} /> +
); } diff --git a/node/vscode_extension/webview-ui/src/services/bridge.ts b/node/vscode_extension/webview-ui/src/services/bridge.ts index c46294a..98ea21c 100644 --- a/node/vscode_extension/webview-ui/src/services/bridge.ts +++ b/node/vscode_extension/webview-ui/src/services/bridge.ts @@ -276,6 +276,14 @@ class Bridge { return this.call(Methods.GetImageDataUri, { filePath }); } + setPlanMode(enabled: boolean) { + return this.call<{ ok: boolean; planMode: boolean }>(Methods.SetPlanMode, { enabled }); + } + + steerChat(content: string | ContentPart[]) { + return this.call<{ ok: boolean }>(Methods.SteerChat, { content }); + } + showLogs() { return this.call<{ ok: boolean }>(Methods.ShowLogs); } diff --git a/node/vscode_extension/webview-ui/src/stores/chat.store.ts b/node/vscode_extension/webview-ui/src/stores/chat.store.ts index c753c0e..2890656 100644 --- a/node/vscode_extension/webview-ui/src/stores/chat.store.ts +++ b/node/vscode_extension/webview-ui/src/stores/chat.store.ts @@ -20,6 +20,7 @@ export interface UIToolCall { export interface UIStep { n: number; items: UIStepItem[]; + planMode?: boolean; } export interface InlineError { @@ -32,6 +33,7 @@ export type UIStepItem = | { type: "thinking"; content: string; finished?: boolean } | { type: "text"; content: string; finished?: boolean } | { type: "compaction" } + | { type: "steer"; content: string | ContentPart[] } | { type: "tool_use"; id: string; @@ -95,6 +97,7 @@ export interface ChatState { pendingInput: PendingInput | null; queue: QueuedItem[]; pendingQuestion: QuestionRequest | null; + planMode: boolean; sendMessage: (text: string) => void; retryLastMessage: () => void; @@ -169,6 +172,7 @@ export const useChatStore = create((set, get) => ({ pendingInput: null, queue: [], pendingQuestion: null, + planMode: false, sendMessage: (text) => { const { draftMedia, isStreaming } = get(); @@ -274,6 +278,7 @@ export const useChatStore = create((set, get) => ({ pendingInput: null, queue: [], pendingQuestion: null, + planMode: false, }); useApprovalStore.getState().clearRequests(); bridge.clearTrackedFiles(); @@ -328,6 +333,7 @@ export const useChatStore = create((set, get) => ({ pendingInput: null, queue: [], pendingQuestion: null, + planMode: false, }); useApprovalStore.getState().clearRequests(); }, diff --git a/node/vscode_extension/webview-ui/src/stores/event-handlers.ts b/node/vscode_extension/webview-ui/src/stores/event-handlers.ts index f9a3704..e84ca1c 100644 --- a/node/vscode_extension/webview-ui/src/stores/event-handlers.ts +++ b/node/vscode_extension/webview-ui/src/stores/event-handlers.ts @@ -388,6 +388,14 @@ const eventHandlers: Record = { } applyEventToSteps(last.steps, { type: "StepBegin", payload }); + + // Tag the newly created step with current plan mode state + // Note: only top-level steps get tagged. Subagent steps go through + // SubagentEvent -> applyEventToSteps and won't inherit main agent's planMode. + const newStep = last.steps.at(-1); + if (newStep && draft.planMode) { + newStep.planMode = true; + } } }, @@ -504,7 +512,11 @@ const eventHandlers: Record = { }, StatusUpdate: (draft, payload) => { - const { context_usage, token_usage } = payload; + const { context_usage, token_usage, plan_mode } = payload; + + if (plan_mode !== undefined && plan_mode !== null) { + draft.planMode = plan_mode; + } if (token_usage) { addTokenUsage(draft.activeTokenUsage, { @@ -523,6 +535,17 @@ const eventHandlers: Record = { last.status = draft.lastStatus; } }, + + SteerInput: (draft, payload: { user_input: string | ContentPart[] }) => { + const last = getLastAssistant(draft); + if (!last?.steps) return; + + const currentStep = last.steps.at(-1); + if (!currentStep) return; + + finishAllTextItems(last.steps); + currentStep.items.push({ type: "steer", content: payload.user_input }); + }, }; export function processEvent(draft: ChatState, event: UIStreamEvent): void {