diff --git a/src/core/state-manager.ts b/src/core/state-manager.ts index 995c29d..94d139d 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,7 +18,11 @@ type StateListener = () => void; export interface AgentStateOptions { initialMessages?: ChatMessage[]; - onConversationChange?: (messages: ChatMessage[]) => void | Promise; + initialTitle?: string | undefined; + onConversationChange?: ( + messages: ChatMessage[], + title: string | undefined, + ) => void | Promise | Promise; } export class AgentStateManager { @@ -26,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 | Promise) | undefined; public constructor(config: AppConfig, options: AgentStateOptions = {}) { @@ -34,6 +42,7 @@ export class AgentStateManager { this.snapshotValue = { config, messages: structuredClone(options.initialMessages ?? []), + title: options.initialTitle, authSource: 'missing', status: 'Idle', isBusy: false, @@ -122,6 +131,7 @@ export class AgentStateManager { public clearConversation(): void { this.update((snapshot) => { snapshot.messages = []; + snapshot.title = undefined; snapshot.status = 'Idle'; snapshot.isBusy = false; snapshot.streamingText = ''; @@ -132,9 +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 = title; snapshot.status = 'Idle'; snapshot.isBusy = false; snapshot.streamingText = ''; @@ -144,6 +155,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; @@ -182,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 1d85b40..b1d8ab0 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().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 currentTranscriptEntries = 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,16 +436,18 @@ 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 [...currentTranscriptEntries, ...archivedSummaries].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt), ); } diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 4b78408..33405c8 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,45 @@ 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(); + + 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 +240,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 +303,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..5c0d524 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,32 +96,71 @@ export interface TranscriptSummary { preview: string; updatedAt: string; isCurrent: boolean; + title: string | undefined; } export async function createTuiSession(config: AppConfig): Promise { - const initialMessages = await loadTranscript(); - let transcriptWriteQueue = Promise.resolve(); + const loadedTranscript = await loadTranscript(); + const initialMessages = loadedTranscript.messages; + const initialTitle = loadedTranscript.title; + let transcriptWriteQueue = Promise.resolve(true); let transcriptArchiveQueue = Promise.resolve(); let activeConfig = config; - const persistConversation = (messages: ChatMessage[]): Promise => { + 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[], + title: string | undefined, + ): Promise => { + const resolvedTitle = generateTitle(messages) ?? title ?? currentTitle; + if (resolvedTitle !== currentTitle) { + currentTitle = resolvedTitle; + titleGenerated = true; + } + const savedTitle = currentTitle; transcriptWriteQueue = transcriptWriteQueue - .catch(() => undefined) + .catch(() => true) .then(async () => { if (messages.length === 0) { await clearTranscript(); - return; + return true; } - await saveTranscript(messages); + try { + await saveTranscript(messages, savedTitle); + return true; + } catch { + return false; + } }); void transcriptWriteQueue.catch(() => undefined); return transcriptWriteQueue; }; - const waitForTranscriptWrites = async (): Promise => { - await transcriptWriteQueue.catch(() => undefined); + const waitForTranscriptWrites = async (): Promise => { + return transcriptWriteQueue.catch(() => false); }; const waitForTranscriptArchives = async (): Promise => { @@ -129,10 +170,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); + await archiveTranscript(messages, savedTitle); }); void transcriptArchiveQueue.catch(() => undefined); @@ -141,6 +183,7 @@ export async function createTuiSession(config: AppConfig): Promise { const state = new AgentStateManager(config, { initialMessages, + initialTitle, onConversationChange: persistConversation, }); const initialProvider = await createProviderClient(activeConfig); @@ -340,6 +383,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 +407,19 @@ 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); + titleGenerated = loaded.title !== undefined; + currentTitle = loaded.title; + state.replaceConversation(loaded.messages, loaded.title); } catch (error) { state.setError( `Failed to open transcript: ${error instanceof Error ? error.message : String(error)}`, @@ -432,6 +479,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 +521,33 @@ 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; + } + + const finalTitle = + trimmedTitle.length > 60 + ? `${trimmedTitle.slice(0, 57)}...` + : trimmedTitle; + 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`); + } + }; + return { state, commands: [ @@ -491,6 +567,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 +612,7 @@ export async function createTuiSession(config: AppConfig): Promise { listTranscripts: listSavedTranscripts, openTranscript, exportTranscript: exportCurrentTranscript, + renameConversation: renameCurrentConversation, refreshAuth, refreshTools, clearConversation: () => state.clearConversation(),