From c9d859c3937d198d9e62d18bb8acfd22ee7d3c38 Mon Sep 17 00:00:00 2001 From: 404-Page-Found Date: Tue, 19 May 2026 19:57:07 +1000 Subject: [PATCH 1/9] feat: add conversation titles and rename support - Auto-generate titles from first user message (truncated to 60 chars) - Add rename command to TUI command palette (Ctrl+K) - Persist title alongside transcript in history.yaml - Display title in StatusBar and TranscriptBrowser - Backward compatible: existing transcripts without titles load correctly Closes #26 --- src/core/state-manager.ts | 13 +++++ src/core/transcript-store.ts | 104 +++++++++++++++++++++++----------- src/tui/App.tsx | 68 +++++++++++++++++++++- src/tui/StreamingRenderer.tsx | 3 + src/tui/session.ts | 78 +++++++++++++++++++++++-- 5 files changed, 225 insertions(+), 41 deletions(-) diff --git a/src/core/state-manager.ts b/src/core/state-manager.ts index 995c29d..4b0c7be 100644 --- a/src/core/state-manager.ts +++ b/src/core/state-manager.ts @@ -3,6 +3,7 @@ import type { AppConfig, ChatMessage, McpInspectorSnapshot } from './types.js'; export interface AgentStateSnapshot { config: AppConfig; messages: ChatMessage[]; + title: string | undefined; authSource: ProviderAuthSource; status: string; isBusy: boolean; @@ -17,6 +18,7 @@ type StateListener = () => void; export interface AgentStateOptions { initialMessages?: ChatMessage[]; + initialTitle?: string | undefined; onConversationChange?: (messages: ChatMessage[]) => void | Promise; } @@ -34,6 +36,7 @@ export class AgentStateManager { this.snapshotValue = { config, messages: structuredClone(options.initialMessages ?? []), + title: options.initialTitle, authSource: 'missing', status: 'Idle', isBusy: false, @@ -122,6 +125,7 @@ export class AgentStateManager { public clearConversation(): void { this.update((snapshot) => { snapshot.messages = []; + snapshot.title = undefined; snapshot.status = 'Idle'; snapshot.isBusy = false; snapshot.streamingText = ''; @@ -135,6 +139,7 @@ export class AgentStateManager { public replaceConversation(messages: ChatMessage[]): void { this.update((snapshot) => { snapshot.messages = structuredClone(messages); + snapshot.title = undefined; snapshot.status = 'Idle'; snapshot.isBusy = false; snapshot.streamingText = ''; @@ -144,6 +149,14 @@ export class AgentStateManager { this.notifyConversationChange(); } + public setTitle(title: string): void { + this.update((snapshot) => { + snapshot.title = title; + }); + + this.notifyConversationChange(); + } + public replaceConfig(config: AppConfig): void { this.update((snapshot) => { snapshot.config = config; diff --git a/src/core/transcript-store.ts b/src/core/transcript-store.ts index 1d85b40..15d03eb 100644 --- a/src/core/transcript-store.ts +++ b/src/core/transcript-store.ts @@ -30,6 +30,7 @@ const chatMessageSchema = z.object({ const transcriptSchema = z.object({ messages: z.array(chatMessageSchema), + title: z.string().optional(), }); export const transcriptPath = join(agentHomeDir, 'history.yaml'); @@ -41,6 +42,7 @@ const archiveIndexEntrySchema = z.object({ messageCount: z.number(), preview: z.string(), updatedAt: z.string(), + title: z.string().or(z.undefined()).optional(), }); const archiveIndexSchema = z.object({ @@ -52,6 +54,7 @@ type ArchiveSummary = { messageCount: number; preview: string; updatedAt: string; + title: string | undefined; }; async function ensureAgentHome(): Promise { @@ -85,12 +88,6 @@ function mapTranscriptMessage( }; } -async function readTranscriptFile(path: string): Promise { - const raw = await readFile(path, 'utf8'); - const parsed = transcriptSchema.parse(YAML.parse(raw)); - return parsed.messages.map(mapTranscriptMessage); -} - function summarizeTranscript(messages: ChatMessage[]): string { const firstMeaningfulMessage = messages.find( (message) => message.role !== 'system', @@ -198,13 +195,16 @@ async function readArchiveSummaryFromFile( const filePath = join(transcriptArchiveDir, fileName); try { - const messages = await readTranscriptFile(filePath); + const raw = await readFile(filePath, 'utf8'); + const parsed = transcriptSchema.parse(YAML.parse(raw)); + const messages = parsed.messages.map(mapTranscriptMessage); const fileStat = await stat(filePath); return { id: fileName, messageCount: messages?.length ?? 0, preview: summarizeTranscript(messages ?? []), updatedAt: fileStat.mtime.toISOString(), + title: parsed.title, }; } catch (error) { debug( @@ -258,29 +258,46 @@ async function rewriteArchiveIndex(entries: ArchiveSummary[]): Promise { ); } -export async function loadTranscript(): Promise { +export interface LoadedTranscript { + messages: ChatMessage[]; + title: string | undefined; +} + +export async function loadTranscript(): Promise { try { - return (await readTranscriptFile(transcriptPath)) ?? []; + const raw = await readFile(transcriptPath, 'utf8'); + const parsed = transcriptSchema.parse(YAML.parse(raw)); + return { + messages: parsed.messages.map(mapTranscriptMessage), + title: parsed.title, + }; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return []; + return { messages: [], title: undefined }; } debug( `Failed to load current transcript: ${error instanceof Error ? error.message : String(error)}`, ); - return []; + return { messages: [], title: undefined }; } } -export async function saveTranscript(messages: ChatMessage[]): Promise { +export async function saveTranscript( + messages: ChatMessage[], + title?: string, +): Promise { await ensureAgentHome(); - const output = YAML.stringify({ messages }, { indent: 2 }); + const output = YAML.stringify( + { messages, ...(title !== undefined ? { title } : {}) }, + { indent: 2 }, + ); await writeFile(transcriptPath, output, { encoding: 'utf8' }); } export async function archiveTranscript( messages: ChatMessage[], + title?: string, ): Promise { if (messages.length === 0) { return; @@ -288,7 +305,10 @@ export async function archiveTranscript( await ensureTranscriptArchiveDir(); const fileName = createArchiveFileName(); - const output = YAML.stringify({ messages }, { indent: 2 }); + const output = YAML.stringify( + { messages, ...(title !== undefined ? { title } : {}) }, + { indent: 2 }, + ); await writeFile(join(transcriptArchiveDir, fileName), output, { encoding: 'utf8', }); @@ -299,6 +319,7 @@ export async function archiveTranscript( messageCount: messages.length, preview, updatedAt: new Date().toISOString(), + title, }; try { @@ -323,14 +344,7 @@ export async function archiveTranscript( } } -export async function loadArchivedSummaries(): Promise< - Array<{ - id: string; - messageCount: number; - preview: string; - updatedAt: string; - }> -> { +export async function loadArchivedSummaries(): Promise { await ensureTranscriptArchiveDir(); const archiveFiles = await listArchiveFiles(); @@ -345,7 +359,7 @@ export async function loadArchivedSummaries(): Promise< for (const fileName of archiveFiles) { const indexedSummary = indexedEntries.get(fileName); if (indexedSummary) { - summaries.push(indexedSummary); + summaries.push(indexedSummary as ArchiveSummary); continue; } @@ -364,7 +378,7 @@ export async function loadArchivedSummaries(): Promise< } } -export async function loadTranscriptById(id: string): Promise { +export async function loadTranscriptById(id: string): Promise { if (id === 'current') { return await loadTranscript(); } @@ -377,8 +391,12 @@ export async function loadTranscriptById(id: string): Promise { } try { - const messages = await readTranscriptFile(resolvedPath); - return messages ?? []; + const raw = await readFile(resolvedPath, 'utf8'); + const parsed = transcriptSchema.parse(YAML.parse(raw)); + return { + messages: parsed.messages.map(mapTranscriptMessage), + title: parsed.title, + }; } catch (error) { throw new Error( `Failed to read transcript "${id}": ${error instanceof Error ? error.message : String(error)}`, @@ -394,19 +412,22 @@ export async function listTranscripts(): Promise< preview: string; updatedAt: string; isCurrent: boolean; + title: string | undefined; }> > { - const currentMessages = await loadTranscript(); - const currentTranscript = currentMessages.length + const currentTranscript = await loadTranscript(); + const currentMessages = currentTranscript.messages; + const currentTranscriptEntry = currentMessages.length ? [ { id: 'current', - label: 'Current conversation', + label: currentTranscript.title ?? 'Current conversation', messageCount: currentMessages.length, preview: summarizeTranscript(currentMessages), updatedAt: (await statIfExists(transcriptPath)) ?? new Date(0).toISOString(), isCurrent: true, + title: currentTranscript.title, }, ] : []; @@ -415,20 +436,39 @@ export async function listTranscripts(): Promise< const archivedSummaries = archiveTranscripts.map((transcript) => ({ id: transcript.id, label: - transcript.preview.length > 48 + transcript.title ?? + (transcript.preview.length > 48 ? `${transcript.preview.slice(0, 45)}...` - : transcript.preview, + : transcript.preview), messageCount: transcript.messageCount, preview: transcript.preview, updatedAt: transcript.updatedAt, isCurrent: false, + title: transcript.title, })); - return [...currentTranscript, ...archivedSummaries].sort((left, right) => + return [...currentTranscriptEntry, ...archivedSummaries].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt), ); } +export async function renameTranscript(title: string): Promise { + try { + const raw = await readFile(transcriptPath, 'utf8'); + const parsed = transcriptSchema.parse(YAML.parse(raw)); + const output = YAML.stringify( + { messages: parsed.messages, ...(title !== undefined ? { title } : {}) }, + { indent: 2 }, + ); + await writeFile(transcriptPath, output, { encoding: 'utf8' }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw error; + } +} + export async function clearTranscript(): Promise { try { await unlink(transcriptPath); diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 4b78408..bd8742c 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -30,6 +30,8 @@ export function App({ session }: { session: TuiSession }): ReactElement { const [draft, setDraft] = useState(''); const [exportDraft, setExportDraft] = useState(''); const [exportPromptOpen, setExportPromptOpen] = useState(false); + const [renameDraft, setRenameDraft] = useState(''); + const [renamePromptOpen, setRenamePromptOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [paletteIndex, setPaletteIndex] = useState(0); const [transcriptBrowserOpen, setTranscriptBrowserOpen] = useState(false); @@ -134,6 +136,48 @@ export function App({ session }: { session: TuiSession }): ReactElement { return; } + if (renamePromptOpen) { + if (key.ctrl && input === 'c') { + exit(); + return; + } + + if (key.escape) { + setRenamePromptOpen(false); + setRenameDraft(''); + return; + } + + if (key.return) { + const value = renameDraft.trim(); + if (!value) { + return; + } + + void (async () => { + try { + await session.renameConversation(value); + setRenamePromptOpen(false); + setRenameDraft(''); + } catch { + // The session already surfaced the failure. + } + })(); + return; + } + + if (key.backspace || key.delete) { + setRenameDraft((value) => value.slice(0, -1)); + return; + } + + if (input) { + setRenameDraft((value) => `${value}${input}`); + } + + return; + } + if (transcriptBrowserOpen) { if (key.ctrl && input === 'c') { exit(); @@ -199,6 +243,9 @@ export function App({ session }: { session: TuiSession }): ReactElement { } else if (action === CommandAction.ExportTranscript) { setExportDraft(createDefaultTranscriptExportName()); setExportPromptOpen(true); + } else if (action === CommandAction.RenameConversation) { + setRenameDraft(snapshot.title ?? ''); + setRenamePromptOpen(true); } })(); if (selected.id === 'quit') { @@ -259,15 +306,30 @@ export function App({ session }: { session: TuiSession }): ReactElement { authSource={snapshot.authSource} busy={snapshot.isBusy} error={snapshot.error} + title={snapshot.title} /> '} + draft={ + exportPromptOpen + ? exportDraft + : renamePromptOpen + ? renameDraft + : draft + } + prompt={ + exportPromptOpen + ? 'Export transcript to: ' + : renamePromptOpen + ? 'Rename conversation: ' + : '> ' + } placeholder={ exportPromptOpen ? 'transcript-2026-05-18T12-34-56.789Z.txt' - : 'Type a message. Ctrl+K opens the command palette.' + : renamePromptOpen + ? 'Enter a title for this conversation' + : 'Type a message. Ctrl+K opens the command palette.' } /> {paletteOpen ? ( diff --git a/src/tui/StreamingRenderer.tsx b/src/tui/StreamingRenderer.tsx index de5d18c..66e1cf5 100644 --- a/src/tui/StreamingRenderer.tsx +++ b/src/tui/StreamingRenderer.tsx @@ -75,6 +75,7 @@ export function StatusBar({ authSource, busy, error, + title, }: { status: string; provider: string; @@ -82,10 +83,12 @@ export function StatusBar({ authSource: string; busy: boolean; error: string | undefined; + title: string | undefined; }): ReactElement { return ( + {title ? `${title} ` : ''} {status} {' '} {provider}/{model} diff --git a/src/tui/session.ts b/src/tui/session.ts index ae3df83..77d312a 100644 --- a/src/tui/session.ts +++ b/src/tui/session.ts @@ -69,6 +69,7 @@ export interface TuiCommand { export enum CommandAction { BrowseTranscripts = 'browse-transcripts', ExportTranscript = 'export-transcript', + RenameConversation = 'rename-conversation', } export interface TuiSession { @@ -79,6 +80,7 @@ export interface TuiSession { listTranscripts(): Promise; openTranscript(transcriptId: string): Promise; exportTranscript(filePath: string): Promise; + renameConversation(title: string): Promise; refreshAuth(): Promise; refreshTools(): Promise; clearConversation(): void; @@ -94,15 +96,41 @@ export interface TranscriptSummary { preview: string; updatedAt: string; isCurrent: boolean; + title: string | undefined; } export async function createTuiSession(config: AppConfig): Promise { - const initialMessages = await loadTranscript(); + const loadedTranscript = await loadTranscript(); + const initialMessages = loadedTranscript.messages; + const initialTitle = loadedTranscript.title; let transcriptWriteQueue = Promise.resolve(); let transcriptArchiveQueue = Promise.resolve(); let activeConfig = config; + let titleGenerated = initialTitle !== undefined; + let currentTitle: string | undefined = initialTitle; + + const generateTitle = (messages: ChatMessage[]): string | undefined => { + if (titleGenerated) { + return undefined; + } + const firstUserMessage = messages.find((m) => m.role === 'user'); + if (!firstUserMessage) { + return undefined; + } + const content = firstUserMessage.content.trim(); + if (!content) { + return undefined; + } + titleGenerated = true; + const title = + content.length > 60 ? `${content.slice(0, 57)}...` : content; + currentTitle = title; + return title; + }; + const persistConversation = (messages: ChatMessage[]): Promise => { + const title = generateTitle(messages) ?? currentTitle; transcriptWriteQueue = transcriptWriteQueue .catch(() => undefined) .then(async () => { @@ -111,7 +139,7 @@ export async function createTuiSession(config: AppConfig): Promise { return; } - await saveTranscript(messages); + await saveTranscript(messages, title); }); void transcriptWriteQueue.catch(() => undefined); @@ -132,7 +160,7 @@ export async function createTuiSession(config: AppConfig): Promise { transcriptArchiveQueue = transcriptArchiveQueue .catch(() => undefined) .then(async () => { - await archiveTranscript(messages); + await archiveTranscript(messages, currentTitle); }); void transcriptArchiveQueue.catch(() => undefined); @@ -141,6 +169,7 @@ export async function createTuiSession(config: AppConfig): Promise { const state = new AgentStateManager(config, { initialMessages, + initialTitle, onConversationChange: persistConversation, }); const initialProvider = await createProviderClient(activeConfig); @@ -150,6 +179,10 @@ export async function createTuiSession(config: AppConfig): Promise { await tools.refresh(); state.setMcpInspector(tools.getMcpInspector()); + const syncTitle = () => { + currentTitle = state.getSnapshot().title; + }; + let activeController: AbortController | null = null; let refreshInProgress = false; let refreshPromise: Promise | null = null; @@ -340,6 +373,8 @@ export async function createTuiSession(config: AppConfig): Promise { state.clearConversation(); state.setMcpInspector(tools.getMcpInspector()); + titleGenerated = false; + currentTitle = undefined; } catch (error) { state.setError( `Failed to start a new chat: ${error instanceof Error ? error.message : String(error)}`, @@ -362,17 +397,22 @@ export async function createTuiSession(config: AppConfig): Promise { try { await waitForTranscriptWrites(); await waitForTranscriptArchives(); - const messages = await loadTranscriptById(transcriptId); + const loaded = await loadTranscriptById(transcriptId); const currentMessages = state.getSnapshot().messages; if ( transcriptId !== 'current' && currentMessages.length > 0 && - !messagesMatch(currentMessages, messages) + !messagesMatch(currentMessages, loaded.messages) ) { await archiveConversation(currentMessages); } - state.replaceConversation(messages); + if (loaded.title) { + titleGenerated = true; + currentTitle = loaded.title; + state.setTitle(loaded.title); + } + state.replaceConversation(loaded.messages); } catch (error) { state.setError( `Failed to open transcript: ${error instanceof Error ? error.message : String(error)}`, @@ -432,6 +472,8 @@ export async function createTuiSession(config: AppConfig): Promise { return CommandAction.BrowseTranscripts; case 'export-transcript': return CommandAction.ExportTranscript; + case 'rename-conversation': + return CommandAction.RenameConversation; case 'edit-config': if (state.getSnapshot().isBusy) { state.setStatus('Finish the active turn before editing config'); @@ -472,6 +514,24 @@ export async function createTuiSession(config: AppConfig): Promise { } }; + const renameCurrentConversation = async (title: string): Promise => { + if (state.getSnapshot().isBusy) { + state.setStatus('Finish the active turn before renaming conversation'); + return; + } + + const trimmedTitle = title.trim(); + if (!trimmedTitle) { + state.setStatus('Conversation title cannot be empty'); + return; + } + + state.setTitle(trimmedTitle); + syncTitle(); + await waitForTranscriptWrites(); + state.markIdle(`Conversation renamed to "${trimmedTitle}"`); + }; + return { state, commands: [ @@ -491,6 +551,11 @@ export async function createTuiSession(config: AppConfig): Promise { description: 'Write the current conversation to a file without clearing it', }, + { + id: 'rename-conversation', + label: 'Rename conversation', + description: 'Give the current chat a human-friendly title', + }, { id: 'edit-config', label: 'Edit config', @@ -531,6 +596,7 @@ export async function createTuiSession(config: AppConfig): Promise { listTranscripts: listSavedTranscripts, openTranscript, exportTranscript: exportCurrentTranscript, + renameConversation: renameCurrentConversation, refreshAuth, refreshTools, clearConversation: () => state.clearConversation(), From d6c55582e3941dfc14577a3820c11cb9a47175fe Mon Sep 17 00:00:00 2001 From: 404-Page-Found Date: Tue, 19 May 2026 20:07:02 +1000 Subject: [PATCH 2/9] fix: persist renamed title to disk and restore title after loading transcripts - Update currentTitle before setTitle in rename so persistConversation captures the new value - Call setTitle after replaceConversation when loading archived transcripts - Remove unused renameTranscript and syncTitle functions --- src/core/transcript-store.ts | 17 ----------------- src/tui/session.ts | 10 ++++------ 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/src/core/transcript-store.ts b/src/core/transcript-store.ts index 15d03eb..4922634 100644 --- a/src/core/transcript-store.ts +++ b/src/core/transcript-store.ts @@ -452,23 +452,6 @@ export async function listTranscripts(): Promise< ); } -export async function renameTranscript(title: string): Promise { - try { - const raw = await readFile(transcriptPath, 'utf8'); - const parsed = transcriptSchema.parse(YAML.parse(raw)); - const output = YAML.stringify( - { messages: parsed.messages, ...(title !== undefined ? { title } : {}) }, - { indent: 2 }, - ); - await writeFile(transcriptPath, output, { encoding: 'utf8' }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return; - } - throw error; - } -} - export async function clearTranscript(): Promise { try { await unlink(transcriptPath); diff --git a/src/tui/session.ts b/src/tui/session.ts index 77d312a..d8df77b 100644 --- a/src/tui/session.ts +++ b/src/tui/session.ts @@ -179,10 +179,6 @@ export async function createTuiSession(config: AppConfig): Promise { await tools.refresh(); state.setMcpInspector(tools.getMcpInspector()); - const syncTitle = () => { - currentTitle = state.getSnapshot().title; - }; - let activeController: AbortController | null = null; let refreshInProgress = false; let refreshPromise: Promise | null = null; @@ -410,9 +406,11 @@ export async function createTuiSession(config: AppConfig): Promise { if (loaded.title) { titleGenerated = true; currentTitle = loaded.title; - state.setTitle(loaded.title); } state.replaceConversation(loaded.messages); + if (loaded.title) { + state.setTitle(loaded.title); + } } catch (error) { state.setError( `Failed to open transcript: ${error instanceof Error ? error.message : String(error)}`, @@ -526,8 +524,8 @@ export async function createTuiSession(config: AppConfig): Promise { return; } + currentTitle = trimmedTitle; state.setTitle(trimmedTitle); - syncTitle(); await waitForTranscriptWrites(); state.markIdle(`Conversation renamed to "${trimmedTitle}"`); }; From 6bc8b142b5ab6eada83771eee8a5eb030779bfc4 Mon Sep 17 00:00:00 2001 From: 404-Page-Found Date: Tue, 19 May 2026 20:14:14 +1000 Subject: [PATCH 3/9] fix: mark title as generated when manually renamed to prevent overwrite --- src/tui/session.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tui/session.ts b/src/tui/session.ts index d8df77b..301a0b1 100644 --- a/src/tui/session.ts +++ b/src/tui/session.ts @@ -525,6 +525,7 @@ export async function createTuiSession(config: AppConfig): Promise { } currentTitle = trimmedTitle; + titleGenerated = true; state.setTitle(trimmedTitle); await waitForTranscriptWrites(); state.markIdle(`Conversation renamed to "${trimmedTitle}"`); From d0102e73d497542d3a4df06dd1696eefe7486f8c Mon Sep 17 00:00:00 2001 From: 404-Page-Found Date: Tue, 19 May 2026 20:22:24 +1000 Subject: [PATCH 4/9] fix: reset titleGenerated flag when loading transcript without title --- src/tui/session.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/tui/session.ts b/src/tui/session.ts index 301a0b1..f60e6d1 100644 --- a/src/tui/session.ts +++ b/src/tui/session.ts @@ -403,10 +403,8 @@ export async function createTuiSession(config: AppConfig): Promise { await archiveConversation(currentMessages); } - if (loaded.title) { - titleGenerated = true; - currentTitle = loaded.title; - } + titleGenerated = loaded.title !== undefined; + currentTitle = loaded.title; state.replaceConversation(loaded.messages); if (loaded.title) { state.setTitle(loaded.title); From 2dc638c28800043cde9092f329149ff9692696bb Mon Sep 17 00:00:00 2001 From: 404-Page-Found Date: Tue, 19 May 2026 20:31:29 +1000 Subject: [PATCH 5/9] fix: address review findings from PR #49 - Batch replaceConversation + setTitle to avoid double persist on transcript load - Pass title through onConversationChange callback instead of relying on closure state - Truncate renamed titles to 60 chars to match auto-generated title behavior - Remove redundant .or(z.undefined()) on archiveIndexEntrySchema.title --- src/core/state-manager.ts | 15 +++++++++++---- src/core/transcript-store.ts | 2 +- src/tui/session.ts | 28 ++++++++++++++++++---------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/core/state-manager.ts b/src/core/state-manager.ts index 4b0c7be..16e1fce 100644 --- a/src/core/state-manager.ts +++ b/src/core/state-manager.ts @@ -19,7 +19,10 @@ type StateListener = () => void; export interface AgentStateOptions { initialMessages?: ChatMessage[]; initialTitle?: string | undefined; - onConversationChange?: (messages: ChatMessage[]) => void | Promise; + onConversationChange?: ( + messages: ChatMessage[], + title: string | undefined, + ) => void | Promise; } export class AgentStateManager { @@ -28,7 +31,10 @@ export class AgentStateManager { private readonly listeners = new Set(); private readonly onConversationChange: - | ((messages: ChatMessage[]) => void | Promise) + | (( + messages: ChatMessage[], + title: string | undefined, + ) => void | Promise) | undefined; public constructor(config: AppConfig, options: AgentStateOptions = {}) { @@ -136,10 +142,10 @@ export class AgentStateManager { this.notifyConversationChange(); } - public replaceConversation(messages: ChatMessage[]): void { + public replaceConversation(messages: ChatMessage[], title?: string): void { this.update((snapshot) => { snapshot.messages = structuredClone(messages); - snapshot.title = undefined; + snapshot.title = title; snapshot.status = 'Idle'; snapshot.isBusy = false; snapshot.streamingText = ''; @@ -195,6 +201,7 @@ export class AgentStateManager { void this.onConversationChange( structuredClone(this.snapshotValue.messages), + this.snapshotValue.title, ); } } diff --git a/src/core/transcript-store.ts b/src/core/transcript-store.ts index 4922634..4ad5c10 100644 --- a/src/core/transcript-store.ts +++ b/src/core/transcript-store.ts @@ -42,7 +42,7 @@ const archiveIndexEntrySchema = z.object({ messageCount: z.number(), preview: z.string(), updatedAt: z.string(), - title: z.string().or(z.undefined()).optional(), + title: z.string().optional(), }); const archiveIndexSchema = z.object({ diff --git a/src/tui/session.ts b/src/tui/session.ts index f60e6d1..8522f50 100644 --- a/src/tui/session.ts +++ b/src/tui/session.ts @@ -129,8 +129,15 @@ export async function createTuiSession(config: AppConfig): Promise { return title; }; - const persistConversation = (messages: ChatMessage[]): Promise => { - const title = generateTitle(messages) ?? currentTitle; + const persistConversation = ( + messages: ChatMessage[], + title: string | undefined, + ): Promise => { + const resolvedTitle = generateTitle(messages) ?? title ?? currentTitle; + if (resolvedTitle !== currentTitle) { + currentTitle = resolvedTitle; + titleGenerated = true; + } transcriptWriteQueue = transcriptWriteQueue .catch(() => undefined) .then(async () => { @@ -139,7 +146,7 @@ export async function createTuiSession(config: AppConfig): Promise { return; } - await saveTranscript(messages, title); + await saveTranscript(messages, currentTitle); }); void transcriptWriteQueue.catch(() => undefined); @@ -405,10 +412,7 @@ export async function createTuiSession(config: AppConfig): Promise { titleGenerated = loaded.title !== undefined; currentTitle = loaded.title; - state.replaceConversation(loaded.messages); - if (loaded.title) { - state.setTitle(loaded.title); - } + state.replaceConversation(loaded.messages, loaded.title); } catch (error) { state.setError( `Failed to open transcript: ${error instanceof Error ? error.message : String(error)}`, @@ -522,11 +526,15 @@ export async function createTuiSession(config: AppConfig): Promise { return; } - currentTitle = trimmedTitle; + const finalTitle = + trimmedTitle.length > 60 + ? `${trimmedTitle.slice(0, 57)}...` + : trimmedTitle; + currentTitle = finalTitle; titleGenerated = true; - state.setTitle(trimmedTitle); + state.setTitle(finalTitle); await waitForTranscriptWrites(); - state.markIdle(`Conversation renamed to "${trimmedTitle}"`); + state.markIdle(`Conversation renamed to "${finalTitle}"`); }; return { From 566064700535048a1642e457f17dd181864d82dd Mon Sep 17 00:00:00 2001 From: 404-Page-Found Date: Tue, 19 May 2026 20:42:14 +1000 Subject: [PATCH 6/9] fix: capture title by value in async queues and track write success --- src/core/transcript-store.ts | 4 ++-- src/tui/session.ts | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/core/transcript-store.ts b/src/core/transcript-store.ts index 4ad5c10..b1d8ab0 100644 --- a/src/core/transcript-store.ts +++ b/src/core/transcript-store.ts @@ -417,7 +417,7 @@ export async function listTranscripts(): Promise< > { const currentTranscript = await loadTranscript(); const currentMessages = currentTranscript.messages; - const currentTranscriptEntry = currentMessages.length + const currentTranscriptEntries = currentMessages.length ? [ { id: 'current', @@ -447,7 +447,7 @@ export async function listTranscripts(): Promise< title: transcript.title, })); - return [...currentTranscriptEntry, ...archivedSummaries].sort((left, right) => + return [...currentTranscriptEntries, ...archivedSummaries].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt), ); } diff --git a/src/tui/session.ts b/src/tui/session.ts index 8522f50..ce7e2ea 100644 --- a/src/tui/session.ts +++ b/src/tui/session.ts @@ -105,6 +105,7 @@ export async function createTuiSession(config: AppConfig): Promise { const initialTitle = loadedTranscript.title; let transcriptWriteQueue = Promise.resolve(); let transcriptArchiveQueue = Promise.resolve(); + let lastWriteSucceeded = true; let activeConfig = config; let titleGenerated = initialTitle !== undefined; @@ -138,15 +139,22 @@ export async function createTuiSession(config: AppConfig): Promise { currentTitle = resolvedTitle; titleGenerated = true; } + const savedTitle = currentTitle; transcriptWriteQueue = transcriptWriteQueue .catch(() => undefined) .then(async () => { if (messages.length === 0) { await clearTranscript(); + lastWriteSucceeded = true; return; } - await saveTranscript(messages, currentTitle); + try { + await saveTranscript(messages, savedTitle); + lastWriteSucceeded = true; + } catch { + lastWriteSucceeded = false; + } }); void transcriptWriteQueue.catch(() => undefined); @@ -164,10 +172,11 @@ export async function createTuiSession(config: AppConfig): Promise { const archiveConversation = async ( messages: ChatMessage[], ): Promise => { + const savedTitle = currentTitle; transcriptArchiveQueue = transcriptArchiveQueue .catch(() => undefined) .then(async () => { - await archiveTranscript(messages, currentTitle); + await archiveTranscript(messages, savedTitle); }); void transcriptArchiveQueue.catch(() => undefined); @@ -534,7 +543,11 @@ export async function createTuiSession(config: AppConfig): Promise { titleGenerated = true; state.setTitle(finalTitle); await waitForTranscriptWrites(); - state.markIdle(`Conversation renamed to "${finalTitle}"`); + if (lastWriteSucceeded) { + state.markIdle(`Conversation renamed to "${finalTitle}"`); + } else { + state.markIdle(`Title updated in memory, but failed to persist to disk`); + } }; return { From e475c9a9f16c1ad31c6a601e913e7be0bb1b852a Mon Sep 17 00:00:00 2001 From: 404-Page-Found Date: Tue, 19 May 2026 20:58:29 +1000 Subject: [PATCH 7/9] fix: patch title generation and write success tracking --- src/core/state-manager.ts | 4 ++-- src/tui/session.ts | 24 +++++++++++------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/core/state-manager.ts b/src/core/state-manager.ts index 16e1fce..94d139d 100644 --- a/src/core/state-manager.ts +++ b/src/core/state-manager.ts @@ -22,7 +22,7 @@ export interface AgentStateOptions { onConversationChange?: ( messages: ChatMessage[], title: string | undefined, - ) => void | Promise; + ) => void | Promise | Promise; } export class AgentStateManager { @@ -34,7 +34,7 @@ export class AgentStateManager { | (( messages: ChatMessage[], title: string | undefined, - ) => void | Promise) + ) => void | Promise | Promise) | undefined; public constructor(config: AppConfig, options: AgentStateOptions = {}) { diff --git a/src/tui/session.ts b/src/tui/session.ts index ce7e2ea..fd7284d 100644 --- a/src/tui/session.ts +++ b/src/tui/session.ts @@ -103,9 +103,8 @@ export async function createTuiSession(config: AppConfig): Promise { const loadedTranscript = await loadTranscript(); const initialMessages = loadedTranscript.messages; const initialTitle = loadedTranscript.title; - let transcriptWriteQueue = Promise.resolve(); + let transcriptWriteQueue = Promise.resolve(true); let transcriptArchiveQueue = Promise.resolve(); - let lastWriteSucceeded = true; let activeConfig = config; let titleGenerated = initialTitle !== undefined; @@ -133,7 +132,7 @@ export async function createTuiSession(config: AppConfig): Promise { const persistConversation = ( messages: ChatMessage[], title: string | undefined, - ): Promise => { + ): Promise => { const resolvedTitle = generateTitle(messages) ?? title ?? currentTitle; if (resolvedTitle !== currentTitle) { currentTitle = resolvedTitle; @@ -141,19 +140,18 @@ export async function createTuiSession(config: AppConfig): Promise { } const savedTitle = currentTitle; transcriptWriteQueue = transcriptWriteQueue - .catch(() => undefined) + .catch(() => true) .then(async () => { if (messages.length === 0) { await clearTranscript(); - lastWriteSucceeded = true; - return; + return true; } try { await saveTranscript(messages, savedTitle); - lastWriteSucceeded = true; + return true; } catch { - lastWriteSucceeded = false; + return false; } }); @@ -161,8 +159,8 @@ export async function createTuiSession(config: AppConfig): Promise { return transcriptWriteQueue; }; - const waitForTranscriptWrites = async (): Promise => { - await transcriptWriteQueue.catch(() => undefined); + const waitForTranscriptWrites = async (): Promise => { + return transcriptWriteQueue.catch(() => false); }; const waitForTranscriptArchives = async (): Promise => { @@ -419,7 +417,7 @@ export async function createTuiSession(config: AppConfig): Promise { await archiveConversation(currentMessages); } - titleGenerated = loaded.title !== undefined; + titleGenerated = true; currentTitle = loaded.title; state.replaceConversation(loaded.messages, loaded.title); } catch (error) { @@ -542,8 +540,8 @@ export async function createTuiSession(config: AppConfig): Promise { currentTitle = finalTitle; titleGenerated = true; state.setTitle(finalTitle); - await waitForTranscriptWrites(); - if (lastWriteSucceeded) { + const succeeded = await waitForTranscriptWrites(); + if (succeeded) { state.markIdle(`Conversation renamed to "${finalTitle}"`); } else { state.markIdle(`Title updated in memory, but failed to persist to disk`); From 69d2da59b8cafaea20b93d730d591dea12c32ab9 Mon Sep 17 00:00:00 2001 From: 404-Page-Found Date: Tue, 19 May 2026 21:03:53 +1000 Subject: [PATCH 8/9] fix: preserve titleGenerated flag for untitled transcripts and guard markIdle in rename --- src/tui/session.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/tui/session.ts b/src/tui/session.ts index fd7284d..243e1f7 100644 --- a/src/tui/session.ts +++ b/src/tui/session.ts @@ -417,7 +417,7 @@ export async function createTuiSession(config: AppConfig): Promise { await archiveConversation(currentMessages); } - titleGenerated = true; + titleGenerated = loaded.title !== undefined; currentTitle = loaded.title; state.replaceConversation(loaded.messages, loaded.title); } catch (error) { @@ -540,11 +540,17 @@ export async function createTuiSession(config: AppConfig): Promise { currentTitle = finalTitle; titleGenerated = true; state.setTitle(finalTitle); - const succeeded = await waitForTranscriptWrites(); - if (succeeded) { - state.markIdle(`Conversation renamed to "${finalTitle}"`); - } else { - state.markIdle(`Title updated in memory, but failed to persist to disk`); + try { + const succeeded = await waitForTranscriptWrites(); + if (succeeded) { + state.markIdle(`Conversation renamed to "${finalTitle}"`); + } else { + state.markIdle(`Title updated in memory, but failed to persist to disk`); + } + } finally { + if (state.getSnapshot().isBusy) { + state.markIdle(); + } } }; From 88400b08986a76cd6624c68291ea25bd96e5bd68 Mon Sep 17 00:00:00 2001 From: 404-Page-Found Date: Tue, 19 May 2026 21:21:03 +1000 Subject: [PATCH 9/9] fix: remove dead finally block and improve empty title UX --- src/tui/App.tsx | 3 --- src/tui/session.ts | 16 +++++----------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index bd8742c..33405c8 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -150,9 +150,6 @@ export function App({ session }: { session: TuiSession }): ReactElement { if (key.return) { const value = renameDraft.trim(); - if (!value) { - return; - } void (async () => { try { diff --git a/src/tui/session.ts b/src/tui/session.ts index 243e1f7..5c0d524 100644 --- a/src/tui/session.ts +++ b/src/tui/session.ts @@ -540,17 +540,11 @@ export async function createTuiSession(config: AppConfig): Promise { currentTitle = finalTitle; titleGenerated = true; state.setTitle(finalTitle); - try { - const succeeded = await waitForTranscriptWrites(); - if (succeeded) { - state.markIdle(`Conversation renamed to "${finalTitle}"`); - } else { - state.markIdle(`Title updated in memory, but failed to persist to disk`); - } - } finally { - if (state.getSnapshot().isBusy) { - state.markIdle(); - } + const succeeded = await waitForTranscriptWrites(); + if (succeeded) { + state.markIdle(`Conversation renamed to "${finalTitle}"`); + } else { + state.markIdle(`Title updated in memory, but failed to persist to disk`); } };