mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-29 04:37:12 -07:00
1447 lines
45 KiB
TypeScript
1447 lines
45 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import * as path from 'node:path';
|
|
import type React from 'react';
|
|
import { Fragment, useState, useMemo, useCallback, useEffect } from 'react';
|
|
import { Box, Text } from 'ink';
|
|
import { theme } from '../semantic-colors.js';
|
|
import { useUIState } from '../contexts/UIStateContext.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 {
|
|
DiffRenderer,
|
|
parseDiffWithLineNumbers,
|
|
renderDiffLines,
|
|
type DiffLine,
|
|
} from './messages/DiffRenderer.js';
|
|
import { ScrollableList } from './shared/ScrollableList.js';
|
|
import { ShowMoreLines } from './ShowMoreLines.js';
|
|
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
|
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
|
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'
|
|
| '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 {
|
|
destination: InboxSkillDestination;
|
|
label: string;
|
|
description: string;
|
|
}
|
|
|
|
interface PatchAction {
|
|
action: 'apply' | 'dismiss';
|
|
label: string;
|
|
description: string;
|
|
}
|
|
|
|
interface MemoryPatchAction {
|
|
action: 'apply' | 'dismiss';
|
|
label: string;
|
|
description: string;
|
|
}
|
|
|
|
const SKILL_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',
|
|
},
|
|
];
|
|
|
|
interface SkillPreviewAction {
|
|
action: 'move' | 'dismiss';
|
|
label: string;
|
|
description: string;
|
|
}
|
|
|
|
const SKILL_PREVIEW_CHOICES: SkillPreviewAction[] = [
|
|
{
|
|
action: 'move',
|
|
label: 'Move',
|
|
description: 'Choose where to install this skill',
|
|
},
|
|
{
|
|
action: 'dismiss',
|
|
label: 'Dismiss',
|
|
description: 'Delete from inbox',
|
|
},
|
|
];
|
|
|
|
const PATCH_ACTION_CHOICES: PatchAction[] = [
|
|
{
|
|
action: 'apply',
|
|
label: 'Apply',
|
|
description: 'Apply patch and delete from inbox',
|
|
},
|
|
{
|
|
action: 'dismiss',
|
|
label: 'Dismiss',
|
|
description: 'Delete from inbox without applying',
|
|
},
|
|
];
|
|
|
|
// 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('\\', '/'));
|
|
}
|
|
|
|
function getPathBasename(filePath: string): string {
|
|
const normalizedPath = normalizePathForUi(filePath);
|
|
const basename = path.posix.basename(normalizedPath);
|
|
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,
|
|
): Promise<boolean> {
|
|
const entryTargetsProjectSkills = await Promise.all(
|
|
patch.entries.map((entry) =>
|
|
isProjectSkillPatchTarget(entry.targetPath, config),
|
|
),
|
|
);
|
|
return entryTargetsProjectSkills.some(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Derives a bracketed origin tag from a skill file path,
|
|
* matching the existing [Built-in] convention in SkillsList.
|
|
*/
|
|
function getSkillOriginTag(filePath: string): string {
|
|
const normalizedPath = normalizePathForUi(filePath);
|
|
|
|
if (normalizedPath.includes('/bundle/')) {
|
|
return 'Built-in';
|
|
}
|
|
if (normalizedPath.includes('/extensions/')) {
|
|
return 'Extension';
|
|
}
|
|
if (normalizedPath.includes('/.gemini/skills/')) {
|
|
const homeDirs = [process.env['HOME'], process.env['USERPROFILE']]
|
|
.filter((homeDir): homeDir is string => Boolean(homeDir))
|
|
.map(normalizePathForUi);
|
|
if (
|
|
homeDirs.some((homeDir) =>
|
|
normalizedPath.startsWith(`${homeDir}/.gemini/skills/`),
|
|
)
|
|
) {
|
|
return 'Global';
|
|
}
|
|
return 'Workspace';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Creates a unified diff string representing a new file.
|
|
*/
|
|
function newFileDiff(filename: string, content: string): string {
|
|
const lines = content.split('\n');
|
|
const hunkLines = lines.map((l) => `+${l}`).join('\n');
|
|
return [
|
|
`--- /dev/null`,
|
|
`+++ ${filename}`,
|
|
`@@ -0,0 +1,${lines.length} @@`,
|
|
hunkLines,
|
|
].join('\n');
|
|
}
|
|
|
|
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 DiffSection {
|
|
/** Stable identifier for the section (e.g. patch entry path + index). */
|
|
key: string;
|
|
/** Header rendered above the diff body, e.g. file path or "SKILL.md". */
|
|
header: string;
|
|
/** Raw unified-diff string. Parsed via parseDiffWithLineNumbers. */
|
|
diffContent: string;
|
|
}
|
|
|
|
interface DiffViewportItem {
|
|
key: string;
|
|
/** Pre-rendered React node for this row. */
|
|
element: React.ReactElement;
|
|
}
|
|
|
|
/**
|
|
* A fixed-height, scrollable diff viewer used by the skill, patch, and
|
|
* memory-patch preview phases. It flattens one or more DiffSections into
|
|
* individual line items so ScrollableList can virtualize and so
|
|
* PgUp/PgDn/Shift+arrows move the viewport over arbitrarily long diffs
|
|
* without overflowing the alternate buffer.
|
|
*
|
|
* The visual styling matches DiffRenderer's renderDiffLines path; we share
|
|
* that helper instead of nesting DiffRenderer (whose own MaxSizedBox
|
|
* wrapping would interfere with virtualization).
|
|
*/
|
|
const ScrollableDiffViewport: React.FC<{
|
|
sections: DiffSection[];
|
|
width: number;
|
|
height: number;
|
|
hasFocus: boolean;
|
|
}> = ({ sections, width, height, hasFocus }) => {
|
|
const items = useMemo<DiffViewportItem[]>(() => {
|
|
const result: DiffViewportItem[] = [];
|
|
sections.forEach((section, sectionIndex) => {
|
|
// Header (with a blank spacer row above for separation between
|
|
// sections — skipped above the first section).
|
|
if (sectionIndex > 0) {
|
|
result.push({
|
|
key: `${section.key}:spacer`,
|
|
element: <Text> </Text>,
|
|
});
|
|
}
|
|
result.push({
|
|
key: `${section.key}:header`,
|
|
element: (
|
|
<Text color={theme.text.secondary} bold>
|
|
{section.header}
|
|
</Text>
|
|
),
|
|
});
|
|
|
|
const parsed: DiffLine[] = parseDiffWithLineNumbers(section.diffContent);
|
|
const rendered = renderDiffLines({
|
|
parsedLines: parsed,
|
|
filename: section.header,
|
|
terminalWidth: width,
|
|
});
|
|
rendered.forEach((node, index) => {
|
|
result.push({
|
|
key: `${section.key}:line:${index}`,
|
|
// renderDiffLines emits ReactNodes with their own keys; wrap each
|
|
// in a Fragment so ScrollableList sees a single ReactElement per
|
|
// row regardless of node shape.
|
|
element: <Fragment>{node}</Fragment>,
|
|
});
|
|
});
|
|
});
|
|
return result;
|
|
}, [sections, width]);
|
|
|
|
const renderItem = useCallback(
|
|
({ item }: { item: DiffViewportItem }) => item.element,
|
|
[],
|
|
);
|
|
const keyExtractor = useCallback((item: DiffViewportItem) => item.key, []);
|
|
// Most diff rows are exactly one line tall; long lines wrap so this is a
|
|
// lower bound. ScrollableList re-measures via ResizeObserver, so the
|
|
// estimate only matters for initial sizing.
|
|
const estimatedItemHeight = useCallback(() => 1, []);
|
|
|
|
return (
|
|
<Box height={height} width={width} flexShrink={0} flexDirection="column">
|
|
<ScrollableList<DiffViewportItem>
|
|
data={items}
|
|
renderItem={renderItem}
|
|
keyExtractor={keyExtractor}
|
|
estimatedItemHeight={estimatedItemHeight}
|
|
hasFocus={hasFocus}
|
|
initialScrollIndex={0}
|
|
scrollbar={true}
|
|
/>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
interface InboxDialogProps {
|
|
config: Config;
|
|
onClose: () => void;
|
|
onReloadSkills: () => Promise<void>;
|
|
onReloadMemory?: () => Promise<void>;
|
|
}
|
|
|
|
export const InboxDialog: React.FC<InboxDialogProps> = ({
|
|
config,
|
|
onClose,
|
|
onReloadSkills,
|
|
onReloadMemory,
|
|
}) => {
|
|
const keyMatchers = useKeyMatchers();
|
|
const { terminalWidth, terminalHeight, constrainHeight } = useUIState();
|
|
const isAlternateBuffer = useAlternateBuffer();
|
|
const isTrustedFolder = config.isTrustedFolder();
|
|
const [phase, setPhase] = useState<Phase>('list');
|
|
const [items, setItems] = useState<InboxItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedItem, setSelectedItem] = useState<InboxItem | null>(null);
|
|
const [feedback, setFeedback] = useState<{
|
|
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, memoryPatches] = await Promise.all([
|
|
listInboxSkills(config),
|
|
listInboxPatches(config),
|
|
listInboxMemoryPatches(config),
|
|
]);
|
|
const patchItems = await Promise.all(
|
|
patches.map(async (patch): Promise<InboxItem> => {
|
|
let targetsProjectSkills = false;
|
|
try {
|
|
targetsProjectSkills = await patchTargetsProjectSkills(
|
|
patch,
|
|
config,
|
|
);
|
|
} catch {
|
|
targetsProjectSkills = false;
|
|
}
|
|
|
|
return {
|
|
type: 'patch',
|
|
patch,
|
|
targetsProjectSkills,
|
|
};
|
|
}),
|
|
);
|
|
if (!cancelled) {
|
|
const combined: InboxItem[] = [
|
|
...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
|
|
...patchItems,
|
|
...memoryPatches.map(
|
|
(memoryPatch): InboxItem => ({
|
|
type: 'memory-patch',
|
|
memoryPatch,
|
|
}),
|
|
),
|
|
];
|
|
setItems(combined);
|
|
setLoading(false);
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setItems([]);
|
|
setLoading(false);
|
|
}
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [config]);
|
|
|
|
const getItemKey = useCallback(
|
|
(item: InboxItem): string =>
|
|
item.type === 'skill'
|
|
? `skill:${item.skill.dirName}`
|
|
: item.type === 'patch'
|
|
? `patch:${item.patch.fileName}`
|
|
: 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>> = [];
|
|
|
|
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;
|
|
|
|
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;
|
|
}, [items, getItemKey]);
|
|
|
|
const destinationItems: Array<SelectionListItem<DestinationChoice>> = useMemo(
|
|
() =>
|
|
SKILL_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 selectedPatchTargetsProjectSkills = useMemo(() => {
|
|
if (!selectedItem || selectedItem.type !== 'patch') {
|
|
return false;
|
|
}
|
|
|
|
return selectedItem.targetsProjectSkills;
|
|
}, [selectedItem]);
|
|
|
|
const patchActionItems: Array<SelectionListItem<PatchAction>> = useMemo(
|
|
() =>
|
|
PATCH_ACTION_CHOICES.map((choice) => {
|
|
if (
|
|
choice.action === 'apply' &&
|
|
selectedPatchTargetsProjectSkills &&
|
|
!isTrustedFolder
|
|
) {
|
|
return {
|
|
key: choice.action,
|
|
value: {
|
|
...choice,
|
|
description:
|
|
'.gemini/skills — unavailable until this workspace is trusted',
|
|
},
|
|
disabled: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
key: choice.action,
|
|
value: choice,
|
|
};
|
|
}),
|
|
[isTrustedFolder, selectedPatchTargetsProjectSkills],
|
|
);
|
|
|
|
const skillPreviewItems: Array<SelectionListItem<SkillPreviewAction>> =
|
|
useMemo(
|
|
() =>
|
|
SKILL_PREVIEW_CHOICES.map((choice) => ({
|
|
key: choice.action,
|
|
value: choice,
|
|
})),
|
|
[],
|
|
);
|
|
|
|
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) => {
|
|
setItems((prev) =>
|
|
prev.filter((i) => getItemKey(i) !== getItemKey(item)),
|
|
);
|
|
},
|
|
[getItemKey],
|
|
);
|
|
|
|
const handleSkillPreviewAction = useCallback(
|
|
(choice: SkillPreviewAction) => {
|
|
if (!selectedItem || selectedItem.type !== 'skill') return;
|
|
|
|
if (choice.action === 'move') {
|
|
setFeedback(null);
|
|
setPhase('skill-action');
|
|
return;
|
|
}
|
|
|
|
// Dismiss
|
|
setFeedback(null);
|
|
const skill = selectedItem.skill;
|
|
void (async () => {
|
|
try {
|
|
const result = await dismissInboxSkill(config, skill.dirName);
|
|
setFeedback({ text: result.message, isError: !result.success });
|
|
if (result.success) {
|
|
removeItem(selectedItem);
|
|
setSelectedItem(null);
|
|
setPhase('list');
|
|
}
|
|
} catch (error) {
|
|
setFeedback({
|
|
text: `Failed to dismiss skill: ${getErrorMessage(error)}`,
|
|
isError: true,
|
|
});
|
|
}
|
|
})();
|
|
},
|
|
[config, selectedItem, removeItem],
|
|
);
|
|
|
|
const handleSelectDestination = useCallback(
|
|
(choice: DestinationChoice) => {
|
|
if (!selectedItem || selectedItem.type !== 'skill') return;
|
|
const skill = selectedItem.skill;
|
|
|
|
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 {
|
|
const result = await moveInboxSkill(
|
|
config,
|
|
skill.dirName,
|
|
choice.destination,
|
|
);
|
|
|
|
setFeedback({ text: result.message, isError: !result.success });
|
|
|
|
if (!result.success) {
|
|
return;
|
|
}
|
|
|
|
removeItem(selectedItem);
|
|
setSelectedItem(null);
|
|
setPhase('list');
|
|
|
|
try {
|
|
await onReloadSkills();
|
|
} catch (error) {
|
|
setFeedback({
|
|
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
|
|
isError: true,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
setFeedback({
|
|
text: `Failed to install skill: ${getErrorMessage(error)}`,
|
|
isError: true,
|
|
});
|
|
}
|
|
})();
|
|
},
|
|
[config, selectedItem, onReloadSkills, removeItem],
|
|
);
|
|
|
|
const handleSelectPatchAction = useCallback(
|
|
(choice: PatchAction) => {
|
|
if (!selectedItem || selectedItem.type !== 'patch') return;
|
|
const patch = selectedItem.patch;
|
|
|
|
if (
|
|
choice.action === 'apply' &&
|
|
!config.isTrustedFolder() &&
|
|
selectedItem.targetsProjectSkills
|
|
) {
|
|
setFeedback({
|
|
text: 'Project skill patches are unavailable until this workspace is trusted.',
|
|
isError: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
setFeedback(null);
|
|
|
|
void (async () => {
|
|
try {
|
|
let result: { success: boolean; message: string };
|
|
if (choice.action === 'apply') {
|
|
result = await applyInboxPatch(config, patch.fileName);
|
|
} else {
|
|
result = await dismissInboxPatch(config, patch.fileName);
|
|
}
|
|
|
|
setFeedback({ text: result.message, isError: !result.success });
|
|
|
|
if (!result.success) {
|
|
return;
|
|
}
|
|
|
|
removeItem(selectedItem);
|
|
setSelectedItem(null);
|
|
setPhase('list');
|
|
|
|
if (choice.action === 'apply') {
|
|
try {
|
|
await onReloadSkills();
|
|
} catch (error) {
|
|
setFeedback({
|
|
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
|
|
isError: true,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const operation =
|
|
choice.action === 'apply' ? 'apply patch' : 'dismiss patch';
|
|
setFeedback({
|
|
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
|
|
isError: true,
|
|
});
|
|
}
|
|
})();
|
|
},
|
|
[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)) {
|
|
if (phase === 'skill-action') {
|
|
setPhase('skill-preview');
|
|
setFeedback(null);
|
|
} else if (phase !== 'list') {
|
|
setPhase('list');
|
|
setSelectedItem(null);
|
|
setFeedback(null);
|
|
} else {
|
|
onClose();
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
{ isActive: true, priority: true },
|
|
);
|
|
|
|
// Hoist the per-phase preview data so the array literals passed to
|
|
// ScrollableDiffViewport don't change identity on every parent render.
|
|
// ScrollableDiffViewport memoizes its expensive `parseDiffWithLineNumbers`
|
|
// + `renderDiffLines` on `sections`, so a new array literal every render
|
|
// would defeat that and re-colorize the diff each time. Keying on
|
|
// `selectedItem` captures every input that affects the rendered diffs.
|
|
// Must live above the early returns below so React sees a consistent
|
|
// hook order.
|
|
const previewData = useMemo(() => {
|
|
if (!selectedItem) {
|
|
return {
|
|
skillSections: undefined as DiffSection[] | undefined,
|
|
patchSections: undefined as DiffSection[] | undefined,
|
|
memoryGroups: undefined as
|
|
| Array<[string, { isNewFile: boolean; diffs: string[] }]>
|
|
| undefined,
|
|
memorySections: undefined as DiffSection[] | undefined,
|
|
};
|
|
}
|
|
|
|
if (selectedItem.type === 'skill') {
|
|
const skill = selectedItem.skill;
|
|
if (!skill.content) {
|
|
return {
|
|
skillSections: undefined,
|
|
patchSections: undefined,
|
|
memoryGroups: undefined,
|
|
memorySections: undefined,
|
|
};
|
|
}
|
|
return {
|
|
skillSections: [
|
|
{
|
|
key: `skill:${skill.dirName}`,
|
|
header: 'SKILL.md',
|
|
diffContent: newFileDiff('SKILL.md', skill.content),
|
|
},
|
|
],
|
|
patchSections: undefined,
|
|
memoryGroups: undefined,
|
|
memorySections: undefined,
|
|
};
|
|
}
|
|
|
|
if (selectedItem.type === 'patch') {
|
|
const patch = selectedItem.patch;
|
|
return {
|
|
skillSections: undefined,
|
|
patchSections: patch.entries.map((entry, index) => ({
|
|
key: `${patch.fileName}:${entry.targetPath}:${index}`,
|
|
header: entry.targetPath,
|
|
diffContent: entry.diffContent,
|
|
})),
|
|
memoryGroups: undefined,
|
|
memorySections: undefined,
|
|
};
|
|
}
|
|
|
|
if (selectedItem.type === 'memory-patch') {
|
|
// 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 less 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 (entry.isNewFile) existing.isNewFile = true;
|
|
} else {
|
|
groups.set(entry.targetPath, {
|
|
isNewFile: entry.isNewFile,
|
|
diffs: [entry.diffContent],
|
|
});
|
|
}
|
|
}
|
|
const memoryGroups = Array.from(groups.entries());
|
|
|
|
const memorySections: DiffSection[] = [];
|
|
memoryGroups.forEach(([targetPath, { isNewFile, diffs }], groupIndex) => {
|
|
const headerAnnotation = `${isNewFile ? ' (new file)' : ''}${
|
|
diffs.length > 1
|
|
? ` · ${diffs.length} changes from different patches`
|
|
: ''
|
|
}`;
|
|
diffs.forEach((diff, hunkIndex) => {
|
|
memorySections.push({
|
|
key: `${targetPath}:${groupIndex}:${hunkIndex}`,
|
|
header:
|
|
hunkIndex === 0 ? `${targetPath}${headerAnnotation}` : targetPath,
|
|
diffContent: diff,
|
|
});
|
|
});
|
|
});
|
|
|
|
return {
|
|
skillSections: undefined,
|
|
patchSections: undefined,
|
|
memoryGroups,
|
|
memorySections,
|
|
};
|
|
}
|
|
|
|
return {
|
|
skillSections: undefined,
|
|
patchSections: undefined,
|
|
memoryGroups: undefined,
|
|
memorySections: undefined,
|
|
};
|
|
}, [selectedItem]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<Box
|
|
flexDirection="column"
|
|
borderStyle="round"
|
|
borderColor={theme.border.default}
|
|
paddingX={2}
|
|
paddingY={1}
|
|
>
|
|
<Text>Loading inbox…</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (items.length === 0 && !feedback) {
|
|
return (
|
|
<Box
|
|
flexDirection="column"
|
|
borderStyle="round"
|
|
borderColor={theme.border.default}
|
|
paddingX={2}
|
|
paddingY={1}
|
|
>
|
|
<Text bold>Memory Inbox</Text>
|
|
<Box marginTop={1}>
|
|
<Text color={theme.text.secondary}>No items in inbox.</Text>
|
|
</Box>
|
|
<DialogFooter primaryAction="Esc to close" cancelAction="" />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Border + paddingX account for 6 chars of width
|
|
const contentWidth = terminalWidth - 6;
|
|
|
|
// Diff-rendering budgets. Two strategies, picked by `isAlternateBuffer`:
|
|
//
|
|
// - Alt-buffer: a fixed-height ScrollableList viewport. There is no
|
|
// terminal scrollback, so we must scroll inside the dialog itself
|
|
// via PgUp/PgDn/Shift+arrows.
|
|
//
|
|
// - Non-alt-buffer: the codebase's standard pattern of a bounded
|
|
// DiffRenderer + ShowMoreLines + Ctrl+O (see FolderTrustDialog,
|
|
// ThemeDialog). Clipped content lands in terminal scrollback when
|
|
// the user expands via Ctrl+O.
|
|
//
|
|
// Chrome accounts for the dialog's borders, padding, title + subtitle,
|
|
// action list (two `minHeight={2}` rows), the section's `marginTop`,
|
|
// the dialog footer, and a couple of safety rows. Bumped when inline
|
|
// feedback is showing.
|
|
const DIALOG_CHROME_HEIGHT = 16;
|
|
const feedbackHeight = feedback ? 2 : 0;
|
|
const diffViewportHeight = Math.max(
|
|
3,
|
|
terminalHeight - DIALOG_CHROME_HEIGHT - feedbackHeight,
|
|
);
|
|
|
|
// For the non-alt-buffer DiffRenderer path, mirror MainContent /
|
|
// DialogManager and drop the clamp when the user has pressed Ctrl+O.
|
|
const availableContentHeight = constrainHeight
|
|
? diffViewportHeight
|
|
: undefined;
|
|
const PATCH_ENTRY_OVERHEAD = 2; // target-path label + marginBottom
|
|
const patchEntryCount =
|
|
selectedItem?.type === 'patch'
|
|
? selectedItem.patch.entries.length
|
|
: selectedItem?.type === 'memory-patch'
|
|
? selectedItem.memoryPatch.entries.length
|
|
: 1;
|
|
const availablePatchEntryHeight =
|
|
availableContentHeight === undefined
|
|
? undefined
|
|
: Math.max(
|
|
3,
|
|
Math.floor(
|
|
(availableContentHeight - patchEntryCount * PATCH_ENTRY_OVERHEAD) /
|
|
Math.max(1, patchEntryCount),
|
|
),
|
|
);
|
|
|
|
const previewNavigationHint = isAlternateBuffer
|
|
? 'PgUp/PgDn to scroll'
|
|
: undefined;
|
|
|
|
// Budget the list phase so the dialog footer never clips on shorter
|
|
// terminals. Every visible row — skill items, patch items, memory-patch
|
|
// items, and the section headers — renders at exactly 2 rows tall
|
|
// (enforced by `height={2}` on item renders and `marginTop={1}` + 1
|
|
// text line for headers), so the windowed-slot count maps directly to
|
|
// terminal rows.
|
|
//
|
|
// Chrome rows accounted for:
|
|
// - round border (2)
|
|
// - paddingY (2)
|
|
// - DefaultAppLayout's alt-buffer paddingBottom (1)
|
|
// - title + subtitle (2)
|
|
// - marginTop above the list (1)
|
|
// - dialog footer marginTop + text (2)
|
|
// - BaseSelectionList ▲ + ▼ scroll arrows (2) — always shown when
|
|
// items > maxItemsToShow, which is precisely when this budget
|
|
// matters
|
|
const LIST_PHASE_CHROME_HEIGHT = 12;
|
|
const LIST_ROW_HEIGHT = 2;
|
|
const listMaxItemsToShow = Math.max(
|
|
1,
|
|
Math.min(
|
|
8,
|
|
Math.floor(
|
|
(terminalHeight - LIST_PHASE_CHROME_HEIGHT - feedbackHeight) /
|
|
LIST_ROW_HEIGHT,
|
|
),
|
|
),
|
|
);
|
|
|
|
return (
|
|
<OverflowProvider>
|
|
<Box
|
|
flexDirection="column"
|
|
borderStyle="round"
|
|
borderColor={theme.border.default}
|
|
paddingX={2}
|
|
paddingY={1}
|
|
width="100%"
|
|
>
|
|
{phase === 'list' && (
|
|
<>
|
|
<Text bold>
|
|
Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''})
|
|
</Text>
|
|
<Text color={theme.text.secondary}>
|
|
Extracted from past sessions. Select one to review.
|
|
</Text>
|
|
|
|
<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}
|
|
showScrollArrows={true}
|
|
maxItemsToShow={listMaxItemsToShow}
|
|
renderItem={(item, { titleColor }) => {
|
|
if (item.value.type === 'header') {
|
|
return (
|
|
<Box marginTop={1}>
|
|
<Text color={theme.text.secondary} bold>
|
|
{item.value.label}
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
if (item.value.type === 'skill') {
|
|
const skill = item.value.skill;
|
|
const subtitle = skill.extractedAt
|
|
? `${skill.description} · ${formatDate(skill.extractedAt)}`
|
|
: skill.description;
|
|
return (
|
|
<Box flexDirection="column" height={2}>
|
|
<Text color={titleColor} bold wrap="truncate-end">
|
|
{skill.name}
|
|
</Text>
|
|
<Text color={theme.text.secondary} wrap="truncate-end">
|
|
{subtitle}
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
if (item.value.type === 'memory-patch') {
|
|
const memoryPatch = item.value.memoryPatch;
|
|
const summary = formatMemoryPatchSummary(memoryPatch);
|
|
const subtitle = memoryPatch.extractedAt
|
|
? `${summary} · ${formatDate(memoryPatch.extractedAt)}`
|
|
: summary;
|
|
return (
|
|
<Box flexDirection="column" height={2}>
|
|
<Text color={titleColor} bold wrap="truncate-end">
|
|
{memoryPatch.name}
|
|
</Text>
|
|
<Text color={theme.text.secondary} wrap="truncate-end">
|
|
{subtitle}
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
const patch = item.value.patch;
|
|
const fileNames = patch.entries.map((e) =>
|
|
getPathBasename(e.targetPath),
|
|
);
|
|
const origin = getSkillOriginTag(
|
|
patch.entries[0]?.targetPath ?? '',
|
|
);
|
|
const titleLine = origin
|
|
? `${patch.name} [${origin}]`
|
|
: patch.name;
|
|
const subtitle = patch.extractedAt
|
|
? `${fileNames.join(', ')} · ${formatDate(patch.extractedAt)}`
|
|
: fileNames.join(', ');
|
|
return (
|
|
<Box flexDirection="column" height={2}>
|
|
<Text color={titleColor} bold wrap="truncate-end">
|
|
{titleLine}
|
|
</Text>
|
|
<Text color={theme.text.secondary} wrap="truncate-end">
|
|
{subtitle}
|
|
</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 select"
|
|
cancelAction="Esc to close"
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{phase === 'skill-preview' && selectedItem?.type === 'skill' && (
|
|
<>
|
|
<Text bold>{selectedItem.skill.name}</Text>
|
|
<Text color={theme.text.secondary}>
|
|
Review new skill before installing.
|
|
</Text>
|
|
|
|
{selectedItem.skill.content &&
|
|
(isAlternateBuffer ? (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<ScrollableDiffViewport
|
|
sections={previewData.skillSections ?? []}
|
|
width={contentWidth}
|
|
height={diffViewportHeight}
|
|
hasFocus={true}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<Text color={theme.text.secondary} bold>
|
|
SKILL.md
|
|
</Text>
|
|
<DiffRenderer
|
|
diffContent={newFileDiff(
|
|
'SKILL.md',
|
|
selectedItem.skill.content,
|
|
)}
|
|
filename="SKILL.md"
|
|
terminalWidth={contentWidth}
|
|
availableTerminalHeight={availableContentHeight}
|
|
/>
|
|
</Box>
|
|
))}
|
|
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<BaseSelectionList<SkillPreviewAction>
|
|
items={skillPreviewItems}
|
|
onSelect={handleSkillPreviewAction}
|
|
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>
|
|
)}
|
|
|
|
{!isAlternateBuffer && (
|
|
<ShowMoreLines constrainHeight={constrainHeight} />
|
|
)}
|
|
|
|
<DialogFooter
|
|
primaryAction="Enter to confirm"
|
|
navigationActions={previewNavigationHint}
|
|
cancelAction="Esc to go back"
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{phase === 'skill-action' && selectedItem?.type === 'skill' && (
|
|
<>
|
|
<Text bold>Move "{selectedItem.skill.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"
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{phase === 'patch-preview' && selectedItem?.type === 'patch' && (
|
|
<>
|
|
<Text bold>{selectedItem.patch.name}</Text>
|
|
<Box flexDirection="row">
|
|
<Text color={theme.text.secondary}>
|
|
Review changes before applying.
|
|
</Text>
|
|
{(() => {
|
|
const origin = getSkillOriginTag(
|
|
selectedItem.patch.entries[0]?.targetPath ?? '',
|
|
);
|
|
return origin ? (
|
|
<Text color={theme.text.secondary}>{` [${origin}]`}</Text>
|
|
) : null;
|
|
})()}
|
|
</Box>
|
|
|
|
<Box flexDirection="column" marginTop={1}>
|
|
{isAlternateBuffer ? (
|
|
<ScrollableDiffViewport
|
|
sections={previewData.patchSections ?? []}
|
|
width={contentWidth}
|
|
height={diffViewportHeight}
|
|
hasFocus={true}
|
|
/>
|
|
) : (
|
|
selectedItem.patch.entries.map((entry, index) => (
|
|
<Box
|
|
key={`${selectedItem.patch.fileName}:${entry.targetPath}:${index}`}
|
|
flexDirection="column"
|
|
marginBottom={1}
|
|
>
|
|
<Text color={theme.text.secondary} bold>
|
|
{entry.targetPath}
|
|
</Text>
|
|
<DiffRenderer
|
|
diffContent={entry.diffContent}
|
|
filename={entry.targetPath}
|
|
terminalWidth={contentWidth}
|
|
availableTerminalHeight={availablePatchEntryHeight}
|
|
/>
|
|
</Box>
|
|
))
|
|
)}
|
|
</Box>
|
|
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<BaseSelectionList<PatchAction>
|
|
items={patchActionItems}
|
|
onSelect={handleSelectPatchAction}
|
|
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>
|
|
)}
|
|
|
|
{!isAlternateBuffer && (
|
|
<ShowMoreLines constrainHeight={constrainHeight} />
|
|
)}
|
|
|
|
<DialogFooter
|
|
primaryAction="Enter to confirm"
|
|
navigationActions={previewNavigationHint}
|
|
cancelAction="Esc to go back"
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{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>
|
|
|
|
{(() => {
|
|
// Grouping + section flattening were hoisted into the
|
|
// `previewData` useMemo so the array identities passed into
|
|
// ScrollableDiffViewport stay stable across re-renders.
|
|
const groupEntries = previewData.memoryGroups ?? [];
|
|
|
|
if (isAlternateBuffer) {
|
|
return (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<ScrollableDiffViewport
|
|
sections={previewData.memorySections ?? []}
|
|
width={contentWidth}
|
|
height={diffViewportHeight}
|
|
hasFocus={true}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return groupEntries.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}
|
|
availableTerminalHeight={availablePatchEntryHeight}
|
|
/>
|
|
))}
|
|
</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>
|
|
)}
|
|
|
|
{!isAlternateBuffer && (
|
|
<ShowMoreLines constrainHeight={constrainHeight} />
|
|
)}
|
|
|
|
<DialogFooter
|
|
primaryAction="Enter to confirm"
|
|
navigationActions={previewNavigationHint}
|
|
cancelAction="Esc to go back"
|
|
/>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</OverflowProvider>
|
|
);
|
|
};
|