Skip to content
Merged
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: 6 additions & 0 deletions .changeset/restore-approval-previews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---

Show file content and diff in Write and Edit approval prompts, and open them in a dedicated full-screen viewer on ctrl+e instead of expanding inline.
45 changes: 32 additions & 13 deletions apps/kimi-code/src/tui/components/dialogs/approval-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import chalk from 'chalk';

import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight';
import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview';
import type { ApprovalPanelChoice, DisplayBlock, PendingApproval } from '#/tui/reverse-rpc/types';
import type {
ApprovalPanelChoice,
DiffDisplayBlock,
DisplayBlock,
FileContentDisplayBlock,
PendingApproval,
} from '#/tui/reverse-rpc/types';
import type { ColorPalette } from '#/tui/theme/colors';

export interface ApprovalPanelResponse {
Expand Down Expand Up @@ -55,22 +61,20 @@ function makeBlockStyles(colors: ColorPalette): BlockStyles {

function renderDisplayBlock(
block: DisplayBlock,
expanded: boolean,
s: BlockStyles,
colors: ColorPalette,
): string[] {
switch (block.type) {
case 'diff':
return renderDiffLinesClustered(block.old_text, block.new_text, block.path, colors, {
contextLines: 3,
expandKeyHint: 'ctrl+e',
...(expanded ? {} : { maxLines: DIFF_SUMMARY_MAX_LINES }),
expandKeyHint: 'ctrl+e to preview',
maxLines: DIFF_SUMMARY_MAX_LINES,
});
case 'file_content': {
const lang = block.language ?? langFromPath(block.path);
const allLines = highlightLines(block.content, lang);
const cap = expanded ? allLines.length : CONTENT_SUMMARY_MAX_LINES;
const shown = allLines.slice(0, cap);
const shown = allLines.slice(0, CONTENT_SUMMARY_MAX_LINES);
const lines = [s.strong(block.path)];
for (const [i, line] of shown.entries()) {
lines.push(s.gutter(String(i + 1).padStart(4) + ' ') + line);
Expand All @@ -79,7 +83,7 @@ function renderDisplayBlock(
if (remaining > 0) {
lines.push(
s.dim(
` … ${String(remaining)} more line${remaining > 1 ? 's' : ''} hidden (ctrl+e to expand)`,
` … ${String(remaining)} more line${remaining > 1 ? 's' : ''} hidden (ctrl+e to preview)`,
),
);
}
Expand Down Expand Up @@ -181,26 +185,30 @@ export class ApprovalPanelComponent extends Container implements Focusable {
private selectedIndex = 0;
private feedbackMode = false;
private readonly feedbackInput = new Input();
private expanded = false;
private onResponse: (response: ApprovalPanelResponse) => void;
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;

constructor(
request: PendingApproval,
onResponse: (response: ApprovalPanelResponse) => void,
colors: ColorPalette,
onToggleToolOutput?: () => void,
onTogglePlanExpand?: () => void,
onOpenPreview?: (block: DiffDisplayBlock | FileContentDisplayBlock) => void,
) {
super();
this.request = request;
this.onResponse = onResponse;
this.colors = colors;
this.onToggleToolOutput = onToggleToolOutput;
this.onTogglePlanExpand = onTogglePlanExpand;
this.onOpenPreview = onOpenPreview;
this.feedbackInput.onSubmit = (value) => {
this.submit(this.selectedIndex, value);
};
Expand Down Expand Up @@ -242,8 +250,12 @@ export class ApprovalPanelComponent extends Container implements Focusable {
}

if (matchesKey(data, Key.ctrl('e'))) {
this.expanded = !this.expanded;
this.onTogglePlanExpand?.();
const previewable = this.findPreviewableBlock();
if (previewable !== undefined && this.onOpenPreview !== undefined) {
this.onOpenPreview(previewable);
} else {
this.onTogglePlanExpand?.();
}
return;
}

Expand Down Expand Up @@ -312,14 +324,14 @@ export class ApprovalPanelComponent extends Container implements Focusable {
(block) => !isDuplicateBriefBlock(block, data.description),
);
const visibleBlocks = dedupedBlocks.slice(0, 5);
const hasExpandable = visibleBlocks.some(
const hasPreviewable = visibleBlocks.some(
(block) => block.type === 'diff' || block.type === 'file_content',
);

if (visibleBlocks.length > 0) {
lines.push('');
for (const block of visibleBlocks) {
const blockLines = renderDisplayBlock(block, this.expanded, blockStyles, this.colors);
const blockLines = renderDisplayBlock(block, blockStyles, this.colors);
for (const line of blockLines) {
lines.push(indent(line));
}
Expand Down Expand Up @@ -352,7 +364,7 @@ export class ApprovalPanelComponent extends Container implements Focusable {
if (this.feedbackMode) {
lines.push(indent(dim('Type feedback · ↵ submit.')));
} else {
const expandHint = hasExpandable ? ` · ctrl+e ${this.expanded ? 'collapse' : 'expand'}` : '';
const expandHint = hasPreviewable ? ' · ctrl+e preview' : '';
lines.push(
indent(
dim(
Expand All @@ -366,6 +378,13 @@ export class ApprovalPanelComponent extends Container implements Focusable {
return lines.map((line) => truncateToWidth(line, width));
}

private findPreviewableBlock(): DiffDisplayBlock | FileContentDisplayBlock | undefined {
for (const block of this.request.data.display) {
if (block.type === 'diff' || block.type === 'file_content') return block;
}
return undefined;
}

private choiceAt(index: number): ApprovalPanelChoice | undefined {
return this.request.data.choices[index];
}
Expand Down
250 changes: 250 additions & 0 deletions apps/kimi-code/src/tui/components/dialogs/approval-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/**
* ApprovalPreviewViewer — full-screen preview of an Edit diff or Write
* file content for the approval flow.
*
* Mounted by `kimi-tui.ts` via the same nested-takeover pattern as
* `TaskOutputViewer`: the active approval panel is preserved underneath
* and restored on close. The viewer is intentionally a snapshot — its
* lines are rendered once at construction and only sliced on scroll, so
* the per-frame render cost stays in `O(viewport)` even when the
* underlying diff/content is very large.
*
* This avoids the prior failure mode where pressing ctrl+e on an Edit
* with a long hunk inflated the approval panel past one screen, which
* collided with pi-tui's inline differential renderer and the terminal
* emulator's "snap to bottom on stdout" reflex, causing flicker and an
* unscrollable history pane.
*/

import {
Container,
Key,
matchesKey,
type Terminal,
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
import chalk from 'chalk';

import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight';
import { renderDiffLines } from '#/tui/components/media/diff-preview';
import type { DiffDisplayBlock, FileContentDisplayBlock } from '#/tui/reverse-rpc/types';
import type { ColorPalette } from '#/tui/theme/colors';
import { printableChar } from '#/tui/utils/printable-key';

const ELLIPSIS = '…';

export type ApprovalPreviewBlock = DiffDisplayBlock | FileContentDisplayBlock;

export interface ApprovalPreviewViewerProps {
readonly block: ApprovalPreviewBlock;
readonly colors: ColorPalette;
readonly onClose: () => void;
}

function padToWidth(line: string, width: number): string {
const w = visibleWidth(line);
if (w === width) return line;
if (w > width) return truncateToWidth(line, width, ELLIPSIS);
return line + ' '.repeat(width - w);
}

function fitExactly(line: string, width: number): string {
let s = line;
if (visibleWidth(s) > width) s = truncateToWidth(s, width, ELLIPSIS);
return padToWidth(s, width);
}

export class ApprovalPreviewViewer extends Container implements Focusable {
focused = false;

private readonly props: ApprovalPreviewViewerProps;
private readonly terminal: Terminal;
/** Pre-rendered body lines (ANSI-styled, no border / no gutter). */
private readonly bodyLines: string[];
/** Title shown in the header (path + diff stats / "Write" label). */
private readonly headerTitle: string;
/** Index of the topmost visible line. */
private scrollTop = 0;

constructor(props: ApprovalPreviewViewerProps, terminal: Terminal) {
super();
this.props = props;
this.terminal = terminal;
const built = buildBody(props.block, props.colors);
this.bodyLines = built.lines;
this.headerTitle = built.title;
}

handleInput(data: string): void {
const visible = this.viewableRows();
const k = printableChar(data);

if (
matchesKey(data, Key.escape) ||
matchesKey(data, Key.ctrl('e')) ||
k === 'q' ||
k === 'Q'
) {
this.props.onClose();
return;
}
if (matchesKey(data, Key.up) || k === 'k') {
this.scrollBy(-1);
return;
}
if (matchesKey(data, Key.down) || k === 'j') {
this.scrollBy(1);
return;
}
if (matchesKey(data, Key.pageUp) || k === ' ' || data === '') {
this.scrollBy(-Math.max(1, visible - 1));
return;
}
if (matchesKey(data, Key.pageDown) || data === '') {
this.scrollBy(Math.max(1, visible - 1));
return;
}
if (matchesKey(data, Key.home) || k === 'g') {
this.scrollTo(0);
return;
}
if (matchesKey(data, Key.end) || k === 'G') {
this.scrollTo(this.maxScroll());
return;
}
}

private scrollBy(delta: number): void {
this.scrollTo(this.scrollTop + delta);
}

private scrollTo(target: number): void {
this.scrollTop = Math.max(0, Math.min(target, this.maxScroll()));
this.invalidate();
}

private maxScroll(): number {
return Math.max(0, this.bodyLines.length - this.viewableRows());
}

/** Body rows = terminal rows − header(1) − top border(1) − bottom border(1) − footer(1). */
private viewableRows(): number {
return Math.max(1, this.terminal.rows - 4);
}

override render(width: number): string[] {
const rows = Math.max(3, this.terminal.rows);
const bodyHeight = rows - 2;

const header = this.renderHeader(width);
const body = this.renderBody(width, bodyHeight);
const footer = this.renderFooter(width, bodyHeight);

return [header, ...body, footer];
}

private renderHeader(width: number): string {
const colors = this.props.colors;
const title = chalk.hex(colors.primary).bold(' Preview ');
return fitExactly(title + this.headerTitle, width);
}

private renderBody(width: number, bodyHeight: number): string[] {
const colors = this.props.colors;
const stroke = colors.primary;
const innerWidth = Math.max(1, width - 4);

const max = this.maxScroll();
if (this.scrollTop > max) this.scrollTop = max;
if (this.scrollTop < 0) this.scrollTop = 0;

const viewRows = bodyHeight - 2;
const top = chalk.hex(stroke)('┌' + '─'.repeat(Math.max(0, width - 2)) + '┐');
const bottom = chalk.hex(stroke)('└' + '─'.repeat(Math.max(0, width - 2)) + '┘');

const out: string[] = [top];
for (let i = 0; i < viewRows; i++) {
const lineIndex = this.scrollTop + i;
const raw = this.bodyLines[lineIndex] ?? '';
out.push(chalk.hex(stroke)('│ ') + fitExactly(raw, innerWidth) + chalk.hex(stroke)(' │'));
}
out.push(bottom);
return out;
}

private renderFooter(width: number, bodyHeight: number): string {
const colors = this.props.colors;
const key = (text: string): string => chalk.hex(colors.primary).bold(text);
const dim = (text: string): string => chalk.hex(colors.textMuted)(text);

const total = this.bodyLines.length;
const viewRows = Math.max(1, bodyHeight - 2);
const maxScroll = Math.max(0, total - viewRows);
const percent = maxScroll === 0 ? 100 : Math.round((this.scrollTop / maxScroll) * 100);
const lineFrom = total === 0 ? 0 : this.scrollTop + 1;
const lineTo = Math.min(total, this.scrollTop + viewRows);

const position = chalk.hex(colors.textMuted)(
` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `,
);
const keys =
`${key('↑↓')} ${dim('line')} ` +
`${key('PgUp/PgDn')} ${dim('page')} ` +
`${key('g/G')} ${dim('top/bot')} ` +
`${key('Q/Esc/Ctrl+E')} ${dim('back')}`;
const left = ` ${keys}`;
const leftW = visibleWidth(left);
const rightW = visibleWidth(position);
if (leftW + 2 + rightW <= width) {
return left + ' '.repeat(width - leftW - rightW) + position;
}
return fitExactly(left, width);
}
}

interface BuiltBody {
lines: string[];
title: string;
}

function buildBody(block: ApprovalPreviewBlock, colors: ColorPalette): BuiltBody {
if (block.type === 'diff') {
return buildDiffBody(block, colors);
}
return buildFileContentBody(block, colors);
}

function buildDiffBody(block: DiffDisplayBlock, colors: ColorPalette): BuiltBody {
// renderDiffLines emits a `+N -M path` header on its first line followed
// by every changed line. We pull the header out into the viewer chrome so
// the body is purely scrollable diff content; this also means we don't
// double-render the path.
const rendered = renderDiffLines(
block.old_text,
block.new_text,
block.path,
colors,
false,
block.old_start ?? 1,
block.new_start ?? 1,
);
const [header = '', ...rest] = rendered;
return { lines: rest, title: stripLeadingSpace(header) };
}

function buildFileContentBody(block: FileContentDisplayBlock, colors: ColorPalette): BuiltBody {
const lang = block.language ?? langFromPath(block.path);
const highlighted = highlightLines(block.content, lang);
const gutter = chalk.hex(colors.diffGutter);
const lines = highlighted.map(
(line, i) => gutter(String(i + 1).padStart(4) + ' ') + line,
);
const title = chalk.hex(colors.textStrong)(block.path);
return { lines, title };
}

function stripLeadingSpace(s: string): string {
return s.replace(/^ +/, '');
}
Loading
Loading