diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 6b606e4e91..bd894bc25a 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -799,7 +799,7 @@ describe("RedisKeyBrowser command completion", () => { await setCommandInput("VGE"); expect(mocks.listRedisCompletionCommandDocs).toHaveBeenCalledWith("connection", "0"); - expect(commandCompletionLabels()).toContain("VGETReads a vendor key.string"); + expect(commandCompletionLabels()).toEqual(expect.arrayContaining([expect.stringContaining("VGET")])); const input = requiredElement("[data-redis-command-input]"); input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true })); @@ -808,7 +808,7 @@ describe("RedisKeyBrowser command completion", () => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); await settle(); - expect(input.value).toBe("VGET "); + expect(input.value).toBe("VGET arg1"); }); it("completes known keys only at a documented key argument", async () => { @@ -929,8 +929,35 @@ describe("RedisKeyBrowser command completion", () => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); await settle(); - expect(input.value).toBe("SET "); - expect(commandCompletionLabels()).toContain("user:1key"); + expect(input.value).toBe("SET arg1 arg2"); + expect(mocks.redisExecuteCommand).not.toHaveBeenCalled(); + }); + + it("inserts documented Redis argument examples before executing on Enter", async () => { + mocks.listRedisCompletionCommandDocs.mockResolvedValueOnce([ + { + name: "GETBIT", + group: "bitmap", + arity: 3, + keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }], + arguments: [ + { name: "key", type: "key" }, + { name: "offset", type: "integer" }, + ], + }, + ]); + mountBrowser(); + await settle(); + await openCommandPanel(); + await setCommandInput("GETB"); + + const input = requiredElement("[data-redis-command-input]"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settle(); + + expect(input.value).toBe("GETBIT key offset"); + expect(mocks.redisExecuteCommand).not.toHaveBeenCalled(); + expect(commandCompletionLabels()).toEqual([]); }); it("waits for command metadata instead of sending a partial command on Enter", async () => { @@ -952,7 +979,7 @@ describe("RedisKeyBrowser command completion", () => { await settle(); input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); await settle(); - expect(input.value).toBe("SET "); + expect(input.value).toBe("SET arg1 arg2"); expect(mocks.redisExecuteCommand).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 477263e96b..d52bcdc532 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -1792,7 +1792,9 @@ function commandCompletionInsertion(index = commandCompletionSelectedIndex.value 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 }; + const hasCommandExample = commandHead && item.apply !== undefined && item.apply !== item.label; + const replacement = `${insert}${appendSpace && !hasCommandExample ? " " : ""}`; + return { text, from, to, insert, replacement, appendSpace: appendSpace && !hasCommandExample, commandHead }; } function selectedCompletionMatchesInput(): boolean { @@ -2332,6 +2334,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); {{ item.label }} {{ item.summary }} + {{ item.apply }} {{ item.detail }} diff --git a/apps/desktop/src/lib/redis/redisCompletion.ts b/apps/desktop/src/lib/redis/redisCompletion.ts index 7fdc268845..f5728826eb 100644 --- a/apps/desktop/src/lib/redis/redisCompletion.ts +++ b/apps/desktop/src/lib/redis/redisCompletion.ts @@ -141,11 +141,52 @@ function buildSpecDetail(entry: CompletionCommandEntry): string { return entry.group; } +function argumentPlaceholder(argument: RedisCommandArgument): string { + const name = argument.name.trim().replaceAll(/[^a-zA-Z0-9_-]+/g, "_"); + return name || "value"; +} + +function exampleArguments(arguments_: readonly RedisCommandArgument[]): string[] { + const result: string[] = []; + for (const argument of arguments_) { + if (argument.optional) continue; + if (argument.type === "oneof") { + const first = argument.arguments?.find((candidate) => !candidate.optional); + if (first) result.push(...exampleArgument(first)); + continue; + } + result.push(...exampleArgument(argument)); + } + return result; +} + +function exampleArgument(argument: RedisCommandArgument): string[] { + if (argument.enum?.length) return [argument.enum[0]!]; + const nested = argument.arguments?.length ? exampleArguments(argument.arguments) : []; + const token = argument.token?.trim().toUpperCase(); + if (argument.type === "pure-token") return token ? [token] : []; + if (token && nested.length) return [token, ...nested]; + if (nested.length) return nested; + if (token) return [token, argumentPlaceholder(argument)]; + return [argumentPlaceholder(argument)]; +} + +function commandExample(entry: CompletionCommandEntry): string { + let arguments_ = entry.arguments.length ? exampleArguments(entry.arguments) : []; + if (arguments_.length === 0 && entry.arity) { + const count = entry.arity > 0 ? entry.arity - 1 : -entry.arity - 1; + arguments_ = Array.from({ length: Math.min(Math.max(count, 0), 8) }, (_, index) => `arg${index + 1}`); + } + return [entry.name, ...arguments_].join(" "); +} + function buildSpecInfo(entry: CompletionCommandEntry, label: string): string { const info = []; if (entry.summary) info.push(entry.summary); info.push(`Command: ${label}`, `Group: ${entry.group}`, `Arity: ${describeArity(entry.arity)}`); if (entry.since) info.push(`Since: ${entry.since}`); + const example = commandExample(entry); + if (example !== entry.name) info.push(`Example: ${example}`); return info.join("\n"); } @@ -216,6 +257,7 @@ function commandItems(index: CompletionIndex, prefix: string): RedisCompletionIt info: buildSpecInfo(entry, entry.name), summary: entry.summary, since: entry.since, + apply: commandExample(entry), boost: boostFor(entry), })); return items.sort((a, b) => b.boost - a.boost); @@ -231,6 +273,7 @@ function subcommandItems(index: CompletionIndex, commandPrefix: string, prefix: info: buildSpecInfo(entry, entry.name), summary: entry.summary, since: entry.since, + apply: commandExample(entry).slice(commandPrefix.length + 1), boost: boostFor(entry), })); return items.sort((a, b) => b.boost - a.boost); diff --git a/packages/app-tests/redisCompletion.test.ts b/packages/app-tests/redisCompletion.test.ts index 9f885518b3..7fb6e17914 100644 --- a/packages/app-tests/redisCompletion.test.ts +++ b/packages/app-tests/redisCompletion.test.ts @@ -111,6 +111,22 @@ test("command mode completes documented names case-insensitively", () => { assert.ok(!labels(complete("GET")).some((name) => name.includes(" "))); }); +test("command completion inserts a server-documented argument example", () => { + const getbit: RedisCommandDocumentation = { + name: "GETBIT", + group: "bitmap", + arity: 3, + arguments: [ + { name: "key", type: "key" }, + { name: "offset", type: "integer" }, + ], + keySpecs: oneKey, + }; + const item = buildRedisCompletionItems("GETB", 4, { commands: [getbit] })[0]; + assert.equal(item?.apply, "GETBIT key offset"); + assert.match(item?.info ?? "", /Example: GETBIT key offset/); +}); + test("subcommand mode follows the server's command hierarchy", () => { const names = labels(complete("XGROUP ")); assert.deepEqual(new Set(names), new Set(["CREATE", "DESTROY", "SETID"]));