fix(cli): align memory dialog with managed memory (#6434)

* fix(cli): align memory dialog with managed memory

* test(cli): stabilize memory dialog path rendering

* fix(cli): make memory target switch exhaustive

* fix(cli): tighten memory dialog target handling

* fix(cli): handle headless managed memory dialog

* test(cli): cover desktop managed memory dialog branches

* fix(cli): open memory folders asynchronously

* test(cli): assert managed memory folder setup

* fix(cli): simplify memory folder opener

* fix(cli): clarify memory folder opener behavior
This commit is contained in:
callmeYe 2026-07-09 09:04:56 +08:00 committed by GitHub
parent 4d5015389d
commit 25423b1526
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 418 additions and 47 deletions

View file

@ -21,4 +21,8 @@ describe('memoryCommand', () => {
dialog: 'memory',
});
});
it('does not advertise unsupported subcommands', () => {
expect(memoryCommand.argumentHint).toBeUndefined();
});
});

View file

@ -13,7 +13,6 @@ export const memoryCommand: SlashCommand = {
get description() {
return t('Open the memory manager.');
},
argumentHint: 'show|add|refresh',
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
action: async () => ({

View file

@ -7,6 +7,16 @@
import { act } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render } from 'ink-testing-library';
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
AUTO_MEMORY_INDEX_FILENAME,
clearAutoMemoryRootCache,
getAutoMemoryRoot,
} from '@qwen-code/qwen-code-core';
import { MemoryDialog } from './MemoryDialog.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
@ -29,16 +39,88 @@ vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const fsMock = {
access: vi.fn(),
mkdir: vi.fn(),
readFile: vi.fn(() => Promise.reject(new Error('not found'))),
writeFile: vi.fn(),
};
return {
...actual,
...fsMock,
default: { ...actual, ...fsMock },
};
});
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
const { EventEmitter } = await import('node:events');
const spawnMock = vi.fn(() => {
const child = new EventEmitter() as EventEmitter & {
unref: ReturnType<typeof vi.fn>;
};
child.unref = vi.fn();
queueMicrotask(() => child.emit('spawn'));
return child;
});
return {
...actual,
spawn: spawnMock,
default: { ...actual, spawn: spawnMock },
};
});
const mockedUseConfig = vi.mocked(useConfig);
const mockedUseSettings = vi.mocked(useSettings);
const mockedUseLaunchEditor = vi.mocked(useLaunchEditor);
const mockedUseKeypress = vi.mocked(useKeypress);
const mockedSpawn = vi.mocked(spawn);
const mockedFs = vi.mocked(fs);
const originalPlatform = process.platform;
type MockSpawnChild = EventEmitter & { unref: ReturnType<typeof vi.fn> };
const folderOpenCommandByPlatform: Partial<Record<NodeJS.Platform, string>> = {
darwin: 'open',
win32: 'explorer',
};
function createMockSpawnChild(
event: 'spawn' | 'error' = 'spawn',
error?: Error,
): MockSpawnChild {
const child = new EventEmitter() as MockSpawnChild;
child.unref = vi.fn();
queueMicrotask(() => {
child.emit(event, error);
});
return child;
}
function stubPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true,
});
}
function expectedFolderOpenCommand(platform = process.platform): string {
return folderOpenCommandByPlatform[platform] ?? 'xdg-open';
}
describe('MemoryDialog', () => {
let setAutoSkillEnabled: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('DISPLAY', ':99');
vi.stubEnv('QWEN_HOME', path.join(os.homedir(), '.qwen'));
vi.stubEnv(
'QWEN_CODE_MEMORY_BASE_DIR',
path.join(os.homedir(), '.qwen-memory-test'),
);
clearAutoMemoryRootCache();
setAutoSkillEnabled = vi.fn();
mockedUseConfig.mockReturnValue({
@ -51,6 +133,7 @@ describe('MemoryDialog', () => {
getManagedAutoMemoryEnabled: vi.fn(() => false),
getManagedAutoDreamEnabled: vi.fn(() => false),
getAutoSkillEnabled: vi.fn(() => false),
isManagedMemoryAvailable: vi.fn(() => true),
setAutoSkillEnabled,
} as never);
@ -69,21 +152,185 @@ describe('MemoryDialog', () => {
});
afterEach(() => {
stubPlatform(originalPlatform);
clearAutoMemoryRootCache();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('moves selection with down arrow key events', () => {
it('renders managed memory folders without advertising QWEN.md', () => {
const { lastFrame } = render(<MemoryDialog onClose={vi.fn()} />);
expect(lastFrame()).toContain(' 1. User memory');
expect(lastFrame()).toContain('2. Project memory');
expect(lastFrame()).toContain('/memories');
expect(lastFrame()).toContain('/memory');
expect(lastFrame()).not.toContain('QWEN.md');
expect(lastFrame()).not.toContain('Open auto-memory folder');
});
it('opens managed memory folders instead of launching an editor', async () => {
const onClose = vi.fn();
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
render(<MemoryDialog onClose={onClose} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(mockedSpawn).toHaveBeenCalledWith(
expectedFolderOpenCommand(),
[path.join(os.homedir(), '.qwen-memory-test', 'memories')],
expect.objectContaining({ detached: true, stdio: 'ignore' }),
);
expect(mockedFs.mkdir).toHaveBeenCalledWith(
path.join(os.homedir(), '.qwen-memory-test', 'memories'),
{ recursive: true },
);
expect(
(mockedSpawn.mock.results[0]?.value as MockSpawnChild).unref,
).toHaveBeenCalled();
expect(launchEditor).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it.each(['darwin', 'win32'] as const)(
'opens managed memory folders on %s without display variables',
async (platform) => {
stubPlatform(platform);
vi.stubEnv('DISPLAY', '');
vi.stubEnv('WAYLAND_DISPLAY', '');
vi.stubEnv('MIR_SOCKET', '');
const onClose = vi.fn();
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
render(<MemoryDialog onClose={onClose} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(mockedSpawn).toHaveBeenCalledWith(
expectedFolderOpenCommand(platform),
[path.join(os.homedir(), '.qwen-memory-test', 'memories')],
expect.objectContaining({ detached: true, stdio: 'ignore' }),
);
expect(launchEditor).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
},
);
it('opens the project managed memory folder after moving selection down', async () => {
const onClose = vi.fn();
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
const { lastFrame } = render(<MemoryDialog onClose={onClose} />);
expect(lastFrame()).toContain(' 1. User memory');
act(() => {
keypressHandler({ name: 'down' } as never);
mockedUseKeypress.mock.calls.at(-1)![0]({ name: 'down' } as never);
});
expect(lastFrame()).toContain(' 2. Project memory');
await act(async () => {
mockedUseKeypress.mock.calls.at(-1)![0]({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(mockedSpawn).toHaveBeenCalledWith(
expectedFolderOpenCommand(),
[getAutoMemoryRoot('/tmp/project')],
expect.objectContaining({ detached: true, stdio: 'ignore' }),
);
expect(launchEditor).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('opens the second visible item with its numeric shortcut', async () => {
const onClose = vi.fn();
render(<MemoryDialog onClose={onClose} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: '2', sequence: '2' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(mockedSpawn).toHaveBeenCalledWith(
expectedFolderOpenCommand(),
[getAutoMemoryRoot('/tmp/project')],
expect.objectContaining({ detached: true, stdio: 'ignore' }),
);
expect(onClose).toHaveBeenCalled();
});
it('opens the managed memory index in an editor on headless Linux', async () => {
stubPlatform('linux');
vi.stubEnv('DISPLAY', '');
vi.stubEnv('WAYLAND_DISPLAY', '');
vi.stubEnv('MIR_SOCKET', '');
const onClose = vi.fn();
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
render(<MemoryDialog onClose={onClose} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedFs.access).toHaveBeenCalledWith(
path.join(
os.homedir(),
'.qwen-memory-test',
'memories',
AUTO_MEMORY_INDEX_FILENAME,
),
);
expect(launchEditor).toHaveBeenCalledWith(
path.join(
os.homedir(),
'.qwen-memory-test',
'memories',
AUTO_MEMORY_INDEX_FILENAME,
),
);
expect(onClose).toHaveBeenCalled();
});
it('shows an error when opening a managed memory folder fails', async () => {
const onClose = vi.fn();
mockedSpawn.mockImplementationOnce(
() => createMockSpawnChild('error', new Error('ENOENT')) as never,
);
const { lastFrame } = render(<MemoryDialog onClose={onClose} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(lastFrame()).toContain('ENOENT');
expect(onClose).not.toHaveBeenCalled();
});
it('moves selection with Ctrl+N/P readline aliases', () => {
@ -272,6 +519,67 @@ describe('MemoryDialog', () => {
expect(lastFrame()).toContain(' Confirm auto-skills before saving: off');
});
it('keeps QWEN.md editor entries when managed memory is unavailable', async () => {
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
mockedUseConfig.mockReturnValue({
getWorkingDir: vi.fn(() => '/tmp/project'),
getProjectRoot: vi.fn(() => '/tmp/project'),
getBareMode: vi.fn(() => true),
isSafeMode: vi.fn(() => false),
getManagedAutoMemoryEnabled: vi.fn(() => false),
getManagedAutoDreamEnabled: vi.fn(() => false),
getAutoSkillEnabled: vi.fn(() => false),
isManagedMemoryAvailable: vi.fn(() => false),
} as never);
const { lastFrame } = render(<MemoryDialog onClose={vi.fn()} />);
expect(lastFrame()).toContain(' 1. User memory');
expect(lastFrame()).toContain('Saved in ~/.qwen/QWEN.md');
expect(lastFrame()).toContain('2. Project memory');
expect(lastFrame()).toContain('Saved in QWEN.md');
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(launchEditor).toHaveBeenCalledWith(
expect.stringMatching(/\/\.qwen\/QWEN\.md$/),
);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('opens the project QWEN.md editor entry when managed memory is unavailable', async () => {
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
mockedUseConfig.mockReturnValue({
getWorkingDir: vi.fn(() => '/tmp/project'),
getProjectRoot: vi.fn(() => '/tmp/project'),
getBareMode: vi.fn(() => true),
isSafeMode: vi.fn(() => false),
getManagedAutoMemoryEnabled: vi.fn(() => false),
getManagedAutoDreamEnabled: vi.fn(() => false),
getAutoSkillEnabled: vi.fn(() => false),
isManagedMemoryAvailable: vi.fn(() => false),
} as never);
render(<MemoryDialog onClose={vi.fn()} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: '2', sequence: '2' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(launchEditor).toHaveBeenCalledWith('/tmp/project/QWEN.md');
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('reflects the persisted value when the dialog is reopened (remounted)', () => {
// Emulate LoadedSettings: setValue writes through to the merged view,
// exactly like the real saveSettings + recomputeMerged path.

View file

@ -9,12 +9,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import {
getAllGeminiMdFilenames,
Storage,
getAutoMemoryRoot,
getAutoMemoryProjectStateDir,
getUserAutoMemoryRoot,
AUTO_MEMORY_INDEX_FILENAME,
} from '@qwen-code/qwen-code-core';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
@ -26,7 +28,9 @@ import { theme } from '../semantic-colors.js';
import { formatRelativeTime } from '../utils/formatters.js';
import { t } from '../../i18n/index.js';
type MemoryDialogTarget = 'project' | 'global' | 'managed';
type MemoryDialogTarget = 'project' | 'global';
type MemoryDialogAction = 'file' | 'folder';
const DISPLAY_ENV_VARS = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET'] as const;
interface MemoryDialogProps {
onClose: () => void;
@ -35,6 +39,7 @@ interface MemoryDialogProps {
interface DialogItem {
label: string;
value: MemoryDialogTarget;
action: MemoryDialogAction;
description?: string;
}
@ -55,7 +60,7 @@ async function resolvePreferredMemoryFile(
return path.join(dir, fallbackFilename);
}
function openFolderPath(folderPath: string): void {
async function openFolderPath(folderPath: string): Promise<void> {
let command = 'xdg-open';
switch (process.platform) {
@ -70,21 +75,27 @@ function openFolderPath(folderPath: string): void {
break;
}
const needsShell =
process.platform === 'win32' &&
(command.endsWith('.cmd') || command.endsWith('.bat'));
const result = spawnSync(command, [folderPath], {
stdio: 'inherit',
shell: needsShell,
const child = spawn(command, [folderPath], {
detached: true,
stdio: 'ignore',
});
if (result.error) {
throw result.error;
}
if (typeof result.status === 'number' && result.status !== 0) {
throw new Error(`Folder opener exited with status ${result.status}`);
child.unref();
await new Promise<void>((resolve, reject) => {
child.once('error', reject);
// Exit codes are intentionally not observed: the folder opener is
// fire-and-forget, and waiting for exit can block until the file manager
// closes.
child.once('spawn', () => resolve());
});
}
function shouldOpenFolderPath(): boolean {
if (process.platform === 'darwin' || process.platform === 'win32') {
return true;
}
return DISPLAY_ENV_VARS.some((key) => Boolean(process.env[key]));
}
async function ensureFileExists(filePath: string): Promise<void> {
@ -155,17 +166,40 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
() => getAutoMemoryRoot(config.getProjectRoot()),
[config],
);
const managedUserMemoryPath = useMemo(() => getUserAutoMemoryRoot(), []);
const memoryStatePath = useMemo(
() => getAutoMemoryProjectStateDir(config.getProjectRoot()),
[config],
);
const items = useMemo<DialogItem[]>(
() => [
const items = useMemo<DialogItem[]>(() => {
if (config.isManagedMemoryAvailable()) {
return [
{
label: t('User memory'),
value: 'global',
action: 'folder',
description: t('Saved in {{path}}', {
path: formatDisplayPath(managedUserMemoryPath),
}),
},
{
label: t('Project memory'),
value: 'project',
action: 'folder',
description: t('Saved in {{path}}', {
path: formatDisplayPath(managedMemoryPath),
}),
},
];
}
return [
{
label: t('User memory'),
value: 'global',
action: 'file',
description: t('Saved in {{path}}', {
path: formatDisplayPath(globalMemoryPath),
}),
@ -173,19 +207,21 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
{
label: t('Project memory'),
value: 'project',
action: 'file',
description: t('Saved in {{path}}', {
path:
path.relative(config.getWorkingDir(), projectMemoryPath) ||
path.basename(projectMemoryPath),
}),
},
{
label: t('Open auto-memory folder'),
value: 'managed',
},
],
[config, globalMemoryPath, projectMemoryPath],
);
];
}, [
config,
globalMemoryPath,
managedMemoryPath,
managedUserMemoryPath,
projectMemoryPath,
]);
// Load lastDreamAt from meta.json
useEffect(() => {
@ -219,8 +255,21 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
}, [lastDreamAt]);
const resolveTargetPath = useCallback(
async (target: MemoryDialogTarget): Promise<string> => {
switch (target) {
async (item: DialogItem): Promise<string> => {
if (item.action === 'folder') {
switch (item.value) {
case 'global':
return managedUserMemoryPath;
case 'project':
return managedMemoryPath;
default: {
const _exhaustive: never = item.value;
return _exhaustive;
}
}
}
switch (item.value) {
case 'project':
return resolvePreferredMemoryFile(
config.getWorkingDir(),
@ -231,23 +280,29 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
Storage.getGlobalQwenDir(),
getAllGeminiMdFilenames()[0] ?? 'QWEN.md',
);
case 'managed':
return managedMemoryPath;
default:
return managedMemoryPath;
default: {
const _exhaustive: never = item.value;
return _exhaustive;
}
}
},
[config, managedMemoryPath],
[config, managedMemoryPath, managedUserMemoryPath],
);
const handleSelect = useCallback(
async (target: MemoryDialogTarget) => {
async (item: DialogItem) => {
try {
setError(null);
const targetPath = await resolveTargetPath(target);
if (target === 'managed') {
const targetPath = await resolveTargetPath(item);
if (item.action === 'folder') {
await fs.mkdir(targetPath, { recursive: true });
openFolderPath(targetPath);
if (shouldOpenFolderPath()) {
await openFolderPath(targetPath);
} else {
const indexPath = path.join(targetPath, AUTO_MEMORY_INDEX_FILENAME);
await ensureFileExists(indexPath);
await launchEditor(indexPath);
}
} else {
await ensureFileExists(targetPath);
await launchEditor(targetPath);
@ -395,15 +450,18 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
}
if (key.name === 'return') {
void handleSelect(items[highlightedIndex]?.value ?? 'project');
const selectedItem = items[highlightedIndex] ?? items[0];
if (selectedItem) {
void handleSelect(selectedItem);
}
return;
}
if (key.sequence && /^[1-3]$/.test(key.sequence)) {
if (key.sequence && /^[1-9]$/.test(key.sequence)) {
const nextIndex = Number(key.sequence) - 1;
if (items[nextIndex]) {
setHighlightedIndex(nextIndex);
void handleSelect(items[nextIndex].value);
void handleSelect(items[nextIndex]);
}
}
},
@ -483,7 +541,7 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
const isSelected =
focusedSection === 'list' && index === highlightedIndex;
return (
<Box key={item.value} flexDirection="row">
<Box key={`${item.value}-${item.action}`} flexDirection="row">
<Text color={isSelected ? theme.status.success : undefined}>
{isSelected ? ' ' : ' '}
{index + 1}. {item.label}

View file

@ -416,8 +416,7 @@ export class ReadFileTool extends BaseDeclarativeTool<
type: 'integer',
},
pages: {
description:
`Optional: For PDF files, the page range to extract as text (e.g., '1-5', '3', '10-20'). Pages are 1-indexed. Max ${PDF_MAX_PAGES_PER_READ} pages per request. Open-ended ranges like '3-' are not supported. Use this for large PDFs or when the model does not support native PDF input.`,
description: `Optional: For PDF files, the page range to extract as text (e.g., '1-5', '3', '10-20'). Pages are 1-indexed. Max ${PDF_MAX_PAGES_PER_READ} pages per request. Open-ended ranges like '3-' are not supported. Use this for large PDFs or when the model does not support native PDF input.`,
type: 'string',
},
},

View file

@ -96,9 +96,12 @@ describe('StreamingStatus loading phrases', () => {
it('hides the phrase but keeps the dynamic status when showPhrase is false', () => {
pinPhraseSelection();
// A resolver that would otherwise supply a phrase must still be suppressed.
const container = render({ loadingPhrases: () => ['should not appear'] }, {
showPhrase: false,
});
const container = render(
{ loadingPhrases: () => ['should not appear'] },
{
showPhrase: false,
},
);
// The witty phrase (the "废话文学") is gone: no label span.
expect(labelText(container)).toBeUndefined();
expect(container.textContent).not.toContain('should not appear');