feat(memory): add Auto Memory inbox flow with canonical-patch contract (#26338)

This commit is contained in:
Sandy Tao
2026-05-04 12:07:13 -07:00
committed by GitHub
parent 60a6a47d56
commit a7beb890d0
26 changed files with 4279 additions and 115 deletions
+21 -4
View File
@@ -6,6 +6,7 @@
import {
addMemory,
listInboxMemoryPatches,
listInboxSkills,
listInboxPatches,
listMemoryFiles,
@@ -129,7 +130,7 @@ export class AddMemoryCommand implements Command {
export class InboxMemoryCommand implements Command {
readonly name = 'memory inbox';
readonly description =
'Lists skills extracted from past sessions that are pending review.';
'Lists memory items extracted from past sessions that are pending review.';
async execute(
context: CommandContext,
@@ -142,12 +143,17 @@ export class InboxMemoryCommand implements Command {
};
}
const [skills, patches] = await Promise.all([
const [skills, patches, memoryPatches] = await Promise.all([
listInboxSkills(context.agentContext.config),
listInboxPatches(context.agentContext.config),
listInboxMemoryPatches(context.agentContext.config),
]);
if (skills.length === 0 && patches.length === 0) {
if (
skills.length === 0 &&
patches.length === 0 &&
memoryPatches.length === 0
) {
return { name: this.name, data: 'No items in inbox.' };
}
@@ -165,8 +171,19 @@ export class InboxMemoryCommand implements Command {
: '';
lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
}
for (const memoryPatch of memoryPatches) {
const targets = memoryPatch.entries.map((e) => e.targetPath).join(', ');
const date = memoryPatch.extractedAt
? ` (latest extract: ${new Date(memoryPatch.extractedAt).toLocaleDateString()})`
: '';
const sourceCount = memoryPatch.sourceFiles.length;
const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
lines.push(
`- **${memoryPatch.name}** (${sourceCount} source ${sourceLabel}, ${memoryPatch.entries.length} hunks): targets ${targets}${date}`,
);
}
const total = skills.length + patches.length;
const total = skills.length + patches.length + memoryPatches.length;
return {
name: this.name,
data: `Memory inbox (${total}):\n${lines.join('\n')}`,
+1 -1
View File
@@ -2410,7 +2410,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: false,
description:
'Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox.',
'Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review in /memory inbox; nothing is applied until you approve it.',
showInDialog: true,
},
generalistProfile: {
@@ -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);
},
}),
};
},
@@ -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)}
@@ -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>
);
};