diff --git a/docs/cli/completion.md b/docs/cli/completion.md index 0b9b41236755..a4ceed01c288 100644 --- a/docs/cli/completion.md +++ b/docs/cli/completion.md @@ -65,6 +65,7 @@ profile selected in the table above. It has no profile-file destination option. - Completion generation eagerly loads the full command tree, including plugin CLI commands, so nested subcommands are included. - If invalid configuration prevents plugin discovery, generation warns and still includes core commands. Repair the configuration and regenerate to include plugin commands. - Bash completion supports both `--flag value` and `--flag=value`, including named profiles before nested commands and single-quoted, double-quoted, or backslash-escaped value prefixes. +- PowerShell completes commands and option values at the cursor, including when later arguments are already present. - `openclaw update` refreshes the completion cache automatically after a successful update. `openclaw doctor` can repair missing or stale completion setups. ## Related diff --git a/src/cli/completion-cli.test-support.ts b/src/cli/completion-cli.test-support.ts index 5a0f885963dd..99411392608a 100644 --- a/src/cli/completion-cli.test-support.ts +++ b/src/cli/completion-cli.test-support.ts @@ -180,7 +180,7 @@ while (($encodedRequest = [Console]::In.ReadLine()) -ne $null) { $completions = @( [System.Management.Automation.CommandCompletion]::CompleteInput( $commandLine, - $commandLine.Length, + [int]$request.cursorPosition, $null ).CompletionMatches | ForEach-Object { [string]$_.CompletionText } ) @@ -249,15 +249,23 @@ export class PowerShellCompletionRunner { private readyPromise: Promise | undefined; private stdoutLines: ReadlineInterface | undefined; - complete(program: Command, commandLine: string): Promise { + complete( + program: Command, + commandLine: string, + cursorPosition = commandLine.length, + ): Promise { const script = getCompletionScript("powershell", program); const caseId = createHash("sha256") .update(script) .update("\0") .update(commandLine) + .update("\0") + .update(String(cursorPosition)) .digest("hex") .slice(0, 20); - const result = this.queue.then(() => this.completeCase(caseId, script, commandLine)); + const result = this.queue.then(() => + this.completeCase(caseId, script, commandLine, cursorPosition), + ); this.queue = result.then( () => undefined, () => undefined, @@ -307,6 +315,7 @@ export class PowerShellCompletionRunner { caseId: string, script: string, commandLine: string, + cursorPosition: number, ): Promise { await this.start(); if (this.failure) { @@ -322,6 +331,7 @@ export class PowerShellCompletionRunner { id: caseId, script: Buffer.from(script, "utf8").toString("base64"), commandLine: Buffer.from(commandLine, "utf8").toString("base64"), + cursorPosition, }), "utf8", ).toString("base64"); diff --git a/src/cli/completion-cli.test.ts b/src/cli/completion-cli.test.ts index 449a63f4f754..768b6b338e73 100644 --- a/src/cli/completion-cli.test.ts +++ b/src/cli/completion-cli.test.ts @@ -287,6 +287,31 @@ _openclaw_root_completion ).toEqual(["fish"]); }); + itWithPowerShell.each([ + { + name: "an option after a shell value", + prefix: "openclaw completion --shell f", + suffix: " --yes", + expected: ["fish"], + }, + { + name: "a shared command name after the root command", + prefix: "openclaw g", + suffix: " status --json", + expected: ["gateway"], + }, + ])( + "ignores real PowerShell words after the cursor: $name", + async ({ prefix, suffix, expected }) => { + const program = createDocumentedCompletionProgram(); + program.command("status").description("Root status").option("--json", "JSON output"); + + expect( + await powerShellCompletion.complete(program, `${prefix}${suffix}`, prefix.length), + ).toEqual(expected); + }, + ); + itWithPowerShell.each([ { name: "an omitted optional value", diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index c44272e736c1..073991d60b73 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -494,7 +494,8 @@ ${optionChoiceCases} Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) - $commandElements = $commandAst.CommandElements + # Limit context to the cursor; command-path parsing below skips option operands. + $commandElements = @($commandAst.CommandElements.Where({ $_.Extent.StartOffset -lt $cursorPosition })) $commandPath = "" $valueOptions = ${formatPowerShellArray(rootValueOptions)} $previousElementIndex = if ($wordToComplete -eq '') { $commandElements.Count - 1 } else { $commandElements.Count - 2 } @@ -508,7 +509,6 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock { $choiceCompletionPrefix = "$choiceFlag=" } - # Skip option values so global and nested flags cannot hide the command path. for ($i = 1; $i -lt $commandElements.Count; $i++) { $element = $commandElements[$i].Extent.Text if ($i -eq $commandElements.Count - 1 -and $wordToComplete -ne "") { break }