feat(coding-agent): added auto session titles for better identification
- Added automatic session title generation using small models (Haiku/GPT-4o-mini/Flash) to improve session identification. - Implemented terminal window title updates to display current session context. - Updated session selector to show title with dimmed first message preview for better session browsing. - Extended SessionInfo and SessionHeader interfaces to persist and display generated titles. - Added title model preference hierarchy with fallback to any available model for reliability.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Model<any> | 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<string | null> {
|
||||
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`);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string, LoadedCustomTool>;
|
||||
|
||||
// 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();
|
||||
|
||||
Reference in New Issue
Block a user