From 2eca8b72bae5f9b638fbca86a887d7014b630843 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 28 May 2026 13:05:30 +0800 Subject: [PATCH 1/3] fix(approval): include file content and diff in approval display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After #26 the WriteTool/EditTool input display was reduced to `{kind: 'file_io', operation, path}`, dropping the args carried by the previous generic fallback. The approval panel then only had a path to show — no file content for Write, no diff hunk for Edit — and ctrl+e expanded to the same one-liner. Extend the file_io display with optional `content` / `before` / `after` fields so Write can attach its full content and Edit can attach its old_string/new_string hunk. The adapter promotes file_io+content to a file_content block and file_io+before/after to a diff block, matching what the panel renders for the legacy generic-fallback path. --- .../src/tui/reverse-rpc/approval/adapter.ts | 21 +++++- .../tui/reverse-rpc/approval-adapter.test.ts | 66 +++++++++++++++++++ .../agent-core/src/tools/builtin/file/edit.ts | 8 ++- .../src/tools/builtin/file/write.ts | 2 +- .../agent-core/src/tools/display/schemas.ts | 7 ++ packages/agent-core/test/tools/edit.test.ts | 19 ++++++ packages/agent-core/test/tools/write.test.ts | 17 +++++ 7 files changed, 136 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts index c53cce7533..a17e60586a 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts @@ -251,15 +251,32 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] { new_text: display.after ?? '', }, ]; - case 'file_io': + case 'file_io': { + const path = display.path ?? ''; + // Write attaches the full file content — render it as a syntax- + // highlighted code block so the approval panel can preview (and + // ctrl+e expand) what is about to land on disk. + if (display.operation === 'write' && typeof display.content === 'string') { + return [{ type: 'file_content', path, content: display.content }]; + } + // Edit attaches the old_string/new_string hunk as before/after — render + // it as a diff block so ctrl+e expansion works on the change. + if ( + display.operation === 'edit' && + typeof display.before === 'string' && + typeof display.after === 'string' + ) { + return [{ type: 'diff', path, old_text: display.before, new_text: display.after }]; + } return [ { type: 'file_op', operation: display.operation, - path: display.path ?? '', + path, detail: display.detail, }, ]; + } case 'url_fetch': return [ { diff --git a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts index 72c2b6141e..997230fec9 100644 --- a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts +++ b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts @@ -91,6 +91,72 @@ describe('approval adapter', () => { ]); }); + // The builtin Write tool emits its display as file_io (operation=write) with + // the file content alongside the path, so the approval panel can show — and + // ctrl+e expand — the bytes about to land on disk. + it('emits a file_content block for file_io write with content', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-write-io', + toolName: 'Write', + action: 'Writing src/new.ts', + display: { + kind: 'file_io', + operation: 'write', + path: 'src/new.ts', + content: 'export const x = 1;\nexport const y = 2;', + }, + }); + + expect(adapted.display).toEqual([ + { + type: 'file_content', + path: 'src/new.ts', + content: 'export const x = 1;\nexport const y = 2;', + }, + ]); + }); + + // The builtin Edit tool emits its display as file_io (operation=edit) with + // before/after carrying old_string/new_string, so the panel can render the + // hunk as a diff just like the generic-fallback path used to. + it('emits a diff block for file_io edit with before/after', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-edit-io', + toolName: 'Edit', + action: 'Editing src/foo.ts', + display: { + kind: 'file_io', + operation: 'edit', + path: 'src/foo.ts', + before: 'a\nb\nc', + after: 'a\nB\nc', + }, + }); + + expect(adapted.display).toEqual([ + { type: 'diff', path: 'src/foo.ts', old_text: 'a\nb\nc', new_text: 'a\nB\nc' }, + ]); + }); + + // Read/Glob/Grep have no content to preview, so file_io without + // content/before/after still collapses to a path-only file_op row. + it('keeps a path-only file_op block for file_io without preview fields', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-read', + toolName: 'Read', + action: 'Reading src/foo.ts', + display: { + kind: 'file_io', + operation: 'read', + path: 'src/foo.ts', + }, + }); + + expect(adapted.display).toEqual([ + { type: 'file_op', operation: 'read', path: 'src/foo.ts', detail: undefined }, + ]); + }); + it('omits plan review content from the approval panel while keeping Python-style choices', () => { const adapted = adaptApprovalRequest({ toolCallId: 'tc-plan', diff --git a/packages/agent-core/src/tools/builtin/file/edit.ts b/packages/agent-core/src/tools/builtin/file/edit.ts index 0142fbea4a..5c4b32a468 100644 --- a/packages/agent-core/src/tools/builtin/file/edit.ts +++ b/packages/agent-core/src/tools/builtin/file/edit.ts @@ -74,7 +74,13 @@ export class EditTool implements BuiltinTool { return { accesses: ToolAccesses.readWriteFile(path), description: `Editing ${args.path}`, - display: { kind: 'file_io', operation: 'edit', path }, + display: { + kind: 'file_io', + operation: 'edit', + path, + before: args.old_string, + after: args.new_string, + }, approvalRule: literalRulePattern(this.name, path), matchesRule: (ruleArgs) => matchesPathRuleSubject(ruleArgs, path, { diff --git a/packages/agent-core/src/tools/builtin/file/write.ts b/packages/agent-core/src/tools/builtin/file/write.ts index 7103eb782d..df74d34183 100644 --- a/packages/agent-core/src/tools/builtin/file/write.ts +++ b/packages/agent-core/src/tools/builtin/file/write.ts @@ -69,7 +69,7 @@ export class WriteTool implements BuiltinTool { return { accesses: ToolAccesses.writeFile(path), description: `Writing ${args.path}`, - display: { kind: 'file_io', operation: 'write', path }, + display: { kind: 'file_io', operation: 'write', path, content: args.content }, approvalRule: literalRulePattern(this.name, path), matchesRule: (ruleArgs) => matchesPathRuleSubject(ruleArgs, path, { diff --git a/packages/agent-core/src/tools/display/schemas.ts b/packages/agent-core/src/tools/display/schemas.ts index abf1800884..aabb582f3f 100644 --- a/packages/agent-core/src/tools/display/schemas.ts +++ b/packages/agent-core/src/tools/display/schemas.ts @@ -20,6 +20,13 @@ export const ToolInputDisplaySchema = z.discriminatedUnion('kind', [ operation: z.enum(['read', 'write', 'edit', 'glob', 'grep']), path: z.string(), detail: z.string().optional(), + // Optional preview payload for the approval panel. Write attaches the + // full content; Edit attaches the old/new hunk as before/after. UIs that + // want a diff or code preview read these instead of having to re-derive + // them from the raw tool args. + content: z.string().optional(), + before: z.string().optional(), + after: z.string().optional(), }), z.object({ kind: z.literal('diff'), diff --git a/packages/agent-core/test/tools/edit.test.ts b/packages/agent-core/test/tools/edit.test.ts index 379ca173fc..315049a3f5 100644 --- a/packages/agent-core/test/tools/edit.test.ts +++ b/packages/agent-core/test/tools/edit.test.ts @@ -11,6 +11,25 @@ function context(args: EditInput) { } describe('EditTool', () => { + it('exposes before/after on the file_io display so the approval panel can render a diff', () => { + const tool = new EditTool(createFakeKaos(), PERMISSIVE_WORKSPACE); + const execution = tool.resolveExecution({ + path: '/tmp/foo.ts', + old_string: 'a\nb\nc', + new_string: 'a\nB\nc', + }); + if (execution.isError === true) { + throw new TypeError('expected runnable execution'); + } + expect(execution.display).toEqual({ + kind: 'file_io', + operation: 'edit', + path: '/tmp/foo.ts', + before: 'a\nb\nc', + after: 'a\nB\nc', + }); + }); + it('exposes current metadata and schema', () => { const tool = new EditTool(createFakeKaos(), PERMISSIVE_WORKSPACE); diff --git a/packages/agent-core/test/tools/write.test.ts b/packages/agent-core/test/tools/write.test.ts index c07a87dc57..e7d48209da 100644 --- a/packages/agent-core/test/tools/write.test.ts +++ b/packages/agent-core/test/tools/write.test.ts @@ -58,6 +58,23 @@ describe('WriteTool', () => { expect(params.properties.path.description).toMatch(/absolute/i); }); + it('exposes the content on the file_io display so the approval panel can preview it', () => { + const tool = new WriteTool(createFakeKaos(), PERMISSIVE_WORKSPACE); + const execution = tool.resolveExecution({ + path: '/tmp/new.txt', + content: 'hello\nworld', + }); + if (execution.isError === true) { + throw new TypeError('expected runnable execution'); + } + expect(execution.display).toEqual({ + kind: 'file_io', + operation: 'write', + path: '/tmp/new.txt', + content: 'hello\nworld', + }); + }); + it('matches permission args with negated glob path semantics', () => { const tool = new WriteTool(createFakeKaos(), { workspaceDir: '/workspace', From f25e61ec0eec6f08d836fa853f736adea5effeca Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 28 May 2026 13:05:44 +0800 Subject: [PATCH 2/3] feat(tui): open full-screen viewer for approval previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline ctrl+e expand-in-place inflated the approval panel past one viewport for any non-trivial Edit / Write, which collided with pi-tui's inline differential renderer and the terminal's "snap to bottom on stdout" reflex: scrolling back glitched and the screen flickered. On top of that, the diff renderer's O(m·n) LCS DP ran every frame the panel was visible, so each spinner tick re-paid the cost. Make ctrl+e hand off to a dedicated full-screen viewer instead. The viewer renders all body lines once at construction and slices them on scroll, so per-frame cost is O(viewport) regardless of payload size. It uses the same nested-takeover pattern as TaskOutputViewer; the approval panel instance is preserved and refocused on close so the selection / feedback state survives. The panel itself drops its local `expanded` toggle and always renders the compact cluster view; ctrl+e now exclusively forwards to the host when there is something to preview, and falls through to the existing plan-expand toggle otherwise. --- .../tui/components/dialogs/approval-panel.ts | 45 +++- .../components/dialogs/approval-preview.ts | 250 ++++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 63 +++++ .../components/dialogs/approval-panel.test.ts | 148 ++++++----- .../dialogs/approval-preview.test.ts | 156 +++++++++++ 5 files changed, 590 insertions(+), 72 deletions(-) create mode 100644 apps/kimi-code/src/tui/components/dialogs/approval-preview.ts create mode 100644 apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts index ed93602f6f..51aea329a7 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -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 { @@ -55,7 +61,6 @@ function makeBlockStyles(colors: ColorPalette): BlockStyles { function renderDisplayBlock( block: DisplayBlock, - expanded: boolean, s: BlockStyles, colors: ColorPalette, ): string[] { @@ -63,14 +68,13 @@ function renderDisplayBlock( 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); @@ -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)`, ), ); } @@ -181,12 +185,14 @@ 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, @@ -194,6 +200,7 @@ export class ApprovalPanelComponent extends Container implements Focusable { colors: ColorPalette, onToggleToolOutput?: () => void, onTogglePlanExpand?: () => void, + onOpenPreview?: (block: DiffDisplayBlock | FileContentDisplayBlock) => void, ) { super(); this.request = request; @@ -201,6 +208,7 @@ export class ApprovalPanelComponent extends Container implements Focusable { this.colors = colors; this.onToggleToolOutput = onToggleToolOutput; this.onTogglePlanExpand = onTogglePlanExpand; + this.onOpenPreview = onOpenPreview; this.feedbackInput.onSubmit = (value) => { this.submit(this.selectedIndex, value); }; @@ -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; } @@ -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)); } @@ -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( @@ -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]; } diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts new file mode 100644 index 0000000000..8133a9f36a --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts @@ -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(/^ +/, ''); +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 89add4e24e..f364d05b36 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -139,6 +139,10 @@ import { ApprovalPanelComponent, type ApprovalPanelResponse, } from './components/dialogs/approval-panel'; +import { + ApprovalPreviewViewer, + type ApprovalPreviewBlock, +} from './components/dialogs/approval-preview'; import { ApiKeyInputDialogComponent, type ApiKeyInputResult, @@ -647,6 +651,20 @@ export class KimiTUI { private pendingThinkingFlush = false; private readonly pendingToolCallFlushIds = new Set(); + // The currently-mounted approval panel, if any. Kept so the full-screen + // preview viewer can restore focus to the exact same instance (and its + // selection / feedback state) when it closes. + private activeApprovalPanel: ApprovalPanelComponent | undefined; + // Active full-screen approval preview. While set, the root UI's normal + // children are stashed in `savedChildren`; closing restores them. + private approvalPreview: + | { + component: ApprovalPreviewViewer; + savedChildren: readonly Component[]; + panel: ApprovalPanelComponent; + } + | undefined; + public onExit?: (exitCode?: number) => Promise; private track( @@ -5739,16 +5757,61 @@ export class KimiTUI { () => { this.togglePlanExpansion(); }, + (block) => { + this.openApprovalPreview(panel, block); + }, ); + this.activeApprovalPanel = panel; this.mountEditorReplacement(panel); } // Hides the active approval panel. private hideApprovalPanel(): void { + // If the full-screen preview is open, fold it back first so the saved- + // children stack stays consistent with what mountEditorReplacement set up. + if (this.approvalPreview !== undefined) this.closeApprovalPreview(); + this.activeApprovalPanel = undefined; this.patchLivePane({ pendingApproval: null }); this.restoreEditor(); } + // Mounts the full-screen approval preview viewer on top of the current + // approval panel. Uses the same nested-takeover pattern as + // openTaskOutputViewer: we snapshot the root container's children, swap + // in the viewer, and restore on close. The approval panel instance is + // kept around in `activeApprovalPanel` so its selection state survives. + private openApprovalPreview(panel: ApprovalPanelComponent, block: ApprovalPreviewBlock): void { + if (this.approvalPreview !== undefined) return; + const savedChildren = [...this.state.ui.children]; + const viewer = new ApprovalPreviewViewer( + { + block, + colors: this.state.theme.colors, + onClose: () => { + this.closeApprovalPreview(); + }, + }, + this.state.terminal, + ); + this.state.ui.clear(); + this.state.ui.addChild(viewer); + this.state.ui.setFocus(viewer); + this.state.ui.requestRender(true); + this.approvalPreview = { component: viewer, savedChildren, panel }; + } + + private closeApprovalPreview(): void { + const preview = this.approvalPreview; + if (preview === undefined) return; + this.approvalPreview = undefined; + this.state.ui.clear(); + for (const child of preview.savedChildren) { + this.state.ui.addChild(child); + } + this.state.ui.setFocus(preview.panel); + this.state.ui.requestRender(true); + } + // Shows a question dialog and connects its response callback. private showQuestionDialog(payload: QuestionPanelData): void { this.patchLivePane({ pendingQuestion: { data: payload } }); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts index 451c08ba59..0b844ee7c5 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts @@ -2,7 +2,11 @@ import { CURSOR_MARKER } from '@earendil-works/pi-tui'; import { describe, expect, it } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; -import type { PendingApproval } from '#/tui/reverse-rpc/types'; +import type { + DiffDisplayBlock, + FileContentDisplayBlock, + PendingApproval, +} from '#/tui/reverse-rpc/types'; import { getColorPalette } from '#/tui/theme/colors'; import { captureProcessWrite } from '../../../helpers/process'; @@ -192,7 +196,13 @@ describe('ApprovalPanelComponent', () => { expect(out).not.toContain('Investigate'); }); - it('renders an Edit diff collapsed by default and expands on ctrl+e', () => { + // Inline expand-in-place used to inflate the panel past the viewport on + // any non-trivial Edit, which then collided with pi-tui's inline scroll + // and made the terminal flicker / refuse to scroll. The panel now always + // renders the diff in its compact cluster form; ctrl+e instead asks the + // host to open a dedicated full-screen preview that can manage its own + // scrolling. + it('renders an Edit diff in compact form and asks the host to open a preview on ctrl+e', () => { const responses: Array<{ response: string }> = []; const oldLines: string[] = []; const newLines: string[] = []; @@ -200,6 +210,12 @@ describe('ApprovalPanelComponent', () => { oldLines.push(`old${String(i)}`); newLines.push(`new${String(i)}`); } + const diffBlock: DiffDisplayBlock = { + type: 'diff', + path: 'src/foo.ts', + old_text: oldLines.join('\n'), + new_text: newLines.join('\n'), + }; const pending: PendingApproval = { data: { id: 'approval_diff', @@ -207,45 +223,42 @@ describe('ApprovalPanelComponent', () => { tool_name: 'Edit', action: 'edit', description: '', - display: [ - { - type: 'diff', - path: 'src/foo.ts', - old_text: oldLines.join('\n'), - new_text: newLines.join('\n'), - }, - ], + display: [diffBlock], choices: [{ label: 'Approve once', response: 'approved' }], }, }; - let globalToggleCalls = 0; + let toolOutputToggles = 0; + let planToggles = 0; + const previewCalls: Array = []; const dialog = new ApprovalPanelComponent( pending, (r) => responses.push(r), COLORS, - () => globalToggleCalls++, + () => toolOutputToggles++, + () => planToggles++, + (block) => previewCalls.push(block), ); - const collapsed = strip(dialog.render(120).join('\n')); - expect(collapsed).not.toMatch(/\bedit\s+src\/foo\.ts\b/); - expect(collapsed).toContain('+30'); - expect(collapsed).toContain('-30'); - expect(collapsed).toContain('ctrl+e expand'); - expect(collapsed).toContain('ctrl+e to expand'); - expect(collapsed).toMatch(/old\d+|new\d+/); - expect(collapsed).not.toContain('new30'); - - dialog.handleInput('\u0005'); // Ctrl+E — local toggle, no global callback. - - const expanded = strip(dialog.render(120).join('\n')); - expect(expanded).toContain('new30'); - expect(expanded).toContain('ctrl+e collapse'); - expect(expanded).not.toContain('more changes hidden'); - expect(globalToggleCalls).toBe(0); + const before = strip(dialog.render(120).join('\n')); + expect(before).toContain('+30'); + expect(before).toContain('-30'); + expect(before).toContain('ctrl+e preview'); + expect(before).not.toContain('new30'); // compact view stays compact + + dialog.handleInput('\u0005'); // Ctrl+E + + // The panel itself does not expand; it delegates to the host. + const after = strip(dialog.render(120).join('\n')); + 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); + expect(toolOutputToggles).toBe(0); expect(responses).toEqual([]); }); - it('forwards ctrl+o to the global tool-output toggle without changing local expansion', () => { + it('forwards ctrl+o to the global tool-output toggle without affecting the panel', () => { const pending: PendingApproval = { data: { id: 'approval_forward', @@ -267,53 +280,54 @@ describe('ApprovalPanelComponent', () => { let globalToggleCalls = 0; const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS, () => globalToggleCalls++); - dialog.handleInput('\u000F'); // Ctrl+O — forwarded; local stays collapsed. + dialog.handleInput('\u000F'); // Ctrl+O const after = strip(dialog.render(120).join('\n')); expect(globalToggleCalls).toBe(1); - expect(after).toContain('ctrl+e expand'); + expect(after).toContain('ctrl+e preview'); expect(after).not.toContain('new30'); }); - it('also forwards ctrl+e to the global plan-expand toggle while toggling local content', () => { + // 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', () => { const pending: PendingApproval = { data: { - id: 'approval_plan_forward', - tool_call_id: 'tool_plan_forward', - tool_name: 'Edit', - action: 'edit', + id: 'approval_plan_only', + tool_call_id: 'tool_plan_only', + tool_name: 'ExitPlanMode', + action: 'review plan', description: '', - display: [ - { - type: 'diff', - path: 'src/foo.ts', - old_text: Array.from({ length: 30 }, (_, i) => `old${String(i + 1)}`).join('\n'), - new_text: Array.from({ length: 30 }, (_, i) => `new${String(i + 1)}`).join('\n'), - }, - ], - choices: [{ label: 'Approve once', response: 'approved' }], + display: [], + choices: [{ label: 'Approve', response: 'approved' }], }, }; let planToggles = 0; + const previewCalls: Array = []; const dialog = new ApprovalPanelComponent( pending, () => {}, COLORS, undefined, () => planToggles++, + (block) => previewCalls.push(block), ); dialog.handleInput('\u0005'); // Ctrl+E - const out = strip(dialog.render(120).join('\n')); expect(planToggles).toBe(1); - expect(out).toContain('ctrl+e collapse'); // local also expanded - expect(out).toContain('new30'); + expect(previewCalls).toEqual([]); }); it('renders Write as a syntax-highlighted code block (file_content), not a diff', () => { const responses: Array<{ response: string }> = []; const lines: string[] = []; for (let i = 1; i <= 30; i++) lines.push(`const x${String(i)} = ${String(i)};`); + const contentBlock: FileContentDisplayBlock = { + type: 'file_content', + path: 'src/new.ts', + content: lines.join('\n'), + }; const pending: PendingApproval = { data: { id: 'approval_write', @@ -321,11 +335,19 @@ describe('ApprovalPanelComponent', () => { tool_name: 'Write', action: 'write', description: '', - display: [{ type: 'file_content', path: 'src/new.ts', content: lines.join('\n') }], + display: [contentBlock], choices: [{ label: 'Approve once', response: 'approved' }], }, }; - const dialog = new ApprovalPanelComponent(pending, (r) => responses.push(r), COLORS); + const previewCalls: Array = []; + const dialog = new ApprovalPanelComponent( + pending, + (r) => responses.push(r), + COLORS, + undefined, + undefined, + (block) => previewCalls.push(block), + ); const collapsed = strip(dialog.render(120).join('\n')); // No diff markers, no +N -M header. @@ -335,13 +357,14 @@ describe('ApprovalPanelComponent', () => { expect(collapsed).toContain('const x1 = 1;'); expect(collapsed).toContain('const x10 = 10;'); expect(collapsed).not.toContain('const x25 = 25;'); - expect(collapsed).toContain('20 more lines hidden (ctrl+e to expand)'); - expect(collapsed).toContain('ctrl+e expand'); + expect(collapsed).toContain('20 more lines hidden (ctrl+e to preview)'); + expect(collapsed).toContain('ctrl+e preview'); - dialog.handleInput('\u0005'); // Ctrl+E - const expanded = strip(dialog.render(120).join('\n')); - expect(expanded).toContain('const x30 = 30;'); - expect(expanded).not.toContain('more lines hidden'); + dialog.handleInput('\u0005'); // Ctrl+E hands off to the host preview. + const after = strip(dialog.render(120).join('\n')); + // The panel itself stays compact; the full content is opened elsewhere. + expect(after).not.toContain('const x30 = 30;'); + expect(previewCalls).toEqual([contentBlock]); expect(responses).toEqual([]); }); @@ -359,13 +382,20 @@ describe('ApprovalPanelComponent', () => { }; const stderr = captureProcessWrite('stderr'); try { - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const previewCalls: Array = []; + const dialog = new ApprovalPanelComponent( + pending, + () => {}, + COLORS, + undefined, + undefined, + (block) => previewCalls.push(block), + ); const collapsed = strip(dialog.render(120).join('\n')); expect(collapsed).toContain('hello'); dialog.handleInput('\u0005'); // Ctrl+E - const expanded = strip(dialog.render(120).join('\n')); - expect(expanded).toContain('world'); + expect(previewCalls).toHaveLength(1); expect(stderr.text()).not.toContain('Could not find the language'); } finally { stderr.restore(); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts new file mode 100644 index 0000000000..e3f216a1e2 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts @@ -0,0 +1,156 @@ +import type { Terminal } from '@earendil-works/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { + ApprovalPreviewViewer, + type ApprovalPreviewBlock, +} from '#/tui/components/dialogs/approval-preview'; +import { getColorPalette } from '#/tui/theme/colors'; + +const COLORS = getColorPalette('dark'); + +const ANSI_SGR = /\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function fakeTerminal(rows: number, columns = 120): Terminal { + return { + start: () => {}, + stop: () => {}, + drainInput: () => Promise.resolve(), + write: () => {}, + get columns() { + return columns; + }, + get rows() { + return rows; + }, + get kittyProtocolActive() { + return false; + }, + moveBy: () => {}, + hideCursor: () => {}, + showCursor: () => {}, + clearLine: () => {}, + clearFromCursor: () => {}, + clearScreen: () => {}, + setTitle: () => {}, + setProgress: () => {}, + }; +} + +function makeViewer(opts: { + block: ApprovalPreviewBlock; + rows?: number; + columns?: number; + onClose?: () => void; +}): ApprovalPreviewViewer { + return new ApprovalPreviewViewer( + { + block: opts.block, + colors: COLORS, + onClose: opts.onClose ?? (() => {}), + }, + fakeTerminal(opts.rows ?? 24, opts.columns ?? 100), + ); +} + +describe('ApprovalPreviewViewer', () => { + it('fills exactly terminal.rows lines', () => { + const lines: string[] = []; + for (let i = 1; i <= 50; i++) lines.push(`line ${String(i)}`); + const viewer = makeViewer({ + block: { type: 'file_content', path: 'src/big.ts', content: lines.join('\n') }, + rows: 24, + }); + expect(viewer.render(100).length).toBe(24); + }); + + // The whole point of the viewer: a long Write file content stays accessible + // by paging, not by inflating the approval panel inline. + it('reveals later lines of a long file_content after PageDown', () => { + const lines: string[] = []; + for (let i = 1; i <= 200; i++) lines.push(`row-${String(i)}`); + const viewer = makeViewer({ + block: { type: 'file_content', path: 'src/big.ts', content: lines.join('\n') }, + rows: 24, + }); + + const initial = strip(viewer.render(100).join('\n')); + expect(initial).toContain('row-1'); + expect(initial).not.toContain('row-150'); + + viewer.handleInput('[6~'); // PageDown + viewer.handleInput('[6~'); + viewer.handleInput('[6~'); + + const scrolled = strip(viewer.render(100).join('\n')); + expect(scrolled).not.toContain('row-1\n'); // start of file is gone + expect(scrolled).toMatch(/row-\d{2,}/); + }); + + it('scrolls to the end with G and back to the top with g', () => { + const lines: string[] = []; + for (let i = 1; i <= 100; i++) lines.push(`L${String(i)}`); + const viewer = makeViewer({ + block: { type: 'file_content', path: 'src/x.ts', content: lines.join('\n') }, + rows: 20, + }); + + viewer.handleInput('G'); + const atEnd = strip(viewer.render(100).join('\n')); + expect(atEnd).toContain('L100'); + + viewer.handleInput('g'); + const atTop = strip(viewer.render(100).join('\n')); + expect(atTop).toContain('L1'); + expect(atTop).not.toContain('L100'); + }); + + // Both esc and ctrl+e close — ctrl+e is the same key that opened the + // viewer, so making it a toggle keeps the muscle memory simple. + it.each([ + ['escape', ''], + ['ctrl+e', ''], + ['q', 'q'], + ])('%s closes the viewer', (_label, key) => { + let closed = 0; + const viewer = makeViewer({ + block: { type: 'file_content', path: 'a.ts', content: 'x' }, + onClose: () => closed++, + }); + viewer.handleInput(key); + expect(closed).toBe(1); + }); + + it('renders a diff block with +N -M header and both sides of the hunk', () => { + const viewer = makeViewer({ + block: { + type: 'diff', + path: 'src/foo.ts', + old_text: 'alpha\nbeta\ngamma', + new_text: 'alpha\nBETA\ngamma', + }, + rows: 24, + }); + + const text = strip(viewer.render(100).join('\n')); + expect(text).toContain('src/foo.ts'); + expect(text).toContain('+1'); + expect(text).toContain('-1'); + expect(text).toContain('beta'); + expect(text).toContain('BETA'); + }); + + // Sanity: rendering is a pure slice — repeated render() calls without + // input changes produce the same output, no incremental state drift. + it('renders deterministically across repeated calls', () => { + const viewer = makeViewer({ + block: { type: 'file_content', path: 'a.ts', content: 'one\ntwo\nthree' }, + }); + const first = viewer.render(80).join('\n'); + const second = viewer.render(80).join('\n'); + expect(first).toBe(second); + }); +}); From 4530d43b86fa732562d5582a0342ed91a5920ecc Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 28 May 2026 13:08:51 +0800 Subject: [PATCH 3/3] chore(changeset): restore approval previews --- .changeset/restore-approval-previews.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/restore-approval-previews.md diff --git a/.changeset/restore-approval-previews.md b/.changeset/restore-approval-previews.md new file mode 100644 index 0000000000..b3df230311 --- /dev/null +++ b/.changeset/restore-approval-previews.md @@ -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.