mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-12 22:31:12 -07:00
feat(memory): add /memory inbox command for reviewing extracted skills (#24544)
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
import {
|
||||
addMemory,
|
||||
listInboxSkills,
|
||||
listMemoryFiles,
|
||||
refreshMemory,
|
||||
showMemory,
|
||||
@@ -30,6 +31,7 @@ export class MemoryCommand implements Command {
|
||||
new RefreshMemoryCommand(),
|
||||
new ListMemoryCommand(),
|
||||
new AddMemoryCommand(),
|
||||
new InboxMemoryCommand(),
|
||||
];
|
||||
readonly requiresWorkspace = true;
|
||||
|
||||
@@ -122,3 +124,39 @@ 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.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
if (!context.agentContext.config.isMemoryManagerEnabled()) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
|
||||
};
|
||||
}
|
||||
|
||||
const skills = await listInboxSkills(context.agentContext.config);
|
||||
|
||||
if (skills.length === 0) {
|
||||
return { name: this.name, data: 'No extracted skills in inbox.' };
|
||||
}
|
||||
|
||||
const lines = skills.map((s) => {
|
||||
const date = s.extractedAt
|
||||
? ` (extracted: ${new Date(s.extractedAt).toLocaleDateString()})`
|
||||
: '';
|
||||
return `- **${s.name}**: ${s.description}${date}`;
|
||||
});
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Skill inbox (${skills.length}):\n${lines.join('\n')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,4 +457,78 @@ describe('memoryCommand', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory inbox', () => {
|
||||
let inboxCommand: SlashCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
inboxCommand = memoryCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'inbox',
|
||||
)!;
|
||||
expect(inboxCommand).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return custom_dialog when config is available and flag is enabled', () => {
|
||||
if (!inboxCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const mockConfig = {
|
||||
reloadSkills: vi.fn(),
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: { config: mockConfig },
|
||||
},
|
||||
ui: {
|
||||
removeComponent: vi.fn(),
|
||||
reloadCommands: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = inboxCommand.action(context, '');
|
||||
|
||||
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||
expect(result).toHaveProperty('component');
|
||||
});
|
||||
|
||||
it('should return info message when memory manager is disabled', () => {
|
||||
if (!inboxCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const mockConfig = {
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: { config: mockConfig },
|
||||
},
|
||||
});
|
||||
|
||||
const result = inboxCommand.action(context, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when config is not loaded', () => {
|
||||
if (!inboxCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = inboxCommand.action(context, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
addMemory,
|
||||
listMemoryFiles,
|
||||
@@ -13,9 +14,11 @@ import {
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
CommandKind,
|
||||
type OpenCustomDialogActionReturn,
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import { SkillInboxDialog } from '../components/SkillInboxDialog.js';
|
||||
|
||||
export const memoryCommand: SlashCommand = {
|
||||
name: 'memory',
|
||||
@@ -124,5 +127,45 @@ export const memoryCommand: SlashCommand = {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inbox',
|
||||
description:
|
||||
'Review skills extracted from past sessions and move them to global or project skills',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (
|
||||
context,
|
||||
): OpenCustomDialogActionReturn | SlashCommandActionReturn | void => {
|
||||
const config = context.services.agentContext?.config;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.isMemoryManagerEnabled()) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: React.createElement(SkillInboxDialog, {
|
||||
config,
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
onReloadSkills: async () => {
|
||||
await config.reloadSkills();
|
||||
context.ui.reloadCommands();
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
187
packages/cli/src/ui/components/SkillInboxDialog.test.tsx
Normal file
187
packages/cli/src/ui/components/SkillInboxDialog.test.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config, InboxSkill } from '@google/gemini-cli-core';
|
||||
import {
|
||||
dismissInboxSkill,
|
||||
listInboxSkills,
|
||||
moveInboxSkill,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { SkillInboxDialog } from './SkillInboxDialog.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
|
||||
return {
|
||||
...original,
|
||||
dismissInboxSkill: vi.fn(),
|
||||
listInboxSkills: vi.fn(),
|
||||
moveInboxSkill: vi.fn(),
|
||||
getErrorMessage: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const mockListInboxSkills = vi.mocked(listInboxSkills);
|
||||
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
|
||||
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
|
||||
|
||||
const inboxSkill: InboxSkill = {
|
||||
dirName: 'inbox-skill',
|
||||
name: 'Inbox Skill',
|
||||
description: 'A test skill',
|
||||
extractedAt: '2025-01-15T10:00:00Z',
|
||||
};
|
||||
|
||||
describe('SkillInboxDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListInboxSkills.mockResolvedValue([inboxSkill]);
|
||||
mockMoveInboxSkill.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
|
||||
});
|
||||
mockDismissInboxSkill.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Dismissed "inbox-skill" from inbox.',
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the project destination when the workspace is untrusted', async () => {
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Inbox Skill');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Project');
|
||||
expect(frame).toContain('unavailable until this workspace is trusted');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[B');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissInboxSkill).toHaveBeenCalledWith(config, 'inbox-skill');
|
||||
});
|
||||
expect(mockMoveInboxSkill).not.toHaveBeenCalled();
|
||||
expect(onReloadSkills).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows inline feedback when moving a skill throws', async () => {
|
||||
mockMoveInboxSkill.mockRejectedValue(new Error('permission denied'));
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Inbox Skill');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Move "Inbox Skill"');
|
||||
expect(frame).toContain('Failed to install skill: permission denied');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows inline feedback when reloading skills fails after a move', async () => {
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const onReloadSkills = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('reload hook failed'));
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Inbox Skill');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'Moved "inbox-skill" to ~/.gemini/skills. Failed to reload skills: reload hook failed',
|
||||
);
|
||||
});
|
||||
expect(onReloadSkills).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
378
packages/cli/src/ui/components/SkillInboxDialog.tsx
Normal file
378
packages/cli/src/ui/components/SkillInboxDialog.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../hooks/useSelectionList.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
import {
|
||||
type Config,
|
||||
type InboxSkill,
|
||||
type InboxSkillDestination,
|
||||
getErrorMessage,
|
||||
listInboxSkills,
|
||||
moveInboxSkill,
|
||||
dismissInboxSkill,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
type Phase = 'list' | 'action';
|
||||
|
||||
interface DestinationChoice {
|
||||
destination: InboxSkillDestination | 'dismiss';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const DESTINATION_CHOICES: DestinationChoice[] = [
|
||||
{
|
||||
destination: 'global',
|
||||
label: 'Global',
|
||||
description: '~/.gemini/skills — available in all projects',
|
||||
},
|
||||
{
|
||||
destination: 'project',
|
||||
label: 'Project',
|
||||
description: '.gemini/skills — available in this workspace',
|
||||
},
|
||||
{
|
||||
destination: 'dismiss',
|
||||
label: 'Dismiss',
|
||||
description: 'Delete from inbox',
|
||||
},
|
||||
];
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
interface SkillInboxDialogProps {
|
||||
config: Config;
|
||||
onClose: () => void;
|
||||
onReloadSkills: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
config,
|
||||
onClose,
|
||||
onReloadSkills,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const isTrustedFolder = config.isTrustedFolder();
|
||||
const [phase, setPhase] = useState<Phase>('list');
|
||||
const [skills, setSkills] = useState<InboxSkill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedSkill, setSelectedSkill] = useState<InboxSkill | null>(null);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
text: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Load inbox skills on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await listInboxSkills(config);
|
||||
if (!cancelled) {
|
||||
setSkills(result);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSkills([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
const skillItems: Array<SelectionListItem<InboxSkill>> = useMemo(
|
||||
() =>
|
||||
skills.map((skill) => ({
|
||||
key: skill.dirName,
|
||||
value: skill,
|
||||
})),
|
||||
[skills],
|
||||
);
|
||||
|
||||
const destinationItems: Array<SelectionListItem<DestinationChoice>> = useMemo(
|
||||
() =>
|
||||
DESTINATION_CHOICES.map((choice) => {
|
||||
if (choice.destination === 'project' && !isTrustedFolder) {
|
||||
return {
|
||||
key: choice.destination,
|
||||
value: {
|
||||
...choice,
|
||||
description:
|
||||
'.gemini/skills — unavailable until this workspace is trusted',
|
||||
},
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: choice.destination,
|
||||
value: choice,
|
||||
};
|
||||
}),
|
||||
[isTrustedFolder],
|
||||
);
|
||||
|
||||
const handleSelectSkill = useCallback((skill: InboxSkill) => {
|
||||
setSelectedSkill(skill);
|
||||
setFeedback(null);
|
||||
setPhase('action');
|
||||
}, []);
|
||||
|
||||
const handleSelectDestination = useCallback(
|
||||
(choice: DestinationChoice) => {
|
||||
if (!selectedSkill) return;
|
||||
|
||||
if (choice.destination === 'project' && !config.isTrustedFolder()) {
|
||||
setFeedback({
|
||||
text: 'Project skills are unavailable until this workspace is trusted.',
|
||||
isError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let result: { success: boolean; message: string };
|
||||
if (choice.destination === 'dismiss') {
|
||||
result = await dismissInboxSkill(config, selectedSkill.dirName);
|
||||
} else {
|
||||
result = await moveInboxSkill(
|
||||
config,
|
||||
selectedSkill.dirName,
|
||||
choice.destination,
|
||||
);
|
||||
}
|
||||
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the skill from the local list.
|
||||
setSkills((prev) =>
|
||||
prev.filter((skill) => skill.dirName !== selectedSkill.dirName),
|
||||
);
|
||||
setSelectedSkill(null);
|
||||
setPhase('list');
|
||||
|
||||
if (choice.destination === 'dismiss') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onReloadSkills();
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const operation =
|
||||
choice.destination === 'dismiss'
|
||||
? 'dismiss skill'
|
||||
: 'install skill';
|
||||
setFeedback({
|
||||
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedSkill, onReloadSkills],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
if (phase === 'action') {
|
||||
setPhase('list');
|
||||
setSelectedSkill(null);
|
||||
setFeedback(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true },
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text>Loading inbox…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (skills.length === 0 && !feedback) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold>Skill Inbox</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
No extracted skills in inbox.
|
||||
</Text>
|
||||
</Box>
|
||||
<DialogFooter primaryAction="Esc to close" cancelAction="" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width="100%"
|
||||
>
|
||||
{phase === 'list' ? (
|
||||
<>
|
||||
<Text bold>
|
||||
Skill Inbox ({skills.length} skill{skills.length !== 1 ? 's' : ''})
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Skills extracted from past sessions. Select one to move or dismiss.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<InboxSkill>
|
||||
items={skillItems}
|
||||
onSelect={handleSelectSkill}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
showScrollArrows={true}
|
||||
maxItemsToShow={8}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.name}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{item.value.description}
|
||||
</Text>
|
||||
{item.value.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(item.value.extractedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</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 select"
|
||||
cancelAction="Esc to close"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text bold>Move "{selectedSkill?.name}"</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Choose where to install this skill.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<DestinationChoice>
|
||||
items={destinationItems}
|
||||
onSelect={handleSelectDestination}
|
||||
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