feat(session): auto-title sessions via fast model, add /rename --auto (#3540)

* feat(session): auto-title sessions via fast model, add /rename --auto

The /rename work in #3093 generates kebab-case titles only when the user
explicitly runs `/rename` with no args; until they do, the session picker
shows the first user prompt (often truncated or misleading). This change
adds a sentence-case auto-title that fires once per session after the
first assistant turn, using the configured fast model.

New service: `packages/core/src/services/sessionTitle.ts` —
`tryGenerateSessionTitle(config, signal)` returns a discriminated outcome
(`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can
either handle failures generically or map reasons to actionable messages.
Prompt shape: 3-7 words, sentence case, good/bad examples including a
CJK row, JSON schema enforced via `baseLlmClient.generateJson`.
`maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight
rate limits.

Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after
`recordAssistantTurn`. Fire-and-forget promise, guarded by:

- `currentCustomTitle` — don't overwrite any existing title.
- `autoTitleController` doubles as in-flight flag; a second turn while
  the first is still pending is a no-op.
- `autoTitleAttempts` cap of 3 — the first assistant turn may be a
  pure tool-call with no user-visible text; retry for a handful of
  turns until a title lands. Cap bounds total waste.
- `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto-
  titles; spending fast-model tokens on a one-shot session is waste.
- `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out.
- `config.getFastModel()` falsy — skip entirely rather than falling
  back to the main model; auto-titling on main-model tokens is too
  expensive to be silent.

Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' |
'manual'` field. Absent on pre-change records (treated as `undefined`
→ manual, safe default so a user's pre-upgrade `/rename` is never
silently reclassified). `SessionPicker` renders `titleSource === 'auto'`
titles in dim (secondary) color; manual stays full contrast. On resume,
the persisted source is rehydrated into `currentTitleSource` — without
this, finalize's re-append would rewrite an auto title as manual on
every resume cycle.

Cross-process manual-rename guard: when two CLI tabs target the same
JSONL, in-memory state can diverge. Before writing an auto record, the
IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a
`/rename` from another process landed as manual, bail and sync local
state — never clobber a deliberately-chosen manual title with a model
guess. Cost is one 64KB tail read per successful generation.

`finalize()` aborts the in-flight controller before re-appending the
title record. Session switch / shutdown doesn't have to wait on a slow
fast-model call.

New user-facing command: `/rename --auto` regenerates via the same
generator — explicit user trigger, overwrites whatever's there (manual
or auto) because the user asked. Errors route through
`autoFailureMessage(reason)` so `empty_history`, `model_error`,
`aborted`, etc. each get actionable guidance rather than a generic
"could not generate". `/rename -- --literal-name` is the sentinel for
titles that start with `--`; unknown `--flag` tokens error with a hint
pointing at the sentinel. Existing `/rename <name>` and bare `/rename`
(kebab-case via existing path) are unchanged, except the kebab path now
prefers fast model when available and runs its output through
`stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the
sentence-case path).

New shared util: `packages/core/src/utils/terminalSafe.ts` —
`stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI
(\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A
model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise
execute on every SessionPicker render; both sentence-case and kebab
paths now route titles through the helper before they reach the JSONL
or the UI.

Tail-read extractor: `extractLastJsonStringFields(text, primaryKey,
otherKeys, lineContains)` reads multiple fields from the same matching
line in a single pass. Two separate tail scans could return a mismatched
pair (primary from a newer record, secondary from an older one with only
the primary set); the new helper guarantees the pair is atomic. Validates
a proper closing quote on the primary value so a crash-truncated trailing
record can't win the latest-match race. `readLastJsonStringFieldsSync`
is its file-reading wrapper — same tail-window fast path and full-file
fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB`
cap so a corrupt multi-GB session file can't freeze the picker. Session
reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows
where the constant isn't exposed) — defense in depth against a symlink
planted in `~/.qwen/projects/<proj>/chats/`.

Character handling: `flattenToTail` on the LLM prompt drops a dangling
low surrogate after `slice(-1000)` — otherwise a CJK supplementary char
or emoji cut mid-pair produces invalid UTF-16 that some providers 400.
`sanitizeTitle` applies the same surrogate scrub after max-length trim,
and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so
a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char
strip. `lineContains` in the title reader is tightened from the loose
substring `'custom_title'` to `'"subtype":"custom_title"'` so user text
containing the literal `custom_title` can't shadow a real record.

Tests: 46 new unit tests across
- `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call
  filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets.
- `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix,
  in-flight guard, abort propagation on finalize, manual/auto/legacy
  resume symmetry, cross-process race, env opt-out, retry-after-
  transient.
- `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle
  boundary, truncated trailing record, lineContains, multi-field atom.
- `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel,
  unknown-flag hint, positional rejection, manual/SessionService
  fallbacks.

* docs(session): design doc for auto session titles

Matches the session-recap design doc shape (Overview / Triggers /
Architecture / Prompt Design / History Filtering / Persistence /
Concurrency / Configuration / Observability / Out of Scope) and adds a
Security Hardening section unique to the title path — titles render
directly in the picker and persist in user-readable JSONL, so
LLM-returned control sequences are an attack surface the recap path
doesn't have.

Captures decisions a code-only reader has to reverse-engineer:

- Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop).
- Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call).
- Why the auto trigger does NOT fall back to the main model but
  session-recap does (auto-title fires on every turn; silently charging
  main-model tokens is a bill surprise).
- Why `titleSource: undefined` stays unwritten on legacy records (no
  rewrite risks silently reclassifying user intent).
- Why the cross-process re-read sits between the LLM await and the
  append (manual wins at both in-process and on-disk layers).
- Why `finalize()`'s abort tolerates a controller swap (in-flight
  identity check).
- Why JSON-schema function calling instead of tag extraction (avoid
  reasoning preamble bleed; cross-provider reliability).

Placed at docs/design/session-title/ alongside session-recap,
compact-mode, fork-subagent, and other per-feature design docs. No
sidebar index update required — the design folder is unindexed.

* test(rename): pin model choice in bare /rename kebab path

Addresses reviewer feedback: the bare `/rename` model selection
(`config.getFastModel() ?? config.getModel()`) had no test pinning
it either way. Previous tests mocked `getHistory: []`, which exits
the function before the model is ever chosen, so a silent regression
to either direction (always-main or always-fast) would pass CI.

Two explicit cases now:
- fastModel set → `generateContent` called with `model: 'qwen-turbo'`.
- fastModel unset → `generateContent` called with `model: 'main-model'`.

The tests intentionally mock a non-empty history so the kebab path
reaches the generateContent call site instead of bailing on empty input.
This commit is contained in:
Shaojin Wen 2026-04-23 20:37:05 +08:00 committed by GitHub
parent d14ce16b95
commit d36f12c4c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2864 additions and 53 deletions

View file

@ -9,16 +9,31 @@ import { renameCommand } from './renameCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
const tryGenerateSessionTitleMock = vi.fn();
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
(await importOriginal()) as typeof import('@qwen-code/qwen-code-core');
return {
...original,
tryGenerateSessionTitle: (...args: unknown[]) =>
tryGenerateSessionTitleMock(...args),
};
});
describe('renameCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
tryGenerateSessionTitleMock.mockReset();
});
it('should have the correct name and description', () => {
expect(renameCommand.name).toBe('rename');
expect(renameCommand.description).toBe('Rename the current conversation');
expect(renameCommand.description).toBe(
'Rename the current conversation. --auto lets the fast model pick a title.',
);
});
it('should return error when config is not available', async () => {
@ -103,7 +118,7 @@ describe('renameCommand', () => {
const result = await renameCommand.action!(mockContext, 'my-feature');
expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature');
expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature', 'manual');
expect(result).toEqual({
type: 'message',
messageType: 'info',
@ -130,6 +145,7 @@ describe('renameCommand', () => {
expect(mockRenameSession).toHaveBeenCalledWith(
'test-session-id',
'my-feature',
'manual',
);
expect(result).toEqual({
type: 'message',
@ -159,4 +175,270 @@ describe('renameCommand', () => {
content: 'Failed to rename session.',
});
});
describe('bare /rename model selection', () => {
// Pins the kebab-case path's model choice: bare `/rename` (no args)
// prefers fastModel when one is configured, falls back to the main
// model otherwise. Previous tests mocked `getHistory: []` which bailed
// before the model selection ran, leaving this regression-prone.
function mockConfigForKebab(opts: { fastModel?: string; model?: string }): {
config: unknown;
generateContent: ReturnType<typeof vi.fn>;
} {
const generateContent = vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'fix-login-bug' }] } }],
});
const config = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn().mockReturnValue(true),
}),
getFastModel: vi.fn().mockReturnValue(opts.fastModel),
getModel: vi.fn().mockReturnValue(opts.model ?? 'main-model'),
getGeminiClient: vi.fn().mockReturnValue({
getHistory: vi.fn().mockReturnValue([
{ role: 'user', parts: [{ text: 'fix the login bug' }] },
{
role: 'model',
parts: [{ text: 'Looking at the handler now.' }],
},
]),
}),
getContentGenerator: vi.fn().mockReturnValue({ generateContent }),
};
return { config, generateContent };
}
it('uses fastModel when configured', async () => {
const { config, generateContent } = mockConfigForKebab({
fastModel: 'qwen-turbo',
model: 'main-model',
});
mockContext = createMockCommandContext({
services: { config: config as never },
});
await renameCommand.action!(mockContext, '');
expect(generateContent).toHaveBeenCalledOnce();
expect(generateContent.mock.calls[0][0].model).toBe('qwen-turbo');
});
it('falls back to main model when fastModel is unset', async () => {
const { config, generateContent } = mockConfigForKebab({
fastModel: undefined,
model: 'main-model',
});
mockContext = createMockCommandContext({
services: { config: config as never },
});
await renameCommand.action!(mockContext, '');
expect(generateContent).toHaveBeenCalledOnce();
expect(generateContent.mock.calls[0][0].model).toBe('main-model');
});
});
describe('--auto flag', () => {
it('refuses --auto when no fast model is configured', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue(undefined),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'/rename --auto requires a fast model. Configure one with `/model --fast <model>`.',
});
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
});
it('refuses --auto combined with a positional name', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto my-name');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'/rename --auto does not take a name. Use `/rename <name>` to set a name yourself.',
});
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
});
it('writes an auto-sourced title on --auto success', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: true,
title: 'Fix login button on mobile',
modelUsed: 'qwen-turbo',
});
const mockRecordCustomTitle = vi.fn().mockReturnValue(true);
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: mockRecordCustomTitle,
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(tryGenerateSessionTitleMock).toHaveBeenCalledOnce();
expect(mockRecordCustomTitle).toHaveBeenCalledWith(
'Fix login button on mobile',
'auto',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Session renamed to "Fix login button on mobile"',
});
});
it('surfaces empty_history reason with actionable hint', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: false,
reason: 'empty_history',
});
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'No conversation to title yet — send at least one message first.',
});
});
it('surfaces model_error reason distinctly', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: false,
reason: 'model_error',
});
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(result).toMatchObject({
messageType: 'error',
});
expect((result as { content: string }).content).toMatch(
/rate limit, auth, or network error/,
);
});
it('rejects unknown flag with sentinel hint', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(
mockContext,
'--my-label-with-dashes',
);
expect(result).toMatchObject({ messageType: 'error' });
const content = (result as { content: string }).content;
expect(content).toMatch(/Unknown flag "--my-label-with-dashes"/);
expect(content).toMatch(/\/rename -- --my-label-with-dashes/);
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
});
it('surfaces aborted reason when user cancels', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: false,
reason: 'aborted',
});
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Title generation was cancelled.',
});
});
it('falls back to SessionService.renameSession with auto source', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: true,
title: 'Audit auth middleware',
modelUsed: 'qwen-turbo',
});
const mockRenameSession = vi.fn().mockResolvedValue(true);
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue(undefined),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getSessionService: vi.fn().mockReturnValue({
renameSession: mockRenameSession,
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(mockRenameSession).toHaveBeenCalledWith(
'test-session-id',
'Audit auth middleware',
'auto',
);
expect(result).toMatchObject({ messageType: 'info' });
});
});
});

View file

@ -5,10 +5,13 @@
*/
import type { Content } from '@google/genai';
import type { Config } from '@qwen-code/qwen-code-core';
import {
getResponseText,
SESSION_TITLE_MAX_LENGTH,
stripTerminalControlSequences,
tryGenerateSessionTitle,
type Config,
type SessionTitleFailureReason,
} from '@qwen-code/qwen-code-core';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
@ -38,9 +41,11 @@ function extractConversationText(history: Content[]): string {
}
/**
* Calls the LLM to generate a short session title from conversation history.
* Calls the LLM to generate a short kebab-case session title from conversation
* history. Used when `/rename` is invoked with no arguments produces a
* filesystem-style name for sessions the user wants to keep long-term.
*/
async function generateSessionTitle(
async function generateKebabTitle(
config: Config,
signal?: AbortSignal,
): Promise<string | null> {
@ -51,9 +56,15 @@ async function generateSessionTitle(
return null;
}
// Prefer the fast model for title generation — it's much cheaper and
// faster than the main model, and title generation is a small bounded
// task that doesn't need main-model reasoning. Falls back to the main
// model when no fast model is configured so this path never fails to
// start.
const model = config.getFastModel() ?? config.getModel();
const response = await config.getContentGenerator().generateContent(
{
model: config.getModel(),
model,
contents: [
{
role: 'user',
@ -79,8 +90,13 @@ async function generateSessionTitle(
if (!text) {
return null;
}
// Clean up: take first line, remove quotes/backticks
const cleaned = text.split('\n')[0].replace(/["`']/g, '').trim();
// Clean up: strip ANSI / control sequences via the shared helper
// (same security concern as the sentence-case path — the title renders
// directly in the picker), then take the first line and drop quotes.
const cleaned = stripTerminalControlSequences(text)
.split('\n')[0]
.replace(/["`']/g, '')
.trim();
return cleaned.length > 0 && cleaned.length <= MAX_TITLE_LENGTH
? cleaned
: null;
@ -89,12 +105,88 @@ async function generateSessionTitle(
}
}
/**
* Translate a title-generation failure reason into a human-actionable
* message. Exists so `/rename --auto` doesn't collapse to a generic "could
* not generate" that leaves the user guessing about the cause.
*/
function autoFailureMessage(reason: SessionTitleFailureReason): string {
switch (reason) {
case 'no_fast_model':
return t(
'/rename --auto requires a fast model. Configure one with `/model --fast <model>`.',
);
case 'empty_history':
return t(
'No conversation to title yet — send at least one message first.',
);
case 'empty_result':
return t(
'The fast model returned no usable title. Try `/rename <name>` to set one yourself.',
);
case 'aborted':
return t('Title generation was cancelled.');
case 'model_error':
return t(
'The fast model could not generate a title (rate limit, auth, or network error). Check debug log or try again.',
);
case 'no_client':
return t('Session is still initializing — try again in a moment.');
default:
return t('Could not generate a title.');
}
}
/**
* Parse `--auto` out of the args. Kept simple rather than bringing in an
* argv parser we only have one flag.
*
* Rules:
* - `--auto` (case-insensitive) sets auto=true.
* - `--` terminates flag parsing; everything after is positional, so users
* can legitimately name sessions starting with `--` via `/rename -- --foo`.
* - Any other `--xxx` before `--` bubbles up as `unknownFlag` for a clean
* error, rather than silently becoming part of the title (`--Auto` typo,
* `--help` expectation, etc.).
*/
function parseArgs(raw: string): {
auto: boolean;
positional: string;
unknownFlag?: string;
} {
const trimmed = raw.trim().replace(/[\r\n]+/g, ' ');
if (!trimmed) return { auto: false, positional: '' };
const parts = trimmed.split(/\s+/);
let auto = false;
let unknownFlag: string | undefined;
let flagsDone = false;
const rest: string[] = [];
for (const p of parts) {
if (!flagsDone && p === '--') {
flagsDone = true;
continue;
}
if (!flagsDone && p.startsWith('--')) {
if (p.toLowerCase() === '--auto') {
auto = true;
continue;
}
if (!unknownFlag) unknownFlag = p;
continue;
}
rest.push(p);
}
return { auto, positional: rest.join(' '), unknownFlag };
}
export const renameCommand: SlashCommand = {
name: 'rename',
altNames: ['tag'],
kind: CommandKind.BUILT_IN,
get description() {
return t('Rename the current conversation');
return t(
'Rename the current conversation. --auto lets the fast model pick a title.',
);
},
action: async (context, args): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
@ -107,10 +199,87 @@ export const renameCommand: SlashCommand = {
};
}
let name = args.trim().replace(/[\r\n]+/g, ' ');
const { auto, positional, unknownFlag } = parseArgs(args);
if (unknownFlag) {
return {
type: 'message',
messageType: 'error',
content: t(
'Unknown flag "{{flag}}". Supported: --auto. To use this as a literal name, run `/rename -- {{flag}}`.',
{ flag: unknownFlag },
),
};
}
let name = positional;
// Track where the title came from so the session picker can dim
// auto-generated titles; explicit user text stays 'manual'.
let titleSource: 'auto' | 'manual' = 'manual';
// If no name provided, auto-generate one from conversation history
if (!name) {
if (auto) {
// Explicit user-triggered auto-title. This overwrites whatever title
// is currently set (manual or auto) because the user asked for it.
// Requires a configured fast model — we don't silently fall back to
// the main model here because `--auto` is a deliberate opt-in to the
// sentence-case fast-model flow, and surprising a user with a main-
// model call would defeat the purpose.
if (!config.getFastModel()) {
return {
type: 'message',
messageType: 'error',
content: t(
'/rename --auto requires a fast model. Configure one with `/model --fast <model>`.',
),
};
}
if (positional) {
return {
type: 'message',
messageType: 'error',
content: t(
'/rename --auto does not take a name. Use `/rename <name>` to set a name yourself.',
),
};
}
const dots = ['.', '..', '...'];
let dotIndex = 0;
const baseText = t('Regenerating session title');
context.ui.setPendingItem({
type: 'info',
text: baseText + dots[dotIndex],
});
const timer = setInterval(() => {
dotIndex = (dotIndex + 1) % dots.length;
context.ui.setPendingItem({
type: 'info',
text: baseText + dots[dotIndex],
});
}, 500);
// try/finally ensures the spinner stops even if tryGenerateSessionTitle
// ever throws (it currently swallows internally, but defensively so
// future regressions don't leak an interval timer).
let outcome: Awaited<ReturnType<typeof tryGenerateSessionTitle>>;
try {
outcome = await tryGenerateSessionTitle(
config,
context.abortSignal ?? new AbortController().signal,
);
} finally {
clearInterval(timer);
context.ui.setPendingItem(null);
}
if (!outcome.ok) {
return {
type: 'message',
messageType: 'error',
content: autoFailureMessage(outcome.reason),
};
}
name = outcome.title;
titleSource = 'auto';
} else if (!name) {
// Legacy no-arg behavior: kebab-case, generated via the main content
// generator with fallback to fastModel. Preserved as-is for users who
// prefer filesystem-style names.
const dots = ['.', '..', '...'];
let dotIndex = 0;
const baseText = t('Generating session name');
@ -125,9 +294,13 @@ export const renameCommand: SlashCommand = {
text: baseText + dots[dotIndex],
});
}, 500);
const generated = await generateSessionTitle(config, context.abortSignal);
clearInterval(timer);
context.ui.setPendingItem(null);
let generated: string | null;
try {
generated = await generateKebabTitle(config, context.abortSignal);
} finally {
clearInterval(timer);
context.ui.setPendingItem(null);
}
if (!generated) {
return {
type: 'message',
@ -151,7 +324,7 @@ export const renameCommand: SlashCommand = {
// Record the custom title in the current session's JSONL file
const chatRecordingService = config.getChatRecordingService();
if (chatRecordingService) {
const ok = chatRecordingService.recordCustomTitle(name);
const ok = chatRecordingService.recordCustomTitle(name, titleSource);
if (!ok) {
return {
type: 'message',
@ -163,7 +336,11 @@ export const renameCommand: SlashCommand = {
// Fallback: write via SessionService for non-recording sessions
const sessionId = config.getSessionId();
const sessionService = config.getSessionService();
const success = await sessionService.renameSession(sessionId, name);
const success = await sessionService.renameSession(
sessionId,
name,
titleSource,
);
if (!success) {
return {
type: 'message',

View file

@ -94,6 +94,11 @@ function SessionListItemView({
const promptText = session.customTitle || session.prompt || '(empty prompt)';
const truncatedPrompt = truncateText(promptText, maxPromptWidth);
// Dim auto-generated titles so users can distinguish a model guess from
// a title they chose themselves with `/rename`. Selected row keeps the
// accent color — legibility of the focused row wins over source hinting.
const isAutoTitle =
session.titleSource === 'auto' && Boolean(session.customTitle);
return (
<Box flexDirection="column" marginBottom={isLast ? 0 : 1}>
@ -111,7 +116,13 @@ function SessionListItemView({
{prefix}
</Text>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
color={
isSelected
? theme.text.accent
: isAutoTitle
? theme.text.secondary
: theme.text.primary
}
bold={isSelected}
>
{truncatedPrompt}