mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
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:
parent
d14ce16b95
commit
d36f12c4c4
16 changed files with 2864 additions and 53 deletions
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue