mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 07:10:55 +00:00
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:
parent
d1c8dff4d2
commit
0c423deedf
47 changed files with 3275 additions and 204 deletions
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
32
packages/cli/src/ui/commands/deleteCommand.test.ts
Normal file
32
packages/cli/src/ui/commands/deleteCommand.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/cli/src/ui/commands/deleteCommand.ts
Normal file
21
packages/cli/src/ui/commands/deleteCommand.ts
Normal 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',
|
||||
}),
|
||||
};
|
||||
162
packages/cli/src/ui/commands/renameCommand.test.ts
Normal file
162
packages/cli/src/ui/commands/renameCommand.test.ts
Normal 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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
185
packages/cli/src/ui/commands/renameCommand.ts
Normal file
185
packages/cli/src/ui/commands/renameCommand.ts
Normal 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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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".',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1248,6 +1248,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
}
|
||||
prefix={prefixNode}
|
||||
borderColor={borderColor}
|
||||
topRightLabel={uiState.sessionName || undefined}
|
||||
isActive={!isEmbeddedShellFocused}
|
||||
renderLine={renderLineWithHighlighting}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
100
packages/cli/src/ui/hooks/useDeleteCommand.ts
Normal file
100
packages/cli/src/ui/hooks/useDeleteCommand.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -51,6 +51,9 @@ vi.mock('@qwen-code/qwen-code-core', () => {
|
|||
})
|
||||
);
|
||||
}
|
||||
getSessionTitle(_sessionId: string) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
|||
toggleVimEnabled: async () => false,
|
||||
setGeminiMdFileCount: (_count) => {},
|
||||
reloadCommands: () => {},
|
||||
setSessionName: () => {},
|
||||
extensionsUpdateState: new Map(),
|
||||
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue