mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
4d5015389d
commit
25423b1526
6 changed files with 418 additions and 47 deletions
|
|
@ -21,4 +21,8 @@ describe('memoryCommand', () => {
|
|||
dialog: 'memory',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not advertise unsupported subcommands', () => {
|
||||
expect(memoryCommand.argumentHint).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('› 2. Project memory');
|
||||
|
||||
await act(async () => {
|
||||
mockedUseKeypress.mock.calls.at(-1);
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue