feat(session): add rename, delete, and auto-title generation for session (#3093)

* feat(session): add rename, delete, and auto-title generation for sessions

- Add /rename command with LLM auto-title generation when no args provided
- Add /delete command to remove sessions from the session picker
- Display session name tag embedded in input prompt top border
- Restore session name on /resume and --resume <title> CLI flag
- Support rename and delete via ACP extMethod for VSCode extension
- Add rename/delete UI to WebUI SessionSelector with two-click delete confirmation
- Fix parentUuid chain: custom_title records now correctly reference the
  previous record's UUID, preventing session history from appearing empty
  after rename
- Add SESSION_FILE_PATTERN validation to all SessionService methods that
  construct file paths from sessionId (defense-in-depth against path traversal)
- Fix fd leak in readCustomTitleFromFile with try/finally
- Fix --resume <title> exit code (exit 1 when no match found)
- Add project ownership checks to VSCode qwenSessionReader delete/rename

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(session): fix broken imports and missing mocks from rename/auto-title feature

- Fix renameCommand.ts import path to use barrel export instead of deep path
- Add setSessionName to mock CommandContext
- Add getSessionTitle to SessionService mock in useResumeCommand tests
- Update renameCommand tests for auto-generate title behavior
- Update InputPrompt snapshots

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(session): add head+tail dual-read, string-level extraction, and finalize mechanism

- Add sessionStorageUtils with extractLastJsonStringField() for fast
  string-level JSON field extraction without full parse
- Add readHeadAndTailSync() to read first and last 64KB of session files
- Replace readCustomTitleFromFile() with readSessionTitleFromFile() using
  head+tail dual-read (tail customTitle > head customTitle)
- Add finalize() to ChatRecordingService as single entry point for
  re-appending session metadata on any session departure
- Call finalize() on resume, session switch, and shutdown
- Export sessionStorageUtils from core package

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(session): show filtered picker when /resume <title> matches multiple sessions

Previously, multiple title matches opened the full session picker,
forcing the user to re-find their session. Now the matched sessions
are passed through as initialSessions to the picker, skipping the
full listSessions() load and showing only the relevant results.

Also clears sessionName on /clear so new sessions don't carry stale
title tags from the previous session.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ui): use stringWidth for CJK-safe border alignment in input prompt

topRightLabel.length counts UTF-16 code units, not terminal columns.
CJK characters take 2 columns but .length returns 1, causing the
border line to overflow. Use string-width for correct display width.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(session): address remaining PR #3093 review feedback

- Add SESSION_TITLE_MAX_LENGTH shared constant in core, replace
  hardcoded 200 in CLI/ACP/VSCode/WebUI
- Add title length validation to ACP renameSession endpoint
- Make recordCustomTitle return boolean; renameCommand checks it
  before updating UI to prevent silent data loss
- Add gitBranch to VSCode rename record for consistency with CLI
- Remove misleading "enforce kebab-case" comment
- Remove duplicate JSDoc on topRightLabel

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ui): add animated dots to session name generation loading indicator

The static "Generating session name…" text gave no visual feedback that
the operation was in progress. Cycle through ".", "..", "..." every
500ms so users can tell the LLM call is still running.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* feat(cli): add /tag as alias for /rename command

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(vscode): add loading overlay when switching to historical conversations

Adds isSwitchingSession state and sessionLoadComplete message to show
a loading transition while session history is being rehydrated via ACP.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(vscode): add 15s timeout fallback for session switching loading state

Prevents loading overlay from getting stuck indefinitely if
sessionLoadComplete message is never received.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(core): fix extractLastJsonStringField offset tracking and add lineContains filter

1. Track global character offset across both pattern variants so the
   truly last match wins (previously the second pattern scan could
   overwrite a later match from the first pattern).

2. Add optional lineContains parameter to scope matches to lines
   containing a marker (e.g. "custom_title"), preventing false matches
   from user content that happens to include a "customTitle" field.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* chore(cli): add i18n import to DialogManager

Co-Authored-By: Qwen-Coder <noreply@qwen.ai>

* fix(vscode): align currentConversationId with webview on fallback restore

When session/load falls back to creating a fresh ACP session, backend
was tracking the new ACP id while the webview still viewed the archived
sessionId. That desync caused delete/rename/title-update to target the
wrong session during the fallback window, and prevented the post-first-
message sync path from firing because the two ids were pre-aligned.

Keep currentConversationId pointing at the archived sessionId until the
existing stream-end sync flips both sides to the live ACP id on the
first user message. Matches the pattern already used by the offline
branch.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(core): exhaustive scan in findSessionsByTitle to avoid mtime-boundary misses

listSessions() paginates with an mtime-only cursor and strict `<`
filter. When several session files share the same mtime across a page
boundary, the next page's filter drops them, so --resume <title> could
silently miss valid matches.

Scan all session files directly for title lookup, with filename as a
stable tie-breaker. Also check the (cheap) custom title before the
full hydration pass (first-record read, project filter, message count,
prompt extraction) so non-matching sessions skip the extra I/O.

listSessions() itself is left alone: its cursor crosses ACP/webview
package boundaries as a number and this edge case only affects UI
display order, not data loss.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(acp): plumb listSessions page size through _meta

VSCode companion passes `size` to acpConnection.listSessions, but the
ACP spec's ListSessionsRequest schema has no `size` field, so the SDK's
zod validator strips it before the agent handler sees it. The agent
then only forwarded `cursor` to SessionService.listSessions, silently
ignoring the caller's page-size intent.

Carry page size through `_meta.size` on both sides, matching the
pattern already used for other Qwen Code ACP extensions (e.g. the
filesystem service's `_meta.bom` / `_meta.encoding`). `_meta` is typed
as an open record in the ACP schema, so extra keys survive validation.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(webui): avoid unintended rename when canceling with Escape

The rename input auto-submits on blur, and pressing Escape also triggers
blur (via setRenamingSessionId(null) unmounting the input). Because state
updates are async, the blur handler's handleRenameSubmit could still read
the pre-Escape renameValue from its closure and call onRenameSession,
turning a cancel into an accidental rename.

Track cancellation via an isCancelingRenameRef flag: set it in the Escape
branch, and have onBlur short-circuit when the flag is true, then reset it.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(vscode-ide-companion): clear switch timeout on unmount

The 15s session-switch fallback timer was only cleared on the next call
to setIsSwitchingSession. If the webview is torn down mid-switch, the
timer stays alive and later fires setIsSwitchingSessionRaw(false) on an
unmounted hook. Add a useEffect cleanup to clear any pending timer on
unmount.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(cli): break handleResume/slashCommandActions circular dep

slashCommandActions (useMemo) depends on handleResume, but handleResume
was declared after useSlashCommandProcessor so it could call
setAwayRecapItem(null). useSlashCommandProcessor itself consumes
slashCommandActions, closing a three-way cycle that tsc catches as
TS2448 "used before declaration" once #3478's AppContainer changes land
in main and get auto-merged into open PRs.

Move handleResume above slashCommandActions and route the recap clear
through a ref that a later useEffect syncs with setAwayRecapItem.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(core): scan full file when title is not in tail window

Replace the head+tail dual-read with readLastJsonStringFieldSync: scan the
tail first and return on hit, otherwise stream the whole file and return
the last match. Closes the blind spot where a custom_title record landing
between the head and tail windows would be missed on large session files.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <noreply@qwen.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <noreply@qwen.ai>
Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
This commit is contained in:
qqqys 2026-04-22 11:48:01 +08:00 committed by GitHub
parent d1c8dff4d2
commit 0c423deedf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 3275 additions and 204 deletions

View file

@ -14,6 +14,7 @@ import {
qwenOAuth2Events,
MCPServerConfig,
SessionService,
SESSION_TITLE_MAX_LENGTH,
tokenLimit,
type Config,
type ConversationRecord,
@ -296,17 +297,29 @@ class QwenAgent implements Agent {
): Promise<ListSessionsResponse> {
const cwd = params.cwd || process.cwd();
const numericCursor = params.cursor ? Number(params.cursor) : undefined;
// The ACP spec's ListSessionsRequest doesn't include a page-size field,
// so the SDK's zod validator strips any top-level `size` the client sends
// before it reaches this handler. Carry page size through `_meta.size`
// (same pattern filesystem.ts uses for `_meta.bom` / `_meta.encoding`).
const metaSize = params._meta?.['size'];
const size =
typeof metaSize === 'number' && metaSize > 0
? Math.floor(metaSize)
: undefined;
const result = await runWithAcpRuntimeOutputDir(this.settings, cwd, () => {
const sessionService = new SessionService(cwd);
return sessionService.listSessions({
cursor: Number.isNaN(numericCursor) ? undefined : numericCursor,
size,
});
});
const sessions: SessionInfo[] = result.items.map((item) => ({
cwd: item.cwd,
sessionId: item.sessionId,
title: item.prompt || '(session)',
title: item.customTitle || item.prompt || '(session)',
updatedAt: new Date(item.mtime).toISOString(),
}));
@ -403,19 +416,74 @@ class QwenAgent implements Agent {
method: string,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (method === 'getAccountInfo') {
const sessionId = params['sessionId'] as string | undefined;
const session = sessionId ? this.sessions.get(sessionId) : undefined;
const config = session ? session.getConfig() : this.config;
const cfg = config.getContentGeneratorConfig();
return {
authType: cfg?.authType ?? config.getAuthType() ?? null,
model: cfg?.model ?? config.getModel() ?? null,
baseUrl: cfg?.baseUrl ?? null,
apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null,
};
const cwd = (params['cwd'] as string) || process.cwd();
const SESSION_ID_RE = /^[0-9a-fA-F-]{32,36}$/;
switch (method) {
case 'deleteSession': {
const sessionId = params['sessionId'] as string;
if (!sessionId || !SESSION_ID_RE.test(sessionId)) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing sessionId',
);
}
const success = await runWithAcpRuntimeOutputDir(
this.settings,
cwd,
async () => {
const sessionService = new SessionService(cwd);
return sessionService.removeSession(sessionId);
},
);
return { success };
}
case 'renameSession': {
const sessionId = params['sessionId'] as string;
const title = params['title'] as string;
if (!sessionId || !SESSION_ID_RE.test(sessionId)) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing sessionId',
);
}
if (!title || typeof title !== 'string') {
throw RequestError.invalidParams(
undefined,
'Invalid or missing title',
);
}
if (title.length > SESSION_TITLE_MAX_LENGTH) {
throw RequestError.invalidParams(
undefined,
`Title too long (max ${SESSION_TITLE_MAX_LENGTH} chars)`,
);
}
const success = await runWithAcpRuntimeOutputDir(
this.settings,
cwd,
async () => {
const sessionService = new SessionService(cwd);
return sessionService.renameSession(sessionId, title);
},
);
return { success };
}
case 'getAccountInfo': {
const sessionId = params['sessionId'] as string | undefined;
const session = sessionId ? this.sessions.get(sessionId) : undefined;
const config = session ? session.getConfig() : this.config;
const cfg = config.getContentGeneratorConfig();
return {
authType: cfg?.authType ?? config.getAuthType() ?? null,
model: cfg?.model ?? config.getModel() ?? null,
baseUrl: cfg?.baseUrl ?? null,
apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null,
};
}
default:
throw RequestError.methodNotFound(method);
}
throw RequestError.methodNotFound(method);
}
// --- private helpers ---

View file

@ -62,7 +62,7 @@ const SESSION_ID_REGEX =
* Accepts a standard UUID, or a UUID followed by `-agent-{suffix}`
* (used by Arena to give each agent a deterministic session ID).
*/
function isValidSessionId(value: string): boolean {
export function isValidSessionId(value: string): boolean {
return SESSION_ID_REGEX.test(value);
}
@ -612,9 +612,7 @@ export async function parseArguments(): Promise<CliArgs> {
) {
return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
}
if (argv['resume'] && !isValidSessionId(argv['resume'] as string)) {
return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
}
// --resume accepts either a session UUID or a custom title
if (argv['jsonFd'] != null && argv['jsonFile'] != null) {
return '--json-fd and --json-file are mutually exclusive. Use one or the other.';
}
@ -1082,6 +1080,9 @@ export async function loadCliConfig(
}
if (argv.resume) {
// By the time we get here, argv.resume has been resolved to a valid
// session UUID by gemini.tsx (which handles custom title lookup and
// the interactive picker for ambiguous matches).
sessionId = argv.resume;
sessionData = await sessionService.loadSession(argv.resume);
if (!sessionData) {

View file

@ -12,6 +12,7 @@ import {
logUserPrompt,
QWEN_CODE_SIMPLE_ENV_VAR,
Storage,
SessionService,
type Config,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
@ -431,23 +432,52 @@ export async function main() {
}
}
// Handle --resume without a session ID by showing the session picker.
// Set the runtime output dir early so the picker can find sessions stored
// under a custom runtimeOutputDir (setRuntimeBaseDir is idempotent and will
// be called again inside loadCliConfig).
if (argv.resume === '') {
// Handle --resume without a session ID, or with a custom title, by showing
// the session picker. Set the runtime output dir early so the picker can find
// sessions stored under a custom runtimeOutputDir (setRuntimeBaseDir is
// idempotent and will be called again inside loadCliConfig).
if (argv.resume !== undefined) {
Storage.setRuntimeBaseDir(
settings.merged.advanced?.runtimeOutputDir,
process.cwd(),
);
const selectedSessionId = await showResumeSessionPicker();
if (!selectedSessionId) {
// User cancelled or no sessions available
process.exit(0);
let resolvedSessionId: string | undefined;
if (argv.resume === '') {
// No argument — show picker
resolvedSessionId = await showResumeSessionPicker();
} else if (!cliConfig.isValidSessionId(argv.resume)) {
// Non-UUID argument — treat as custom title search
const sessionService = new SessionService(process.cwd());
const matches = await sessionService.findSessionsByTitle(argv.resume);
if (matches.length === 1) {
resolvedSessionId = matches[0].sessionId;
} else if (matches.length > 1) {
// Multiple matches — show picker to let user choose
writeStderrLine(
`Multiple sessions found with title "${argv.resume}". Please select one:`,
);
resolvedSessionId = await showResumeSessionPicker(
process.cwd(),
matches,
);
}
// matches.length === 0 → resolvedSessionId stays undefined, handled below
}
// Update argv with the selected session ID
argv = { ...argv, resume: selectedSessionId };
if (resolvedSessionId !== undefined) {
argv = { ...argv, resume: resolvedSessionId };
} else if (argv.resume === '' || !cliConfig.isValidSessionId(argv.resume)) {
// User cancelled the picker or no sessions found for the title
if (argv.resume !== '') {
writeStderrLine(`No saved session found with title "${argv.resume}".`);
process.exit(1);
} else {
process.exit(0);
}
}
// else: argv.resume is already a valid UUID, pass through to loadCliConfig
}
// We are now past the logic handling potentially launching a child process

View file

@ -15,6 +15,7 @@ import { authCommand } from '../ui/commands/authCommand.js';
import { btwCommand } from '../ui/commands/btwCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { deleteCommand } from '../ui/commands/deleteCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { contextCommand } from '../ui/commands/contextCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
@ -41,6 +42,7 @@ import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { trustCommand } from '../ui/commands/trustCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { recapCommand } from '../ui/commands/recapCommand.js';
import { renameCommand } from '../ui/commands/renameCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
@ -97,6 +99,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
compressCommand,
contextCommand,
copyCommand,
deleteCommand,
docsCommand,
doctorCommand,
directoryCommand,
@ -120,6 +123,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.getFolderTrust() ? [trustCommand] : []),
quitCommand,
recapCommand,
renameCommand,
restoreCommand(this.config),
resumeCommand,
skillsCommand,

View file

@ -65,6 +65,7 @@ export const createMockCommandContext = (
extensionsUpdateState: new Map(),
setExtensionsUpdateState: vi.fn(),
reloadCommands: vi.fn(),
setSessionName: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
session: {

View file

@ -72,6 +72,7 @@ import { useModelCommand } from './hooks/useModelCommand.js';
import { useArenaCommand } from './hooks/useArenaCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js';
import { useDeleteCommand } from './hooks/useDeleteCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js';
import { CompactModeProvider } from './contexts/CompactModeContext.js';
@ -320,6 +321,14 @@ export const AppContainer = (props: AppContainerProps) => {
config,
);
historyManager.loadHistory(historyItems);
// Restore session name tag from custom title
const title = config
.getSessionService()
.getSessionTitle(config.getSessionId());
if (title) {
setSessionName(title);
}
}
// Fire SessionStart event after config is initialized
@ -566,8 +575,12 @@ export const AppContainer = (props: AppContainerProps) => {
const { activeArenaDialog, openArenaDialog, closeArenaDialog } =
useArenaCommand();
// Session name state (set via /rename, restored on /resume)
const [sessionName, setSessionName] = useState<string | null>(null);
const {
isResumeDialogOpen,
resumeMatchedSessions,
openResumeDialog,
closeResumeDialog,
handleResume,
@ -575,9 +588,20 @@ export const AppContainer = (props: AppContainerProps) => {
config,
historyManager,
startNewSession,
setSessionName,
remount: refreshStatic,
});
const {
isDeleteDialogOpen,
openDeleteDialog,
closeDeleteDialog,
handleDelete,
} = useDeleteCommand({
config,
addItem: historyManager.addItem,
});
const { toggleVimEnabled } = useVimMode();
const {
@ -627,6 +651,8 @@ export const AppContainer = (props: AppContainerProps) => {
openMcpDialog,
openHooksDialog,
openResumeDialog,
handleResume,
openDeleteDialog,
}),
[
openAuthDialog,
@ -648,6 +674,8 @@ export const AppContainer = (props: AppContainerProps) => {
openMcpDialog,
openHooksDialog,
openResumeDialog,
handleResume,
openDeleteDialog,
],
);
@ -677,6 +705,7 @@ export const AppContainer = (props: AppContainerProps) => {
extensionsUpdateStateInternal,
isConfigInitialized,
logger,
setSessionName,
);
// onDebugMessage should log to debug logfile, not update footer debugMessage
@ -1996,6 +2025,7 @@ export const AppContainer = (props: AppContainerProps) => {
isHooksDialogOpen ||
isApprovalModeDialogOpen ||
isResumeDialogOpen ||
isDeleteDialogOpen ||
isExtensionsManagerDialogOpen;
dialogsVisibleRef.current = dialogsVisible;
@ -2039,6 +2069,8 @@ export const AppContainer = (props: AppContainerProps) => {
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
resumeMatchedSessions,
isDeleteDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@ -2122,6 +2154,9 @@ export const AppContainer = (props: AppContainerProps) => {
// Real-time token display
streamingResponseLengthRef,
isReceivingContent,
// Session name
sessionName,
setSessionName,
// Prompt suggestion
promptSuggestion,
dismissPromptSuggestion,
@ -2149,6 +2184,8 @@ export const AppContainer = (props: AppContainerProps) => {
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
resumeMatchedSessions,
isDeleteDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@ -2233,6 +2270,9 @@ export const AppContainer = (props: AppContainerProps) => {
// Real-time token display
streamingResponseLengthRef,
isReceivingContent,
// Session name
sessionName,
setSessionName,
// Prompt suggestion
promptSuggestion,
dismissPromptSuggestion,
@ -2296,6 +2336,10 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Delete session dialog
openDeleteDialog,
closeDeleteDialog,
handleDelete,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
@ -2356,6 +2400,10 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Delete session dialog
openDeleteDialog,
closeDeleteDialog,
handleDelete,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,

View file

@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { deleteCommand } from './deleteCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('deleteCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should have the correct name and description', () => {
expect(deleteCommand.name).toBe('delete');
expect(deleteCommand.description).toBe('Delete a previous session');
});
it('should return a dialog action to open the delete dialog', async () => {
const result = await deleteCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'delete',
});
});
});

View file

@ -0,0 +1,21 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const deleteCommand: SlashCommand = {
name: 'delete',
kind: CommandKind.BUILT_IN,
get description() {
return t('Delete a previous session');
},
action: async (): Promise<SlashCommandActionReturn> => ({
type: 'dialog',
dialog: 'delete',
}),
};

View file

@ -0,0 +1,162 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renameCommand } from './renameCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('renameCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should have the correct name and description', () => {
expect(renameCommand.name).toBe('rename');
expect(renameCommand.description).toBe('Rename the current conversation');
});
it('should return error when config is not available', async () => {
mockContext.services.config = null;
const result = await renameCommand.action!(mockContext, 'my-feature');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config is not available.',
});
});
it('should return error when no name is provided and auto-generate fails', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue(undefined),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getSessionService: vi.fn().mockReturnValue({
renameSession: vi.fn().mockResolvedValue(true),
}),
getGeminiClient: vi.fn().mockReturnValue({
getHistory: vi.fn().mockReturnValue([]),
}),
getContentGenerator: vi.fn(),
getModel: vi.fn(),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Could not generate a title. Usage: /rename <name>',
});
});
it('should return error when only whitespace is provided and auto-generate fails', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue(undefined),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getSessionService: vi.fn().mockReturnValue({
renameSession: vi.fn().mockResolvedValue(true),
}),
getGeminiClient: vi.fn().mockReturnValue({
getHistory: vi.fn().mockReturnValue([]),
}),
getContentGenerator: vi.fn(),
getModel: vi.fn(),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Could not generate a title. Usage: /rename <name>',
});
});
it('should rename via ChatRecordingService when available', async () => {
const mockRecordCustomTitle = vi.fn().mockReturnValue(true);
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: mockRecordCustomTitle,
}),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getSessionService: vi.fn().mockReturnValue({
renameSession: vi.fn().mockResolvedValue(true),
}),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, 'my-feature');
expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Session renamed to "my-feature"',
});
});
it('should fall back to SessionService when ChatRecordingService is unavailable', async () => {
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,
}),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, 'my-feature');
expect(mockRenameSession).toHaveBeenCalledWith(
'test-session-id',
'my-feature',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Session renamed to "my-feature"',
});
});
it('should return error when SessionService fallback fails', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue(undefined),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getSessionService: vi.fn().mockReturnValue({
renameSession: vi.fn().mockResolvedValue(false),
}),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, 'my-feature');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to rename session.',
});
});
});

View file

@ -0,0 +1,185 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/genai';
import type { Config } from '@qwen-code/qwen-code-core';
import {
getResponseText,
SESSION_TITLE_MAX_LENGTH,
} from '@qwen-code/qwen-code-core';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
const MAX_TITLE_LENGTH = SESSION_TITLE_MAX_LENGTH;
/**
* Extracts a short text summary from conversation history for title generation.
* Takes the last few user/assistant messages, truncated to ~1000 chars.
*/
function extractConversationText(history: Content[]): string {
const texts: string[] = [];
// Walk backwards to get the most recent context
for (let i = history.length - 1; i >= 0 && texts.length < 6; i--) {
const content = history[i];
const role = content.role === 'user' ? 'User' : 'Assistant';
for (const part of content.parts ?? []) {
if ('text' in part && part.text) {
texts.unshift(`${role}: ${part.text}`);
break;
}
}
}
const joined = texts.join('\n');
return joined.length > 1000 ? joined.slice(-1000) : joined;
}
/**
* Calls the LLM to generate a short session title from conversation history.
*/
async function generateSessionTitle(
config: Config,
signal?: AbortSignal,
): Promise<string | null> {
try {
const history = config.getGeminiClient().getHistory(true);
const conversationText = extractConversationText(history);
if (!conversationText) {
return null;
}
const response = await config.getContentGenerator().generateContent(
{
model: config.getModel(),
contents: [
{
role: 'user',
parts: [{ text: conversationText }],
},
],
config: {
systemInstruction: {
role: 'user',
parts: [
{
text: 'Generate a short kebab-case name (2-4 words) that captures the main topic of this conversation. Use lowercase words separated by hyphens. Examples: "fix-login-bug", "add-auth-feature", "refactor-api-client". Reply with ONLY the kebab-case name, nothing else.',
},
],
},
abortSignal: signal,
},
},
'rename_generate_title',
);
const text = getResponseText(response)?.trim();
if (!text) {
return null;
}
// Clean up: take first line, remove quotes/backticks
const cleaned = text.split('\n')[0].replace(/["`']/g, '').trim();
return cleaned.length > 0 && cleaned.length <= MAX_TITLE_LENGTH
? cleaned
: null;
} catch {
return null;
}
}
export const renameCommand: SlashCommand = {
name: 'rename',
altNames: ['tag'],
kind: CommandKind.BUILT_IN,
get description() {
return t('Rename the current conversation');
},
action: async (context, args): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config is not available.'),
};
}
let name = args.trim().replace(/[\r\n]+/g, ' ');
// If no name provided, auto-generate one from conversation history
if (!name) {
const dots = ['.', '..', '...'];
let dotIndex = 0;
const baseText = t('Generating session name');
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);
const generated = await generateSessionTitle(config, context.abortSignal);
clearInterval(timer);
context.ui.setPendingItem(null);
if (!generated) {
return {
type: 'message',
messageType: 'error',
content: t('Could not generate a title. Usage: /rename <name>'),
};
}
name = generated;
}
if (name.length > MAX_TITLE_LENGTH) {
return {
type: 'message',
messageType: 'error',
content: t('Name is too long. Maximum {{max}} characters.', {
max: String(MAX_TITLE_LENGTH),
}),
};
}
// Record the custom title in the current session's JSONL file
const chatRecordingService = config.getChatRecordingService();
if (chatRecordingService) {
const ok = chatRecordingService.recordCustomTitle(name);
if (!ok) {
return {
type: 'message',
messageType: 'error',
content: t('Failed to rename session.'),
};
}
} else {
// Fallback: write via SessionService for non-recording sessions
const sessionId = config.getSessionId();
const sessionService = config.getSessionService();
const success = await sessionService.renameSession(sessionId, name);
if (!success) {
return {
type: 'message',
messageType: 'error',
content: t('Failed to rename session.'),
};
}
}
// Update the UI tag in the input prompt
context.ui.setSessionName(name);
return {
type: 'message',
messageType: 'info',
content: t('Session renamed to "{{name}}"', { name }),
};
},
};

View file

@ -4,35 +4,167 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { resumeCommand } from './resumeCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('../../config/config.js', () => ({
isValidSessionId: vi.fn((value: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
),
),
}));
describe('resumeCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the resume dialog', async () => {
// Ensure the command has an action to test.
if (!resumeCommand.action) {
throw new Error('The resume command must have an action.');
}
const result = await resumeCommand.action(mockContext, '');
// Assert that the action returns the correct object to trigger the resume dialog.
expect(result).toEqual({
type: 'dialog',
dialog: 'resume',
});
});
it('should have the correct name and description', () => {
expect(resumeCommand.name).toBe('resume');
expect(resumeCommand.description).toBe('Resume a previous session');
});
it('should have "continue" as an alias', () => {
expect(resumeCommand.altNames).toContain('continue');
});
it('should return dialog action when no args provided', async () => {
const result = await resumeCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'resume',
});
});
it('should return error when config is not available and args given', async () => {
mockContext.services.config = null;
const result = await resumeCommand.action!(mockContext, 'some-arg');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config is not available.',
});
});
it('should resume directly when valid UUID is provided and session exists', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
const mockConfig = {
getSessionService: vi.fn().mockReturnValue({
sessionExists: vi.fn().mockResolvedValue(true),
}),
getTargetDir: vi.fn().mockReturnValue('/test'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await resumeCommand.action!(mockContext, sessionId);
expect(result).toEqual({
type: 'dialog',
dialog: 'resume',
sessionId,
});
});
it('should return error when valid UUID is provided but session does not exist', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
const mockConfig = {
getSessionService: vi.fn().mockReturnValue({
sessionExists: vi.fn().mockResolvedValue(false),
}),
getTargetDir: vi.fn().mockReturnValue('/test'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await resumeCommand.action!(mockContext, sessionId);
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `No session found with ID "${sessionId}".`,
});
});
it('should resume directly when custom title has single match', async () => {
const matchedSessionId = '550e8400-e29b-41d4-a716-446655440000';
const mockConfig = {
getSessionService: vi.fn().mockReturnValue({
sessionExists: vi.fn().mockResolvedValue(false),
findSessionsByTitle: vi
.fn()
.mockResolvedValue([{ sessionId: matchedSessionId }]),
}),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await resumeCommand.action!(mockContext, 'my-feature');
expect(result).toEqual({
type: 'dialog',
dialog: 'resume',
sessionId: matchedSessionId,
});
});
it('should show picker when custom title has multiple matches', async () => {
const mockConfig = {
getSessionService: vi.fn().mockReturnValue({
sessionExists: vi.fn().mockResolvedValue(false),
findSessionsByTitle: vi
.fn()
.mockResolvedValue([{ sessionId: 'id-1' }, { sessionId: 'id-2' }]),
}),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await resumeCommand.action!(mockContext, 'shared-name');
expect(result).toEqual({
type: 'dialog',
dialog: 'resume',
matchedSessions: [{ sessionId: 'id-1' }, { sessionId: 'id-2' }],
});
});
it('should return error when custom title has no matches', async () => {
const mockConfig = {
getSessionService: vi.fn().mockReturnValue({
sessionExists: vi.fn().mockResolvedValue(false),
findSessionsByTitle: vi.fn().mockResolvedValue([]),
}),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await resumeCommand.action!(mockContext, 'nonexistent');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No session found with title "nonexistent".',
});
});
});

View file

@ -6,17 +6,69 @@
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { isValidSessionId } from '../../config/config.js';
import { t } from '../../i18n/index.js';
export const resumeCommand: SlashCommand = {
name: 'resume',
altNames: ['continue'],
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
get description() {
return t('Resume a previous session');
},
action: async (): Promise<SlashCommandActionReturn> => ({
type: 'dialog',
dialog: 'resume',
}),
action: async (context, args): Promise<SlashCommandActionReturn> => {
const arg = args.trim();
// No argument — show picker
if (!arg) {
return { type: 'dialog', dialog: 'resume' };
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config is not available.'),
};
}
// Try as session UUID
if (isValidSessionId(arg)) {
const sessionService = config.getSessionService();
const exists = await sessionService.sessionExists(arg);
if (exists) {
return { type: 'dialog', dialog: 'resume', sessionId: arg };
}
return {
type: 'message',
messageType: 'error',
content: t('No session found with ID "{{id}}".', { id: arg }),
};
}
// Try as custom title
const sessionService = config.getSessionService();
const matches = await sessionService.findSessionsByTitle(arg);
if (matches.length === 1) {
return {
type: 'dialog',
dialog: 'resume',
sessionId: matches[0].sessionId,
};
}
if (matches.length > 1) {
// Multiple matches — show picker with only the matching sessions
return { type: 'dialog', dialog: 'resume', matchedSessions: matches };
}
return {
type: 'message',
messageType: 'error',
content: t('No session found with title "{{title}}".', { title: arg }),
};
},
};

View file

@ -6,7 +6,12 @@
import type { MutableRefObject, ReactNode } from 'react';
import type { Content, PartListUnion } from '@google/genai';
import type { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
import type {
Config,
GitService,
Logger,
SessionListItem,
} from '@qwen-code/qwen-code-core';
import type {
HistoryItemWithoutId,
HistoryItem,
@ -86,6 +91,7 @@ export interface CommandContext {
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;
setSessionName: (name: string | null) => void;
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
@ -148,6 +154,12 @@ export interface StreamMessagesActionReturn {
export interface OpenDialogActionReturn {
type: 'dialog';
/** Optional session ID to pass directly to the dialog handler (e.g., for /resume <id>). */
sessionId?: string;
/** Pre-filtered sessions for the picker (e.g., multiple title matches from /resume <title>). */
matchedSessions?: SessionListItem[];
dialog:
| 'help'
| 'arena_start'
@ -167,6 +179,7 @@ export interface OpenDialogActionReturn {
| 'permissions'
| 'approval-mode'
| 'resume'
| 'delete'
| 'extensions_manage'
| 'hooks'
| 'mcp';

View file

@ -27,6 +27,7 @@ import type { TextBuffer } from './shared/text-buffer.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import stringWidth from 'string-width';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import { theme } from '../semantic-colors.js';
@ -69,6 +70,8 @@ export interface BaseTextInputProps {
prefix?: React.ReactNode;
/** Border color for the input box. */
borderColor?: string;
/** Label rendered on the top border line (right-aligned). Plain string for width calculation. */
topRightLabel?: string;
/** Whether keyboard handling is active. Defaults to true. */
isActive?: boolean;
/**
@ -129,6 +132,7 @@ export const BaseTextInput: React.FC<BaseTextInputProps> = ({
placeholder,
prefix,
borderColor,
topRightLabel,
isActive = true,
renderLine = defaultRenderLine,
}) => {
@ -246,47 +250,61 @@ export const BaseTextInput: React.FC<BaseTextInputProps> = ({
<Text color={theme.text.accent}>{'> '}</Text>
);
return (
<Box
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={resolvedBorderColor}
>
{resolvedPrefix}
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, idx) => {
const absoluteVisualIndex = scrollVisualRow + idx;
const isOnCursorLine = absoluteVisualIndex === cursorVisualRow;
const columns = process.stdout.columns || 80;
// Build the top border line: ─────── label ──
// Label takes: 1 space + text + 1 space + 2 trailing dashes = label.length + 4
const labelWidth = topRightLabel ? stringWidth(topRightLabel) + 4 : 0;
const dashCount = Math.max(1, columns - labelWidth);
const topBorderLine = topRightLabel
? `${'─'.repeat(dashCount)} ${topRightLabel} ${'─'.repeat(2)}`
: '─'.repeat(columns);
return (
<Box key={idx} height={1}>
{renderLine({
lineText,
isOnCursorLine,
cursorCol: cursorVisualCol,
showCursor,
visualLineIndex: idx,
absoluteVisualIndex,
buffer,
scrollVisualRow,
})}
</Box>
);
})
)}
return (
<Box flexDirection="column">
<Text color={resolvedBorderColor} wrap="truncate-end">
{topBorderLine}
</Text>
<Box
borderStyle="single"
borderTop={false}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={resolvedBorderColor}
>
{resolvedPrefix}
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, idx) => {
const absoluteVisualIndex = scrollVisualRow + idx;
const isOnCursorLine = absoluteVisualIndex === cursorVisualRow;
return (
<Box key={idx} height={1}>
{renderLine({
lineText,
isOnCursorLine,
cursorCol: cursorVisualCol,
showCursor,
visualLineIndex: idx,
absoluteVisualIndex,
buffer,
scrollVisualRow,
})}
</Box>
);
})
)}
</Box>
</Box>
</Box>
);

View file

@ -44,6 +44,7 @@ import { MCPManagementDialog } from './mcp/MCPManagementDialog.js';
import { HooksManagementDialog } from './hooks/HooksManagementDialog.js';
import { SessionPicker } from './SessionPicker.js';
import { MemoryDialog } from './MemoryDialog.js';
import { t } from '../../i18n/index.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@ -379,6 +380,19 @@ export const DialogManager = ({
currentBranch={uiState.branchName}
onSelect={uiActions.handleResume}
onCancel={uiActions.closeResumeDialog}
initialSessions={uiState.resumeMatchedSessions}
/>
);
}
if (uiState.isDeleteDialogOpen) {
return (
<SessionPicker
sessionService={config.getSessionService()}
currentBranch={uiState.branchName}
onSelect={uiActions.handleDelete}
onCancel={uiActions.closeDeleteDialog}
title={t('Delete Session')}
/>
);
}

View file

@ -1248,6 +1248,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
prefix={prefixNode}
borderColor={borderColor}
topRightLabel={uiState.sessionName || undefined}
isActive={!isEmbeddedShellFocused}
renderLine={renderLineWithHighlighting}
/>

View file

@ -25,11 +25,22 @@ export interface SessionPickerProps {
onCancel: () => void;
currentBranch?: string;
/**
* Custom title for the picker header. Defaults to "Resume Session".
*/
title?: string;
/**
* Scroll mode. When true, keep selection centered (fullscreen-style).
* Defaults to true so dialog + standalone behave identically.
*/
centerSelection?: boolean;
/**
* Pre-filtered sessions to display instead of loading all sessions.
* When provided, skips initial load and disables pagination.
*/
initialSessions?: SessionData[];
}
const PREFIX_CHARS = {
@ -81,7 +92,7 @@ function SessionListItemView({
? prefixChars.scrollDown
: prefixChars.normal;
const promptText = session.prompt || '(empty prompt)';
const promptText = session.customTitle || session.prompt || '(empty prompt)';
const truncatedPrompt = truncateText(promptText, maxPromptWidth);
return (
@ -122,7 +133,9 @@ export function SessionPicker(props: SessionPickerProps) {
onSelect,
onCancel,
currentBranch,
title,
centerSelection = true,
initialSessions,
} = props;
const { columns: width, rows: height } = useTerminalSize();
@ -146,6 +159,7 @@ export function SessionPicker(props: SessionPickerProps) {
onCancel,
maxVisibleItems,
centerSelection,
initialSessions,
isActive: true,
});
@ -167,7 +181,7 @@ export function SessionPicker(props: SessionPickerProps) {
{/* Header row */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
{t('Resume Session')}
{title ?? t('Resume Session')}
</Text>
{picker.filterByBranch && currentBranch && (
<Text color={theme.text.secondary}>

View file

@ -6,7 +6,11 @@
import { useState } from 'react';
import { render, Box, useApp } from 'ink';
import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core';
import {
getGitBranch,
SessionService,
type SessionListItem,
} from '@qwen-code/qwen-code-core';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { SessionPicker } from './SessionPicker.js';
import { writeStdoutLine } from '../../utils/stdioHelpers.js';
@ -16,6 +20,7 @@ interface StandalonePickerScreenProps {
onSelect: (sessionId: string) => void;
onCancel: () => void;
currentBranch?: string;
initialSessions?: SessionListItem[];
}
function StandalonePickerScreen({
@ -23,6 +28,7 @@ function StandalonePickerScreen({
onSelect,
onCancel,
currentBranch,
initialSessions,
}: StandalonePickerScreenProps): React.JSX.Element {
const { exit } = useApp();
const [isExiting, setIsExiting] = useState(false);
@ -49,6 +55,7 @@ function StandalonePickerScreen({
}}
currentBranch={currentBranch}
centerSelection={true}
initialSessions={initialSessions}
/>
);
}
@ -67,6 +74,7 @@ function clearScreen(): void {
*/
export async function showResumeSessionPicker(
cwd: string = process.cwd(),
initialSessions?: SessionListItem[],
): Promise<string | undefined> {
const sessionService = new SessionService(cwd);
const hasSession = await sessionService.loadLastSession();
@ -104,6 +112,7 @@ export async function showResumeSessionPicker(
selectedId = undefined;
}}
currentBranch={getGitBranch(cwd)}
initialSessions={initialSessions}
/>
</KeypressProvider>,
{

View file

@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
"────────────────────────────────────────────────────────────────────────────────
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
"────────────────────────────────────────────────────────────────────────────────
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
"────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
"────────────────────────────────────────────────────────────────────────────────
! Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
"────────────────────────────────────────────────────────────────────────────────
* Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
"────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;

View file

@ -101,6 +101,10 @@ export interface UIActions {
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
// Delete session dialog
openDeleteDialog: () => void;
closeDeleteDialog: () => void;
handleDelete: (sessionId: string) => void;
// Feedback dialog
openFeedbackDialog: () => void;
closeFeedbackDialog: () => void;

View file

@ -25,6 +25,7 @@ import type {
IdeContext,
ApprovalMode,
IdeInfo,
SessionListItem,
} from '@qwen-code/qwen-code-core';
import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
@ -61,6 +62,8 @@ export interface UIState {
isPermissionsDialogOpen: boolean;
isApprovalModeDialogOpen: boolean;
isResumeDialogOpen: boolean;
resumeMatchedSessions: SessionListItem[] | undefined;
isDeleteDialogOpen: boolean;
slashCommands: readonly SlashCommand[];
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
commandContext: CommandContext;
@ -148,6 +151,9 @@ export interface UIState {
streamingResponseLengthRef: React.RefObject<number>;
// True = receiving content (↓), false = waiting for API response (↑)
isReceivingContent: boolean;
// Session custom name (set via /rename)
sessionName: string | null;
setSessionName: (name: string | null) => void;
// Prompt suggestion
promptSuggestion: string | null;
/** Dismiss prompt suggestion (clears state, aborts speculation) */

View file

@ -25,6 +25,7 @@ import {
SlashCommandStatus,
ToolConfirmationOutcome,
IdeClient,
type SessionListItem,
} from '@qwen-code/qwen-code-core';
import { useSessionStats } from '../contexts/SessionContext.js';
import type {
@ -72,6 +73,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'reset',
'new',
'resume',
'delete',
'btw',
]);
@ -86,7 +88,9 @@ interface SlashCommandProcessorActions {
openTrustDialog: () => void;
openPermissionsDialog: () => void;
openApprovalModeDialog: () => void;
openResumeDialog: () => void;
openResumeDialog: (matchedSessions?: SessionListItem[]) => void;
handleResume: (sessionId: string) => void;
openDeleteDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
@ -117,6 +121,7 @@ export const useSlashCommandProcessor = (
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
isConfigInitialized: boolean,
logger: Logger | null,
setSessionName?: (name: string | null) => void,
) => {
const { stats: sessionStats, startNewSession } = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
@ -271,6 +276,7 @@ export const useSlashCommandProcessor = (
clearItems();
clearScreen();
refreshStatic();
setSessionName?.(null);
},
loadHistory,
setDebugMessage: actions.setDebugMessage,
@ -284,6 +290,7 @@ export const useSlashCommandProcessor = (
toggleVimEnabled,
setGeminiMdFileCount,
reloadCommands,
setSessionName: setSessionName ?? (() => {}),
extensionsUpdateState,
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest:
@ -316,6 +323,7 @@ export const useSlashCommandProcessor = (
sessionShellAllowlist,
setGeminiMdFileCount,
reloadCommands,
setSessionName,
extensionsUpdateState,
isIdleRef,
],
@ -559,7 +567,14 @@ export const useSlashCommandProcessor = (
actions.openApprovalModeDialog();
return { type: 'handled' };
case 'resume':
actions.openResumeDialog();
if (result.sessionId) {
actions.handleResume(result.sessionId);
} else {
actions.openResumeDialog(result.matchedSessions);
}
return { type: 'handled' };
case 'delete':
actions.openDeleteDialog();
return { type: 'handled' };
case 'extensions_manage':
actions.openExtensionsManagerDialog();

View file

@ -0,0 +1,100 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import type { Config } from '@qwen-code/qwen-code-core';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { t } from '../../i18n/index.js';
export interface UseDeleteCommandOptions {
config: Config | null;
addItem: UseHistoryManagerReturn['addItem'];
}
export interface UseDeleteCommandResult {
isDeleteDialogOpen: boolean;
openDeleteDialog: () => void;
closeDeleteDialog: () => void;
handleDelete: (sessionId: string) => void;
}
export function useDeleteCommand(
options?: UseDeleteCommandOptions,
): UseDeleteCommandResult {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const openDeleteDialog = useCallback(() => {
setIsDeleteDialogOpen(true);
}, []);
const closeDeleteDialog = useCallback(() => {
setIsDeleteDialogOpen(false);
}, []);
const { config, addItem } = options ?? {};
const handleDelete = useCallback(
async (sessionId: string) => {
if (!config) {
return;
}
// Close dialog immediately.
closeDeleteDialog();
// Prevent deleting the current session.
if (sessionId === config.getSessionId()) {
addItem?.(
{
type: 'info',
text: t('Cannot delete the current active session.'),
},
Date.now(),
);
return;
}
try {
const sessionService = config.getSessionService();
const success = await sessionService.removeSession(sessionId);
if (success) {
addItem?.(
{
type: 'info',
text: t('Session deleted successfully.'),
},
Date.now(),
);
} else {
addItem?.(
{
type: 'error',
text: t('Failed to delete session. Session not found.'),
},
Date.now(),
);
}
} catch {
addItem?.(
{
type: 'error',
text: t('Failed to delete session.'),
},
Date.now(),
);
}
},
[closeDeleteDialog, config, addItem],
);
return {
isDeleteDialogOpen,
openDeleteDialog,
closeDeleteDialog,
handleDelete,
};
}

View file

@ -51,6 +51,9 @@ vi.mock('@qwen-code/qwen-code-core', () => {
})
);
}
getSessionTitle(_sessionId: string) {
return undefined;
}
}
return {

View file

@ -8,6 +8,7 @@ import { useState, useCallback } from 'react';
import {
SessionService,
type Config,
type SessionListItem,
SessionStartSource,
type PermissionMode,
} from '@qwen-code/qwen-code-core';
@ -18,12 +19,15 @@ export interface UseResumeCommandOptions {
config: Config | null;
historyManager: Pick<UseHistoryManagerReturn, 'clearItems' | 'loadHistory'>;
startNewSession: (sessionId: string) => void;
setSessionName?: (name: string | null) => void;
remount?: () => void;
}
export interface UseResumeCommandResult {
isResumeDialogOpen: boolean;
openResumeDialog: () => void;
/** Pre-filtered sessions for the picker (when multiple title matches). */
resumeMatchedSessions: SessionListItem[] | undefined;
openResumeDialog: (matchedSessions?: SessionListItem[]) => void;
closeResumeDialog: () => void;
/**
* Async the implementation awaits SessionService and SessionStart hooks.
@ -38,16 +42,25 @@ export function useResumeCommand(
options?: UseResumeCommandOptions,
): UseResumeCommandResult {
const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false);
const [resumeMatchedSessions, setResumeMatchedSessions] = useState<
SessionListItem[] | undefined
>();
const openResumeDialog = useCallback(() => {
setIsResumeDialogOpen(true);
}, []);
const openResumeDialog = useCallback(
(matchedSessions?: SessionListItem[]) => {
setResumeMatchedSessions(matchedSessions);
setIsResumeDialogOpen(true);
},
[],
);
const closeResumeDialog = useCallback(() => {
setIsResumeDialogOpen(false);
setResumeMatchedSessions(undefined);
}, []);
const { config, historyManager, startNewSession, remount } = options ?? {};
const { config, historyManager, startNewSession, setSessionName, remount } =
options ?? {};
const handleResume = useCallback(
async (sessionId: string) => {
@ -69,6 +82,10 @@ export function useResumeCommand(
// Start new session in UI context.
startNewSession(sessionId);
// Restore session name tag from custom title.
const customTitle = sessionService.getSessionTitle(sessionId);
setSessionName?.(customTitle ?? null);
// Reset UI history.
const uiHistoryItems = buildResumedHistoryItems(sessionData, config);
historyManager.clearItems();
@ -94,11 +111,19 @@ export function useResumeCommand(
// Refresh terminal UI.
remount?.();
},
[closeResumeDialog, config, historyManager, startNewSession, remount],
[
closeResumeDialog,
config,
historyManager,
startNewSession,
setSessionName,
remount,
],
);
return {
isResumeDialogOpen,
resumeMatchedSessions,
openResumeDialog,
closeResumeDialog,
handleResume,

View file

@ -37,6 +37,13 @@ export interface UseSessionPickerOptions {
* If false, uses follow mode (scrolls when selection reaches edge).
*/
centerSelection?: boolean;
/**
* Pre-filtered sessions to display instead of loading from sessionService.
* When provided, skips the initial listSessions() call and disables
* pagination (load-more). Used by /resume <title> when multiple sessions
* match the given title.
*/
initialSessions?: SessionListItem[];
/**
* Enable/disable input handling.
*/
@ -63,16 +70,18 @@ export function useSessionPicker({
onCancel,
maxVisibleItems,
centerSelection = false,
initialSessions,
isActive = true,
}: UseSessionPickerOptions): UseSessionPickerResult {
const hasInitialSessions = initialSessions !== undefined;
const [selectedIndex, setSelectedIndex] = useState(0);
const [sessionState, setSessionState] = useState<SessionState>({
sessions: [],
hasMore: true,
nextCursor: undefined,
});
const [sessionState, setSessionState] = useState<SessionState>(
hasInitialSessions
? { sessions: initialSessions, hasMore: false, nextCursor: undefined }
: { sessions: [], hasMore: true, nextCursor: undefined },
);
const [filterByBranch, setFilterByBranch] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(!hasInitialSessions);
// For follow mode (non-centered)
const [followScrollOffset, setFollowScrollOffset] = useState(0);
@ -112,9 +121,9 @@ export function useSessionPicker({
const showScrollDown =
scrollOffset + maxVisibleItems < filteredSessions.length;
// Initial load
// Initial load — skip when pre-filtered sessions are provided
useEffect(() => {
if (!sessionService) {
if (!sessionService || hasInitialSessions) {
return;
}
@ -134,7 +143,7 @@ export function useSessionPicker({
};
void loadInitialSessions();
}, [sessionService]);
}, [sessionService, hasInitialSessions]);
const loadMoreSessions = useCallback(async () => {
if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) {

View file

@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
toggleVimEnabled: async () => false,
setGeminiMdFileCount: (_count) => {},
reloadCommands: () => {},
setSessionName: () => {},
extensionsUpdateState: new Map(),
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
addConfirmUpdateExtensionRequest: (_request) => {},