Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

### Added

- `/rename` without argument auto-generates a session title using the smol model based on recent conversation context
## [14.5.3] - 2026-04-27

### Added
Expand Down
126 changes: 122 additions & 4 deletions packages/coding-agent/src/modes/controllers/command-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,16 @@ import { replaceTabs } from "../../tools/render-utils";
import { getChangelogPath, parseChangelog } from "../../utils/changelog";
import { copyToClipboard } from "../../utils/clipboard";
import { openPath } from "../../utils/open";
import { setSessionTerminalTitle } from "../../utils/title-generator";
import { regenerateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";

/** Extract text content from any message type. */
function extractMessageText(msg: { content: string | { type: string; text?: string }[] }): string {
if (typeof msg.content === "string") return msg.content;
return msg.content
.filter(c => c.type === "text" && c.text)
.map(c => c.text!)
.join("");
}

function showMarkdownPanel(ctx: InteractiveModeContext, title: string, markdown: string): void {
ctx.chatContainer.addChild(new Spacer(1));
Expand Down Expand Up @@ -702,9 +711,117 @@ export class CommandController {
}
}

async handleRenameCommand(title: string): Promise<void> {
async handleRenameCommand(title?: string): Promise<void> {
const wasManual = title !== undefined;
try {
const stored = await this.ctx.sessionManager.setSessionName(title, "user");
if (!title) {
// Build compact context summary from recent conversation
const messages = this.ctx.session.messages;
if (!messages.some(m => m.role === "user")) {
this.ctx.showError("No messages yet \u2014 provide a title manually.");
return;
}
const MAX_PAIRS = 5;
const lines: string[] = [];
const toolNames = new Set<string>();

// Walk messages: for each user message, grab the next assistant first-line
const pairs: { user: string; assistant?: string }[] = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === "toolResult") {
const toolMsg = msg as { toolName?: string };
if (toolMsg.toolName) toolNames.add(toolMsg.toolName);
continue;
}
if (msg.role !== "user") continue;
const userText = extractMessageText(msg).trim();
if (!userText) continue;

// Find the next assistant message after this user message
let assistantLine: string | undefined;
for (let j = i + 1; j < messages.length; j++) {
const next = messages[j];
if (next.role === "user") break;
if (next.role === "toolResult") {
const toolMsg = next as { toolName?: string };
if (toolMsg.toolName) toolNames.add(toolMsg.toolName);
continue;
}
if (next.role === "assistant") {
const fullText = extractMessageText(next).trim();
if (fullText) {
// First meaningful non-heading line
assistantLine = fullText.split("\n").find(l => l.trim() && !l.startsWith("#")) ?? "";
}
break;
}
}

pairs.push({ user: userText, assistant: assistantLine });
}

// Last N pairs for recency
const selected = pairs.slice(-MAX_PAIRS);
for (const pair of selected) {
lines.push(`user: ${pair.user}`);
if (pair.assistant) lines.push(`assistant: ${pair.assistant}`);
}

if (toolNames.size > 0) {
lines.push(`Tools used: ${[...toolNames].slice(-15).join(", ")}`);
}

// Add compaction summary as context
const entries = this.ctx.sessionManager.getEntries();
const compactionEntry = entries.findLast(
e => e.type === "compaction" && typeof (e as { shortSummary?: string }).shortSummary === "string",
);
if (compactionEntry) {
lines.push(`Summary: ${(compactionEntry as { shortSummary: string }).shortSummary}`);
}

const contextSummary = lines.join("\n");
const projectName = path.basename(this.ctx.sessionManager.getCwd());
const loader = new Loader(
this.ctx.ui,
s => theme.fg("accent", s),
t => theme.fg("muted", t),
"Generating title...",
);
this.ctx.statusContainer.addChild(loader);
this.ctx.ui.requestRender();
try {
title =
(await regenerateSessionTitle(
contextSummary,
this.ctx.sessionManager.getSessionName(),
this.ctx.session.modelRegistry,
this.ctx.settings,
this.ctx.session.sessionId,
this.ctx.session.model,
projectName,
)) ?? undefined;
} finally {
loader.stop();
this.ctx.statusContainer.clear();
}

if (!title) {
this.ctx.showError("Could not generate a title. Try /rename <title> instead.");
return;
}
}

// Same-title guard: if auto-generated title matches current, skip the update
const currentName = this.ctx.sessionManager.getSessionName();
if (!wasManual && currentName && title.toLowerCase() === currentName.toLowerCase()) {
this.ctx.showStatus("Title unchanged.");
return;
}

const source = wasManual ? "user" : "auto";
const stored = await this.ctx.sessionManager.setSessionName(title, source);
if (!stored) {
this.ctx.showError("Session name cannot be empty.");
return;
Expand All @@ -713,7 +830,8 @@ export class CommandController {
setSessionTerminalTitle(name, this.ctx.sessionManager.getCwd(), this.ctx.sessionManager.titleSource);
this.ctx.statusLine.invalidate();
this.ctx.updateEditorBorderColor();
this.ctx.showStatus(`Session renamed to "${name}".`);
const verb = source === "auto" ? "Title generated" : "Session renamed to";
this.ctx.showStatus(`${verb} "${name}".`);
} catch (err) {
this.ctx.showError(`Rename failed: ${err instanceof Error ? err.message : String(err)}`);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/modes/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1349,7 +1349,7 @@ export class InteractiveMode implements InteractiveModeContext {
return this.#commandController.handleMoveCommand(targetPath);
}

handleRenameCommand(title: string): Promise<void> {
handleRenameCommand(title?: string): Promise<void> {
return this.#commandController.handleRenameCommand(title);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/modes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export interface InteractiveModeContext {
handleCompactCommand(customInstructions?: string): Promise<void>;
handleHandoffCommand(customInstructions?: string): Promise<void>;
handleMoveCommand(targetPath: string): Promise<void>;
handleRenameCommand(title: string): Promise<void>;
handleRenameCommand(title?: string): Promise<void>;
handleMemoryCommand(text: string): Promise<void>;
handleSTTToggle(): Promise<void>;
executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void>;
Expand Down
13 changes: 11 additions & 2 deletions packages/coding-agent/src/prompts/system/title-system.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
Generate a very short title (3-6 words) for a coding session based on the user's first message. The title **MUST** capture the main task or topic.
You **MUST** output ONLY the title, nothing else. You **MUST NOT** include quotes or punctuation at the end.
Generate a short title (3-6 words) for a coding session. Title case.
{{#if currentTitle}}
Current title: "{{currentTitle}}"
{{/if}}{{#if projectName}}
Project: {{projectName}}
{{/if}}

Rules:
- Output ONLY the title — no quotes, no trailing punctuation
- Focus on the most recent and important activities; use technical terms
- If multiple topics: pick the dominant one, or the most recent if equally weighted
11 changes: 3 additions & 8 deletions packages/coding-agent/src/slash-commands/builtin-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,16 +596,11 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
},
{
name: "rename",
description: "Rename the current session",
inlineHint: "<title>",
description: "Rename the current session (auto-generates title if omitted)",
inlineHint: "[title]",
allowArgs: true,
handle: async (command, runtime) => {
const title = command.args.trim();
if (!title) {
runtime.ctx.showError("Usage: /rename <title>");
runtime.ctx.editor.setText("");
return;
}
const title = command.args.trim() || undefined;
runtime.ctx.editor.setText("");
await runtime.ctx.handleRenameCommand(title);
},
Expand Down
125 changes: 77 additions & 48 deletions packages/coding-agent/src/utils/title-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ import type { Settings } from "../config/settings";
import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
import { toReasoningEffort } from "../thinking";

const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);

const DEFAULT_TERMINAL_TITLE = "π";
const DEFAULT_TERMINAL_TITLE = "\u03C0";
const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;

const MAX_INPUT_CHARS = 2000;
Expand All @@ -39,16 +37,25 @@ function getTitleModel(
return undefined;
}

/** Clean up model-generated title text. */
function cleanTitle(raw: string): string | null {
let title = raw.trim();
if (!title) return null;
// Strip leading markdown heading markers and common LLM artifacts
title = title
.replace(/^#+\s*/, "")
.replace(/^["']|["']$/g, "")
.replace(/[.!?]$/, "");
return title || null;
}

/**
* Generate a title for a session based on the first user message.
*
* @param firstMessage The first user message
* @param registry Model registry
* @param settings Settings used to resolve the smol role, including per-role thinking
* @param sessionId Optional session id for sticky API key selection
* Shared LLM call for title generation.
* Resolves model, API key, calls completeSimple, and cleans the result.
*/
export async function generateSessionTitle(
firstMessage: string,
async function callTitleModel(
userMessage: string,
template: { currentTitle?: string; projectName?: string },
registry: ModelRegistry,
settings: Settings,
sessionId?: string,
Expand All @@ -60,36 +67,25 @@ export async function generateSessionTitle(
return null;
}

// Truncate message if too long
const truncatedMessage =
firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}…` : firstMessage;
const userMessage = `<user-message>
${truncatedMessage}
</user-message>`;

const apiKey = await registry.getApiKey(candidate.model, sessionId);
if (!apiKey) {
logger.debug("title-generator: no API key for smol model", {
logger.debug("title-generator: no API key", {
provider: candidate.model.provider,
id: candidate.model.id,
});
return null;
}

const request = {
model: `${candidate.model.provider}/${candidate.model.id}`,
systemPrompt: TITLE_SYSTEM_PROMPT,
userMessage,
maxTokens: 30,
};
logger.debug("title-generator: request", request);
const systemPrompt = prompt.render(titleSystemPrompt, template);
const model = `${candidate.model.provider}/${candidate.model.id}`;
logger.debug("title-generator: request", { model, userMessage });

try {
const response = await completeSimple(
candidate.model,
{
systemPrompt: request.systemPrompt,
messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
systemPrompt,
messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
},
{
apiKey,
Expand All @@ -99,43 +95,76 @@ ${truncatedMessage}
);

if (response.stopReason === "error") {
logger.debug("title-generator: response error", {
model: request.model,
stopReason: response.stopReason,
errorMessage: response.errorMessage,
});
logger.debug("title-generator: response error", { model, errorMessage: response.errorMessage });
return null;
}

let title = "";
for (const content of response.content) {
if (content.type === "text") {
title += content.text;
}
if (content.type === "text") title += content.text;
}
title = title.trim();

logger.debug("title-generator: response", {
model: request.model,
model,
title,
usage: response.usage,
stopReason: response.stopReason,
});

if (!title) {
return null;
}

return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
return cleanTitle(title);
} catch (err) {
logger.debug("title-generator: error", {
model: request.model,
error: err instanceof Error ? err.message : String(err),
});
logger.debug("title-generator: error", { model, error: err instanceof Error ? err.message : String(err) });
return null;
}
}

/**
* Generate a title for a session based on the first user message.
*/
export async function generateSessionTitle(
firstMessage: string,
registry: ModelRegistry,
settings: Settings,
sessionId?: string,
currentModel?: Model<Api>,
): Promise<string | null> {
const truncated =
firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}\u2026` : firstMessage;
return callTitleModel(
`<user-message>\n${truncated}\n</user-message>`,
{},
registry,
settings,
sessionId,
currentModel,
);
}

/**
* Re-generate a session title from a compact context summary.
* Uses the same prompt but passes currentTitle and projectName as template context
* so the model can decide whether the title still fits.
*/
export async function regenerateSessionTitle(
contextSummary: string,
currentTitle: string | undefined,
registry: ModelRegistry,
settings: Settings,
sessionId?: string,
currentModel?: Model<Api>,
projectName?: string,
): Promise<string | null> {
if (!contextSummary.trim()) return null;

return callTitleModel(
`<session-context>\n${contextSummary}\n</session-context>`,
{ currentTitle, projectName },
registry,
settings,
sessionId,
currentModel,
);
}

/**
* Remove control characters so model-generated titles cannot inject terminal escapes.
*/
Expand Down
Loading