Skip to content

Commit f92e316

Browse files
asanmateuclaude
andcommitted
feat: chat export with Ctrl+s save and auto-save
Press Ctrl+s in chat view to save the conversation as chat.md in the session directory. After the first save, every completed assistant message auto-saves the full transcript. If the summary hasn't been saved yet, saving chat also creates the session directory and persists the summary. Chat messages are lifted to App state so they persist across chat enter/exit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8040b52 commit f92e316

File tree

11 files changed

+297
-15
lines changed

11 files changed

+297
-15
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Chat export: press `Ctrl+s` in chat view to save the conversation as `chat.md` in the session directory; auto-saves on every subsequent message
13+
1014
## [2.1.0] - 2026-02-21
1115

1216
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ YouTube, Slack threads, and Notion pages also work — just paste the URL.
5252

5353
In interactive mode, you can drag and drop files from Finder directly into the terminal — paths from iTerm2, Terminal.app, and other emulators are automatically recognized.
5454

55-
After a summary, use the keyboard shortcuts shown at the bottom: save, save with audio, listen, copy, chat, re-summarize, or discard. You'll be notified in the prompt when a new version is available.
55+
After a summary, use the keyboard shortcuts shown at the bottom: save, save with audio, listen, copy, chat, re-summarize, or discard. In chat mode, press `Ctrl+s` to save the conversation — subsequent messages auto-save. You'll be notified in the prompt when a new version is available.
5656

5757
Type `/` in interactive mode to access commands like `/history`, `/setup`, `/config`, `/theme`, and `/help`.
5858

docs/audio.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ Press **`w`** instead of Enter. Saves both `summary.md` and `audio.mp3`. Press *
4141

4242
After saving, you stay on the result view — you can still copy, chat, re-listen, or re-summarize. The footer shows "Saved" and `[q]` exits with a single tap (no confirmation needed since nothing will be lost).
4343

44+
## Saving chat transcripts
45+
46+
Press **`Ctrl+s`** in chat view to save the conversation as `chat.md` in the session directory. After the first save, every completed assistant message auto-saves the full conversation. If the summary hasn't been saved yet, saving the chat also creates the session directory and saves the summary.
47+
4448
- If you already pressed `a`, the cached audio is reused — no extra API call.
4549
- Audio failures are non-fatal. The summary always saves.
4650

src/App.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
import { addEntry, deduplicateBySource, getRecent, removeEntry } from "./lib/history.js";
2525
import { isClaudeCodeAvailable } from "./lib/providers/claude-code.js";
2626
import { isCodexAvailable } from "./lib/providers/codex.js";
27-
import { getSessionPaths, saveAudioFile, saveSummary } from "./lib/session.js";
27+
import { getSessionPaths, saveAudioFile, saveChat, saveSummary } from "./lib/session.js";
2828
import { rewriteForSpeech, summarize } from "./lib/summarizer.js";
2929
import { resolveTheme } from "./lib/theme.js";
3030
import {
@@ -36,6 +36,7 @@ import {
3636
} from "./lib/tts.js";
3737
import type {
3838
AppState,
39+
ChatMessage,
3940
Config,
4041
ConfigOverrides,
4142
ExtractionResult,
@@ -87,6 +88,8 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
8788
const [cachedSpeechText, setCachedSpeechText] = useState<string | undefined>(undefined);
8889
const [isSavingAudio, setIsSavingAudio] = useState(false);
8990
const [profiles, setProfiles] = useState<{ name: string; active: boolean }[]>([]);
91+
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
92+
const [chatSaveEnabled, setChatSaveEnabled] = useState(false);
9093
const [summaryPinned, setSummaryPinned] = useState(false);
9194
const [pinnedSummaries, setPinnedSummaries] = useState<
9295
{ id: string; extraction: ExtractionResult; summary: string; sessionDir: string | undefined }[]
@@ -161,6 +164,8 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
161164
setPendingResult(undefined);
162165
setSummaryPinned(false);
163166
setPinnedSummaries([]);
167+
setChatMessages([]);
168+
setChatSaveEnabled(false);
164169

165170
const result = await extract(rawInput, signal);
166171

@@ -253,6 +258,37 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
253258
await saveSettings(settings);
254259
}, []);
255260

261+
const handleChatSave = useCallback(
262+
async (messages: ChatMessage[]) => {
263+
if (!config || !extraction) return;
264+
let session = currentSession;
265+
// Ensure session dir exists — save summary if not saved yet
266+
if (pendingResult && session) {
267+
const saved = await saveSummary(session, pendingResult.summary);
268+
setCurrentSession(saved);
269+
session = saved;
270+
await addEntry(pendingResult);
271+
const updated = await getRecent(100);
272+
setHistory(updated);
273+
setPendingResult(undefined);
274+
}
275+
if (session) {
276+
await saveChat(session, messages);
277+
setChatSaveEnabled(true);
278+
}
279+
},
280+
[config, extraction, currentSession, pendingResult],
281+
);
282+
283+
const handleChatAutoSave = useCallback(
284+
async (messages: ChatMessage[]) => {
285+
if (chatSaveEnabled && currentSession) {
286+
await saveChat(currentSession, messages);
287+
}
288+
},
289+
[chatSaveEnabled, currentSession],
290+
);
291+
256292
const handleConfigSave = useCallback(
257293
async (newConfig: Config) => {
258294
await saveConfig(newConfig);
@@ -429,7 +465,7 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
429465
})();
430466
return;
431467
}
432-
if (ch === "s" && audioProcess) {
468+
if (ch === "s" && !key.ctrl && audioProcess) {
433469
stopAudio(audioProcess);
434470
setAudioProcess(undefined);
435471
return;
@@ -551,6 +587,8 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
551587
}
552588
setSummaryPinned(false);
553589
setPinnedSummaries([]);
590+
setChatMessages([]);
591+
setChatSaveEnabled(false);
554592
setState("idle");
555593
setSummary("");
556594
setExtraction(undefined);
@@ -577,6 +615,8 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
577615
}
578616
setSummaryPinned(false);
579617
setPinnedSummaries([]);
618+
setChatMessages([]);
619+
setChatSaveEnabled(false);
580620
setPendingResult(undefined);
581621
setState("idle");
582622
setSummary("");
@@ -693,7 +733,16 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
693733
/>
694734
)}
695735
{state === "chat" && config && (
696-
<ChatView config={config} summaryContent={summary} onExit={() => setState("result")} />
736+
<ChatView
737+
config={config}
738+
summaryContent={summary}
739+
messages={chatMessages}
740+
onMessagesChange={setChatMessages}
741+
chatSaveEnabled={chatSaveEnabled}
742+
onSave={handleChatSave}
743+
onAutoSave={handleChatAutoSave}
744+
onExit={() => setState("result")}
745+
/>
697746
)}
698747
{state === "history" && (
699748
<HistoryView

src/__tests__/app.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({
2727
deduplicateBySource: vi.fn((e: TldrResult[]) => e),
2828
saveSummary: vi.fn(),
2929
saveAudioFile: vi.fn(),
30+
saveChat: vi.fn(),
3031
getSessionPaths: vi.fn(),
3132
generateAudio: vi.fn(),
3233
playAudio: vi.fn(),
@@ -68,6 +69,7 @@ vi.mock("../lib/history.js", () => ({
6869
vi.mock("../lib/session.js", () => ({
6970
saveSummary: mocks.saveSummary,
7071
saveAudioFile: mocks.saveAudioFile,
72+
saveChat: mocks.saveChat,
7173
getSessionPaths: mocks.getSessionPaths,
7274
}));
7375
vi.mock("../lib/tts.js", () => ({
@@ -156,6 +158,7 @@ const TEST_SESSION: SessionPaths = {
156158
sessionDir: "/tmp/tldr-test/2026-02-18-test-article",
157159
summaryPath: "/tmp/tldr-test/2026-02-18-test-article/summary.md",
158160
audioPath: "/tmp/tldr-test/2026-02-18-test-article/audio.mp3",
161+
chatPath: "/tmp/tldr-test/2026-02-18-test-article/chat.md",
159162
};
160163

161164
// ---------------------------------------------------------------------------
@@ -182,6 +185,7 @@ describe("App", () => {
182185
mocks.checkForUpdate.mockResolvedValue(null);
183186
mocks.deduplicateBySource.mockImplementation((e: TldrResult[]) => e);
184187
mocks.addEntry.mockResolvedValue(undefined);
188+
mocks.saveChat.mockResolvedValue(undefined);
185189
mocks.getSessionPaths.mockReturnValue(TEST_SESSION);
186190
mocks.saveSummary.mockResolvedValue(TEST_SESSION);
187191
mocks.extract.mockResolvedValue(TEST_EXTRACTION);
@@ -649,6 +653,54 @@ describe("App", () => {
649653
instance.unmount();
650654
});
651655

656+
it("Ctrl+S in chat triggers saveChat", async () => {
657+
const instance = render(<App initialInput="https://example.com/article" />);
658+
659+
await vi.waitFor(
660+
() => {
661+
expect(instance.lastFrame()).toContain("TL;DR");
662+
},
663+
{ timeout: 2000 },
664+
);
665+
666+
// Enter chat mode
667+
instance.stdin.write("t");
668+
669+
await vi.waitFor(
670+
() => {
671+
expect(instance.lastFrame()).toContain("Chat");
672+
expect(instance.lastFrame()).toContain("Ctrl+s");
673+
expect(instance.lastFrame()).toContain("save chat");
674+
},
675+
{ timeout: 2000 },
676+
);
677+
678+
// Let effects flush before sending Ctrl+S
679+
await new Promise((resolve) => setTimeout(resolve, 50));
680+
681+
// Press Ctrl+S (raw mode: \x13)
682+
instance.stdin.write("\x13");
683+
684+
await vi.waitFor(
685+
() => {
686+
// Since there's a pending result, saveSummary is called first, then saveChat
687+
expect(mocks.saveSummary).toHaveBeenCalled();
688+
expect(mocks.saveChat).toHaveBeenCalled();
689+
},
690+
{ timeout: 3000 },
691+
);
692+
693+
// Toast should appear
694+
await vi.waitFor(
695+
() => {
696+
expect(instance.lastFrame()).toContain("Chat saved");
697+
},
698+
{ timeout: 2000 },
699+
);
700+
701+
instance.unmount();
702+
});
703+
652704
it("shows Chat panel in result view", async () => {
653705
const instance = render(<App initialInput="https://example.com/article" />);
654706

src/__tests__/session.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { join } from "node:path";
44
import { afterEach, beforeEach, describe, expect, it } from "vitest";
55
import {
66
buildSessionName,
7+
formatChatAsMarkdown,
78
getSessionPaths,
89
parseTitleFromSummary,
910
saveAudioFile,
11+
saveChat,
1012
saveSummary,
1113
slugify,
1214
} from "../lib/session.js";
13-
import type { ExtractionResult } from "../lib/types.js";
15+
import type { ChatMessage, ExtractionResult } from "../lib/types.js";
1416

1517
let tempDir: string;
1618

@@ -127,6 +129,7 @@ describe("getSessionPaths", () => {
127129
expect(paths.sessionDir).toMatch(/^\/out\//);
128130
expect(paths.summaryPath).toContain("summary.md");
129131
expect(paths.audioPath).toContain("audio.mp3");
132+
expect(paths.chatPath).toContain("chat.md");
130133
});
131134
});
132135

@@ -137,6 +140,7 @@ describe("saveSummary", () => {
137140
sessionDir,
138141
summaryPath: join(sessionDir, "summary.md"),
139142
audioPath: join(sessionDir, "audio.mp3"),
143+
chatPath: join(sessionDir, "chat.md"),
140144
};
141145

142146
const saved = await saveSummary(paths, "# Hello\nWorld");
@@ -155,6 +159,7 @@ describe("saveSummary", () => {
155159
sessionDir,
156160
summaryPath: join(sessionDir, "summary.md"),
157161
audioPath: join(sessionDir, "audio.mp3"),
162+
chatPath: join(sessionDir, "chat.md"),
158163
};
159164

160165
const first = await saveSummary(paths, "first");
@@ -178,6 +183,7 @@ describe("saveAudioFile", () => {
178183
sessionDir,
179184
summaryPath: join(sessionDir, "summary.md"),
180185
audioPath: join(sessionDir, "audio.mp3"),
186+
chatPath: join(sessionDir, "chat.md"),
181187
};
182188

183189
// Create the session directory first (as saveSummary would)
@@ -199,6 +205,7 @@ describe("saveAudioFile", () => {
199205
sessionDir,
200206
summaryPath: join(sessionDir, "summary.md"),
201207
audioPath: join(sessionDir, "audio.mp3"),
208+
chatPath: join(sessionDir, "chat.md"),
202209
};
203210

204211
// Create two sessions to trigger dedup
@@ -221,3 +228,86 @@ describe("saveAudioFile", () => {
221228
expect(summaryContent).toBe("second");
222229
});
223230
});
231+
232+
describe("formatChatAsMarkdown", () => {
233+
it("returns header only for empty messages", () => {
234+
expect(formatChatAsMarkdown([])).toBe("# Chat\n");
235+
});
236+
237+
it("formats a multi-turn conversation", () => {
238+
const messages: ChatMessage[] = [
239+
{ role: "user", content: "What is this about?" },
240+
{ role: "assistant", content: "It's about testing." },
241+
{ role: "user", content: "Thanks!" },
242+
{ role: "assistant", content: "You're welcome." },
243+
];
244+
const result = formatChatAsMarkdown(messages);
245+
expect(result).toBe(
246+
[
247+
"# Chat",
248+
"",
249+
"**You:** What is this about?",
250+
"",
251+
"**AI:** It's about testing.",
252+
"",
253+
"**You:** Thanks!",
254+
"",
255+
"**AI:** You're welcome.",
256+
"",
257+
].join("\n"),
258+
);
259+
});
260+
});
261+
262+
describe("saveChat", () => {
263+
it("writes chat.md to session directory", async () => {
264+
const sessionDir = join(tempDir, "2026-01-01-chat-test");
265+
const paths = {
266+
sessionDir,
267+
summaryPath: join(sessionDir, "summary.md"),
268+
audioPath: join(sessionDir, "audio.mp3"),
269+
chatPath: join(sessionDir, "chat.md"),
270+
};
271+
272+
// Create session dir first
273+
await saveSummary(paths, "# Test");
274+
275+
const messages: ChatMessage[] = [
276+
{ role: "user", content: "Hello" },
277+
{ role: "assistant", content: "Hi there!" },
278+
];
279+
280+
await saveChat(paths, messages);
281+
282+
const content = await readFile(paths.chatPath, "utf-8");
283+
expect(content).toContain("**You:** Hello");
284+
expect(content).toContain("**AI:** Hi there!");
285+
});
286+
287+
it("overwrites on subsequent saves", async () => {
288+
const sessionDir = join(tempDir, "2026-01-01-chat-overwrite");
289+
const paths = {
290+
sessionDir,
291+
summaryPath: join(sessionDir, "summary.md"),
292+
audioPath: join(sessionDir, "audio.mp3"),
293+
chatPath: join(sessionDir, "chat.md"),
294+
};
295+
296+
await saveSummary(paths, "# Test");
297+
298+
await saveChat(paths, [{ role: "user", content: "First" }]);
299+
300+
const first = await readFile(paths.chatPath, "utf-8");
301+
expect(first).toContain("**You:** First");
302+
303+
await saveChat(paths, [
304+
{ role: "user", content: "First" },
305+
{ role: "assistant", content: "Reply" },
306+
{ role: "user", content: "Second" },
307+
]);
308+
309+
const second = await readFile(paths.chatPath, "utf-8");
310+
expect(second).toContain("**You:** Second");
311+
expect(second).toContain("**AI:** Reply");
312+
});
313+
});

0 commit comments

Comments
 (0)