mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 05:42:54 -07:00
feat(memory): add Auto Memory inbox flow with canonical-patch contract (#26338)
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import { SkillInboxDialog } from '../components/SkillInboxDialog.js';
|
||||
import { InboxDialog } from '../components/InboxDialog.js';
|
||||
|
||||
export const memoryCommand: SlashCommand = {
|
||||
name: 'memory',
|
||||
@@ -156,13 +156,16 @@ export const memoryCommand: SlashCommand = {
|
||||
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: React.createElement(SkillInboxDialog, {
|
||||
component: React.createElement(InboxDialog, {
|
||||
config,
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
onReloadSkills: async () => {
|
||||
await config.reloadSkills();
|
||||
context.ui.reloadCommands();
|
||||
},
|
||||
onReloadMemory: async () => {
|
||||
await refreshMemory(config);
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
+193
-14
@@ -6,19 +6,27 @@
|
||||
|
||||
import { act } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config, InboxSkill, InboxPatch } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
InboxSkill,
|
||||
InboxPatch,
|
||||
InboxMemoryPatch,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
dismissInboxSkill,
|
||||
dismissInboxMemoryPatch,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
listInboxMemoryPatches,
|
||||
moveInboxSkill,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
applyInboxMemoryPatch,
|
||||
isProjectSkillPatchTarget,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { SkillInboxDialog } from './SkillInboxDialog.js';
|
||||
import { InboxDialog } from './InboxDialog.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
@@ -27,11 +35,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...original,
|
||||
dismissInboxSkill: vi.fn(),
|
||||
dismissInboxMemoryPatch: vi.fn(),
|
||||
listInboxSkills: vi.fn(),
|
||||
listInboxPatches: vi.fn(),
|
||||
listInboxMemoryPatches: vi.fn(),
|
||||
moveInboxSkill: vi.fn(),
|
||||
applyInboxPatch: vi.fn(),
|
||||
dismissInboxPatch: vi.fn(),
|
||||
applyInboxMemoryPatch: vi.fn(),
|
||||
isProjectSkillPatchTarget: vi.fn(),
|
||||
getErrorMessage: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
@@ -41,10 +52,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
|
||||
const mockListInboxSkills = vi.mocked(listInboxSkills);
|
||||
const mockListInboxPatches = vi.mocked(listInboxPatches);
|
||||
const mockListInboxMemoryPatches = vi.mocked(listInboxMemoryPatches);
|
||||
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
|
||||
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
|
||||
const mockApplyInboxPatch = vi.mocked(applyInboxPatch);
|
||||
const mockDismissInboxPatch = vi.mocked(dismissInboxPatch);
|
||||
const mockApplyInboxMemoryPatch = vi.mocked(applyInboxMemoryPatch);
|
||||
const mockDismissInboxMemoryPatch = vi.mocked(dismissInboxMemoryPatch);
|
||||
const mockIsProjectSkillPatchTarget = vi.mocked(isProjectSkillPatchTarget);
|
||||
|
||||
const inboxSkill: InboxSkill = {
|
||||
@@ -76,6 +90,27 @@ const inboxPatch: InboxPatch = {
|
||||
extractedAt: '2025-01-20T14:00:00Z',
|
||||
};
|
||||
|
||||
const inboxMemoryPatch: InboxMemoryPatch = {
|
||||
kind: 'private',
|
||||
relativePath: 'private',
|
||||
name: 'Private memory',
|
||||
sourceFiles: ['update-memory.patch'],
|
||||
entries: [
|
||||
{
|
||||
targetPath: '/home/user/.gemini/tmp/project/memory/MEMORY.md',
|
||||
isNewFile: false,
|
||||
diffContent: [
|
||||
'--- /home/user/.gemini/tmp/project/memory/MEMORY.md',
|
||||
'+++ /home/user/.gemini/tmp/project/memory/MEMORY.md',
|
||||
'@@ -1,1 +1,1 @@',
|
||||
'-old',
|
||||
'+use focused tests',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
extractedAt: '2025-01-21T10:00:00Z',
|
||||
};
|
||||
|
||||
const workspacePatch: InboxPatch = {
|
||||
fileName: 'workspace-update.patch',
|
||||
name: 'workspace-update',
|
||||
@@ -137,11 +172,12 @@ const windowsGlobalPatch: InboxPatch = {
|
||||
],
|
||||
};
|
||||
|
||||
describe('SkillInboxDialog', () => {
|
||||
describe('InboxDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListInboxSkills.mockResolvedValue([inboxSkill]);
|
||||
mockListInboxPatches.mockResolvedValue([]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
mockMoveInboxSkill.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
|
||||
@@ -158,6 +194,14 @@ describe('SkillInboxDialog', () => {
|
||||
success: true,
|
||||
message: 'Dismissed "update-docs.patch" from inbox.',
|
||||
});
|
||||
mockApplyInboxMemoryPatch.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Applied memory patch to 1 file.',
|
||||
});
|
||||
mockDismissInboxMemoryPatch.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Dismissed 1 private memory patch from inbox.',
|
||||
});
|
||||
mockIsProjectSkillPatchTarget.mockImplementation(
|
||||
async (targetPath: string, config: Config) => {
|
||||
const projectSkillsDir = config.storage
|
||||
@@ -176,6 +220,64 @@ describe('SkillInboxDialog', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('reviews and applies memory patches', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([inboxMemoryPatch]);
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const onReloadMemory = vi.fn().mockResolvedValue(undefined);
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn()}
|
||||
onReloadMemory={onReloadMemory}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Private memory');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Review');
|
||||
expect(frame).toMatch(/source patch/);
|
||||
});
|
||||
|
||||
// Memory patches default to Dismiss as the highlighted action so a stray
|
||||
// Enter cannot apply durable changes. Arrow-down to reach Apply, then
|
||||
// press Enter to confirm.
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // arrow down → Apply
|
||||
await waitUntilReady();
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Aggregate apply: relativePath equals the kind name.
|
||||
expect(mockApplyInboxMemoryPatch).toHaveBeenCalledWith(
|
||||
config,
|
||||
'private',
|
||||
'private',
|
||||
);
|
||||
expect(onReloadMemory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('disables the project destination when the workspace is untrusted', async () => {
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
@@ -183,7 +285,7 @@ describe('SkillInboxDialog', () => {
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -228,7 +330,7 @@ describe('SkillInboxDialog', () => {
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -276,7 +378,7 @@ describe('SkillInboxDialog', () => {
|
||||
.mockRejectedValue(new Error('reload hook failed'));
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -316,6 +418,83 @@ describe('SkillInboxDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('preserves the highlighted row after Esc-ing back from a sub-phase', async () => {
|
||||
// Reproduces the bug where pressing Esc from the apply dialog re-rendered
|
||||
// the list with focus jumped back to row 0 instead of staying on the row
|
||||
// the user was on.
|
||||
const secondSkill: InboxSkill = {
|
||||
...inboxSkill,
|
||||
dirName: 'second-skill',
|
||||
name: 'Second Skill',
|
||||
};
|
||||
mockListInboxSkills.mockResolvedValue([inboxSkill, secondSkill]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Inbox Skill');
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
// Arrow down to the second row.
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[B');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Enter the second row's preview.
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Review new skill');
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
// Esc back to list.
|
||||
await act(async () => {
|
||||
stdin.write('\x1b');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Inbox Skill');
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
// Re-enter (no arrow keys this time). The active row must still be the
|
||||
// SECOND skill, not the first — which is what the bug reproduced before.
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Review new skill');
|
||||
// The preview header echoes the highlighted skill's name.
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('patch support', () => {
|
||||
it('shows patches alongside skills with section headers', async () => {
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
@@ -328,7 +507,7 @@ describe('SkillInboxDialog', () => {
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -360,7 +539,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -401,7 +580,7 @@ describe('SkillInboxDialog', () => {
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -449,7 +628,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -494,7 +673,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -538,7 +717,7 @@ describe('SkillInboxDialog', () => {
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -593,7 +772,7 @@ describe('SkillInboxDialog', () => {
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -628,7 +807,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
+291
-36
@@ -20,22 +20,32 @@ import {
|
||||
type Config,
|
||||
type InboxSkill,
|
||||
type InboxPatch,
|
||||
type InboxMemoryPatch,
|
||||
type InboxSkillDestination,
|
||||
getErrorMessage,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
listInboxMemoryPatches,
|
||||
moveInboxSkill,
|
||||
dismissInboxSkill,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
applyInboxMemoryPatch,
|
||||
dismissInboxMemoryPatch,
|
||||
isProjectSkillPatchTarget,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
type Phase = 'list' | 'skill-preview' | 'skill-action' | 'patch-preview';
|
||||
type Phase =
|
||||
| 'list'
|
||||
| 'skill-preview'
|
||||
| 'skill-action'
|
||||
| 'patch-preview'
|
||||
| 'memory-preview';
|
||||
|
||||
type InboxItem =
|
||||
| { type: 'skill'; skill: InboxSkill }
|
||||
| { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean }
|
||||
| { type: 'memory-patch'; memoryPatch: InboxMemoryPatch }
|
||||
| { type: 'header'; label: string };
|
||||
|
||||
interface DestinationChoice {
|
||||
@@ -50,6 +60,12 @@ interface PatchAction {
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface MemoryPatchAction {
|
||||
action: 'apply' | 'dismiss';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SKILL_DESTINATION_CHOICES: DestinationChoice[] = [
|
||||
{
|
||||
destination: 'global',
|
||||
@@ -95,6 +111,24 @@ const PATCH_ACTION_CHOICES: PatchAction[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Dismiss-first: memory patches modify durable on-disk state outside the
|
||||
// project (private MEMORY.md and sibling files, plus ~/.gemini/GEMINI.md),
|
||||
// so a stray Enter on a freshly-opened memory-patch preview must NOT apply.
|
||||
// The lower-stakes skill-patch list (PATCH_ACTION_CHOICES) keeps Apply as
|
||||
// the default.
|
||||
const MEMORY_PATCH_ACTION_CHOICES: MemoryPatchAction[] = [
|
||||
{
|
||||
action: 'dismiss',
|
||||
label: 'Dismiss',
|
||||
description: 'Delete from inbox without applying',
|
||||
},
|
||||
{
|
||||
action: 'apply',
|
||||
label: 'Apply',
|
||||
description: 'Apply patch and delete from inbox',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePathForUi(filePath: string): string {
|
||||
return path.posix.normalize(filePath.replaceAll('\\', '/'));
|
||||
}
|
||||
@@ -105,6 +139,14 @@ function getPathBasename(filePath: string): string {
|
||||
return basename === '.' ? filePath : basename;
|
||||
}
|
||||
|
||||
function formatMemoryPatchSummary(patch: InboxMemoryPatch): string {
|
||||
const hunkCount = patch.entries.length;
|
||||
const sourceCount = patch.sourceFiles.length;
|
||||
const hunkLabel = hunkCount === 1 ? 'hunk' : 'hunks';
|
||||
const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
|
||||
return `${hunkCount} ${hunkLabel} from ${sourceCount} source ${sourceLabel}`;
|
||||
}
|
||||
|
||||
async function patchTargetsProjectSkills(
|
||||
patch: InboxPatch,
|
||||
config: Config,
|
||||
@@ -173,16 +215,18 @@ function formatDate(isoString: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
interface SkillInboxDialogProps {
|
||||
interface InboxDialogProps {
|
||||
config: Config;
|
||||
onClose: () => void;
|
||||
onReloadSkills: () => Promise<void>;
|
||||
onReloadMemory?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
export const InboxDialog: React.FC<InboxDialogProps> = ({
|
||||
config,
|
||||
onClose,
|
||||
onReloadSkills,
|
||||
onReloadMemory,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { stdout } = useStdout();
|
||||
@@ -196,15 +240,20 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
text: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
// Tracks the most recent highlighted/selected position in the list so we
|
||||
// can restore focus when the user backs out of a sub-phase (e.g. ESC from
|
||||
// the apply dialog) instead of jumping back to the top of the list.
|
||||
const [lastListIndex, setLastListIndex] = useState(0);
|
||||
|
||||
// Load inbox skills and patches on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [skills, patches] = await Promise.all([
|
||||
const [skills, patches, memoryPatches] = await Promise.all([
|
||||
listInboxSkills(config),
|
||||
listInboxPatches(config),
|
||||
listInboxMemoryPatches(config),
|
||||
]);
|
||||
const patchItems = await Promise.all(
|
||||
patches.map(async (patch): Promise<InboxItem> => {
|
||||
@@ -229,6 +278,12 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
const combined: InboxItem[] = [
|
||||
...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
|
||||
...patchItems,
|
||||
...memoryPatches.map(
|
||||
(memoryPatch): InboxItem => ({
|
||||
type: 'memory-patch',
|
||||
memoryPatch,
|
||||
}),
|
||||
),
|
||||
];
|
||||
setItems(combined);
|
||||
setLoading(false);
|
||||
@@ -251,42 +306,38 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
? `skill:${item.skill.dirName}`
|
||||
: item.type === 'patch'
|
||||
? `patch:${item.patch.fileName}`
|
||||
: `header:${item.label}`,
|
||||
: item.type === 'memory-patch'
|
||||
? `memory:${item.memoryPatch.kind}:${item.memoryPatch.relativePath}`
|
||||
: `header:${item.label}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const listItems: Array<SelectionListItem<InboxItem>> = useMemo(() => {
|
||||
const skills = items.filter((i) => i.type === 'skill');
|
||||
const patches = items.filter((i) => i.type === 'patch');
|
||||
const memoryPatches = items.filter((i) => i.type === 'memory-patch');
|
||||
const result: Array<SelectionListItem<InboxItem>> = [];
|
||||
|
||||
// Only show section headers when both types are present
|
||||
const showHeaders = skills.length > 0 && patches.length > 0;
|
||||
const groups: Array<{ label: string; items: InboxItem[] }> = [
|
||||
{ label: 'New Skills', items: skills },
|
||||
{ label: 'Skill Updates', items: patches },
|
||||
{ label: 'Memory Updates', items: memoryPatches },
|
||||
].filter((group) => group.items.length > 0);
|
||||
const showHeaders = groups.length > 1;
|
||||
|
||||
if (showHeaders) {
|
||||
const header: InboxItem = { type: 'header', label: 'New Skills' };
|
||||
result.push({
|
||||
key: 'header:new-skills',
|
||||
value: header,
|
||||
disabled: true,
|
||||
hideNumber: true,
|
||||
});
|
||||
}
|
||||
for (const item of skills) {
|
||||
result.push({ key: getItemKey(item), value: item });
|
||||
}
|
||||
|
||||
if (showHeaders) {
|
||||
const header: InboxItem = { type: 'header', label: 'Skill Updates' };
|
||||
result.push({
|
||||
key: 'header:skill-updates',
|
||||
value: header,
|
||||
disabled: true,
|
||||
hideNumber: true,
|
||||
});
|
||||
}
|
||||
for (const item of patches) {
|
||||
result.push({ key: getItemKey(item), value: item });
|
||||
for (const group of groups) {
|
||||
if (showHeaders) {
|
||||
const header: InboxItem = { type: 'header', label: group.label };
|
||||
result.push({
|
||||
key: `header:${group.label}`,
|
||||
value: header,
|
||||
disabled: true,
|
||||
hideNumber: true,
|
||||
});
|
||||
}
|
||||
for (const item of group.items) {
|
||||
result.push({ key: getItemKey(item), value: item });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -360,11 +411,36 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback((item: InboxItem) => {
|
||||
setSelectedItem(item);
|
||||
setFeedback(null);
|
||||
setPhase(item.type === 'skill' ? 'skill-preview' : 'patch-preview');
|
||||
}, []);
|
||||
const memoryPatchActionItems: Array<SelectionListItem<MemoryPatchAction>> =
|
||||
useMemo(
|
||||
() =>
|
||||
MEMORY_PATCH_ACTION_CHOICES.map((choice) => ({
|
||||
key: choice.action,
|
||||
value: choice,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(item: InboxItem) => {
|
||||
setSelectedItem(item);
|
||||
setFeedback(null);
|
||||
// Remember which list row we navigated away from so ESC restores focus
|
||||
// instead of jumping the cursor back to the top of the list.
|
||||
const idx = listItems.findIndex((i) => i.value === item);
|
||||
if (idx >= 0) {
|
||||
setLastListIndex(idx);
|
||||
}
|
||||
setPhase(
|
||||
item.type === 'skill'
|
||||
? 'skill-preview'
|
||||
: item.type === 'patch'
|
||||
? 'patch-preview'
|
||||
: 'memory-preview',
|
||||
);
|
||||
},
|
||||
[listItems],
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(item: InboxItem) => {
|
||||
@@ -521,6 +597,65 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
[config, selectedItem, onReloadSkills, removeItem],
|
||||
);
|
||||
|
||||
const handleSelectMemoryPatchAction = useCallback(
|
||||
(choice: MemoryPatchAction) => {
|
||||
if (!selectedItem || selectedItem.type !== 'memory-patch') return;
|
||||
const memoryPatch = selectedItem.memoryPatch;
|
||||
|
||||
setFeedback(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let result: { success: boolean; message: string };
|
||||
if (choice.action === 'apply') {
|
||||
result = await applyInboxMemoryPatch(
|
||||
config,
|
||||
memoryPatch.kind,
|
||||
memoryPatch.relativePath,
|
||||
);
|
||||
} else {
|
||||
result = await dismissInboxMemoryPatch(
|
||||
config,
|
||||
memoryPatch.kind,
|
||||
memoryPatch.relativePath,
|
||||
);
|
||||
}
|
||||
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
|
||||
if (choice.action === 'apply' && onReloadMemory) {
|
||||
try {
|
||||
await onReloadMemory();
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `${result.message} Failed to reload memory: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const operation =
|
||||
choice.action === 'apply'
|
||||
? 'apply memory patch'
|
||||
: 'dismiss memory patch';
|
||||
setFeedback({
|
||||
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedItem, onReloadMemory, removeItem],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
@@ -597,6 +732,10 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<InboxItem>
|
||||
items={listItems}
|
||||
initialIndex={Math.max(
|
||||
0,
|
||||
Math.min(lastListIndex, listItems.length - 1),
|
||||
)}
|
||||
onSelect={handleSelectItem}
|
||||
isFocused={true}
|
||||
showNumbers={false}
|
||||
@@ -633,6 +772,27 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (item.value.type === 'memory-patch') {
|
||||
const memoryPatch = item.value.memoryPatch;
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{memoryPatch.name}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
{formatMemoryPatchSummary(memoryPatch)}
|
||||
</Text>
|
||||
{memoryPatch.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(memoryPatch.extractedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const patch = item.value.patch;
|
||||
const fileNames = patch.entries.map((e) =>
|
||||
getPathBasename(e.targetPath),
|
||||
@@ -871,6 +1031,101 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'memory-preview' && selectedItem?.type === 'memory-patch' && (
|
||||
<>
|
||||
<Text bold>{selectedItem.memoryPatch.name}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Review {formatMemoryPatchSummary(selectedItem.memoryPatch)} before
|
||||
applying. Apply runs each source patch atomically; Dismiss removes
|
||||
them all.
|
||||
</Text>
|
||||
|
||||
{(() => {
|
||||
// Group hunks by target file. Multiple source patches may touch
|
||||
// the same file (e.g. several patches all updating MEMORY.md);
|
||||
// showing the file path once with all its hunks beneath is much
|
||||
// less visually noisy than repeating the path for every hunk.
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ isNewFile: boolean; diffs: string[] }
|
||||
>();
|
||||
for (const entry of selectedItem.memoryPatch.entries) {
|
||||
const existing = groups.get(entry.targetPath);
|
||||
if (existing) {
|
||||
existing.diffs.push(entry.diffContent);
|
||||
// If any hunk for this target was a creation, treat the
|
||||
// group as a creation overall.
|
||||
if (entry.isNewFile) existing.isNewFile = true;
|
||||
} else {
|
||||
groups.set(entry.targetPath, {
|
||||
isNewFile: entry.isNewFile,
|
||||
diffs: [entry.diffContent],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(
|
||||
([targetPath, { isNewFile, diffs }]) => (
|
||||
<Box key={targetPath} flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
{targetPath}
|
||||
{isNewFile ? ' (new file)' : ''}
|
||||
{diffs.length > 1
|
||||
? ` · ${diffs.length} changes from different patches`
|
||||
: ''}
|
||||
</Text>
|
||||
{diffs.map((diff, hunkIndex) => (
|
||||
<DiffRenderer
|
||||
key={`${targetPath}:${hunkIndex}`}
|
||||
diffContent={diff}
|
||||
filename={targetPath}
|
||||
terminalWidth={contentWidth}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
),
|
||||
);
|
||||
})()}
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<MemoryPatchAction>
|
||||
items={memoryPatchActionItems}
|
||||
onSelect={handleSelectMemoryPatchAction}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.value.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to confirm"
|
||||
cancelAction="Esc to go back"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user