feat(chat): improve command autocomplete and command alerts

This commit is contained in:
simoleo89
2026-06-02 18:31:49 +02:00
parent 4ba2d25c85
commit f506b83387
9 changed files with 316 additions and 51 deletions
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { CommandDefinition } from '../../../api';
import { getChatCommandQuery, getRankedCommandSuggestions } from './useChatCommandSelector.helpers';
const commands: CommandDefinition[] = [
{ key: 'commands', description: 'Mostra tutti i comandi' },
{ key: 'empty', description: 'Svuota la stanza' },
{ key: 'emptybots', description: 'Svuota inventario bot' },
{ key: 'xempty', description: 'Comando di test' },
{ key: 'ejectall', description: 'Espelli tutti i furni' },
{ key: 'togglefps', description: 'Mostra o nasconde FPS' }
];
describe('getChatCommandQuery', () =>
{
it('returns null when the input is not a command prefix', () =>
{
expect(getChatCommandQuery('ciao')).toBeNull();
expect(getChatCommandQuery(':empty ')).toBeNull();
});
it('returns the normalized command query', () =>
{
expect(getChatCommandQuery(':Em')).toBe('em');
});
});
describe('getRankedCommandSuggestions', () =>
{
it('ranks prefix matches before contains and description matches', () =>
{
const result = getRankedCommandSuggestions(commands, 'em', 10);
expect(result.map(command => command.key)).toEqual([ 'empty', 'emptybots', 'xempty' ]);
});
it('matches command descriptions when the key does not match', () =>
{
const result = getRankedCommandSuggestions(commands, 'furni', 10);
expect(result.map(command => command.key)).toEqual([ 'ejectall' ]);
expect(result[0].matchType).toBe('description');
});
it('limits the visible suggestions', () =>
{
const result = getRankedCommandSuggestions(commands, '', 2);
expect(result).toHaveLength(2);
});
});
@@ -0,0 +1,57 @@
import { CommandDefinition } from '../../../api';
export interface RankedCommandDefinition extends CommandDefinition
{
matchType: 'prefix' | 'contains' | 'description' | 'all';
}
const normalize = (value: string) => value.trim().toLowerCase();
export const getChatCommandQuery = (chatValue: string): string | null =>
{
if(!chatValue.startsWith(':') || chatValue.includes(' ')) return null;
return normalize(chatValue.slice(1));
};
const getCommandScore = (command: CommandDefinition, query: string): { score: number; matchType: RankedCommandDefinition['matchType'] } | null =>
{
const key = normalize(command.key);
const description = normalize(command.description || '');
if(!query) return { score: 100 + key.length, matchType: 'all' };
if(key === query) return { score: 0, matchType: 'prefix' };
if(key.startsWith(query)) return { score: 10 + (key.length - query.length), matchType: 'prefix' };
if(key.includes(query)) return { score: 40 + key.indexOf(query), matchType: 'contains' };
if(description.includes(query)) return { score: 70 + description.indexOf(query), matchType: 'description' };
return null;
};
export const getRankedCommandSuggestions = (commands: CommandDefinition[], query: string, limit: number): RankedCommandDefinition[] =>
{
const seen = new Set<string>();
return commands
.map(command =>
{
const match = getCommandScore(command, query);
if(!match) return null;
return { command, score: match.score, matchType: match.matchType };
})
.filter((entry): entry is { command: CommandDefinition; score: number; matchType: RankedCommandDefinition['matchType'] } => !!entry)
.sort((a, b) => (a.score - b.score) || a.command.key.localeCompare(b.command.key))
.filter(entry =>
{
const key = normalize(entry.command.key);
if(seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, limit)
.map(entry => ({ ...entry.command, matchType: entry.matchType }));
};
@@ -3,6 +3,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { CommandDefinition, LocalizeText } from '../../../api';
import { createNitroStore } from '../../../state/createNitroStore';
import { useMessageEvent } from '../../events';
import { getChatCommandQuery, getRankedCommandSuggestions } from './useChatCommandSelector.helpers';
const MAX_VISIBLE_COMMANDS = 8;
// Client-only commands are static; safe to keep at module scope. The
// `descriptionKey` is a LocalizeText slot resolved at merge time so
@@ -62,7 +65,7 @@ const useChatCommandStore = createNitroStore<ChatCommandStore>()((set) => ({
markListenerRegistered: () => set({ isListenerRegistered: true })
}));
const ensureGlobalListener = (): void =>
export const ensureChatCommandListener = (): void =>
{
if(useChatCommandStore.getState().isListenerRegistered) return;
@@ -86,20 +89,20 @@ const ensureGlobalListener = (): void =>
// Try once at module load so the server's response landing before any
// React mount still hits the cache.
ensureGlobalListener();
ensureChatCommandListener();
export const useChatCommandSelector = (chatValue: string) =>
{
const serverCommands = useChatCommandStore(s => s.serverCommands);
const setServerCommands = useChatCommandStore(s => s.setServerCommands);
const [ selectedIndex, setSelectedIndex ] = useState(0);
const [ dismissed, setDismissed ] = useState(false);
const [ dismissedQuery, setDismissedQuery ] = useState<string | null>(null);
useEffect(() =>
{
// Cover the case where the module-level registration failed
// because GetCommunication() wasn't ready at import time.
ensureGlobalListener();
ensureChatCommandListener();
}, []);
// Late updates (rank change, etc.) — go through the store so all
@@ -123,61 +126,55 @@ export const useChatCommandSelector = (chatValue: string) =>
return merged.sort((a, b) => a.key.localeCompare(b.key));
}, [ serverCommands ]);
const filterText = useMemo(() =>
{
if(!chatValue.startsWith(':') || chatValue.includes(' ')) return '';
return chatValue.slice(1).toLowerCase();
}, [ chatValue ]);
const filterText = useMemo(() => getChatCommandQuery(chatValue), [ chatValue ]);
const filteredCommands = useMemo(() =>
{
if(!filterText && !chatValue.startsWith(':')) return [];
if(filterText === null) return [];
return allCommands.filter(cmd => cmd.key.toLowerCase().startsWith(filterText));
}, [ allCommands, filterText, chatValue ]);
return getRankedCommandSuggestions(allCommands, filterText, MAX_VISIBLE_COMMANDS);
}, [ allCommands, filterText ]);
const isVisible = useMemo(() =>
{
return chatValue.startsWith(':') && !chatValue.includes(' ') && filteredCommands.length > 0 && !dismissed;
}, [ chatValue, filteredCommands, dismissed ]);
return filterText !== null && filteredCommands.length > 0 && dismissedQuery !== filterText;
}, [ filterText, filteredCommands, dismissedQuery ]);
const boundedSelectedIndex = useMemo(() =>
{
if(!filteredCommands.length) return 0;
return Math.min(selectedIndex, filteredCommands.length - 1);
}, [ filteredCommands.length, selectedIndex ]);
const moveUp = useCallback(() =>
{
setSelectedIndex(prev => (prev <= 0 ? filteredCommands.length - 1 : prev - 1));
if(!filteredCommands.length) return;
setSelectedIndex(prev => ((prev <= 0 || prev >= filteredCommands.length) ? filteredCommands.length - 1 : prev - 1));
}, [ filteredCommands.length ]);
const moveDown = useCallback(() =>
{
if(!filteredCommands.length) return;
setSelectedIndex(prev => (prev >= filteredCommands.length - 1 ? 0 : prev + 1));
}, [ filteredCommands.length ]);
const selectCurrent = useCallback((): CommandDefinition | null =>
{
if(selectedIndex >= 0 && selectedIndex < filteredCommands.length)
if(boundedSelectedIndex >= 0 && boundedSelectedIndex < filteredCommands.length)
{
return filteredCommands[selectedIndex];
return filteredCommands[boundedSelectedIndex];
}
return null;
}, [ selectedIndex, filteredCommands ]);
}, [ boundedSelectedIndex, filteredCommands ]);
const close = useCallback(() =>
{
setDismissed(true);
}, []);
// Reset dismissed when chatValue changes to a new command start
useEffect(() =>
{
if(chatValue === ':' || chatValue === '') setDismissed(false);
}, [ chatValue ]);
// Reset selectedIndex when filtered list changes
useEffect(() =>
{
setSelectedIndex(0);
setDismissedQuery(filterText);
}, [ filterText ]);
return { isVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close };
return { isVisible, filteredCommands, selectedIndex: boundedSelectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close };
};