diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 5836cf8569..97986f69ad 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -21,4 +21,8 @@ describe('memoryCommand', () => { dialog: 'memory', }); }); + + it('does not advertise unsupported subcommands', () => { + expect(memoryCommand.argumentHint).toBeUndefined(); + }); }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 8370884ac6..65c27a6018 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -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 () => ({ diff --git a/packages/cli/src/ui/components/MemoryDialog.test.tsx b/packages/cli/src/ui/components/MemoryDialog.test.tsx index 8c6d93f84a..27b00074cd 100644 --- a/packages/cli/src/ui/components/MemoryDialog.test.tsx +++ b/packages/cli/src/ui/components/MemoryDialog.test.tsx @@ -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(); + 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(); + const { EventEmitter } = await import('node:events'); + const spawnMock = vi.fn(() => { + const child = new EventEmitter() as EventEmitter & { + unref: ReturnType; + }; + 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 }; +const folderOpenCommandByPlatform: Partial> = { + 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; 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(); 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(); 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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. diff --git a/packages/cli/src/ui/components/MemoryDialog.tsx b/packages/cli/src/ui/components/MemoryDialog.tsx index c28bfb0068..650e7ef974 100644 --- a/packages/cli/src/ui/components/MemoryDialog.tsx +++ b/packages/cli/src/ui/components/MemoryDialog.tsx @@ -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 { 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((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 { @@ -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( - () => [ + const items = useMemo(() => { + 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 => { - switch (target) { + async (item: DialogItem): Promise => { + 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 ( - + {isSelected ? '› ' : ' '} {index + 1}. {item.label} diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index c4ccc77b69..947a98b9ec 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -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', }, }, diff --git a/packages/web-shell/client/components/StreamingStatus.test.tsx b/packages/web-shell/client/components/StreamingStatus.test.tsx index 69082a9567..40cdb91a7c 100644 --- a/packages/web-shell/client/components/StreamingStatus.test.tsx +++ b/packages/web-shell/client/components/StreamingStatus.test.tsx @@ -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');