feat(redis): add command terminal autocomplete

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-12 00:41:31 +08:00
1 parent 80bd8dac06
commit dbe3375739
13 files changed
+1865 -337

No files matched your search

@@ -22,9 +22,13 @@ const mocks = vi.hoisted(() => ({
redisCheckJsonModule: vi.fn(),
redisDeleteKey: vi.fn(),
redisDeleteKeys: vi.fn(),
redisExecuteCommand: vi.fn(),
saveHistory: vi.fn(),
canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000),
toast: vi.fn(),
updateRedisDbKeyStats: vi.fn(),
listRedisCompletionCommandDocs: vi.fn(),
listRedisCompletionKeys: vi.fn(),
redisScanPageSize: 100,
infiniteScroll: false,
queryResultMaxRowsEnabled: true,
@@ -46,6 +50,8 @@ vi.mock("@/lib/backend/api", () => ({
redisCheckJsonModule: mocks.redisCheckJsonModule,
redisDeleteKey: mocks.redisDeleteKey,
redisDeleteKeys: mocks.redisDeleteKeys,
redisExecuteCommand: mocks.redisExecuteCommand,
saveHistory: mocks.saveHistory,
}));
vi.mock("@/lib/redis/redisKeyTree", async (importOriginal) => {
@@ -58,6 +64,8 @@ vi.mock("@/stores/connectionStore", () => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }),
updateRedisDbKeyStats: mocks.updateRedisDbKeyStats,
listRedisCompletionCommandDocs: mocks.listRedisCompletionCommandDocs,
listRedisCompletionKeys: mocks.listRedisCompletionKeys,
invalidateCompletionCache: vi.fn(),
refreshRedisDbKeyCounts: vi.fn(),
}),
@@ -346,6 +354,15 @@ function redisKeyInfo(keyType = "json") {
return { key_display: KEY_NAME, key_raw: KEY_RAW, key_type: keyType, ttl: 90, size: 7, value_preview: "{}" };
}
const completionCommands = [
{ name: "GET", group: "string", arity: 2, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
{ name: "GETEX", group: "string", arity: -2, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
{ name: "GETSET", group: "string", arity: 3, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
{ name: "PING", group: "connection", arity: -1, keySpecs: [] },
{ name: "SET", group: "string", arity: -3, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
{ name: "VGET", group: "string", arity: 2, summary: "Reads a vendor key.", keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
];
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -376,6 +393,10 @@ function resetApiMocks() {
mocks.redisCheckJsonModule.mockResolvedValue(true);
mocks.redisDeleteKey.mockResolvedValue(undefined);
mocks.redisDeleteKeys.mockResolvedValue(0);
mocks.redisExecuteCommand.mockResolvedValue({ value: "OK" });
mocks.saveHistory.mockResolvedValue(undefined);
mocks.listRedisCompletionCommandDocs.mockResolvedValue(completionCommands);
mocks.listRedisCompletionKeys.mockResolvedValue(["user:1"]);
mocks.canBuildRedisFuzzyTree.mockImplementation((loadedKeyCount: number) => loadedKeyCount <= 200_000);
}
@@ -386,6 +407,7 @@ function mountBrowser() {
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
mountedApps.push({ unmount: () => app.unmount(), host });
return host;
}
function mountScopedBrowser() {
@@ -502,6 +524,21 @@ async function openCreateDialog() {
await setInput('input[placeholder="redis.createKeyNamePlaceholder"]', KEY_NAME);
}
async function openCommandPanel() {
const trigger = document.querySelector<HTMLElement>('[value="command"]');
expect(trigger, "redis.commandLine trigger").toBeDefined();
trigger!.click();
await settle();
}
function commandCompletionLabels(): string[] {
return Array.from(document.querySelectorAll<HTMLButtonElement>('[role="option"]')).map((item) => item.textContent?.trim() ?? "");
}
async function setCommandInput(value: string) {
await setInput("[data-redis-command-input]", value);
}
async function fillCreateValue(type: CreateType) {
if (type === "string") {
const textarea = requiredElement<HTMLTextAreaElement>("textarea");
@@ -597,6 +634,186 @@ describe("RedisKeyBrowser scope changes", () => {
});
});
describe("RedisKeyBrowser command completion", () => {
it("uses the connected server's module command docs and accepts the selection with Tab", async () => {
mountBrowser();
await settle();
await openCommandPanel();
await setCommandInput("VGE");
expect(mocks.listRedisCompletionCommandDocs).toHaveBeenCalledWith("connection", "0");
expect(commandCompletionLabels()).toContain("VGETReads a vendor key.string");
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
await settle();
expect(input.value).toBe("VGE");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
await settle();
expect(input.value).toBe("VGET ");
});
it("completes known keys only at a documented key argument", async () => {
mountBrowser();
await settle();
await openCommandPanel();
await setCommandInput("VGET ");
expect(mocks.listRedisCompletionKeys).toHaveBeenCalledWith("connection", "0");
expect(commandCompletionLabels()).toContain("user:1key");
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
await settle();
expect(input.value).toBe("VGET user:1");
});
it("replaces an incomplete quoted key with the executable completion text", async () => {
mocks.listRedisCompletionKeys.mockResolvedValueOnce(["user name"]);
mountBrowser();
await settle();
await openCommandPanel();
await setCommandInput('VGET "user');
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
await settle();
expect(input.value).toBe('VGET "user name"');
});
it("does not guess command candidates when server metadata is unavailable", async () => {
mocks.listRedisCompletionCommandDocs.mockRejectedValueOnce(new Error("unknown subcommand 'DOCS'"));
mountBrowser();
await settle();
await openCommandPanel();
await setCommandInput("GE");
expect(commandCompletionLabels()).toEqual([]);
});
it("keeps one selected completion while pointer and keyboard move it", async () => {
mountBrowser();
await settle();
await openCommandPanel();
await setCommandInput("GE");
const options = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="option"]'));
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
const listbox = requiredElement<HTMLElement>('[role="listbox"]');
expect(options.length).toBeGreaterThan(1);
expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[0]]);
expect(options.every((option) => !option.hasAttribute("title"))).toBe(true);
expect(input.getAttribute("aria-controls")).toBe(listbox.id);
expect(input.getAttribute("aria-activedescendant")).toBe(options[0]!.id);
options[1]!.dispatchEvent(new Event("pointerenter", { bubbles: true }));
await settle();
expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[1]]);
expect(input.getAttribute("aria-activedescendant")).toBe(options[1]!.id);
listbox.scrollTop = 20;
vi.spyOn(listbox, "getBoundingClientRect").mockReturnValue({ top: 100, bottom: 200 } as DOMRect);
vi.spyOn(options[2]!, "getBoundingClientRect").mockReturnValue({ top: 180, bottom: 224 } as DOMRect);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
await settle();
expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[2]]);
expect(listbox.scrollTop).toBe(44);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
await settle();
expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[2]]);
vi.spyOn(options[1]!, "getBoundingClientRect").mockReturnValue({ top: 76, bottom: 120 } as DOMRect);
vi.spyOn(options[0]!, "getBoundingClientRect").mockReturnValue({ top: 100, bottom: 144 } as DOMRect);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
await settle();
expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[0]]);
expect(listbox.scrollTop).toBe(20);
});
it("accepts a documented argument keyword and advances to its value", async () => {
mocks.listRedisCompletionCommandDocs.mockResolvedValueOnce([
...completionCommands,
{
name: "XREAD",
group: "stream",
arity: -4,
keySpecs: [],
arguments: [
{ name: "count", token: "COUNT", type: "integer", optional: true },
{ name: "streams", token: "STREAMS", type: "block", arguments: [{ name: "key", type: "key", multiple: true }] },
],
},
]);
mountBrowser();
await settle();
await openCommandPanel();
await setCommandInput("XREAD C");
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
await settle();
expect(input.value).toBe("XREAD COUNT ");
expect(commandCompletionLabels()).toEqual([]);
});
it("accepts the selected completion before executing on Enter", async () => {
mountBrowser();
await settle();
await openCommandPanel();
await setCommandInput("SE");
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
expect(input.value).toBe("SET ");
expect(commandCompletionLabels()).toContain("user:1key");
});
it("waits for command metadata instead of sending a partial command on Enter", async () => {
const docs = deferred<typeof completionCommands>();
mocks.listRedisCompletionCommandDocs.mockReturnValueOnce(docs.promise);
mountBrowser();
await settle();
await openCommandPanel();
await setCommandInput("SE");
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
expect(input.value).toBe("SE");
expect(mocks.redisExecuteCommand).not.toHaveBeenCalled();
docs.resolve(completionCommands);
await settle();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
expect(input.value).toBe("SET ");
expect(mocks.redisExecuteCommand).not.toHaveBeenCalled();
});
it("executes an exact command instead of completing it a second time", async () => {
mountBrowser();
await settle();
await openCommandPanel();
await setCommandInput("PING");
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
expect(mocks.redisExecuteCommand).toHaveBeenCalledWith("connection", 0, "PING", true);
expect(input.value).toBe("");
});
});
describe("RedisKeyBrowser expiry creation", () => {
it.each(["string", "hash", "list", "set", "zset", "stream", "json"] as const)("writes %s before applying one relative TTL", async (type) => {
mountBrowser();
@@ -43,6 +43,8 @@ import {
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, takesKeyArgument, type RedisCompletionItem } from "@/lib/redis/redisCompletion";
import type { RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs";
import { formatRedisConsoleValue, redisValuePreview, redisValueSize } from "@/lib/redis/redisValuePresentation";
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { copyToClipboard } from "@/lib/common/clipboard";
@@ -119,6 +121,15 @@ const commandRunning = ref(false);
const commandDb = ref(props.db);
const commandHistory = ref<RedisCommandHistoryEntry[]>([]);
const commandHistoryIndex = ref(-1);
const commandCompletionItems = ref<RedisCompletionItem[]>([]);
const commandCompletionSelectedIndex = ref(0);
const commandCompletionLoading = ref(false);
const commandCompletionListboxId = `redis-command-completions-${uuid()}`;
const commandCompletionSelectedItem = computed(() => commandCompletionItems.value[commandCompletionSelectedIndex.value]);
const commandCompletionActiveDescendant = computed(() => (commandCompletionSelectedItem.value ? `${commandCompletionListboxId}-option-${commandCompletionSelectedIndex.value}` : undefined));
const commandDocumentationLoading = ref(false);
const commandCompletionOpen = computed(() => commandDocumentationLoading.value || commandCompletionLoading.value || commandCompletionItems.value.length > 0);
const commandDocumentation = shallowRef<RedisCommandDocumentation[]>([]);
const activeSidePanel = ref<RedisSidePanel>("detail");
const showCreateKeyDialog = ref(false);
const creatingKey = ref(false);
@@ -152,6 +163,10 @@ let redisDbFlushedListenerRegistered = false;
let redisInfiniteScrollFrame = 0;
const loadedKeyRaws = new Set<string>();
let treeIndex: RedisKeyTreeIndex | null = null;
const REDIS_COMMAND_COMPLETION_MENU_LIMIT = 12;
let commandCompletionRequestId = 0;
let commandDocumentationConnectionId: string | null = null;
let commandDocumentationRequestId = 0;
const valueQuery = computed(() => searchPattern.value.trim());
const isValueSearchMode = computed(() => searchMode.value === "value" || searchMode.value === "all");
@@ -909,6 +924,7 @@ async function openCommandPanel() {
activeSidePanel.value = "command";
await nextTick();
getCommandInput()?.focus();
requestCommandDocumentation();
}
function makeEntry(): CreateKeyEntry {
@@ -1209,6 +1225,7 @@ async function createRedisKey() {
async function executeCommand() {
const command = commandText.value.trim();
dismissCommandCompletions();
if (!command) {
// 空命令显示提示但不记入历史
appendCommandOutput({
@@ -1345,6 +1362,147 @@ function getCommandInput(): HTMLInputElement | null {
return rootRef.value?.querySelector<HTMLInputElement>("[data-redis-command-input]") ?? null;
}
function resetCommandDocumentation() {
commandDocumentationRequestId++;
commandDocumentationConnectionId = null;
commandDocumentationLoading.value = false;
commandDocumentation.value = [];
}
function requestCommandDocumentation() {
if (commandDocumentationLoading.value || commandDocumentationConnectionId === props.connectionId) return;
const requestId = ++commandDocumentationRequestId;
const connectionId = props.connectionId;
const database = String(commandDb.value);
commandDocumentationLoading.value = true;
void connectionStore
.listRedisCompletionCommandDocs(connectionId, database)
.then((docs) => {
if (requestId !== commandDocumentationRequestId || connectionId !== props.connectionId) return;
commandDocumentation.value = docs;
commandDocumentationConnectionId = connectionId;
if (commandText.value) void refreshCommandCompletions();
})
.catch(() => {
if (requestId !== commandDocumentationRequestId || connectionId !== props.connectionId) return;
// Do not offer guessed commands when the instance's metadata is unavailable.
commandDocumentation.value = [];
commandDocumentationConnectionId = connectionId;
})
.finally(() => {
if (requestId === commandDocumentationRequestId) commandDocumentationLoading.value = false;
});
}
function dismissCommandCompletions() {
commandCompletionRequestId++;
commandCompletionItems.value = [];
commandCompletionSelectedIndex.value = 0;
commandCompletionLoading.value = false;
}
async function refreshCommandCompletions(options: { force?: boolean } = {}) {
const input = getCommandInput();
const text = commandText.value;
if (!options.force && !text) {
dismissCommandCompletions();
return;
}
const cursor = input?.selectionStart ?? text.length;
requestCommandDocumentation();
const completionInput = { commands: commandDocumentation.value };
const context = getRedisCompletionContext(text, cursor, completionInput);
const requestId = ++commandCompletionRequestId;
commandCompletionItems.value = [];
commandCompletionSelectedIndex.value = 0;
let keys: string[] = [];
const needsKeys = context.mode === "argument" && takesKeyArgument(context.commandName, completionInput, context.argumentIndex, context.argumentValues);
commandCompletionLoading.value = needsKeys;
if (needsKeys) {
try {
keys = await connectionStore.listRedisCompletionKeys(props.connectionId, String(commandDb.value));
} catch {
keys = [];
}
}
if (requestId !== commandCompletionRequestId) return;
commandCompletionItems.value = buildRedisCompletionItemsFromContext(context, { keys, ...completionInput }).slice(0, REDIS_COMMAND_COMPLETION_MENU_LIMIT);
commandCompletionLoading.value = false;
}
function onCommandInput() {
void refreshCommandCompletions();
}
function onCommandInputClick() {
void refreshCommandCompletions();
}
function selectCommandCompletion(index: number) {
if (index < 0 || index >= commandCompletionItems.value.length) return;
commandCompletionSelectedIndex.value = index;
void nextTick(() => {
const listbox = document.getElementById(commandCompletionListboxId);
const option = document.getElementById(`${commandCompletionListboxId}-option-${index}`);
if (!listbox || !option) return;
const listboxRect = listbox.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
if (optionRect.top < listboxRect.top) listbox.scrollTop -= listboxRect.top - optionRect.top;
else if (optionRect.bottom > listboxRect.bottom) listbox.scrollTop += optionRect.bottom - listboxRect.bottom;
});
}
function moveCommandCompletionSelection(direction: 1 | -1): boolean {
const count = commandCompletionItems.value.length;
if (count === 0) return false;
const nextIndex = Math.min(Math.max(commandCompletionSelectedIndex.value + direction, 0), count - 1);
if (nextIndex !== commandCompletionSelectedIndex.value) selectCommandCompletion(nextIndex);
return true;
}
function commandCompletionInsertion(index = commandCompletionSelectedIndex.value) {
const item = commandCompletionItems.value[index];
const input = getCommandInput();
if (!item || !input) return null;
const text = commandText.value;
const context = getRedisCompletionContext(text, input.selectionStart ?? text.length, { commands: commandDocumentation.value });
const from = context.from;
const to = input.selectionEnd ?? text.length;
const insert = item.apply ?? item.label;
const commandHead = context.mode === "command" || context.mode === "subcommand";
const appendSpace = (commandHead || item.appendSpace === true) && !/^\s/.test(text.slice(to));
return { text, from, to, insert, replacement: `${insert}${appendSpace ? " " : ""}`, appendSpace, commandHead };
}
function selectedCompletionMatchesInput(): boolean {
const completion = commandCompletionInsertion();
if (!completion) return false;
const current = completion.text.slice(completion.from, completion.to);
return completion.commandHead ? current.toUpperCase() === completion.insert.toUpperCase() : current === completion.insert;
}
function acceptCommandCompletion(index = commandCompletionSelectedIndex.value): boolean {
const completion = commandCompletionInsertion(index);
if (!completion) return false;
commandText.value = `${completion.text.slice(0, completion.from)}${completion.replacement}${completion.text.slice(completion.to)}`;
dismissCommandCompletions();
void nextTick(() => {
const nextInput = getCommandInput();
if (!nextInput) return;
const cursor = completion.from + completion.replacement.length;
nextInput.focus();
nextInput.setSelectionRange(cursor, cursor);
if (completion.appendSpace) void refreshCommandCompletions({ force: true });
});
return true;
}
function focusSearch(): boolean {
if (activeSidePanel.value === "detail" && valueViewerRef.value?.focusSearch()) {
return true;
@@ -1425,6 +1583,38 @@ function onCommandAreaClick() {
}
function onCommandInputKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.code === "Space") {
event.preventDefault();
void refreshCommandCompletions({ force: true });
return;
}
if (event.key === "Tab" && !event.shiftKey && acceptCommandCompletion()) {
event.preventDefault();
return;
}
if (event.key === "Escape" && commandCompletionOpen.value) {
event.preventDefault();
dismissCommandCompletions();
return;
}
if (event.key === "ArrowUp" && moveCommandCompletionSelection(-1)) {
event.preventDefault();
return;
}
if (event.key === "ArrowDown" && moveCommandCompletionSelection(1)) {
event.preventDefault();
return;
}
// Do not execute a partial command before instance metadata can resolve it.
if (event.key === "Enter" && commandDocumentationLoading.value) {
event.preventDefault();
return;
}
if (event.key === "Enter" && !selectedCompletionMatchesInput() && acceptCommandCompletion()) {
event.preventDefault();
return;
}
// 上下键切换历史命令
if (event.key === "ArrowUp") {
event.preventDefault();
@@ -1438,6 +1628,7 @@ function onCommandInputKeydown(event: KeyboardEvent) {
commandHistoryIndex.value--;
}
commandText.value = commandHistory.value[commandHistoryIndex.value].command;
dismissCommandCompletions();
} else if (event.key === "ArrowDown") {
event.preventDefault();
if (commandHistoryIndex.value === -1) return;
@@ -1451,9 +1642,10 @@ function onCommandInputKeydown(event: KeyboardEvent) {
commandHistoryIndex.value = -1;
commandText.value = "";
}
dismissCommandCompletions();
} else if (event.key === "Enter") {
event.preventDefault();
executeCommand();
void executeCommand();
}
}
@@ -1499,6 +1691,7 @@ watch(
// ContentArea remounts this browser for scope changes; keep embedded uses
// in sync as well so an old scan cannot populate the new scope.
commandDb.value = db;
resetCommandDocumentation();
resetLoadedKeys();
try {
await connectionStore.ensureConnected(connectionId);
@@ -1737,17 +1930,53 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<form class="flex shrink-0 items-center gap-2 border-t border-white/10 bg-[#171b21] px-4 py-2" @submit.prevent="executeCommand">
<span class="shrink-0 text-[#d7ba7d]">{{ commandPrompt }}</span>
<input
v-model="commandText"
data-redis-command-input
class="dbx-editor-font-family min-w-0 flex-1 border-0 bg-transparent p-0 text-[13px] text-slate-200 caret-[#d7ba7d] outline-none placeholder:text-slate-500"
:class="{ 'opacity-50': commandRunning }"
:readonly="commandRunning"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
@keydown="onCommandInputKeydown"
/>
<div class="relative min-w-0 flex-1">
<div v-if="commandCompletionOpen" class="absolute bottom-[calc(100%+0.5rem)] left-0 z-20 w-full overflow-hidden rounded-md border border-white/15 bg-[#20262f] py-1 shadow-xl">
<div v-if="commandDocumentationLoading || commandCompletionLoading" class="flex items-center justify-center px-3 py-2 text-slate-400">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
</div>
<div v-else :id="commandCompletionListboxId" role="listbox" aria-label="Redis command completions" class="max-h-60 overflow-y-auto">
<button
v-for="(item, index) in commandCompletionItems"
:id="`${commandCompletionListboxId}-option-${index}`"
:key="`${item.type}:${item.label}:${index}`"
type="button"
role="option"
class="flex w-full items-center gap-3 px-3 py-1.5 text-left text-xs transition-colors"
:class="commandCompletionSelectedIndex === index ? 'bg-[#2b3440] text-white' : 'text-slate-200 hover:text-white'"
:aria-selected="commandCompletionSelectedIndex === index"
:aria-description="item.info"
@pointerenter="selectCommandCompletion(index)"
@mousedown.prevent
@click.stop="acceptCommandCompletion(index)"
>
<span class="min-w-0 flex-1">
<span class="block truncate font-mono">{{ item.label }}</span>
<span v-if="item.summary" class="block truncate text-[11px] text-slate-400">{{ item.summary }}</span>
</span>
<span v-if="item.detail" class="shrink-0 text-slate-400">{{ item.detail }}</span>
</button>
</div>
</div>
<input
v-model="commandText"
data-redis-command-input
class="dbx-editor-font-family min-w-0 w-full border-0 bg-transparent p-0 text-[13px] text-slate-200 caret-[#d7ba7d] outline-none placeholder:text-slate-500"
:class="{ 'opacity-50': commandRunning }"
:readonly="commandRunning"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
aria-autocomplete="list"
aria-haspopup="listbox"
:aria-controls="commandCompletionOpen ? commandCompletionListboxId : undefined"
:aria-activedescendant="commandCompletionOpen ? commandCompletionActiveDescendant : undefined"
:aria-expanded="commandCompletionOpen"
@click.stop="onCommandInputClick"
@input="onCommandInput"
@keydown="onCommandInputKeydown"
/>
</div>
<Loader2 v-if="commandRunning" class="h-3.5 w-3.5 shrink-0 animate-spin text-slate-500" />
</form>
</div>