Skip to content

Commit a1bdda3

Browse files
committed
feat(messages): export plan tool output to markdown file
1 parent cc54bab commit a1bdda3

7 files changed

Lines changed: 188 additions & 2 deletions

File tree

src-tauri/src/files/mod.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use serde_json::json;
2+
use std::path::PathBuf;
23
use tauri::{AppHandle, State};
34

45
use self::io::TextFileResponse;
@@ -81,3 +82,18 @@ pub(crate) async fn file_write(
8182
) -> Result<(), String> {
8283
file_write_impl(scope, kind, workspace_id, content, &*state, &app).await
8384
}
85+
86+
#[tauri::command]
87+
pub(crate) fn write_text_file(path: String, content: String) -> Result<(), String> {
88+
let target = PathBuf::from(path.trim());
89+
if target.as_os_str().is_empty() {
90+
return Err("Path is required".to_string());
91+
}
92+
if let Some(parent) = target.parent() {
93+
if !parent.as_os_str().is_empty() {
94+
std::fs::create_dir_all(parent)
95+
.map_err(|err| format!("Failed to create export directory: {err}"))?;
96+
}
97+
}
98+
std::fs::write(&target, content).map_err(|err| format!("Failed to write export file: {err}"))
99+
}

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ pub fn run() {
170170
settings::get_codex_config_path,
171171
files::file_read,
172172
files::file_write,
173+
files::write_text_file,
173174
codex::get_config_model,
174175
menu::menu_set_accelerators,
175176
codex::codex_doctor,

src/features/messages/components/MessageRows.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import Terminal from "lucide-react/dist/esm/icons/terminal";
1313
import Users from "lucide-react/dist/esm/icons/users";
1414
import Wrench from "lucide-react/dist/esm/icons/wrench";
1515
import X from "lucide-react/dist/esm/icons/x";
16+
import { exportMarkdownFile } from "@services/tauri";
17+
import { pushErrorToast } from "@services/toasts";
1618
import type { ConversationItem } from "../../../types";
1719
import { languageFromPath } from "../../../utils/syntax";
1820
import { DiffBlock } from "../../git/components/DiffBlock";
@@ -273,6 +275,19 @@ function toolIconForSummary(
273275
return Wrench;
274276
}
275277

278+
function buildPlanExportFileName(itemId: string) {
279+
const normalized = itemId
280+
.trim()
281+
.toLowerCase()
282+
.replace(/[^a-z0-9_-]+/g, "-")
283+
.replace(/^-+|-+$/g, "")
284+
.slice(0, 48);
285+
if (!normalized) {
286+
return "plan.md";
287+
}
288+
return normalized.startsWith("plan-") ? `${normalized}.md` : `plan-${normalized}.md`;
289+
}
290+
276291
export const WorkingIndicator = memo(function WorkingIndicator({
277292
isThinking,
278293
processingStartedAt = null,
@@ -531,6 +546,7 @@ export const ToolRow = memo(function ToolRow({
531546
}: ToolRowProps) {
532547
const isFileChange = item.toolType === "fileChange";
533548
const isCommand = item.toolType === "commandExecution";
549+
const isPlan = item.toolType === "plan";
534550
const commandText = isCommand
535551
? item.title.replace(/^Command:\s*/i, "").trim()
536552
: "";
@@ -562,6 +578,7 @@ export const ToolRow = memo(function ToolRow({
562578
typeof item.durationMs === "number" ? item.durationMs : null;
563579
const isLongRunning = commandDurationMs !== null && commandDurationMs >= 1200;
564580
const [showLiveOutput, setShowLiveOutput] = useState(false);
581+
const [isExportingPlan, setIsExportingPlan] = useState(false);
565582

566583
useEffect(() => {
567584
if (!isCommandRunning) {
@@ -587,6 +604,30 @@ export const ToolRow = memo(function ToolRow({
587604
}
588605
}, [isCommandRunning, onRequestAutoScroll, showCommandOutput, showLiveOutput]);
589606

607+
const handlePlanExport = useCallback(
608+
async (event: MouseEvent<HTMLButtonElement>) => {
609+
event.preventDefault();
610+
event.stopPropagation();
611+
const output = (summary.output ?? "").trim();
612+
if (!output) {
613+
return;
614+
}
615+
setIsExportingPlan(true);
616+
try {
617+
await exportMarkdownFile(output, buildPlanExportFileName(item.id));
618+
} catch (error) {
619+
const message = error instanceof Error ? error.message : "Unable to export plan.";
620+
pushErrorToast({
621+
title: "Plan export failed",
622+
message,
623+
});
624+
} finally {
625+
setIsExportingPlan(false);
626+
}
627+
},
628+
[item.id, summary.output],
629+
);
630+
590631
return (
591632
<div className={`tool-inline ${isExpanded ? "tool-inline-expanded" : ""}`}>
592633
<button
@@ -688,6 +729,18 @@ export const ToolRow = memo(function ToolRow({
688729
onOpenThreadLink={onOpenThreadLink}
689730
/>
690731
)}
732+
{showToolOutput && isPlan && (summary.output ?? "").trim() && (
733+
<div className="tool-inline-actions">
734+
<button
735+
type="button"
736+
className="ghost tool-inline-action"
737+
onClick={handlePlanExport}
738+
disabled={isExportingPlan}
739+
>
740+
{isExportingPlan ? "Exporting..." : "Export .md"}
741+
</button>
742+
</div>
743+
)}
691744
</div>
692745
</div>
693746
);

src/features/messages/components/Messages.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ const useFileLinkOpenerMock = vi.fn(
1313
);
1414
const openFileLinkMock = vi.fn();
1515
const showFileLinkMenuMock = vi.fn();
16+
const { exportMarkdownFileMock } = vi.hoisted(() => ({
17+
exportMarkdownFileMock: vi.fn(),
18+
}));
1619

1720
vi.mock("../hooks/useFileLinkOpener", () => ({
1821
useFileLinkOpener: (
@@ -22,6 +25,16 @@ vi.mock("../hooks/useFileLinkOpener", () => ({
2225
) => useFileLinkOpenerMock(workspacePath, openTargets, selectedOpenAppId),
2326
}));
2427

28+
vi.mock("@services/tauri", async () => {
29+
const actual = await vi.importActual<typeof import("@services/tauri")>(
30+
"@services/tauri",
31+
);
32+
return {
33+
...actual,
34+
exportMarkdownFile: exportMarkdownFileMock,
35+
};
36+
});
37+
2538
describe("Messages", () => {
2639
beforeAll(() => {
2740
if (!HTMLElement.prototype.scrollIntoView) {
@@ -37,6 +50,7 @@ describe("Messages", () => {
3750
useFileLinkOpenerMock.mockClear();
3851
openFileLinkMock.mockReset();
3952
showFileLinkMenuMock.mockReset();
53+
exportMarkdownFileMock.mockReset();
4054
});
4155

4256
it("renders image grid above message text and opens lightbox", () => {
@@ -904,6 +918,44 @@ describe("Messages", () => {
904918
).toBeTruthy();
905919
});
906920

921+
it("exports plan tool-call output from the conversation view", async () => {
922+
exportMarkdownFileMock.mockResolvedValueOnce("/tmp/plan-7.md");
923+
const items: ConversationItem[] = [
924+
{
925+
id: "plan-7",
926+
kind: "tool",
927+
toolType: "plan",
928+
title: "Plan",
929+
detail: "completed",
930+
status: "completed",
931+
output: "## Steps\n- Step 1",
932+
},
933+
];
934+
935+
render(
936+
<Messages
937+
items={items}
938+
threadId="thread-1"
939+
workspaceId="ws-1"
940+
isThinking={false}
941+
openTargets={[]}
942+
selectedOpenAppId=""
943+
/>,
944+
);
945+
946+
const exportButton = await screen.findByRole("button", {
947+
name: "Export .md",
948+
});
949+
fireEvent.click(exportButton);
950+
951+
await waitFor(() =>
952+
expect(exportMarkdownFileMock).toHaveBeenCalledWith(
953+
"## Steps\n- Step 1",
954+
"plan-7.md",
955+
),
956+
);
957+
});
958+
907959
it("hides the plan-ready follow-up once the user has replied after the plan", () => {
908960
const onPlanAccept = vi.fn();
909961
const onPlanSubmitChanges = vi.fn();

src/services/tauri.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { invoke } from "@tauri-apps/api/core";
3-
import { open } from "@tauri-apps/plugin-dialog";
3+
import { open, save } from "@tauri-apps/plugin-dialog";
44
import * as notification from "@tauri-apps/plugin-notification";
55
import {
6+
exportMarkdownFile,
67
addWorkspace,
78
compactThread,
89
createGitHubRepo,
@@ -47,6 +48,7 @@ vi.mock("@tauri-apps/api/core", () => ({
4748

4849
vi.mock("@tauri-apps/plugin-dialog", () => ({
4950
open: vi.fn(),
51+
save: vi.fn(),
5052
}));
5153

5254
vi.mock("@tauri-apps/plugin-notification", () => ({
@@ -100,6 +102,36 @@ describe("tauri invoke wrappers", () => {
100102
await expect(pickWorkspacePaths()).resolves.toEqual(["/tmp/one", "/tmp/two"]);
101103
});
102104

105+
it("returns null when markdown export is cancelled", async () => {
106+
const saveMock = vi.mocked(save);
107+
const invokeMock = vi.mocked(invoke);
108+
saveMock.mockResolvedValueOnce(null);
109+
110+
await expect(exportMarkdownFile("# Plan")).resolves.toBeNull();
111+
expect(invokeMock).not.toHaveBeenCalledWith(
112+
"write_text_file",
113+
expect.anything(),
114+
);
115+
});
116+
117+
it("writes markdown to the selected path", async () => {
118+
const saveMock = vi.mocked(save);
119+
const invokeMock = vi.mocked(invoke);
120+
saveMock.mockResolvedValueOnce("/tmp/plan.md");
121+
122+
await expect(exportMarkdownFile("# Plan", "my-plan.md")).resolves.toBe("/tmp/plan.md");
123+
124+
expect(saveMock).toHaveBeenCalledWith({
125+
title: "Export plan as Markdown",
126+
defaultPath: "my-plan.md",
127+
filters: [{ name: "Markdown", extensions: ["md"] }],
128+
});
129+
expect(invokeMock).toHaveBeenCalledWith("write_text_file", {
130+
path: "/tmp/plan.md",
131+
content: "# Plan",
132+
});
133+
});
134+
103135
it("maps workspace_id to workspaceId for git status", async () => {
104136
const invokeMock = vi.mocked(invoke);
105137
invokeMock.mockResolvedValueOnce({

src/services/tauri.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { invoke } from "@tauri-apps/api/core";
2-
import { open } from "@tauri-apps/plugin-dialog";
2+
import { open, save } from "@tauri-apps/plugin-dialog";
33
import type { Options as NotificationOptions } from "@tauri-apps/plugin-notification";
44
import type {
55
AppSettings,
@@ -67,6 +67,27 @@ export async function pickImageFiles(): Promise<string[]> {
6767
return Array.isArray(selection) ? selection : [selection];
6868
}
6969

70+
export async function exportMarkdownFile(
71+
content: string,
72+
defaultFileName = "plan.md",
73+
): Promise<string | null> {
74+
const selection = await save({
75+
title: "Export plan as Markdown",
76+
defaultPath: defaultFileName,
77+
filters: [
78+
{
79+
name: "Markdown",
80+
extensions: ["md"],
81+
},
82+
],
83+
});
84+
if (!selection) {
85+
return null;
86+
}
87+
await invoke("write_text_file", { path: selection, content });
88+
return selection;
89+
}
90+
7091
export async function listWorkspaces(): Promise<WorkspaceInfo[]> {
7192
try {
7293
return await invoke<WorkspaceInfo[]>("list_workspaces");

src/styles/messages.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,17 @@
724724
color: var(--text-quiet);
725725
}
726726

727+
.tool-inline-actions {
728+
display: flex;
729+
justify-content: flex-end;
730+
}
731+
732+
.tool-inline-action {
733+
font-size: 11px;
734+
line-height: 1.2;
735+
padding: 4px 8px;
736+
}
737+
727738
.tool-inline-terminal {
728739
background: var(--surface-command);
729740
border-radius: 10px;

0 commit comments

Comments
 (0)