From 4d9588dfedbd19577250856686d6bf08599ce201 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Jul 2026 12:19:20 -0400 Subject: [PATCH] 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 --- src/cli/completion-cli.test.ts | 55 ++++++++++++++++++- src/cli/completion-cli.ts | 15 ++--- ui/src/e2e/sidebar-customization.e2e.test.ts | 2 + ui/src/e2e/terminal-repaint.e2e.test.ts | 10 ++++ .../panels-tools-skills.browser.test.ts | 14 ++++- 5 files changed, 84 insertions(+), 12 deletions(-) diff --git a/src/cli/completion-cli.test.ts b/src/cli/completion-cli.test.ts index 022b95175302..5d020b857f7c 100644 --- a/src/cli/completion-cli.test.ts +++ b/src/cli/completion-cli.test.ts @@ -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 ", "Profile") + .option("--log-level ", "Log level") + .option("--container ", "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 ", "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\'', diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index 3e6b735e804d..760b23895dc2 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -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) diff --git a/ui/src/e2e/sidebar-customization.e2e.test.ts b/ui/src/e2e/sidebar-customization.e2e.test.ts index f987a5f9ac06..a4908407f090 100644 --- a/ui/src/e2e/sidebar-customization.e2e.test.ts +++ b/ui/src/e2e/sidebar-customization.e2e.test.ts @@ -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); diff --git a/ui/src/e2e/terminal-repaint.e2e.test.ts b/ui/src/e2e/terminal-repaint.e2e.test.ts index 478881a3345c..2c2d5a2821d7 100644 --- a/ui/src/e2e/terminal-repaint.e2e.test.ts +++ b/ui/src/e2e/terminal-repaint.e2e.test.ts @@ -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"); diff --git a/ui/src/pages/agents/panels-tools-skills.browser.test.ts b/ui/src/pages/agents/panels-tools-skills.browser.test.ts index 9a5f1d10b955..c564e1653c76 100644 --- a/ui/src/pages/agents/panels-tools-skills.browser.test.ts +++ b/ui/src/pages/agents/panels-tools-skills.browser.test.ts @@ -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[0]> = {}) {