fix: restore PowerShell completion before later arguments (#147737)

Use the supplied cursor position when deriving completion context, so later words do not hide command and option-value suggestions. Preserve existing end-of-line behavior through the same parser.
This commit is contained in:
Peter Steinberger 2026-09-13 20:26:10 -07:00 • committed by GitHub
parent cde0cdaf59
commit f6d984fcbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 41 additions and 5 deletions

View file

@ -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

View file

@ -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<void> | undefined;
private stdoutLines: ReadlineInterface | undefined;
complete(program: Command, commandLine: string): Promise<string[]> {
complete(
program: Command,
commandLine: string,
cursorPosition = commandLine.length,
): Promise<string[]> {
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<string[]> {
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");

View file

@ -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",

View file

@ -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 }