feat(web): render plan review card with plan body and approach choices (#1101)

* feat(web): render plan review card with plan body and approach choices

The ExitPlanMode plan_review approval in the web UI now renders the plan body as Markdown with one button per approach option, plus Revise and Reject-and-Exit, with the selected label threaded back to the server.

The approval header keeps APPROVAL REQUIRED and the minimize control on the title row and shows the plan path on a second line, and the plan body uses up to half the viewport height.

The ExitPlanMode tool card also gains a link to the plan file, currently hidden behind a flag until the server can read files outside the workspace.

* fix(web): hide misleading shortcut numbers on plan review actions

When a plan review has approach options, the option buttons already own [1]/[2]/[3]. Revise and Reject-and-Exit advertised the same numbers even though those keys approve an option, so hide their shortcut labels whenever options are present.
This commit is contained in:
qer 2026-06-25 19:25:23 +08:00 committed by GitHub
parent 77412b89fa
commit 3ea6ac278d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 265 additions and 19 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Show the plan body and approach choices in the plan review card when exiting plan mode in the web UI.

View file

@ -43,6 +43,10 @@ export interface KimiClientState {
activeSessionId?: string;
messagesBySession: Record<string, AppMessage[]>;
approvalsBySession: Record<string, AppApprovalRequest[]>;
/** Preserved `plan_review` displays keyed by toolCallId. Plan content survives
* approval resolution so the ExitPlanMode tool card can keep rendering the
* plan (approved / rejected / revised) instead of losing it. */
planReviewByToolCallId: Record<string, { plan: string; path?: string }>;
questionsBySession: Record<string, AppQuestionRequest[]>;
tasksBySession: Record<string, AppTask[]>;
goalBySession: Record<string, AppGoal>;
@ -58,6 +62,7 @@ export function createInitialState(): KimiClientState {
activeSessionId: undefined,
messagesBySession: {},
approvalsBySession: {},
planReviewByToolCallId: {},
questionsBySession: {},
tasksBySession: {},
goalBySession: {},
@ -77,6 +82,7 @@ function cloneState(s: KimiClientState): KimiClientState {
sessions: [...s.sessions],
messagesBySession: { ...s.messagesBySession },
approvalsBySession: { ...s.approvalsBySession },
planReviewByToolCallId: { ...s.planReviewByToolCallId },
questionsBySession: { ...s.questionsBySession },
tasksBySession: { ...s.tasksBySession },
goalBySession: { ...s.goalBySession },
@ -454,6 +460,21 @@ export function reduceAppEvent(
if (!exists) {
next.approvalsBySession[sid] = [...list, event.approval];
}
// Preserve a plan_review display so the plan stays visible in the
// ExitPlanMode tool card after the approval resolves.
const display = event.approval.display as
| { kind?: unknown; plan?: unknown; path?: unknown }
| null
| undefined;
if (display?.kind === 'plan_review' && typeof display.plan === 'string' && display.plan.length > 0) {
next.planReviewByToolCallId = {
...next.planReviewByToolCallId,
[event.approval.toolCallId]: {
plan: display.plan,
path: typeof display.path === 'string' ? display.path : undefined,
},
};
}
break;
}

View file

@ -1,9 +1,10 @@
<!-- apps/kimi-web/src/components/chat/ApprovalCard.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ApprovalBlock } from '../../types';
import type { ApprovalDecision } from '../../api/types';
import Markdown from './Markdown.vue';
const props = defineProps<{
block: ApprovalBlock;
@ -11,11 +12,23 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
decide: [response: { decision: ApprovalDecision; scope?: 'session'; feedback?: string }];
decide: [response: { decision: ApprovalDecision; scope?: 'session'; feedback?: string; selectedLabel?: string }];
}>();
const { t } = useI18n();
interface PlanReviewView {
plan: string;
path?: string;
options: { label: string; description?: string }[];
}
const planReview = computed<PlanReviewView | null>(() => {
const b = props.block;
if (b.kind !== 'plan_review') return null;
return { plan: b.plan, path: b.path, options: b.options ?? [] };
});
// Temporarily collapse to a thin bar so the approval stops covering the chat
// while the user reads. The decision buttons + body return on expand.
const minimized = ref(false);
@ -24,7 +37,7 @@ const minimized = ref(false);
// Title by kind
// ---------------------------------------------------------------------------
const titleKinds = ['shell', 'diff', 'file', 'fileop', 'url', 'search', 'invocation', 'todo', 'generic'];
const titleKinds = ['shell', 'diff', 'file', 'fileop', 'url', 'search', 'invocation', 'todo', 'plan_review', 'generic'];
function title(): string {
const kind = titleKinds.includes(props.block.kind) ? props.block.kind : 'generic';
@ -48,7 +61,12 @@ function openFeedback(): void {
function submitFeedback(): void {
const fb = feedbackText.value.trim();
emit('decide', { decision: 'rejected', feedback: fb || undefined });
if (planReview.value) {
// Revise: keep plan mode active and pass optional feedback to the agent.
emit('decide', { decision: 'rejected', selectedLabel: 'Revise', feedback: fb || undefined });
} else {
emit('decide', { decision: 'rejected', feedback: fb || undefined });
}
feedbackOpen.value = false;
feedbackText.value = '';
}
@ -76,8 +94,16 @@ function approve(): void { emit('decide', { decision: 'approved' }); }
function approveSession(): void { emit('decide', { decision: 'approved', scope: 'session' }); }
function reject(): void { emit('decide', { decision: 'rejected' }); }
// plan_review actions
function approvePlan(): void { emit('decide', { decision: 'approved' }); }
function approveOption(label: string): void { emit('decide', { decision: 'approved', selectedLabel: label }); }
function revisePlan(): void { openFeedback(); }
function rejectAndExitPlan(): void { emit('decide', { decision: 'rejected', selectedLabel: 'Reject and Exit' }); }
// ---------------------------------------------------------------------------
// Number key shortcuts: 1=approve, 2=session, 3=reject, 4=feedback
// Number key shortcuts. Generic cards: 1=approve, 2=session, 3=reject,
// 4=feedback. Plan review cards: 1/2/3 map to the offered approaches (or
// approve / revise / reject-and-exit when no approaches are offered).
// Guard: do not fire when a textarea/input is focused
// ---------------------------------------------------------------------------
@ -86,6 +112,19 @@ function handleKeydown(e: KeyboardEvent): void {
if (tag === 'input' || tag === 'textarea') return;
// Hidden actions shouldn't fire from number keys while minimized.
if (minimized.value) return;
const pr = planReview.value;
if (pr) {
if (pr.options.length === 0) {
if (e.key === '1') { e.preventDefault(); approvePlan(); }
else if (e.key === '2') { e.preventDefault(); revisePlan(); }
else if (e.key === '3') { e.preventDefault(); rejectAndExitPlan(); }
return;
}
if (e.key === '1' && pr.options[0]) { e.preventDefault(); approveOption(pr.options[0].label); }
else if (e.key === '2' && pr.options[1]) { e.preventDefault(); approveOption(pr.options[1].label); }
else if (e.key === '3' && pr.options[2]) { e.preventDefault(); approveOption(pr.options[2].label); }
return;
}
if (e.key === '1') { e.preventDefault(); approve(); }
else if (e.key === '2') { e.preventDefault(); approveSession(); }
else if (e.key === '3') { e.preventDefault(); reject(); }
@ -124,6 +163,8 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
<!-- Body + actions collapse when minimized -->
<template v-if="!minimized">
<!-- plan_review: plan file path on the header's second line -->
<div v-if="block.kind === 'plan_review' && block.path" class="ah-path" :title="block.path">{{ block.path }}</div>
<!-- Body by kind -->
<!-- diff -->
@ -187,6 +228,11 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
</div>
</div>
<!-- plan_review -->
<div v-else-if="block.kind === 'plan_review'" class="body-plan">
<Markdown :text="block.plan" />
</div>
<!-- generic -->
<div v-else class="body-generic">
<span class="gen-text">{{ block.summary }}</span>
@ -205,8 +251,24 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
<div class="feedback-hint">{{ t('approval.feedbackHint') }}</div>
</div>
<!-- Actions row -->
<div class="abtn">
<!-- plan_review actions -->
<div v-if="planReview" class="plan-actions">
<template v-if="planReview.options.length > 0">
<div
v-for="(opt, i) in planReview.options"
:key="i"
class="kbtn pri"
:title="opt.description"
@click="approveOption(opt.label)"
>{{ opt.label }}<span class="k">[{{ i + 1 }}]</span></div>
</template>
<div v-else class="kbtn pri" @click="approvePlan">{{ t('approval.approvePlan') }}<span class="k">[1]</span></div>
<div class="kbtn" @click="revisePlan">{{ t('approval.revise') }}<span v-if="planReview.options.length === 0" class="k">[2]</span></div>
<div class="kbtn danger" @click="rejectAndExitPlan">{{ t('approval.rejectAndExit') }}<span v-if="planReview.options.length === 0" class="k">[3]</span></div>
</div>
<!-- default actions row -->
<div v-else class="abtn">
<div class="kbtn pri" @click="approve">{{ t('approval.approve') }}<span class="k">[1]</span></div>
<div class="kbtn" @click="approveSession">{{ t('approval.approveSession') }}<span class="k">[2]</span></div>
<div class="kbtn" @click="reject">{{ t('approval.reject') }}<span class="k">[3]</span></div>
@ -224,7 +286,8 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
border-radius: 3px;
}
/* Header */
/* Header single row: title + truncating path on the left, APPROVAL REQUIRED
badge + minimize button pinned to the right (never wrap onto a second line). */
.ah {
padding: 7px 10px;
background: var(--soft);
@ -234,10 +297,22 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
font-size: var(--ui-font-size);
border-bottom: 1px solid var(--bd);
border-radius: 3px 3px 0 0;
flex-wrap: wrap;
flex-wrap: nowrap;
}
.akind { color: var(--blue2); font-weight: 700; white-space: nowrap; flex: none; }
.apath { color: var(--text); font-family: var(--mono); font-size: calc(var(--ui-font-size) - 2.5px); flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Header second line — full-width plan file path, below the title row. */
.ah-path {
padding: 4px 10px 6px;
background: var(--soft);
border-bottom: 1px solid var(--bd);
color: var(--muted);
font-family: var(--mono);
font-size: calc(var(--ui-font-size) - 3px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.akind { color: var(--blue2); font-weight: 700; white-space: nowrap; }
.apath { color: var(--text); font-family: var(--mono); font-size: calc(var(--ui-font-size) - 2.5px); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.abadge {
font-size: max(9px, calc(var(--ui-font-size) - 4px));
color: var(--muted);
@ -248,6 +323,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
}
.aw {
margin-left: auto;
flex: none;
color: var(--blue2);
border: 1px solid var(--bd);
padding: 1px 7px;
@ -364,6 +440,10 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
/* Generic */
.body-generic { padding: 10px 12px; font-size: calc(var(--ui-font-size) - 1.5px); color: var(--text); word-break: break-word; }
/* Plan review Markdown body, capped at half the viewport height with scroll
for longer plans. */
.body-plan { padding: 4px 12px 10px; max-height: 50vh; overflow-y: auto; }
/* Feedback */
.feedback-wrap {
padding: 8px 12px;
@ -410,6 +490,11 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
.k { color: var(--faint); margin-left: 6px; font-size: max(9px, calc(var(--ui-font-size) - 4px)); }
.kbtn.pri .k { color: color-mix(in srgb, var(--bg) 60%, transparent); }
/* Plan review actions — wraps on desktop so several approach buttons fit. */
.plan-actions { display: flex; flex-wrap: wrap; border-top: 1px solid var(--line); }
.plan-actions .kbtn.danger { color: var(--err); }
.plan-actions .kbtn.danger:hover { background: color-mix(in srgb, var(--err) 8%, var(--bg)); }
/* =========================================================================
MOBILE (640px): the card spans the full chat column (no 33px left gutter),
inner previews scroll horizontally instead of overflowing the page, and the
@ -439,7 +524,8 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
/* Actions full-width stacked rows, each a tall 44px tap target. The
primary Approve sits on top; the rest stack below, separated by hairlines.
Stacking (vs. a cramped 4-up row) keeps every label legible at 360px. */
.abtn { flex-direction: column; }
.abtn,
.plan-actions { flex-direction: column; }
.kbtn {
min-height: 46px;
display: flex;

View file

@ -68,7 +68,7 @@ const emit = defineEmits<{
selectModel: [modelId: string];
answer: [questionId: string, response: QuestionResponse];
dismiss: [questionId: string];
approval: [approvalId: string, response: { decision: 'approved' | 'rejected' | 'cancelled'; scope?: 'session'; feedback?: string }];
approval: [approvalId: string, response: { decision: 'approved' | 'rejected' | 'cancelled'; scope?: 'session'; feedback?: string; selectedLabel?: string }];
cancelTask: [taskId: string];
'toggle-dock-panel': [panel: 'bash' | 'subagent' | 'todos' | 'queue'];
'close-dock-panel': [];

View file

@ -522,11 +522,11 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
<ThinkingBlock v-if="blk.kind === 'thinking'" :text="blk.thinking" :mobile="childBubble" :streaming="isStreamingRenderBlock(turn, blk)" @open="emit('openThinking', { turnId: turn.id, blockIndex: blk.sourceIndex })" />
<div v-else-if="blk.kind === 'text' && blk.text" class="msg"><Markdown :text="blk.text" :streaming="isStreamingRenderBlock(turn, blk)" :open-file="(target) => emit('openFile', target)" /></div>
<div v-else-if="blk.kind === 'tool-stack'" class="tool-stack">
<ToolCall v-for="(item, si) in blk.tools" :key="toolStackKey(item)" :tool="item.tool" :mobile="childBubble" :stack-position="toolStackPosition(si, blk.tools.length)" @open-media="emit('openMedia', $event)" />
<ToolCall v-for="(item, si) in blk.tools" :key="toolStackKey(item)" :tool="item.tool" :mobile="childBubble" :stack-position="toolStackPosition(si, blk.tools.length)" @open-media="emit('openMedia', $event)" @open-file="emit('openFile', $event)" />
</div>
<AgentCard v-else-if="blk.kind === 'agent'" :member="blk.member" @open="emit('openAgent', { turnId: turn.id, blockIndex: blk.sourceIndex, memberId: $event })" />
<AgentGroup v-else-if="blk.kind === 'agentGroup'" :members="blk.members" @open="emit('openAgent', { turnId: turn.id, blockIndex: blk.sourceIndex, memberId: $event })" />
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" :mobile="childBubble" @open-media="emit('openMedia', $event)" />
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" :mobile="childBubble" @open-media="emit('openMedia', $event)" @open-file="emit('openFile', $event)" />
</template>
<div v-if="turn.id !== streamingTurnId && isAssistantRunEnd(ti) && (assistantRunFinalText(ti).trim().length > 0 || turn.durationMs !== undefined)" class="a-msg-ft">
<span v-if="turn.durationMs !== undefined" class="a-duration" :title="`${turn.durationMs} ms`">{{ formatDuration(turn.durationMs) }}</span>
@ -664,11 +664,11 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
<ThinkingBlock v-if="blk.kind === 'thinking'" :text="blk.thinking" :streaming="isStreamingRenderBlock(turn, blk)" @open="emit('openThinking', { turnId: turn.id, blockIndex: blk.sourceIndex })" />
<Markdown v-else-if="blk.kind === 'text' && blk.text" :text="blk.text" :streaming="isStreamingRenderBlock(turn, blk)" :open-file="(target) => emit('openFile', target)" />
<div v-else-if="blk.kind === 'tool-stack'" class="tool-stack">
<ToolCall v-for="(item, si) in blk.tools" :key="toolStackKey(item)" :tool="item.tool" :stack-position="toolStackPosition(si, blk.tools.length)" @open-media="emit('openMedia', $event)" />
<ToolCall v-for="(item, si) in blk.tools" :key="toolStackKey(item)" :tool="item.tool" :stack-position="toolStackPosition(si, blk.tools.length)" @open-media="emit('openMedia', $event)" @open-file="emit('openFile', $event)" />
</div>
<AgentCard v-else-if="blk.kind === 'agent'" :member="blk.member" @open="emit('openAgent', { turnId: turn.id, blockIndex: blk.sourceIndex, memberId: $event })" />
<AgentGroup v-else-if="blk.kind === 'agentGroup'" :members="blk.members" @open="emit('openAgent', { turnId: turn.id, blockIndex: blk.sourceIndex, memberId: $event })" />
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" @open-media="emit('openMedia', $event)" />
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" @open-media="emit('openMedia', $event)" @open-file="emit('openFile', $event)" />
</template>
</template>
</div>

View file

@ -1,7 +1,7 @@
<!-- apps/kimi-web/src/components/chat/ToolCall.vue -->
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { ToolCall, ToolMedia } from '../../types';
import type { FilePreviewRequest, ToolCall, ToolMedia } from '../../types';
import { toolLabel, toolGlyph, toolChip, toolSummary } from '../../lib/toolMeta';
const props = withDefaults(
@ -16,6 +16,7 @@ const props = withDefaults(
);
const emit = defineEmits<{
openMedia: [media: ToolMedia];
openFile: [target: FilePreviewRequest];
}>();
const isRunningBash = computed(() => props.tool.status === 'running' && /^bash$/i.test(props.tool.name));
const hasOutput = computed(() => !!props.tool.output && props.tool.output.length > 0);
@ -53,6 +54,25 @@ const chip = () => toolChip({
const isError = () => props.tool.status === 'error';
const media = computed(() => (props.tool.status === 'ok' ? props.tool.media : undefined));
// ExitPlanMode: expose the plan file as a clickable link (opens file preview).
const isExitPlan = computed(() => props.tool.name === 'ExitPlanMode');
const planPath = computed(() => (isExitPlan.value ? props.tool.planPath : undefined));
const planBasename = computed(() => {
const p = planPath.value;
return p ? p.split(/[\\/]+/).pop() || p : '';
});
function openPlanFile(): void {
if (planPath.value) emit('openFile', { path: planPath.value });
}
// TEMP: plan-file preview link is hidden until the server can read files
// outside the workspace. Plan files live under the session dir (not the cwd),
// and the server's readFile is workspace-scoped, so the preview rejects them
// with "outside workspace". The planPath wiring is kept in place; flip this
// flag to re-enable the chip once a backend API can read the plan file.
const enablePlanFileLink = false;
const showPlanFileLink = computed(() => enablePlanFileLink && Boolean(planPath.value));
function basename(path: string): string {
return path.split(/[\\/]+/).pop() || path;
}
@ -136,6 +156,13 @@ function openMediaPreview(): void {
into the card body (below) so the header stays clean. -->
<span v-if="!open" class="p" :title="summary()">{{ summary() }}</span>
<span class="rt">
<button
v-if="showPlanFileLink"
class="plan-link"
type="button"
:title="planPath"
@click.stop="openPlanFile"
>📄 {{ planBasename }}</button>
<span class="chip" v-if="chip()">{{ chip() }}</span>
<span
v-if="tool.status === 'running'"
@ -278,6 +305,22 @@ function openMediaPreview(): void {
color: var(--dim);
font-size: max(9px, calc(var(--ui-font-size) - 3.5px));
}
.plan-link {
appearance: none;
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 3px;
padding: 0 6px;
color: var(--blue2);
font: inherit;
font-size: max(9px, calc(var(--ui-font-size) - 3.5px));
cursor: pointer;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan-link:hover { background: var(--panel); color: var(--blue); border-color: var(--blue2); }
.ok { color: var(--ok); font-weight: 700; }
.er { color: var(--err); font-weight: 700; }
.tm { color: var(--muted); }

View file

@ -1205,7 +1205,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
async function respondApproval(
approvalId: string,
response: { decision: ApprovalDecision; scope?: 'session'; feedback?: string },
response: { decision: ApprovalDecision; scope?: 'session'; feedback?: string; selectedLabel?: string },
): Promise<void> {
const sid = rawState.activeSessionId;
if (!sid) return;
@ -1215,6 +1215,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
decision: response.decision,
scope: response.scope,
feedback: response.feedback,
selectedLabel: response.selectedLabel,
};
await api.respondApproval(sid, approvalId, fullResponse);
// Remove from local approvals immediately (WS event will confirm)

View file

@ -322,6 +322,22 @@ function buildApprovalBlock(a: AppApprovalRequest): ApprovalBlock {
return { kind: 'todo', items };
}
if (kind === 'plan_review') {
const plan = typeof d['plan'] === 'string' ? d['plan'] : '';
const path = typeof d['path'] === 'string' ? d['path'] : undefined;
const rawOptions = Array.isArray(d['options']) ? d['options'] : [];
const options = rawOptions
.map((item: unknown): { label: string; description?: string } | null => {
const it = (item ?? {}) as Record<string, unknown>;
const label = typeof it['label'] === 'string' ? it['label'] : '';
if (!label) return null;
const description = typeof it['description'] === 'string' ? it['description'] : undefined;
return { label, description };
})
.filter((o): o is { label: string; description?: string } => o !== null);
return { kind: 'plan_review', plan, path, options: options.length > 0 ? options : undefined };
}
return { kind: 'generic', summary: a.action };
}
@ -393,6 +409,19 @@ function continuesAssistantGroup(group: Group | null, promptId: string | undefin
);
}
/** Extract the plan file path from an ExitPlanMode tool result. The approved
* output contains `Plan saved to: <path>`; this survives a page reload (unlike
* the ephemeral plan_review approval display), so the tool card can still link
* to the plan file. */
function parsePlanSavedPath(output: string[] | undefined): string | undefined {
if (!output || output.length === 0) return undefined;
const marker = 'Plan saved to: ';
for (const line of output) {
if (line.startsWith(marker)) return line.slice(marker.length).trim();
}
return undefined;
}
export function messagesToTurns(
messages: AppMessage[],
approvals: AppApprovalRequest[],
@ -406,6 +435,9 @@ export function messagesToTurns(
*/
sessionActive = true,
subagentTasks: AppTask[] = [],
/** Preserved `plan_review` displays keyed by toolCallId used to link the
* ExitPlanMode tool card back to the plan file after the approval resolves. */
planReviewByToolCallId: Record<string, { plan: string; path?: string }> = {},
): ChatTurn[] {
const turns: ChatTurn[] = [];
let no = 1;
@ -541,6 +573,7 @@ export function messagesToTurns(
// flushGroup settles dangling tools of finished turns back to 'ok'.
status: 'running',
output: c.outputLines,
planPath: c.toolName === 'ExitPlanMode' ? planReviewByToolCallId[c.toolCallId]?.path : undefined,
};
g.tools.push(toolCall);
g.blocks.push({ kind: 'tool', tool: toolCall });
@ -560,6 +593,12 @@ export function messagesToTurns(
output: normalizeToolOutput(c.output),
media: c.isError ? undefined : normalizeToolMedia(tool.name, c.output),
};
// ExitPlanMode: if the plan path wasn't captured from the (ephemeral)
// approval display, recover it from the result output so the file link
// survives a reload for approved plans.
if (updated.name === 'ExitPlanMode' && !updated.planPath) {
updated.planPath = parsePlanSavedPath(updated.output);
}
g.tools[idx] = updated;
const blk = g.blocks.find((b) => b.kind === 'tool' && b.tool.id === c.toolCallId);
if (blk && blk.kind === 'tool') blk.tool = updated;

View file

@ -646,6 +646,7 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq
activeSessionId: rawState.activeSessionId,
messagesBySession: rawState.messagesBySession,
approvalsBySession: rawState.approvalsBySession,
planReviewByToolCallId: rawState.planReviewByToolCallId,
questionsBySession: rawState.questionsBySession,
tasksBySession: rawState.tasksBySession,
goalBySession: rawState.goalBySession,
@ -660,6 +661,7 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq
setActiveSessionId(next.activeSessionId);
setMessagesBySession(next.messagesBySession);
rawState.approvalsBySession = next.approvalsBySession;
rawState.planReviewByToolCallId = next.planReviewByToolCallId;
rawState.questionsBySession = next.questionsBySession;
rawState.tasksBySession = next.tasksBySession;
rawState.goalBySession = next.goalBySession;
@ -1045,6 +1047,20 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
...rawState.approvalsBySession,
[sessionId]: snap.pendingApprovals,
};
// Preserve plan_review paths from the snapshot so the ExitPlanMode tool
// card can link to the plan file even after a reload.
for (const a of snap.pendingApprovals) {
const display = a.display as { kind?: unknown; plan?: unknown; path?: unknown } | null | undefined;
if (display?.kind === 'plan_review' && typeof display.plan === 'string' && display.plan.length > 0) {
rawState.planReviewByToolCallId = {
...rawState.planReviewByToolCallId,
[a.toolCallId]: {
plan: display.plan,
path: typeof display.path === 'string' ? display.path : undefined,
},
};
}
}
rawState.questionsBySession = {
...rawState.questionsBySession,
[sessionId]: snap.pendingQuestions,
@ -1262,6 +1278,23 @@ function buildApprovalBlock(a: AppApprovalRequest): ApprovalBlock {
return { kind: 'todo', items };
}
// plan_review — finalised plan presented at plan-mode exit
if (kind === 'plan_review') {
const plan = typeof d.plan === 'string' ? d.plan : '';
const path = typeof d.path === 'string' ? d.path : undefined;
const rawOptions = Array.isArray(d.options) ? d.options : [];
const options = rawOptions
.map((item: unknown): { label: string; description?: string } | null => {
const it = (item ?? {}) as Record<string, unknown>;
const label = typeof it.label === 'string' ? it.label : '';
if (!label) return null;
const description = typeof it.description === 'string' ? it.description : undefined;
return { label, description };
})
.filter((o): o is { label: string; description?: string } => o !== null);
return { kind: 'plan_review', plan, path, options: options.length > 0 ? options : undefined };
}
// Unknown daemon display.kind → 'generic' with summary = action
return { kind: 'generic', summary: a.action };
}
@ -1454,6 +1487,7 @@ const turns = computed<ChatTurn[]>(() => {
(fileId) => getKimiWebApi().getFileUrl(fileId),
activity.value !== 'idle',
activeAppTasks.value,
rawState.planReviewByToolCallId,
);
});

View file

@ -8,6 +8,7 @@ export default {
search: 'Search?',
invocation: 'Invoke?',
todo: 'Update todo?',
plan_review: 'Ready to build with this plan?',
generic: 'Approve action?',
},
subagentBadge: 'sub agent · {name}',
@ -21,4 +22,7 @@ export default {
approveSession: 'Approve for session',
reject: 'Reject',
feedback: 'Feedback',
approvePlan: 'Approve plan',
revise: 'Revise',
rejectAndExit: 'Reject and Exit',
} as const;

View file

@ -8,6 +8,7 @@ export default {
search: '搜索?',
invocation: '调用?',
todo: '更新 todo?',
plan_review: '按这份 plan 开始实现?',
generic: '批准操作?',
},
subagentBadge: '子 agent · {name}',
@ -21,4 +22,7 @@ export default {
approveSession: '本会话内批准',
reject: '拒绝',
feedback: '+反馈',
approvePlan: '批准 plan',
revise: '修改',
rejectAndExit: '拒绝并退出',
} as const;

View file

@ -90,6 +90,9 @@ export interface ToolCall {
output?: string[]; // shown line by line when expanded
media?: ToolMedia;
defaultExpanded?: boolean;
/** Absolute path of the plan file (ExitPlanMode only) rendered as a
* clickable link that opens the plan in the file preview. */
planPath?: string;
}
export interface ToolMedia {
@ -159,6 +162,12 @@ export type ApprovalBlock =
| { kind: 'search'; query: string; scope?: string }
| { kind: 'invocation'; kind2: string; name: string; description?: string }
| { kind: 'todo'; items: { title: string; status: string }[] }
| {
kind: 'plan_review';
plan: string;
path?: string;
options?: { label: string; description?: string }[];
}
| { kind: 'generic'; summary: string };
export type TurnRole = 'user' | 'assistant' | 'compaction';