fix: restore Fish completion after global CLI options (#115222)

* fix(cli): restore Fish completions after root options

* test(ui): restore shared history after agent tool clicks

* test(ui): wait for terminal availability before toggling

* test(ui): wait for Settings takeover before asserting sidebar
This commit is contained in:
Peter Steinberger
2026-07-28 12:19:20 -04:00
committed by GitHub
parent d44d0d96ad
commit 4d9588dfed
5 changed files with 84 additions and 12 deletions
+52 -3
View File
@@ -304,7 +304,7 @@ describe("completion-cli", () => {
const script = getCompletionScript("fish", createCompletionProgram());
expect(script).toContain(
'complete -c openclaw -n "__fish_use_subcommand" -a "gateway" -d \'Gateway commands\'',
'complete -c openclaw -n "__openclaw_command_path_matches --" -a "gateway" -d \'Gateway commands\'',
);
expect(script).toContain(
'complete -c openclaw -n "__openclaw_command_path_matches gateway -- -t --token" -a "status" -d \'Show gateway status\'',
@@ -326,6 +326,43 @@ describe("completion-cli", () => {
expect(script).toContain("'gateway status'");
});
itWithFish.each([
["a separate long root option", "openclaw --profile work g"],
["an inline long root option", "openclaw --profile=work g"],
["a separate short root option", "openclaw -p work g"],
["an inline short root option", "openclaw -p=work g"],
["an attached short root option", "openclaw -pwork g"],
["a separate log-level root option", "openclaw --log-level debug g"],
["an inline log-level root option", "openclaw --log-level=debug g"],
["a separate container root option", "openclaw --container local g"],
["an inline container root option", "openclaw --container=local g"],
["repeated root options", "openclaw --profile first --profile second g"],
[
"mixed value-taking root options",
"openclaw --profile work --log-level debug --container local g",
],
["a preceding boolean root option", "openclaw -v --profile work g"],
["a root option value named like a command", "openclaw --profile gateway g"],
])("completes root commands in real Fish after %s", (_name, commandLine) => {
const program = createCompletionProgram()
.option("-p, --profile <name>", "Profile")
.option("--log-level <level>", "Log level")
.option("--container <name>", "Container");
expect(runGeneratedFishCompletion(program, commandLine)).toContain("gateway");
});
itWithFish.each([
["a separate long root option", "openclaw --profile work --p"],
["an inline long root option", "openclaw --profile=work --p"],
["a separate short root option", "openclaw -p work --p"],
["repeated root options", "openclaw --profile first --profile second --p"],
])("completes root options in real Fish after %s", (_name, commandLine) => {
const program = createCompletionProgram().option("-p, --profile <name>", "Profile");
expect(runGeneratedFishCompletion(program, commandLine)).toContain("--profile");
});
itWithFish.each([
["the exact nested command", "openclaw gateway status -"],
["a separate long option value", "openclaw gateway --token secret status -"],
@@ -383,7 +420,7 @@ describe("completion-cli", () => {
const fishScript = getCompletionScript("fish", program);
expect(fishScript).toContain(
"complete -c openclaw -n \"__fish_use_subcommand\" -l trigger-script -d 'Condition script file, or - for stdin'",
"complete -c openclaw -n \"__openclaw_command_path_matches -- --trigger-script --ws --workspace\" -l trigger-script -d 'Condition script file, or - for stdin'",
);
expect(fishScript).not.toContain(" -s > ");
expect(fishScript).toContain(" -l ws -l workspace -d 'Workspace'");
@@ -508,6 +545,18 @@ function createAliasedCompletionProgram(): Command {
}
describe("completion-cli command aliases", () => {
itWithFish.each([
["a canonical root command", "openclaw --profile work inf", "infer"],
["an aliased root command", "openclaw --profile work cap", "capability"],
["an inline profile and alias", "openclaw --profile=work cap", "capability"],
["an alias-shaped profile value", "openclaw --profile capability cap", "capability"],
["a repeated profile and alias", "openclaw --profile first --profile second cap", "capability"],
])("completes real Fish root aliases after %s", (_name, commandLine, expected) => {
expect(runGeneratedFishCompletion(createAliasedCompletionProgram(), commandLine)).toContain(
expected,
);
});
it("completes root and nested aliases in zsh lists and dispatch", () => {
const script = getCompletionScript("zsh", createAliasedCompletionProgram());
@@ -568,7 +617,7 @@ printf '%s\\n' "\${COMPREPLY[@]}"
const script = getCompletionScript("fish", createAliasedCompletionProgram());
expect(script).toContain(
'complete -c openclaw -n "__fish_use_subcommand" -a "capability" -d \'Run inference\'',
'complete -c openclaw -n "__openclaw_command_path_matches -- --profile" -a "capability" -d \'Run inference\'',
);
expect(script).toContain(
'complete -c openclaw -n "__openclaw_command_path_matches capability -- --profile" -a "embed" -d \'Embed text\'',
+8 -7
View File
@@ -147,9 +147,11 @@ function __${rootCmd}_command_path_matches
end
set -a command_tokens $token
end
for i in (seq (count $expected))
if test "$command_tokens[$i]" != "$expected[$i]"
return 1
if test (count $expected) -gt 0
for i in (seq (count $expected))
if test "$command_tokens[$i]" != "$expected[$i]"
return 1
end
end
end
${rejectDescendantCommands}
@@ -164,7 +166,8 @@ function fishCommandPathCondition(
parents: readonly string[],
): string {
const valueOptions = collectFishPathOptionFlags(program, parents, true);
return `__${rootCmd}_command_path_matches ${parents.join(" ")} -- ${fishWords(valueOptions)}`.trimEnd();
const commandPath = parents.length > 0 ? ` ${parents.join(" ")}` : "";
return `__${rootCmd}_command_path_matches${commandPath} -- ${fishWords(valueOptions)}`.trimEnd();
}
async function writeCompletionCache(params: {
@@ -626,9 +629,7 @@ function generateFishCompletion(program: Command): string {
// One condition per alias-expanded parent path so completion keeps working
// after the user typed an alias segment.
const conditions = parentVariants.map((parents) =>
parents.length === 0
? "__fish_use_subcommand"
: fishCommandPathCondition(program, rootCmd, parents),
fishCommandPathCondition(program, rootCmd, parents),
);
for (const condition of conditions) {
// Subcommands (canonical names and aliases)
@@ -341,6 +341,8 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await expect.poll(() => identityCard.isVisible()).toBe(true);
await openSettingsFromIdentity();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/general");
// Route changes paint Settings before the previous app sidebar finishes yielding.
await sidebar.waitFor({ state: "hidden" });
const { search: settingsSearch, sidebar: settingsSidebar } =
await waitForSettingsSidebar(page);
await expect.poll(() => sidebar.isVisible()).toBe(false);
+10
View File
@@ -75,6 +75,16 @@ describeControlUiE2e("Control UI terminal repaint", () => {
try {
await page.goto(server.baseUrl);
await gateway.waitForRequest("connect");
// The connect request is observable before its response upgrades the lazy terminal panel.
// Wait for the advertised surface before exercising the real keyboard shortcut.
await page.waitForFunction(
() =>
(
document.querySelector("openclaw-terminal-panel") as
| (HTMLElement & { available?: boolean })
| null
)?.available === true,
);
await page.keyboard.press("Control+Backquote");
await gateway.waitForRequest("terminal.open");
@@ -1,11 +1,21 @@
// Control UI tests cover agents panels tools skills behavior.
import { render } from "lit";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { SkillStatusEntry } from "../../api/types.ts";
import { renderAgentSkills, renderAgentTools } from "./panels-tools-skills.ts";
let previousBrowserLocation: { state: unknown; url: string };
beforeEach(() => {
previousBrowserLocation = {
state: window.history.state,
url: `${window.location.pathname}${window.location.search}${window.location.hash}`,
};
});
afterEach(() => {
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
// Non-isolated UI tests share browser history; restore both its state and URL.
window.history.replaceState(previousBrowserLocation.state, "", previousBrowserLocation.url);
});
function createBaseParams(overrides: Partial<Parameters<typeof renderAgentTools>[0]> = {}) {