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/tui-export-md.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": minor
"@moonshot-ai/kimi-code-sdk": minor
---

Add `/export-md` slash command to export the current session as a Markdown file.
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ export const BUILTIN_SLASH_COMMANDS = [
description: 'Connect a provider from a model catalog',
priority: 40,
},
{
name: 'export-md',
aliases: ['export'],
description: 'Export current session as a Markdown file',
priority: 40,
},
{
name: 'export-debug-zip',
aliases: [],
Expand Down
51 changes: 50 additions & 1 deletion apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
*/

import { writeFileSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { release as osRelease, type as osType } from 'node:os';
import { join } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

import {
Expand Down Expand Up @@ -111,6 +112,7 @@ import { parseImageMeta } from '#/utils/image/image-mime';
import { getInputHistoryFile } from '#/utils/paths';
import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor';
import { detectFdPath } from '#/utils/process/fd-detect';
import { buildExportMarkdown } from './utils/export-markdown';

import {
BUILTIN_SLASH_COMMANDS,
Expand Down Expand Up @@ -1590,6 +1592,9 @@ export class KimiTUI {
case 'fork':
await this.handleForkCommand(args);
return;
case 'export-md':
await this.handleExportMdCommand(args);
return;
case 'export-debug-zip':
await this.handleExportDebugZipCommand();
return;
Expand Down Expand Up @@ -5531,6 +5536,50 @@ export class KimiTUI {
}
}

private async handleExportMdCommand(args: string): Promise<void> {
const session = this.session;
if (session === undefined) {
this.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}

this.showStatus('Exporting session as Markdown…');
try {
const context = await session.getContext();
if (context.history.length === 0) {
this.showError('No messages to export.');
return;
}

const now = new Date();
const shortId = session.id.slice(0, 8);
const timestamp = now.toISOString().replaceAll(/[-:]/g, '').replace(/T/, '-').slice(0, 15);
const defaultName = `kimi-export-${shortId}-${timestamp}.md`;

const trimmedArgs = args.trim();
const outputPath = trimmedArgs.length > 0
? resolve(trimmedArgs)
: resolve(this.state.appState.workDir, defaultName);

const md = buildExportMarkdown({
sessionId: session.id,
workDir: this.state.appState.workDir,
history: context.history,
tokenCount: context.tokenCount,
now,
});

await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, md, 'utf-8');

const linked = toTerminalHyperlink(outputPath, pathToFileURL(outputPath).href);
this.showNotice(`Exported ${String(context.history.length)} messages`, linked);
} catch (error) {
const msg = formatErrorMessage(error);
this.showError(`Failed to export session: ${msg}`);
}
}

private async handleExportDebugZipCommand(): Promise<void> {
const session = this.session;
if (session === undefined) {
Expand Down
241 changes: 241 additions & 0 deletions apps/kimi-code/src/tui/utils/export-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import type { ContentPart, ContextMessage, PromptOrigin, ToolCall } from '@moonshot-ai/kimi-code-sdk';

const HINT_KEYS = ['path', 'file_path', 'command', 'query', 'url', 'name', 'pattern'] as const;

const MAX_HINT_WIDTH = 60;

export function extractToolCallHint(argsJson: string): string {
let parsed: unknown;
try {
parsed = JSON.parse(argsJson);
} catch {
return '';
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return '';

const args = parsed as Record<string, unknown>;

for (const key of HINT_KEYS) {
const val = args[key];
if (typeof val === 'string' && val.trim().length > 0) {
return shorten(val, MAX_HINT_WIDTH);
}
}

for (const val of Object.values(args)) {
if (typeof val === 'string' && val.length > 0 && val.length <= 80) {
return shorten(val, MAX_HINT_WIDTH);
}
}

return '';
}

function shorten(text: string, width: number): string {
if (text.length <= width) return text;
return `${text.slice(0, width)}…`;
}

export function formatContentPartMd(part: ContentPart): string {
switch (part.type) {
case 'text':
return part.text;
case 'think':
if (!part.think.trim()) return '';
return `<details><summary>Thinking</summary>\n\n${part.think}\n\n</details>`;
case 'image_url':
return '[image]';
case 'audio_url':
return '[audio]';
case 'video_url':
return '[video]';
default:
return `[${(part as ContentPart).type}]`;
}
}

export function formatToolCallMd(tc: ToolCall): string {
const argsRaw = tc.arguments ?? '{}';
const hint = extractToolCallHint(argsRaw);
let title = `#### Tool Call: ${tc.name}`;
if (hint) {
title += ` (\`${hint}\`)`;
}

let argsFormatted: string;
try {
argsFormatted = JSON.stringify(JSON.parse(argsRaw), null, 2);
} catch {
argsFormatted = argsRaw;
}

return `${title}\n<!-- call_id: ${tc.id} -->\n\`\`\`json\n${argsFormatted}\n\`\`\``;
}

function formatToolResultMd(msg: ContextMessage, toolName: string, hint: string): string {
const callId = msg.toolCallId ?? 'unknown';
const parts: string[] = [];
for (const part of msg.content) {
const text = formatContentPartMd(part);
if (text.trim()) parts.push(text);
}
const resultText = parts.join('\n');

let summary = `Tool Result: ${toolName}`;
if (hint) summary += ` (\`${hint}\`)`;

return (
`<details><summary>${summary}</summary>\n\n` +
`<!-- call_id: ${callId} -->\n` +
`${resultText}\n\n` +
'</details>'
);
}

const INTERNAL_ORIGINS = new Set<PromptOrigin['kind']>([
'injection',
'system_trigger',
'compaction_summary',
'hook_result',
]);

export function isInternalMessage(msg: ContextMessage): boolean {
const origin = msg.origin;
if (origin === undefined) return false;
return INTERNAL_ORIGINS.has(origin.kind);
}

export function groupIntoTurns(history: readonly ContextMessage[]): ContextMessage[][] {
const turns: ContextMessage[][] = [];
let current: ContextMessage[] = [];

for (const msg of history) {
if (isInternalMessage(msg)) continue;
if (msg.role === 'user' && current.length > 0) {
turns.push(current);
current = [];
}
current.push(msg);
}

if (current.length > 0) turns.push(current);
return turns;
}

function formatTurnMd(messages: readonly ContextMessage[], turnNumber: number): string {
const lines: string[] = [`## Turn ${String(turnNumber)}`, ''];

const toolCallInfo = new Map<string, { name: string; hint: string }>();
let assistantHeaderWritten = false;

for (const msg of messages) {
if (isInternalMessage(msg)) continue;

if (msg.role === 'user') {
lines.push('### User', '');
for (const part of msg.content) {
const text = formatContentPartMd(part);
if (text.trim()) {
lines.push(text, '');
}
}
} else if (msg.role === 'assistant') {
if (!assistantHeaderWritten) {
lines.push('### Assistant', '');
assistantHeaderWritten = true;
}

for (const part of msg.content) {
const text = formatContentPartMd(part);
if (text.trim()) {
lines.push(text, '');
}
}

for (const tc of msg.toolCalls) {
const hint = extractToolCallHint(tc.arguments ?? '{}');
toolCallInfo.set(tc.id, { name: tc.name, hint });
lines.push(formatToolCallMd(tc), '');
}
} else if (msg.role === 'tool') {
const tcId = msg.toolCallId ?? '';
const info = toolCallInfo.get(tcId) ?? { name: 'unknown', hint: '' };
lines.push(formatToolResultMd(msg, info.name, info.hint), '');
} else if (msg.role === 'system') {
lines.push(`### ${msg.role.charAt(0).toUpperCase()}${msg.role.slice(1)}`, '');
for (const part of msg.content) {
const text = formatContentPartMd(part);
if (text.trim()) {
lines.push(text, '');
}
}
}
}

return lines.join('\n');
}

function buildOverview(
history: readonly ContextMessage[],
turns: readonly ContextMessage[][],
): string {
let topic = '';
for (const msg of history) {
if (msg.role === 'user' && !isInternalMessage(msg)) {
const textParts = msg.content
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text);
topic = shorten(textParts.join(' '), 80);
break;
}
}

const toolCallCount = history.reduce(
(sum, msg) => sum + msg.toolCalls.length,
0,
);

return [
'## Overview',
'',
topic ? `- **Topic**: ${topic}` : '- **Topic**: (empty)',
`- **Conversation**: ${String(turns.length)} turns | ${String(toolCallCount)} tool calls`,
'',
'---',
].join('\n');
}

export interface BuildExportMarkdownInput {
readonly sessionId: string;
readonly workDir: string;
readonly history: readonly ContextMessage[];
readonly tokenCount: number;
readonly now: Date;
}

export function buildExportMarkdown(input: BuildExportMarkdownInput): string {
const { sessionId, workDir, history, tokenCount, now } = input;

const lines: string[] = [
'---',
`session_id: ${sessionId}`,
`exported_at: ${now.toISOString()}`,
`work_dir: ${workDir}`,
`message_count: ${String(history.length)}`,
`token_count: ${String(tokenCount)}`,
'---',
'',
'# Kimi Session Export',
'',
];

const turns = groupIntoTurns(history);
lines.push(buildOverview(history, turns));
lines.push('');

for (let i = 0; i < turns.length; i++) {
lines.push(formatTurnMd(turns[i]!, i + 1));
}

return lines.join('\n');
}
Loading
Loading