/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as path from 'node:path'; import type React from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react'; import { Box, Text, useStdout } 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 { DiffRenderer } from './messages/DiffRenderer.js'; import { type Config, type InboxSkill, type InboxPatch, type InboxSkillDestination, getErrorMessage, listInboxSkills, listInboxPatches, moveInboxSkill, dismissInboxSkill, applyInboxPatch, dismissInboxPatch, isProjectSkillPatchTarget, } from '@google/gemini-cli-core'; type Phase = 'list' | 'skill-preview' | 'skill-action' | 'patch-preview'; type InboxItem = | { type: 'skill'; skill: InboxSkill } | { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean } | { type: 'header'; label: string }; interface DestinationChoice { destination: InboxSkillDestination; label: string; description: string; } interface PatchAction { 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', }, ]; 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; } async function patchTargetsProjectSkills( patch: InboxPatch, config: Config, ): Promise { 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 SkillInboxDialogProps { config: Config; onClose: () => void; onReloadSkills: () => Promise; } export const SkillInboxDialog: React.FC = ({ config, onClose, onReloadSkills, }) => { const keyMatchers = useKeyMatchers(); const { stdout } = useStdout(); const terminalWidth = stdout?.columns ?? 80; const isTrustedFolder = config.isTrustedFolder(); const [phase, setPhase] = useState('list'); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [selectedItem, setSelectedItem] = useState(null); const [feedback, setFeedback] = useState<{ text: string; isError: boolean; } | null>(null); // Load inbox skills and patches on mount useEffect(() => { let cancelled = false; void (async () => { try { const [skills, patches] = await Promise.all([ listInboxSkills(config), listInboxPatches(config), ]); const patchItems = await Promise.all( patches.map(async (patch): Promise => { 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, ]; 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}` : `header:${item.label}`, [], ); const listItems: Array> = useMemo(() => { const skills = items.filter((i) => i.type === 'skill'); const patches = items.filter((i) => i.type === 'patch'); const result: Array> = []; // Only show section headers when both types are present const showHeaders = skills.length > 0 && patches.length > 0; 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 }); } return result; }, [items, getItemKey]); const destinationItems: Array> = 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> = 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> = useMemo( () => SKILL_PREVIEW_CHOICES.map((choice) => ({ key: choice.action, value: choice, })), [], ); const handleSelectItem = useCallback((item: InboxItem) => { setSelectedItem(item); setFeedback(null); setPhase(item.type === 'skill' ? 'skill-preview' : 'patch-preview'); }, []); 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], ); 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 }, ); if (loading) { return ( Loading inbox… ); } if (items.length === 0 && !feedback) { return ( Memory Inbox No items in inbox. ); } // Border + paddingX account for 6 chars of width const contentWidth = terminalWidth - 6; return ( {phase === 'list' && ( <> Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''}) Extracted from past sessions. Select one to review. items={listItems} onSelect={handleSelectItem} isFocused={true} showNumbers={false} showScrollArrows={true} maxItemsToShow={8} renderItem={(item, { titleColor }) => { if (item.value.type === 'header') { return ( {item.value.label} ); } if (item.value.type === 'skill') { const skill = item.value.skill; return ( {skill.name} {skill.description} {skill.extractedAt && ( {' · '} {formatDate(skill.extractedAt)} )} ); } const patch = item.value.patch; const fileNames = patch.entries.map((e) => getPathBasename(e.targetPath), ); const origin = getSkillOriginTag( patch.entries[0]?.targetPath ?? '', ); return ( {patch.name} {origin && ( {` [${origin}]`} )} {fileNames.join(', ')} {patch.extractedAt && ( {' · '} {formatDate(patch.extractedAt)} )} ); }} /> {feedback && ( {feedback.isError ? '✗ ' : '✓ '} {feedback.text} )} )} {phase === 'skill-preview' && selectedItem?.type === 'skill' && ( <> {selectedItem.skill.name} Review new skill before installing. {selectedItem.skill.content && ( SKILL.md )} items={skillPreviewItems} onSelect={handleSkillPreviewAction} isFocused={true} showNumbers={true} renderItem={(item, { titleColor }) => ( {item.value.label} {item.value.description} )} /> {feedback && ( {feedback.isError ? '✗ ' : '✓ '} {feedback.text} )} )} {phase === 'skill-action' && selectedItem?.type === 'skill' && ( <> Move "{selectedItem.skill.name}" Choose where to install this skill. items={destinationItems} onSelect={handleSelectDestination} isFocused={true} showNumbers={true} renderItem={(item, { titleColor }) => ( {item.value.label} {item.value.description} )} /> {feedback && ( {feedback.isError ? '✗ ' : '✓ '} {feedback.text} )} )} {phase === 'patch-preview' && selectedItem?.type === 'patch' && ( <> {selectedItem.patch.name} Review changes before applying. {(() => { const origin = getSkillOriginTag( selectedItem.patch.entries[0]?.targetPath ?? '', ); return origin ? ( {` [${origin}]`} ) : null; })()} {selectedItem.patch.entries.map((entry, index) => ( {entry.targetPath} ))} items={patchActionItems} onSelect={handleSelectPatchAction} isFocused={true} showNumbers={true} renderItem={(item, { titleColor }) => ( {item.value.label} {item.value.description} )} /> {feedback && ( {feedback.isError ? '✗ ' : '✓ '} {feedback.text} )} )} ); };