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:
can1357
2026-01-03 03:07:03 +01:00
parent ccc0a8b6c8
commit ebcdf50e48
5 changed files with 216 additions and 12 deletions
+1
View File
@@ -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();