diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 42e00763c..ae04cef9e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,6 +20,7 @@ ### Fixed - Recent sessions now show in welcome banner (was never wired up). +- Auto-generated session titles: Sessions are now automatically titled based on the first message using a small model (Haiku/GPT-4o-mini/Flash). Titles are shown in the terminal window title, recent sessions list, and --resume picker. The resume picker shows title with dimmed first message preview below. ## [1.340.0] - 2026-01-03 diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 97a4c96c8..bad45bf89 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -156,6 +156,7 @@ export interface SessionContext { export interface SessionInfo { path: string; id: string; + title?: string; created: Date; modified: Date; messageCount: number; @@ -486,15 +487,16 @@ export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionI .filter(isValidSessionFile) .map((path) => { const stat = statSync(path); - // Try to get session name from first line + // Try to get session title or id from first line let name = path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown"; try { const content = readFileSync(path, "utf-8"); const firstLine = content.split("\n")[0]; if (firstLine) { const header = JSON.parse(firstLine) as SessionHeader; - if (header.type === "session" && header.id) { - name = header.id; + if (header.type === "session") { + // Prefer title over id + name = header.title ?? header.id ?? name; } } } catch { @@ -506,7 +508,7 @@ export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionI .slice(0, limit); return files.map((f) => ({ - name: f.name.length > 30 ? `${f.name.slice(0, 27)}...` : f.name, + name: f.name.length > 40 ? `${f.name.slice(0, 37)}...` : f.name, path: f.path, timeAgo: formatTimeAgo(f.mtime), })); @@ -528,6 +530,7 @@ export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionI */ export class SessionManager { private sessionId: string = ""; + private sessionTitle: string | undefined; private sessionFile: string | undefined; private sessionDir: string; private cwd: string; @@ -560,6 +563,7 @@ export class SessionManager { this.fileEntries = loadEntriesFromFile(this.sessionFile); const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined; this.sessionId = header?.id ?? crypto.randomUUID(); + this.sessionTitle = header?.title; if (migrateToCurrentVersion(this.fileEntries)) { this._rewriteFile(); @@ -640,6 +644,31 @@ export class SessionManager { return this.sessionFile; } + getSessionTitle(): string | undefined { + return this.sessionTitle; + } + + setSessionTitle(title: string): void { + this.sessionTitle = title; + // Update the session file header with the title + if (this.persist && this.sessionFile && existsSync(this.sessionFile)) { + try { + const content = readFileSync(this.sessionFile, "utf-8"); + const lines = content.split("\n"); + if (lines.length > 0) { + const header = JSON.parse(lines[0]) as SessionHeader; + if (header.type === "session") { + header.title = title; + lines[0] = JSON.stringify(header); + writeFileSync(this.sessionFile, lines.join("\n")); + } + } + } catch { + // Ignore errors updating title + } + } + } + _persist(entry: SessionEntry): void { if (!this.persist || !this.sessionFile) return; @@ -1122,7 +1151,7 @@ export class SessionManager { if (lines.length === 0) continue; // Check first line for valid session header - let header: { type: string; id: string; timestamp: string } | null = null; + let header: { type: string; id: string; title?: string; timestamp: string } | null = null; try { const first = JSON.parse(lines[0]); if (first.type === "session" && first.id) { @@ -1168,6 +1197,7 @@ export class SessionManager { sessions.push({ path: file, id: header.id, + title: header.title, created: new Date(header.timestamp), modified: stats.mtime, messageCount, diff --git a/packages/coding-agent/src/core/title-generator.ts b/packages/coding-agent/src/core/title-generator.ts new file mode 100644 index 000000000..eeb0d781b --- /dev/null +++ b/packages/coding-agent/src/core/title-generator.ts @@ -0,0 +1,108 @@ +/** + * Generate session titles using a small, fast model. + */ + +import type { Model } from "@oh-my-pi/pi-ai"; +import { completeSimple } from "@oh-my-pi/pi-ai"; +import type { ModelRegistry } from "./model-registry.js"; + +const TITLE_SYSTEM_PROMPT = `Generate a very short title (3-6 words) for a coding session based on the user's first message. The title should capture the main task or topic. Output ONLY the title, nothing else. No quotes, no punctuation at the end. + +Examples: +- "Fix TypeScript compilation errors" +- "Add user authentication" +- "Refactor database queries" +- "Debug payment webhook" +- "Update React components"`; + +const MAX_INPUT_CHARS = 2000; + +/** + * Find the best available model for title generation. + * Prefers small, fast models in this order: + * 1. Claude Haiku (anthropic) + * 2. GPT-4o-mini (openai) + * 3. Gemini Flash (google) + * 4. Any available model + */ +export async function findTitleModel(registry: ModelRegistry): Promise | null> { + const preferences = [ + { provider: "anthropic", pattern: /haiku/i }, + { provider: "openai", pattern: /gpt-4o-mini|gpt-4\.1-mini/i }, + { provider: "google", pattern: /flash/i }, + { provider: "anthropic", pattern: /sonnet/i }, + ]; + + for (const pref of preferences) { + const models = registry.getAll().filter((m) => m.provider === pref.provider && pref.pattern.test(m.id)); + for (const model of models) { + if (await registry.getApiKey(model)) { + return model; + } + } + } + + // Fallback to any available model + for (const model of registry.getAll()) { + if (await registry.getApiKey(model)) { + return model; + } + } + + return null; +} + +/** + * Generate a title for a session based on the first user message. + */ +export async function generateSessionTitle(firstMessage: string, registry: ModelRegistry): Promise { + const model = await findTitleModel(registry); + if (!model) return null; + + const apiKey = await registry.getApiKey(model); + if (!apiKey) return null; + + // Truncate message if too long + const truncatedMessage = + firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}...` : firstMessage; + + try { + const response = await completeSimple( + model, + { + systemPrompt: TITLE_SYSTEM_PROMPT, + messages: [{ role: "user", content: truncatedMessage, timestamp: Date.now() }], + }, + { + apiKey, + maxTokens: 30, + }, + ); + + // Extract title from response text content + let title = ""; + for (const content of response.content) { + if (content.type === "text") { + title += content.text; + } + } + title = title.trim(); + + if (!title || title.length > 60) { + return null; + } + + // Clean up: remove quotes, trailing punctuation + return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, ""); + } catch { + return null; + } +} + +/** + * Set the terminal title using ANSI escape sequences. + */ +export function setTerminalTitle(title: string): void { + // OSC 2 sets the window title + process.stdout.write(`\x1b]2;${title}\x07`); +} diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 66b7f5a8e..5a780af7f 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -90,7 +90,7 @@ class SessionList implements Component { ); const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length); - // Render visible sessions (2 lines per session + blank line) + // Render visible sessions (2-3 lines per session + blank line) for (let i = startIndex; i < endIndex; i++) { const session = this.filteredSessions[i]; const isSelected = i === this.selectedIndex; @@ -98,19 +98,32 @@ class SessionList implements Component { // Normalize first message to single line const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim(); - // First line: cursor + message (truncate to visible width) + // First line: cursor + title (or first message if no title) const cursor = isSelected ? theme.fg("accent", "› ") : " "; - const maxMsgWidth = width - 2; // Account for cursor (2 visible chars) - const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "..."); - const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); + const maxWidth = width - 2; // Account for cursor (2 visible chars) - // Second line: metadata (dimmed) - also truncate for safety + if (session.title) { + // Has title: show title on first line, dimmed first message on second line + const truncatedTitle = truncateToWidth(session.title, maxWidth, "..."); + const titleLine = cursor + (isSelected ? theme.bold(truncatedTitle) : truncatedTitle); + lines.push(titleLine); + + // Second line: dimmed first message preview + const truncatedPreview = truncateToWidth(normalizedMessage, maxWidth, "..."); + lines.push(` ${theme.fg("dim", truncatedPreview)}`); + } else { + // No title: show first message as main line + const truncatedMsg = truncateToWidth(normalizedMessage, maxWidth, "..."); + const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); + lines.push(messageLine); + } + + // Metadata line: date + message count const modified = formatDate(session.modified); const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`; const metadata = ` ${modified} · ${msgCount}`; const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, "")); - lines.push(messageLine); lines.push(metadataLine); lines.push(""); // Blank line between sessions } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index de72a315e..1a8d69e6b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -31,6 +31,7 @@ import { createCompactionSummaryMessage } from "../../core/messages.js"; import { getRecentSessions, type SessionContext, SessionManager } from "../../core/session-manager.js"; import { loadSkills } from "../../core/skills.js"; import { loadProjectContextFiles } from "../../core/system-prompt.js"; +import { generateSessionTitle, setTerminalTitle } from "../../core/title-generator.js"; import type { TruncationResult } from "../../core/tools/truncate.js"; import { getChangelogPath, parseChangelog } from "../../utils/changelog.js"; import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard.js"; @@ -141,6 +142,9 @@ export class InteractiveMode { // Custom tools for custom rendering private customTools: Map; + // Title generation state + private titleGenerationAttempted = false; + // Convenience accessors private get agent() { return this.session.agent; @@ -245,6 +249,13 @@ export class InteractiveMode { // Add welcome header const welcome = new WelcomeComponent(this.version, modelName, providerName, recentSessions, lspServerInfo); + // Set terminal title if session already has one (resumed session) + const existingTitle = this.sessionManager.getSessionTitle(); + if (existingTitle) { + setTerminalTitle(`pi: ${existingTitle}`); + this.titleGenerationAttempted = true; // Don't try to generate again + } + // Setup UI layout this.ui.addChild(new Spacer(1)); this.ui.addChild(welcome); @@ -1042,6 +1053,12 @@ export class InteractiveMode { } this.pendingTools.clear(); this.ui.requestRender(); + + // Generate session title after first turn (if not already titled) + if (!this.titleGenerationAttempted && !this.sessionManager.getSessionTitle()) { + this.titleGenerationAttempted = true; + this.maybeGenerateTitle(); + } break; case "auto_compaction_start": { @@ -1569,6 +1586,41 @@ export class InteractiveMode { this.ui.requestRender(); } + /** + * Generate a title for the session based on the first user message. + * Runs in background, doesn't block UI. + */ + private maybeGenerateTitle(): void { + // Find the first user message + const messages = this.agent.state.messages; + const firstUserMessage = messages.find((m) => m.role === "user"); + if (!firstUserMessage) return; + + // Extract text content + let messageText = ""; + for (const content of firstUserMessage.content) { + if (typeof content === "string") { + messageText += content; + } else if (content.type === "text") { + messageText += content.text; + } + } + if (!messageText.trim()) return; + + // Generate title in background + const registry = this.session.modelRegistry; + generateSessionTitle(messageText, registry) + .then((title) => { + if (title) { + this.sessionManager.setSessionTitle(title); + setTerminalTitle(`pi: ${title}`); + } + }) + .catch(() => { + // Ignore title generation errors + }); + } + private updatePendingMessagesDisplay(): void { this.pendingMessagesContainer.clear(); const queuedMessages = this.session.getQueuedMessages();