feat: show full plan cards (#536)

* feat: show full plan cards

* chore: patch plan card changeset
This commit is contained in:
liruifengv 2026-06-08 15:35:49 +08:00 committed by GitHub
parent 0fe13173f4
commit b785e2698a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 21 additions and 208 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Show full plan cards directly and remove the Plan card keyboard shortcut.

View file

@ -217,7 +217,6 @@ export class ApprovalPanelComponent extends Container implements Focusable {
private request: PendingApproval;
private readonly colors: ColorPalette;
private readonly onToggleToolOutput: (() => void) | undefined;
private readonly onTogglePlanExpand: (() => void) | undefined;
private readonly onOpenPreview:
| ((block: DiffDisplayBlock | FileContentDisplayBlock) => void)
| undefined;
@ -227,7 +226,6 @@ export class ApprovalPanelComponent extends Container implements Focusable {
onResponse: (response: ApprovalPanelResponse) => void,
colors: ColorPalette,
onToggleToolOutput?: () => void,
onTogglePlanExpand?: () => void,
onOpenPreview?: (block: DiffDisplayBlock | FileContentDisplayBlock) => void,
) {
super();
@ -235,7 +233,6 @@ export class ApprovalPanelComponent extends Container implements Focusable {
this.onResponse = onResponse;
this.colors = colors;
this.onToggleToolOutput = onToggleToolOutput;
this.onTogglePlanExpand = onTogglePlanExpand;
this.onOpenPreview = onOpenPreview;
this.feedbackInput.onSubmit = (value) => {
this.submit(this.selectedIndex, value);
@ -281,8 +278,6 @@ export class ApprovalPanelComponent extends Container implements Focusable {
const previewable = this.findPreviewableBlock();
if (previewable !== undefined && this.onOpenPreview !== undefined) {
this.onOpenPreview(previewable);
} else {
this.onTogglePlanExpand?.();
}
return;
}

View file

@ -99,7 +99,6 @@ export class QuestionDialogComponent extends Container implements Focusable {
private readonly answers: (string | undefined)[];
private readonly onToggleToolOutput: (() => void) | undefined;
private readonly onTogglePlanExpand: (() => void) | undefined;
constructor(
request: PendingQuestion,
@ -107,7 +106,6 @@ export class QuestionDialogComponent extends Container implements Focusable {
colors: ColorPalette,
maxVisibleOptions = 6,
onToggleToolOutput?: () => void,
onTogglePlanExpand?: () => void,
) {
super();
this.request = request;
@ -115,7 +113,6 @@ export class QuestionDialogComponent extends Container implements Focusable {
this.colors = colors;
this.maxVisibleOptions = maxVisibleOptions;
this.onToggleToolOutput = onToggleToolOutput;
this.onTogglePlanExpand = onTogglePlanExpand;
this.otherInput.onSubmit = (value) => {
this.commitOtherInput(value, 'enter');
};
@ -147,11 +144,6 @@ export class QuestionDialogComponent extends Container implements Focusable {
return;
}
if (matchesKey(data, Key.ctrl('e'))) {
this.onTogglePlanExpand?.();
return;
}
if (this.isEditingOther()) {
this.handleOtherInput(data);
return;

View file

@ -96,10 +96,6 @@ export class CustomEditor extends Editor {
public onCtrlD?: () => void;
public onCtrlC?: () => void;
public onToggleToolExpand?: () => void;
// Returns true when a plan card actually handled the toggle. When it
// returns false (no plan in the transcript) the keystroke falls through
// to pi-tui's default ctrl+e binding (move cursor to end of line).
public onTogglePlanExpand?: () => boolean;
public onOpenExternalEditor?: () => void;
public onCtrlS?: () => void;
public onUndo?: () => void;
@ -292,11 +288,6 @@ export class CustomEditor extends Editor {
return;
}
if (matchesKey(normalized, Key.ctrl('e'))) {
if (this.onTogglePlanExpand?.() === true) return;
// No plan to toggle — fall through to pi-tui's end-of-line.
}
if (matchesKey(normalized, Key.ctrl('s'))) {
this.onCtrlS?.();
return;

View file

@ -19,8 +19,6 @@ const TITLE_PREFIX = ' plan: ';
const TITLE_SUFFIX = ' ';
export interface PlanBoxOptions {
maxContentLines?: number;
expanded?: boolean;
status?: {
readonly label: string;
readonly colorHex: string;
@ -29,8 +27,6 @@ export interface PlanBoxOptions {
export class PlanBoxComponent implements Component {
private readonly markdown: Markdown;
private readonly maxContentLines: number | undefined;
private readonly expanded: boolean;
private readonly status: PlanBoxOptions['status'];
private cachedWidth: number | undefined;
private cachedLines: string[] | undefined;
@ -47,8 +43,6 @@ export class PlanBoxComponent implements Component {
// instance means repeated render() calls from the parent Container
// hit the cache instead of re-parsing on every frame.
this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme);
this.maxContentLines = opts?.maxContentLines;
this.expanded = opts?.expanded ?? false;
this.status = opts?.status;
}
@ -81,20 +75,12 @@ export class PlanBoxComponent implements Component {
const bottom = indent + paint('└' + '─'.repeat(horzLen) + '┘');
const rawLines = this.markdown.render(contentWidth);
const { shown, hiddenCount } = this.capContentLines(rawLines);
const lines: string[] = [top];
for (const raw of shown) {
for (const raw of rawLines) {
const pad = Math.max(0, contentWidth - visibleWidth(raw));
lines.push(indent + paint('│') + ' ' + raw + ' '.repeat(pad) + ' ' + paint('│'));
}
if (hiddenCount > 0) {
const footer = chalk.dim(
`... (${String(hiddenCount)} more line${hiddenCount === 1 ? '' : 's'}, ctrl+e to expand)`,
);
const pad = Math.max(0, contentWidth - visibleWidth(footer));
lines.push(indent + paint('│') + ' ' + footer + ' '.repeat(pad) + ' ' + paint('│'));
}
lines.push(bottom);
this.cachedWidth = width;
@ -102,15 +88,6 @@ export class PlanBoxComponent implements Component {
return lines;
}
private capContentLines(rawLines: string[]): { shown: string[]; hiddenCount: number } {
const cap = this.maxContentLines;
if (this.expanded || cap === undefined || rawLines.length <= cap) {
return { shown: rawLines, hiddenCount: 0 };
}
const shownCount = Math.max(0, cap - 1);
return { shown: rawLines.slice(0, shownCount), hiddenCount: rawLines.length - shownCount };
}
private buildTitle(horzLen: number): string {
const fallback = ' plan ';
const statusSuffix = this.buildStatusSuffix();

View file

@ -472,7 +472,6 @@ class PrefixedWrappedLine implements Component {
export class ToolCallComponent extends Container {
private expanded = false;
private planExpanded = false;
private toolCall: ToolCallBlockData;
private result: ToolResultBlockData | undefined;
private colors: ColorPalette;
@ -592,17 +591,6 @@ export class ToolCallComponent extends Container {
this.rebuildBody();
}
// Toggle the plan box's expanded state independently from tool-output
// expansion. Returns true iff this card actually owns a plan preview
// (ExitPlanMode), so the caller can decide whether to consume the keystroke.
setPlanExpanded(expanded: boolean): boolean {
if (this.toolCall.name !== 'ExitPlanMode') return false;
if (this.planExpanded === expanded) return true;
this.planExpanded = expanded;
this.rebuildBody();
return true;
}
setResult(result: ToolResultBlockData): void {
this.result = result;
// Result supersedes any live progress chatter; the result body is the
@ -1740,8 +1728,6 @@ export class ToolCallComponent extends Container {
if (this.markdownTheme !== undefined) {
this.addChild(
new PlanBoxComponent(plan, this.markdownTheme, this.colors.success, path, {
maxContentLines: this.computePlanBoxMaxContentLines(),
expanded: this.planExpanded,
status: this.resolvePlanBoxStatus(),
}),
);
@ -1750,12 +1736,6 @@ export class ToolCallComponent extends Container {
}
}
private computePlanBoxMaxContentLines(): number | undefined {
const rows = this.ui?.terminal.rows;
if (rows === undefined || !Number.isFinite(rows) || rows <= 0) return undefined;
return Math.max(8, Math.floor(rows * 0.6) - 4);
}
private resolvePlanForPreview(): string {
const inlinePlan = str(this.toolCall.args['plan']);
if (inlinePlan.length > 0) return inlinePlan;

View file

@ -31,7 +31,6 @@ export interface EditorKeyboardHost {
updateEditorBorderHighlight(text?: string): void;
updateQueueDisplay(): void;
toggleToolOutputExpansion(): void;
togglePlanExpansion(): boolean;
hideSessionPicker(): void;
stop(exitCode?: number): Promise<void>;
handlePlanToggle(next: boolean): void;
@ -150,8 +149,6 @@ export class EditorKeyboardController {
host.toggleToolOutputExpansion();
};
editor.onTogglePlanExpand = () => host.togglePlanExpansion();
editor.onCtrlS = () => {
if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) return;
const text = editor.getText().trim();

View file

@ -617,7 +617,6 @@ export class StreamingUIController {
state.appState.workDir,
);
if (state.toolOutputExpanded) tc.setExpanded(true);
if (state.planExpanded) tc.setPlanExpanded(true);
this._pendingToolComponents.set(toolCall.id, tc);
if (toolCall.name !== 'Agent') this._pendingAgentGroup = null;
@ -664,7 +663,6 @@ export class StreamingUIController {
state.appState.workDir,
);
if (state.toolOutputExpanded) completed.setExpanded(true);
if (state.planExpanded) completed.setPlanExpanded(true);
state.transcriptContainer.addChild(completed);
state.ui.requestRender();
}

View file

@ -113,7 +113,7 @@ import {
type TUIStartupState,
} from './types';
import { createTUIState, type TUIState } from './tui-state';
import { isExpandable, isPlanExpandable } from './utils/component-capabilities';
import { isExpandable } from './utils/component-capabilities';
import { isDeadTerminalError } from './utils/dead-terminal';
import { formatErrorMessage } from './utils/event-payload';
import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store';
@ -1313,7 +1313,6 @@ export class KimiTUI {
this.state.appState.workDir,
);
if (this.state.toolOutputExpanded) tc.setExpanded(true);
if (this.state.planExpanded) tc.setPlanExpanded(true);
return tc;
}
if (entry.backgroundAgentStatus !== undefined) {
@ -1589,21 +1588,6 @@ export class KimiTUI {
this.state.ui.requestRender();
}
// Returns true when at least one card toggled, so the caller can consume the keystroke.
togglePlanExpansion(): boolean {
const next = !this.state.planExpanded;
let toggled = false;
for (const child of this.state.transcriptContainer.children) {
if (isPlanExpandable(child) && child.setPlanExpanded(next)) {
toggled = true;
}
}
if (!toggled) return false;
this.state.planExpanded = next;
this.state.ui.requestRender();
return true;
}
updateEditorBorderHighlight(text?: string): void {
const trimmed = (text ?? this.state.editor.getText()).trimStart();
const highlighted = this.state.appState.planMode || trimmed.startsWith('/');
@ -1832,9 +1816,6 @@ export class KimiTUI {
() => {
this.toggleToolOutputExpansion();
},
() => {
this.togglePlanExpansion();
},
(block) => {
this.openApprovalPreview(panel, block);
},
@ -1905,9 +1886,6 @@ export class KimiTUI {
() => {
this.toggleToolOutputExpansion();
},
() => {
this.togglePlanExpansion();
},
);
this.mountEditorReplacement(dialog);
}

View file

@ -44,7 +44,6 @@ export interface TUIState {
terminalState: TerminalState;
activitySpinner: { instance: MoonLoader; style: SpinnerStyle } | null;
toolOutputExpanded: boolean;
planExpanded: boolean;
sessions: SessionRow[];
loadingSessions: boolean;
activeDialog: 'session-picker' | 'help' | null;
@ -93,7 +92,6 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
terminalState: createTerminalState(),
activitySpinner: null,
toolOutputExpanded: false,
planExpanded: false,
sessions: [],
loadingSessions: false,
activeDialog: null,

View file

@ -2,12 +2,6 @@ export interface Expandable {
setExpanded(expanded: boolean): void;
}
export interface PlanExpandable {
// Returns true iff the component actually owns a plan preview and
// applied the new state.
setPlanExpanded(expanded: boolean): boolean;
}
export interface Disposable {
dispose(): void;
}
@ -21,15 +15,6 @@ export function isExpandable(obj: unknown): obj is Expandable {
);
}
export function isPlanExpandable(obj: unknown): obj is PlanExpandable {
return (
typeof obj === 'object' &&
obj !== null &&
'setPlanExpanded' in obj &&
typeof (obj as PlanExpandable).setPlanExpanded === 'function'
);
}
export function hasDispose(value: unknown): value is Disposable {
return (
typeof value === 'object' &&

View file

@ -260,14 +260,12 @@ describe('ApprovalPanelComponent', () => {
},
};
let toolOutputToggles = 0;
let planToggles = 0;
const previewCalls: Array<DiffDisplayBlock | FileContentDisplayBlock> = [];
const dialog = new ApprovalPanelComponent(
pending,
(r) => responses.push(r),
COLORS,
() => toolOutputToggles++,
() => planToggles++,
(block) => previewCalls.push(block),
);
@ -284,8 +282,7 @@ describe('ApprovalPanelComponent', () => {
expect(after).not.toContain('new30');
expect(after).toContain('ctrl+e preview');
expect(previewCalls).toEqual([diffBlock]);
// The unrelated forward-only callbacks must not fire for ctrl+e.
expect(planToggles).toBe(0);
// The unrelated forward-only callback must not fire for ctrl+e.
expect(toolOutputToggles).toBe(0);
expect(responses).toEqual([]);
});
@ -320,10 +317,7 @@ describe('ApprovalPanelComponent', () => {
expect(after).not.toContain('new30');
});
// When there is no diff / file_content block to preview (e.g. plan_review
// with an empty display), ctrl+e falls through to the legacy global plan
// expand toggle so plan mode keeps working.
it('falls through to onTogglePlanExpand when there is nothing to preview', () => {
it('does nothing on ctrl+e when there is nothing to preview', () => {
const pending: PendingApproval = {
data: {
id: 'approval_plan_only',
@ -335,19 +329,16 @@ describe('ApprovalPanelComponent', () => {
choices: [{ label: 'Approve', response: 'approved' }],
},
};
let planToggles = 0;
const previewCalls: Array<DiffDisplayBlock | FileContentDisplayBlock> = [];
const dialog = new ApprovalPanelComponent(
pending,
() => {},
COLORS,
undefined,
() => planToggles++,
(block) => previewCalls.push(block),
);
dialog.handleInput('\u0005'); // Ctrl+E
expect(planToggles).toBe(1);
expect(previewCalls).toEqual([]);
});
@ -377,7 +368,6 @@ describe('ApprovalPanelComponent', () => {
(r) => responses.push(r),
COLORS,
undefined,
undefined,
(block) => previewCalls.push(block),
);
@ -420,7 +410,6 @@ describe('ApprovalPanelComponent', () => {
() => {},
COLORS,
undefined,
undefined,
(block) => previewCalls.push(block),
);
const collapsed = strip(dialog.render(120).join('\n'));

View file

@ -36,7 +36,6 @@ function makePending(
function makeDialog(
pending: PendingQuestion,
onToggleToolOutput?: () => void,
onTogglePlanExpand?: () => void,
): {
dialog: QuestionDialogComponent;
collected: string[][];
@ -53,7 +52,6 @@ function makeDialog(
darkColors,
6,
onToggleToolOutput,
onTogglePlanExpand,
);
return { dialog, collected, methods };
}
@ -431,17 +429,6 @@ describe('QuestionDialogComponent', () => {
expect(collected).toEqual([]);
});
it('forwards ctrl+e to the global plan-expand toggle without answering', () => {
let planToggles = 0;
const pending = makePending([
{ question: 'Q?', multi_select: false, options: [{ label: 'A' }] },
]);
const { dialog, collected } = makeDialog(pending, undefined, () => planToggles++);
dialog.handleInput('\u0005'); // Ctrl+E
expect(planToggles).toBe(1);
expect(collected).toEqual([]);
});
describe('long-content wrapping', () => {
const longQuestion =
'Please confirm whether this dangerous shell command should really be executed in the current workspace, including all of its side effects on the filesystem and the network.';

View file

@ -255,7 +255,7 @@ describe('ToolCallComponent', () => {
expect(after).not.toContain('/tmp/refactor.md');
});
it('caps the plan preview to the terminal height and expands on ctrl+e', () => {
it('renders the full plan preview', () => {
const longPlan = `# Refactor session\n\n${Array.from({ length: 40 }, (_, i) => `- step ${String(i + 1)}`).join('\n')}`;
const component = new ToolCallComponent(
{
@ -269,39 +269,13 @@ describe('ToolCallComponent', () => {
createMarkdownTheme(darkColors),
);
const collapsed = strip(component.render(100).join('\n'));
expect(collapsed).toContain('step 1');
expect(collapsed).toMatch(/\.\.\. \(\d+ more lines, ctrl\+e to expand\)/);
expect(collapsed).not.toContain('step 40');
expect(component.setPlanExpanded(true)).toBe(true);
const expanded = strip(component.render(100).join('\n'));
expect(expanded).toContain('step 40');
expect(expanded).not.toContain('ctrl+e to expand');
});
it('plan preview controls are no-ops for non-ExitPlanMode tool calls', () => {
const component = new ToolCallComponent(
{
id: 'call_bash_plan',
name: 'Bash',
args: { command: 'echo hi' },
},
undefined,
darkColors,
undefined,
createMarkdownTheme(darkColors),
);
expect(component.setPlanExpanded(true)).toBe(false);
component.setPlanInfo({ plan: 'should be ignored', path: '/etc/hosts' });
const out = strip(component.render(100).join('\n'));
expect(out).not.toContain('should be ignored');
expect(out).not.toContain('plan:');
expect(out).toContain('step 1');
expect(out).toContain('step 40');
expect(out).not.toContain('more lines');
});
it('ctrl+o does not affect the plan preview cap', () => {
it('ctrl+o does not affect the full plan preview', () => {
const longPlan = `# P\n\n${Array.from({ length: 40 }, (_, i) => `- step ${String(i + 1)}`).join('\n')}`;
const component = new ToolCallComponent(
{
@ -316,8 +290,8 @@ describe('ToolCallComponent', () => {
);
component.setExpanded(true);
const out = strip(component.render(100).join('\n'));
expect(out).toContain('ctrl+e to expand');
expect(out).not.toContain('step 40');
expect(out).toContain('step 40');
expect(out).not.toContain('more lines');
});
it('header chips an Approved status when ExitPlanMode result indicates approval', () => {

View file

@ -89,44 +89,13 @@ describe('PlanBoxComponent', () => {
expect(top).not.toContain('plan:');
});
it('renders all lines when content fits under maxContentLines', () => {
const plan = Array.from({ length: 5 }, (_, i) => `- step ${String(i + 1)}`).join('\n');
const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, {
maxContentLines: 20,
});
it('renders all plan lines without a truncation footer', () => {
const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n');
const box = new PlanBoxComponent(plan, theme, darkColors.success);
const out = strip(box.render(80).join('\n'));
expect(out).toContain('step 1');
expect(out).toContain('step 5');
expect(out).not.toContain('ctrl+e to expand');
});
it('truncates content over maxContentLines with a footer inside the box', () => {
const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n');
const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, {
maxContentLines: 10,
});
const rendered = box.render(80);
const out = strip(rendered.join('\n'));
expect(out).toContain('step 1');
expect(out).toContain('step 9');
expect(out).not.toContain('step 10');
expect(out).toMatch(/\.\.\. \(\d+ more lines, ctrl\+e to expand\)/);
// Footer must live inside the bordered box, not after it.
const footerIdx = rendered.findIndex((line) => strip(line).includes('ctrl+e to expand'));
const bottomIdx = rendered.findIndex((line) => strip(line).includes('└'));
expect(footerIdx).toBeGreaterThan(-1);
expect(footerIdx).toBeLessThan(bottomIdx);
expect(strip(rendered[footerIdx]!)).toMatch(/^\s+│.*│\s*$/);
});
it('renders the full plan when expanded is true, ignoring maxContentLines', () => {
const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n');
const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, {
maxContentLines: 10,
expanded: true,
});
const out = strip(box.render(80).join('\n'));
expect(out).toContain('step 30');
expect(out).not.toContain('ctrl+e to expand');
expect(out).not.toContain('more lines');
});
});

View file

@ -34,7 +34,6 @@ Press `Shift-Tab` to enable or disable Plan mode. When enabled, the Agent priori
| `Ctrl-G` | Edit the current input in an external editor |
| `Ctrl-V` | Paste an image or video from the clipboard (Unix / macOS) |
| `Alt-V` | Paste an image or video from the clipboard (Windows) |
| `Ctrl-E` | Expand or collapse the Plan card (moves the cursor to end-of-line when no Plan card is present) |
| `Ctrl--` | Undo |
Pressing `Ctrl-G` opens an external editor, selected according to the following priority:

View file

@ -34,7 +34,6 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用
| `Ctrl-G` | 在外部编辑器中编辑当前输入 |
| `Ctrl-V` | 粘贴剪贴板中的图片或视频Unix / macOS |
| `Alt-V` | 粘贴剪贴板中的图片或视频Windows |
| `Ctrl-E` | 展开或折叠 Plan 卡片(无 Plan 卡片时将光标移到行尾) |
| `Ctrl--` | 撤销Undo |
`Ctrl-G` 会打开外部编辑器,编辑器按以下优先级选择: