This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 60034e10cab Convert `check_translation_completeness.py` into breeze 
command (#58637)
60034e10cab is described below

commit 60034e10cab135cfbec660f7406d848762389ebd
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Nov 25 00:39:08 2025 +0100

    Convert `check_translation_completeness.py` into breeze command (#58637)
    
    The script had no good support for auto-complete and the like and
    since everyone now use breeze for some tasks, we also should
    have it as a regular breeze command.
    
    Also the sorting order implemented by eslint jsonc natural
    sorting was quite a bit different from what you'd expect:
    
    * it sorts character-by-character case-insensitive
    * it uses original sorting as tie-breker in case of characters
      case-insensitve order
    * handles numbers natuarlly (2<10)
    * treats _ as end of word and sort it before everything else
    * similarly with end of word - shorter words are before longer
      if they have the same prefix
    
    This PR updates the command and also fixes sorting order and
    add unit tests to test vital parts of the translation script.
---
 .github/workflows/basic-tests.yml                  |  17 +-
 .pre-commit-config.yaml                            |   7 -
 airflow-core/src/airflow/ui/public/i18n/README.md  |  15 +-
 dev/breeze/doc/09_release_management_tasks.rst     |  10 +-
 dev/breeze/doc/10_ui_tasks.rst                     |  75 +++++
 ...ze_topics.rst => 11_advanced_breeze_topics.rst} |   0
 dev/breeze/doc/README.rst                          |   3 +-
 dev/breeze/doc/images/output-commands.svg          |  34 +-
 .../output_setup_check-all-params-in-groups.svg    |   4 +-
 .../output_setup_check-all-params-in-groups.txt    |   2 +-
 .../output_setup_regenerate-command-images.svg     |   4 +-
 .../output_setup_regenerate-command-images.txt     |   2 +-
 dev/breeze/doc/images/output_ui.svg                | 103 ++++++
 dev/breeze/doc/images/output_ui.txt                |   1 +
 .../output_ui_check-translation-completeness.svg   | 120 +++++++
 .../output_ui_check-translation-completeness.txt   |   1 +
 dev/breeze/src/airflow_breeze/breeze.py            |   2 +
 .../src/airflow_breeze/commands/ui_commands.py}    | 366 +++++++++++----------
 .../airflow_breeze/commands/ui_commands_config.py  |  37 +++
 .../src/airflow_breeze/configure_rich_click.py     |  15 +-
 dev/breeze/tests/test_ui_commands.py               | 347 +++++++++++++++++++
 devel-common/src/docs/utils/conf_constants.py      |   1 -
 22 files changed, 938 insertions(+), 228 deletions(-)

diff --git a/.github/workflows/basic-tests.yml 
b/.github/workflows/basic-tests.yml
index 84accb1d802..7c70bffd45d 100644
--- a/.github/workflows/basic-tests.yml
+++ b/.github/workflows/basic-tests.yml
@@ -188,21 +188,10 @@ jobs:
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # 
v4.2.2
         with:
           persist-credentials: false
-      - name: "Install prek"
-        uses: ./.github/actions/install-prek
-        id: prek
-        with:
-          python-version: ${{steps.breeze.outputs.host-python-version}}
-          platform: ${{ inputs.platform }}
-          save-cache: false
+      - name: "Install Breeze"
+        uses: ./.github/actions/breeze
       - name: "Check translation completeness"
-        run: >
-          prek --show-diff-on-failure --color always
-          --hook-stage manual --verbose --all-files
-          check-translations-completeness
-        env:
-          SKIP: ${{ inputs.skip-prek-hooks }}
-          COLUMNS: "202"
+        run: breeze check-translations-completeness || true
 
   # Those checks are run if no image needs to be built for checks. This is for 
simple changes that
   # Do not touch any of the python code or any of the important files that 
might require building
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9960e0522fb..babeba5d22a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -192,13 +192,6 @@ repos:
           ^scripts/ci/prek/update_installers_and_prek\.py$
         pass_filenames: false
         require_serial: true
-      - id: check-translations-completeness
-        name: Check translation completeness (manual)
-        entry: ./dev/i18n/check_translations_completeness.py
-        stages: ['manual']
-        language: python
-        pass_filenames: false
-        require_serial: true
   - repo: https://github.com/adamchainz/blacken-docs
     rev: dda8db18cfc68df532abf33b185ecd12d5b7b326  # frozen: 1.20.0
     hooks:
diff --git a/airflow-core/src/airflow/ui/public/i18n/README.md 
b/airflow-core/src/airflow/ui/public/i18n/README.md
index e2fe51ff739..e219db4e830 100644
--- a/airflow-core/src/airflow/ui/public/i18n/README.md
+++ b/airflow-core/src/airflow/ui/public/i18n/README.md
@@ -224,7 +224,8 @@ The following steps outline the process for approving a new 
locale to be added t
 
 - Creating a PR for adding the suggested locale to the
   codebase ([see 
example](https://github.com/apache/airflow/pull/51258/files)), which includes:
-  - Adding the plural form rules for the suggested locale under 
`PLURAL_SUFFIXES` constant in `dev/i18n/check_translations_completeness.py`.
+  - Adding the plural form rules for the suggested locale under 
`PLURAL_SUFFIXES` constant in
+    `dev/breeze/commands/ui_commands.py`.
   - The locale files (translated according to the guidelines) in the
     `airflow-core/src/airflow/ui/public/i18n/locales/<LOCALE_CODE>` directory, 
where `<LOCALE_CODE>` is the
     code of the language according to ISO 639-1 standard (e.g., `fr` for 
French). Languages with regional
@@ -310,7 +311,7 @@ Language proficiency for translation owners can be 
demonstrated through any of t
 All files:
 
 ```bash
-uv run dev/i18n/check_translations_completeness.py
+breeze ui check-translation-completeness
 ```
 
 > [!NOTE]
@@ -320,7 +321,7 @@ uv run dev/i18n/check_translations_completeness.py
 Files for specific languages:
 
 ```bash
-uv run dev/i18n/check_translations_completeness.py --language <language_code>
+breeze ui check-translation-completeness --language <language_code>
 ```
 
 Where `<language_code>` is the code of the language you want to check, e.g., 
`en`, `fr`, `de`, etc.
@@ -328,25 +329,25 @@ Where `<language_code>` is the code of the language you 
want to check, e.g., `en
 Adding missing translations (with `TODO: translate` prefix):
 
 ```bash
-uv run dev/i18n/check_translations_completeness.py --language <language_code> 
--add-missing
+breeze ui check-translation-completeness --language <language_code> 
--add-missing
 ```
 
 You can also remove extra translations from the language of your choice:
 
 ```bash
-uv run dev/i18n/check_translations_completeness.py --language <language_code> 
--remove-extra
+breeze ui check-translation-completeness --language <language_code> 
--remove-extra
 ```
 
 Or from all languages:
 
 ```bash
-uv run dev/i18n/check_translations_completeness.py --remove-extra
+breeze ui check-translation-completeness --remove-extra
 ```
 
 The script is also added as a prek hook (manual) so that it can be run from 
within `prek` and CI:
 
 ```bash
-prek run --hook-stage manual check-translations-completeness --verbose 
--all-files
+breeze ui check-translation-completeness --verbose --all-files
 ```
 
 
diff --git a/dev/breeze/doc/09_release_management_tasks.rst 
b/dev/breeze/doc/09_release_management_tasks.rst
index ced15768b19..3d3108c6df2 100644
--- a/dev/breeze/doc/09_release_management_tasks.rst
+++ b/dev/breeze/doc/09_release_management_tasks.rst
@@ -800,11 +800,6 @@ properties of the dependencies. This is done by the 
``export-dependency-informat
   :width: 100%
   :alt: Breeze sbom export dependency information
 
------
-
-Next step: Follow the `Advanced Breeze topics 
<10_advanced_breeze_topics.rst>`_ to
-learn more about Breeze internals.
-
 Preparing Airflow Task SDK distributions
 """""""""""""""""""""""""""""""""""
 
@@ -1028,3 +1023,8 @@ Example usage:
 .. code-block:: bash
 
      breeze release-management constraints-version-check --python 3.10 
--airflow-constraints-mode constraints-source-providers --explain-why
+
+
+-----
+
+Next step: Follow the `UI Tasks <10_ui_tasks.rst>`_ to learn more about UI 
tasks.
diff --git a/dev/breeze/doc/10_ui_tasks.rst b/dev/breeze/doc/10_ui_tasks.rst
new file mode 100644
index 00000000000..0af6f739b24
--- /dev/null
+++ b/dev/breeze/doc/10_ui_tasks.rst
@@ -0,0 +1,75 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+ ..   http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+
+UI tasks
+--------
+
+There are some Breeze commands that are used to support Apache Airflow project 
UI.
+
+Those are all of the available ui commands:
+
+.. image:: ./images/output_ui.svg
+  :target: 
https://raw.githubusercontent.com/apache/airflow/main/dev/breeze/doc/images/output_ui.svg
+  :width: 100%
+  :alt: Breeze ui commands
+
+Check translation completeness
+""""""""""""""""""""""""""""""
+
+To check if the UI translations are complete for all languages, you can use the
+``breeze ui check-translation-completeness`` command. This command compares 
all non-English
+locale translations against the English (base) translations to identify:
+
+* Missing translation keys that need to be added
+* Extra translation keys that should be removed
+* TODO markers indicating incomplete translations
+* Translation coverage percentages per language
+
+The command supports language-specific plural forms (e.g., Polish has 4 forms, 
Arabic has 6)
+and can automatically add missing keys or remove extra keys.
+
+These are all available flags of ``check-translation-completeness`` command:
+
+.. image:: ./images/output_ui_check-translation-completeness.svg
+  :target: 
https://raw.githubusercontent.com/apache/airflow/main/dev/breeze/doc/images/output_ui_check-translation-completeness.svg
+  :width: 100%
+  :alt: Breeze check translation completeness
+
+Example usage:
+
+.. code-block:: bash
+
+     # Check all languages
+     breeze ui check-translation-completeness
+
+     # Check a specific language
+     breeze ui check-translation-completeness --language pl
+
+     # Add missing translations with TODO markers
+     breeze ui check-translation-completeness --add-missing
+
+     # Remove extra translations not present in English
+     breeze ui check-translation-completeness --remove-extra
+
+     # Fix translations for a specific language
+     breeze ui check-translation-completeness --language de --add-missing 
--remove-extra
+
+
+-----
+
+Next step: Follow the `Advanced Breeze topics 
<11_advanced_breeze_topics.rst>`__ instructions to learn more
+about advanced Breeze topics and internals.
diff --git a/dev/breeze/doc/10_advanced_breeze_topics.rst 
b/dev/breeze/doc/11_advanced_breeze_topics.rst
similarity index 100%
rename from dev/breeze/doc/10_advanced_breeze_topics.rst
rename to dev/breeze/doc/11_advanced_breeze_topics.rst
diff --git a/dev/breeze/doc/README.rst b/dev/breeze/doc/README.rst
index 9f5d0b9ad8e..026482f9055 100644
--- a/dev/breeze/doc/README.rst
+++ b/dev/breeze/doc/README.rst
@@ -48,7 +48,8 @@ The following documents describe how to use the Breeze 
environment:
 * `Breeze maintenance tasks <07_breeze_maintenance_tasks.rst>`_ - describes 
how to perform maintenance tasks when you develop Breeze itself.
 * `CI tasks <08_ci_tasks.rst>`_ - describes how Breeze commands are used in 
our CI.
 * `Release management tasks <09_release_management_tasks.rst>`_ - describes 
how to use Breeze for release management tasks.
-* `Advanced Breeze topics <10_advanced_breeze_topics.rst>`_ - describes 
advanced Breeze topics/internals of Breeze.
+* `UI tasks <10_ui_tasks.rst>`_ - describes how Breeze commands are used to 
support Apache Airflow project UI.
+* `Advanced Breeze topics <11_advanced_breeze_topics.rst>`_ - describes 
advanced Breeze topics/internals of Breeze.
 
 You can also learn more context and Architecture Decisions taken when 
developing Breeze in the
 `Architecture Decision Records <adr>`_.
diff --git a/dev/breeze/doc/images/output-commands.svg 
b/dev/breeze/doc/images/output-commands.svg
index aadf9fa52dd..2688d8b8e61 100644
--- a/dev/breeze/doc/images/output-commands.svg
+++ b/dev/breeze/doc/images/output-commands.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 2294.7999999999997" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 2416.7999999999997" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -43,7 +43,7 @@
 
     <defs>
     <clipPath id="breeze-help-clip-terminal">
-      <rect x="0" y="0" width="1463.0" height="2243.7999999999997" />
+      <rect x="0" y="0" width="1463.0" height="2365.7999999999997" />
     </clipPath>
     <clipPath id="breeze-help-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -318,9 +318,24 @@
 <clipPath id="breeze-help-line-90">
     <rect x="0" y="2197.5" width="1464" height="24.65"/>
             </clipPath>
+<clipPath id="breeze-help-line-91">
+    <rect x="0" y="2221.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-help-line-92">
+    <rect x="0" y="2246.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-help-line-93">
+    <rect x="0" y="2270.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-help-line-94">
+    <rect x="0" y="2295.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-help-line-95">
+    <rect x="0" y="2319.5" width="1464" height="24.65"/>
+            </clipPath>
     </defs>
 
-    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="2292.8" rx="8"/><text 
class="breeze-help-title" fill="#c5c8c6" text-anchor="middle" x="740" 
y="27">Breeze&#160;commands</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="2414.8" rx="8"/><text 
class="breeze-help-title" fill="#c5c8c6" text-anchor="middle" x="740" 
y="27">Breeze&#160;commands</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -418,10 +433,15 @@
 </text><text class="breeze-help-r5" x="0" y="2094" textLength="12.2" 
clip-path="url(#breeze-help-line-85)">│</text><text class="breeze-help-r4" 
x="24.4" y="2094" textLength="280.6" 
clip-path="url(#breeze-help-line-85)">sbom&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-help-r1" x="329.4" y="2094" textLength="1110.2" 
clip-path="url(#breeze-help-line-85)">Tools&#160;that&#160;release&#160;managers
 [...]
 </text><text class="breeze-help-r5" x="0" y="2118.4" textLength="12.2" 
clip-path="url(#breeze-help-line-86)">│</text><text class="breeze-help-r4" 
x="24.4" y="2118.4" textLength="280.6" 
clip-path="url(#breeze-help-line-86)">workflow-run&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-help-r1" x="329.4" y="2118.4" textLength="1110.2" 
clip-path="url(#breeze-help-line-86)">Tools&#160;to&#160;manage&#160;Airflow&#160;repository&#160;workflows&#160;&
 [...]
 </text><text class="breeze-help-r5" x="0" y="2142.8" textLength="1464" 
clip-path="url(#breeze-help-line-87)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-help-r1" x="1464" y="2142.8" textLength="12.2" 
clip-path="url(#breeze-help-line-87)">
-</text><text class="breeze-help-r5" x="0" y="2167.2" textLength="24.4" 
clip-path="url(#breeze-help-line-88)">╭─</text><text class="breeze-help-r5" 
x="24.4" y="2167.2" textLength="195.2" 
clip-path="url(#breeze-help-line-88)">&#160;Other&#160;commands&#160;</text><text
 class="breeze-help-r5" x="219.6" y="2167.2" textLength="1220" 
clip-path="url(#breeze-help-line-88)">────────────────────────────────────────────────────────────────────────────────────────────────────</text><text
 class="bree [...]
-</text><text class="breeze-help-r5" x="0" y="2191.6" textLength="12.2" 
clip-path="url(#breeze-help-line-89)">│</text><text class="breeze-help-r4" 
x="24.4" y="2191.6" textLength="122" 
clip-path="url(#breeze-help-line-89)">setup&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-help-r1" x="170.8" y="2191.6" textLength="1268.8" 
clip-path="url(#breeze-help-line-89)">Tools&#160;that&#160;developers&#160;can&#160;use&#160;to&#160;configure&#160;Breeze&#160;&#160;&#160;&#160;&#160;&#160;&
 [...]
-</text><text class="breeze-help-r5" x="0" y="2216" textLength="12.2" 
clip-path="url(#breeze-help-line-90)">│</text><text class="breeze-help-r4" 
x="24.4" y="2216" textLength="122" 
clip-path="url(#breeze-help-line-90)">ci&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-help-r1" x="170.8" y="2216" textLength="1268.8" 
clip-path="url(#breeze-help-line-90)">Tools&#160;that&#160;CI&#160;workflows&#160;use&#160;to&#160;cleanup/manage&#160;CI&#160;environment&#160;&#160;
 [...]
-</text><text class="breeze-help-r5" x="0" y="2240.4" textLength="1464" 
clip-path="url(#breeze-help-line-91)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-help-r1" x="1464" y="2240.4" textLength="12.2" 
clip-path="url(#breeze-help-line-91)">
+</text><text class="breeze-help-r5" x="0" y="2167.2" textLength="24.4" 
clip-path="url(#breeze-help-line-88)">╭─</text><text class="breeze-help-r5" 
x="24.4" y="2167.2" textLength="158.6" 
clip-path="url(#breeze-help-line-88)">&#160;CI&#160;commands&#160;</text><text 
class="breeze-help-r5" x="183" y="2167.2" textLength="1256.6" 
clip-path="url(#breeze-help-line-88)">───────────────────────────────────────────────────────────────────────────────────────────────────────</text><text
 class="bree [...]
+</text><text class="breeze-help-r5" x="0" y="2191.6" textLength="12.2" 
clip-path="url(#breeze-help-line-89)">│</text><text class="breeze-help-r4" 
x="24.4" y="2191.6" textLength="61" 
clip-path="url(#breeze-help-line-89)">ci&#160;&#160;&#160;</text><text 
class="breeze-help-r1" x="109.8" y="2191.6" textLength="1329.8" 
clip-path="url(#breeze-help-line-89)">Tools&#160;that&#160;CI&#160;workflows&#160;use&#160;to&#160;cleanup/manage&#160;CI&#160;environment&#160;&#160;&#160;&#160;&#160;&#160;&
 [...]
+</text><text class="breeze-help-r5" x="0" y="2216" textLength="1464" 
clip-path="url(#breeze-help-line-90)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-help-r1" x="1464" y="2216" textLength="12.2" 
clip-path="url(#breeze-help-line-90)">
+</text><text class="breeze-help-r5" x="0" y="2240.4" textLength="24.4" 
clip-path="url(#breeze-help-line-91)">╭─</text><text class="breeze-help-r5" 
x="24.4" y="2240.4" textLength="158.6" 
clip-path="url(#breeze-help-line-91)">&#160;UI&#160;commands&#160;</text><text 
class="breeze-help-r5" x="183" y="2240.4" textLength="1256.6" 
clip-path="url(#breeze-help-line-91)">───────────────────────────────────────────────────────────────────────────────────────────────────────</text><text
 class="bree [...]
+</text><text class="breeze-help-r5" x="0" y="2264.8" textLength="12.2" 
clip-path="url(#breeze-help-line-92)">│</text><text class="breeze-help-r4" 
x="24.4" y="2264.8" textLength="85.4" 
clip-path="url(#breeze-help-line-92)">ui&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-help-r1" x="134.2" y="2264.8" textLength="1305.4" 
clip-path="url(#breeze-help-line-92)">Tools&#160;for&#160;UI&#160;development&#160;and&#160;maintenance&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#16
 [...]
+</text><text class="breeze-help-r5" x="0" y="2289.2" textLength="1464" 
clip-path="url(#breeze-help-line-93)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-help-r1" x="1464" y="2289.2" textLength="12.2" 
clip-path="url(#breeze-help-line-93)">
+</text><text class="breeze-help-r5" x="0" y="2313.6" textLength="24.4" 
clip-path="url(#breeze-help-line-94)">╭─</text><text class="breeze-help-r5" 
x="24.4" y="2313.6" textLength="195.2" 
clip-path="url(#breeze-help-line-94)">&#160;Setup&#160;commands&#160;</text><text
 class="breeze-help-r5" x="219.6" y="2313.6" textLength="1220" 
clip-path="url(#breeze-help-line-94)">────────────────────────────────────────────────────────────────────────────────────────────────────</text><text
 class="bree [...]
+</text><text class="breeze-help-r5" x="0" y="2338" textLength="12.2" 
clip-path="url(#breeze-help-line-95)">│</text><text class="breeze-help-r4" 
x="24.4" y="2338" textLength="146.4" 
clip-path="url(#breeze-help-line-95)">setup&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-help-r1" x="195.2" y="2338" textLength="1244.4" 
clip-path="url(#breeze-help-line-95)">Tools&#160;that&#160;developers&#160;can&#160;use&#160;to&#160;configure&#160;Breeze&#160;&#160;&#160;&#160;&#160
 [...]
+</text><text class="breeze-help-r5" x="0" y="2362.4" textLength="1464" 
clip-path="url(#breeze-help-line-96)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-help-r1" x="1464" y="2362.4" textLength="12.2" 
clip-path="url(#breeze-help-line-96)">
 </text>
     </g>
     </g>
diff --git a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg 
b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg
index bf486b5983a..0cec401f3eb 100644
--- a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg
+++ b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg
@@ -230,8 +230,8 @@
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" y="874" 
textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-35)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="183" y="874" 
textLength="1256.6" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-35)">testing:airflow-ctl-tests&#160;|&#160;testing:airflow-e2e-tests&#160;|&#160;testing:core-integration-tests&#160;|&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#
 [...]
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="898.4" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-36)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="183" y="898.4" 
textLength="1256.6" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-36)">testing:core-tests&#160;|&#160;testing:docker-compose-tests&#160;|&#160;testing:helm-tests&#160;|&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#
 [...]
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="922.8" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-37)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="183" y="922.8" 
textLength="1256.6" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-37)">testing:providers-integration-tests&#160;|&#160;testing:providers-tests&#160;|&#160;testing:python-api-client-tests&#160;|&#160;&#160;&#160;&#160;&#160;&#160;<
 [...]
-</text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="947.2" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-38)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="183" y="947.2" 
textLength="1256.6" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-38)">testing:system-tests&#160;|&#160;testing:task-sdk-integration-tests&#160;|&#160;testing:task-sdk-tests&#160;|&#160;workflow-run&#160;|&#160;&#160;&#160;&#160;<
 [...]
-</text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="971.6" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-39)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="183" y="971.6" 
textLength="1256.6" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-39)">workflow-run:publish-docs)&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&
 [...]
+</text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="947.2" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-38)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="183" y="947.2" 
textLength="1256.6" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-38)">testing:system-tests&#160;|&#160;testing:task-sdk-integration-tests&#160;|&#160;testing:task-sdk-tests&#160;|&#160;ui&#160;|&#160;&#160;&#160;&#160;&#160;&#160
 [...]
+</text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="971.6" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-39)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="183" y="971.6" 
textLength="1256.6" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-39)">ui:check-translation-completeness&#160;|&#160;workflow-run&#160;|&#160;workflow-run:publish-docs)&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#
 [...]
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" y="996" 
textLength="1464" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-40)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-setup-check-all-params-in-groups-r1" x="1464" y="996" 
textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-40)">
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="1020.4" textLength="24.4" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-41)">╭─</text><text
 class="breeze-setup-check-all-params-in-groups-r5" x="24.4" y="1020.4" 
textLength="195.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-41)">&#160;Common&#160;options&#160;</text><text
 class="breeze-setup-check-all-params-in-groups-r5" x="219.6" y="1020.4" 
textLength="1220" clip-path="url(#breeze [...]
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="1044.8" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-42)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r4" x="24.4" y="1044.8" 
textLength="109.8" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-42)">--verbose</text><text
 class="breeze-setup-check-all-params-in-groups-r7" x="158.6" y="1044.8" 
textLength="24.4" clip-path="url(#breeze-setup-check-all-params [...]
diff --git a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt 
b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt
index 75525b96b1c..c0c7b1f56a1 100644
--- a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt
+++ b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt
@@ -1 +1 @@
-d27bbff9d25e82d1301e7c2551d58a7c
+5dab62d284471790819379a477af4a18
diff --git a/dev/breeze/doc/images/output_setup_regenerate-command-images.svg 
b/dev/breeze/doc/images/output_setup_regenerate-command-images.svg
index 7b850fafce6..a89333e1f66 100644
--- a/dev/breeze/doc/images/output_setup_regenerate-command-images.svg
+++ b/dev/breeze/doc/images/output_setup_regenerate-command-images.svg
@@ -248,8 +248,8 @@
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="947.2" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-38)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="219.6" y="947.2" 
textLength="1220" 
clip-path="url(#breeze-setup-regenerate-command-images-line-38)">testing:airflow-e2e-tests&#160;|&#160;testing:core-integration-tests&#160;|&#160;testing:core-tests&#160;|&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#1
 [...]
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="971.6" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-39)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="219.6" y="971.6" 
textLength="1220" 
clip-path="url(#breeze-setup-regenerate-command-images-line-39)">testing:docker-compose-tests&#160;|&#160;testing:helm-tests&#160;|&#160;testing:providers-integration-tests&#160;|&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&
 [...]
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="996" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-40)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="219.6" y="996" 
textLength="1220" 
clip-path="url(#breeze-setup-regenerate-command-images-line-40)">testing:providers-tests&#160;|&#160;testing:python-api-client-tests&#160;|&#160;testing:system-tests&#160;|&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;
 [...]
-</text><text class="breeze-setup-regenerate-command-images-r5" x="0" 
y="1020.4" textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-41)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="219.6" y="1020.4" 
textLength="1220" 
clip-path="url(#breeze-setup-regenerate-command-images-line-41)">testing:task-sdk-integration-tests&#160;|&#160;testing:task-sdk-tests&#160;|&#160;workflow-run&#160;|&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;
 [...]
-</text><text class="breeze-setup-regenerate-command-images-r5" x="0" 
y="1044.8" textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-42)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="219.6" y="1044.8" 
textLength="1220" 
clip-path="url(#breeze-setup-regenerate-command-images-line-42)">workflow-run:publish-docs)&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#1
 [...]
+</text><text class="breeze-setup-regenerate-command-images-r5" x="0" 
y="1020.4" textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-41)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="219.6" y="1020.4" 
textLength="1220" 
clip-path="url(#breeze-setup-regenerate-command-images-line-41)">testing:task-sdk-integration-tests&#160;|&#160;testing:task-sdk-tests&#160;|&#160;ui&#160;|&#160;ui:check-translation-completeness</text><text
 class="breeze-setu [...]
+</text><text class="breeze-setup-regenerate-command-images-r5" x="0" 
y="1044.8" textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-42)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="219.6" y="1044.8" 
textLength="1220" 
clip-path="url(#breeze-setup-regenerate-command-images-line-42)">|&#160;workflow-run&#160;|&#160;workflow-run:publish-docs)&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&
 [...]
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" 
y="1069.2" textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-43)">│</text><text 
class="breeze-setup-regenerate-command-images-r4" x="24.4" y="1069.2" 
textLength="146.4" 
clip-path="url(#breeze-setup-regenerate-command-images-line-43)">--check-only</text><text
 class="breeze-setup-regenerate-command-images-r1" x="219.6" y="1069.2" 
textLength="1220" clip-path="url(#breeze-setup-regenerate-command [...]
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" 
y="1093.6" textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-44)">│</text><text 
class="breeze-setup-regenerate-command-images-r1" x="219.6" y="1093.6" 
textLength="170.8" 
clip-path="url(#breeze-setup-regenerate-command-images-line-44)">together&#160;with&#160;</text><text
 class="breeze-setup-regenerate-command-images-r4" x="390.4" y="1093.6" 
textLength="109.8" clip-path="url(#breeze-setup-rege [...]
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="1118" 
textLength="1464" 
clip-path="url(#breeze-setup-regenerate-command-images-line-45)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-setup-regenerate-command-images-r1" x="1464" y="1118" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-45)">
diff --git a/dev/breeze/doc/images/output_setup_regenerate-command-images.txt 
b/dev/breeze/doc/images/output_setup_regenerate-command-images.txt
index 3a3ad4da377..7e0db4d0ba4 100644
--- a/dev/breeze/doc/images/output_setup_regenerate-command-images.txt
+++ b/dev/breeze/doc/images/output_setup_regenerate-command-images.txt
@@ -1 +1 @@
-6c27bb0f42301f5a0a8329c791d95c79
+edd856a0a570e6ff1cb050d0177ea242
diff --git a/dev/breeze/doc/images/output_ui.svg 
b/dev/breeze/doc/images/output_ui.svg
new file mode 100644
index 00000000000..3e473eef89e
--- /dev/null
+++ b/dev/breeze/doc/images/output_ui.svg
@@ -0,0 +1,103 @@
+<svg class="rich-terminal" viewBox="0 0 1482 318.4" 
xmlns="http://www.w3.org/2000/svg";>
+    <!-- Generated with Rich https://www.textualize.io -->
+    <style>
+
+    @font-face {
+        font-family: "Fira Code";
+        src: local("FiraCode-Regular"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2";)
 format("woff2"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff";)
 format("woff");
+        font-style: normal;
+        font-weight: 400;
+    }
+    @font-face {
+        font-family: "Fira Code";
+        src: local("FiraCode-Bold"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2";)
 format("woff2"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff";)
 format("woff");
+        font-style: bold;
+        font-weight: 700;
+    }
+
+    .breeze-ui-matrix {
+        font-family: Fira Code, monospace;
+        font-size: 20px;
+        line-height: 24.4px;
+        font-variant-east-asian: full-width;
+    }
+
+    .breeze-ui-title {
+        font-size: 18px;
+        font-weight: bold;
+        font-family: arial;
+    }
+
+    .breeze-ui-r1 { fill: #c5c8c6 }
+.breeze-ui-r2 { fill: #d0b344 }
+.breeze-ui-r3 { fill: #c5c8c6;font-weight: bold }
+.breeze-ui-r4 { fill: #68a0b3;font-weight: bold }
+.breeze-ui-r5 { fill: #868887 }
+.breeze-ui-r6 { fill: #98a84b;font-weight: bold }
+    </style>
+
+    <defs>
+    <clipPath id="breeze-ui-clip-terminal">
+      <rect x="0" y="0" width="1463.0" height="267.4" />
+    </clipPath>
+    <clipPath id="breeze-ui-line-0">
+    <rect x="0" y="1.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-line-1">
+    <rect x="0" y="25.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-line-2">
+    <rect x="0" y="50.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-line-3">
+    <rect x="0" y="74.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-line-4">
+    <rect x="0" y="99.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-line-5">
+    <rect x="0" y="123.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-line-6">
+    <rect x="0" y="147.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-line-7">
+    <rect x="0" y="172.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-line-8">
+    <rect x="0" y="196.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-line-9">
+    <rect x="0" y="221.1" width="1464" height="24.65"/>
+            </clipPath>
+    </defs>
+
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="316.4" rx="8"/><text class="breeze-ui-title" 
fill="#c5c8c6" text-anchor="middle" x="740" y="27">Command:&#160;ui</text>
+            <g transform="translate(26,22)">
+            <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+            <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+            <circle cx="44" cy="0" r="7" fill="#28c840"/>
+            </g>
+        
+    <g transform="translate(9, 41)" clip-path="url(#breeze-ui-clip-terminal)">
+    
+    <g class="breeze-ui-matrix">
+    <text class="breeze-ui-r1" x="1464" y="20" textLength="12.2" 
clip-path="url(#breeze-ui-line-0)">
+</text><text class="breeze-ui-r2" x="12.2" y="44.4" textLength="73.2" 
clip-path="url(#breeze-ui-line-1)">Usage:</text><text class="breeze-ui-r3" 
x="97.6" y="44.4" textLength="109.8" 
clip-path="url(#breeze-ui-line-1)">breeze&#160;ui</text><text 
class="breeze-ui-r1" x="219.6" y="44.4" textLength="12.2" 
clip-path="url(#breeze-ui-line-1)">[</text><text class="breeze-ui-r4" x="231.8" 
y="44.4" textLength="85.4" 
clip-path="url(#breeze-ui-line-1)">OPTIONS</text><text class="breeze-ui-r1" 
x="317. [...]
+</text><text class="breeze-ui-r1" x="1464" y="68.8" textLength="12.2" 
clip-path="url(#breeze-ui-line-2)">
+</text><text class="breeze-ui-r1" x="12.2" y="93.2" textLength="488" 
clip-path="url(#breeze-ui-line-3)">Tools&#160;for&#160;UI&#160;development&#160;and&#160;maintenance</text><text
 class="breeze-ui-r1" x="1464" y="93.2" textLength="12.2" 
clip-path="url(#breeze-ui-line-3)">
+</text><text class="breeze-ui-r1" x="1464" y="117.6" textLength="12.2" 
clip-path="url(#breeze-ui-line-4)">
+</text><text class="breeze-ui-r5" x="0" y="142" textLength="24.4" 
clip-path="url(#breeze-ui-line-5)">╭─</text><text class="breeze-ui-r5" x="24.4" 
y="142" textLength="195.2" 
clip-path="url(#breeze-ui-line-5)">&#160;Common&#160;options&#160;</text><text 
class="breeze-ui-r5" x="219.6" y="142" textLength="1220" 
clip-path="url(#breeze-ui-line-5)">────────────────────────────────────────────────────────────────────────────────────────────────────</text><text
 class="breeze-ui-r5" x="1439.6" y=" [...]
+</text><text class="breeze-ui-r5" x="0" y="166.4" textLength="12.2" 
clip-path="url(#breeze-ui-line-6)">│</text><text class="breeze-ui-r4" x="24.4" 
y="166.4" textLength="73.2" 
clip-path="url(#breeze-ui-line-6)">--help</text><text class="breeze-ui-r6" 
x="122" y="166.4" textLength="24.4" 
clip-path="url(#breeze-ui-line-6)">-h</text><text class="breeze-ui-r1" 
x="170.8" y="166.4" textLength="329.4" 
clip-path="url(#breeze-ui-line-6)">Show&#160;this&#160;message&#160;and&#160;exit.</text><text
 c [...]
+</text><text class="breeze-ui-r5" x="0" y="190.8" textLength="1464" 
clip-path="url(#breeze-ui-line-7)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ui-r1" x="1464" y="190.8" textLength="12.2" 
clip-path="url(#breeze-ui-line-7)">
+</text><text class="breeze-ui-r5" x="0" y="215.2" textLength="24.4" 
clip-path="url(#breeze-ui-line-8)">╭─</text><text class="breeze-ui-r5" x="24.4" 
y="215.2" textLength="158.6" 
clip-path="url(#breeze-ui-line-8)">&#160;UI&#160;commands&#160;</text><text 
class="breeze-ui-r5" x="183" y="215.2" textLength="1256.6" 
clip-path="url(#breeze-ui-line-8)">───────────────────────────────────────────────────────────────────────────────────────────────────────</text><text
 class="breeze-ui-r5" x="1439. [...]
+</text><text class="breeze-ui-r5" x="0" y="239.6" textLength="12.2" 
clip-path="url(#breeze-ui-line-9)">│</text><text class="breeze-ui-r4" x="24.4" 
y="239.6" textLength="622.2" 
clip-path="url(#breeze-ui-line-9)">check-translation-completeness&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ui-r1" x="671" y="239.6" textLength="768.6" 
clip-path="url(#breeze-ui-line-9)">Check&#160;complete [...]
+</text><text class="breeze-ui-r5" x="0" y="264" textLength="1464" 
clip-path="url(#breeze-ui-line-10)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ui-r1" x="1464" y="264" textLength="12.2" 
clip-path="url(#breeze-ui-line-10)">
+</text>
+    </g>
+    </g>
+</svg>
diff --git a/dev/breeze/doc/images/output_ui.txt 
b/dev/breeze/doc/images/output_ui.txt
new file mode 100644
index 00000000000..fe1dda1cc91
--- /dev/null
+++ b/dev/breeze/doc/images/output_ui.txt
@@ -0,0 +1 @@
+1c824059bb3d2ae726b7ece89d6b13b7
diff --git a/dev/breeze/doc/images/output_ui_check-translation-completeness.svg 
b/dev/breeze/doc/images/output_ui_check-translation-completeness.svg
new file mode 100644
index 00000000000..c05a8d5f6ce
--- /dev/null
+++ b/dev/breeze/doc/images/output_ui_check-translation-completeness.svg
@@ -0,0 +1,120 @@
+<svg class="rich-terminal" viewBox="0 0 1482 416.0" 
xmlns="http://www.w3.org/2000/svg";>
+    <!-- Generated with Rich https://www.textualize.io -->
+    <style>
+
+    @font-face {
+        font-family: "Fira Code";
+        src: local("FiraCode-Regular"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2";)
 format("woff2"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff";)
 format("woff");
+        font-style: normal;
+        font-weight: 400;
+    }
+    @font-face {
+        font-family: "Fira Code";
+        src: local("FiraCode-Bold"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2";)
 format("woff2"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff";)
 format("woff");
+        font-style: bold;
+        font-weight: 700;
+    }
+
+    .breeze-ui-check-translation-completeness-matrix {
+        font-family: Fira Code, monospace;
+        font-size: 20px;
+        line-height: 24.4px;
+        font-variant-east-asian: full-width;
+    }
+
+    .breeze-ui-check-translation-completeness-title {
+        font-size: 18px;
+        font-weight: bold;
+        font-family: arial;
+    }
+
+    .breeze-ui-check-translation-completeness-r1 { fill: #c5c8c6 }
+.breeze-ui-check-translation-completeness-r2 { fill: #d0b344 }
+.breeze-ui-check-translation-completeness-r3 { fill: #c5c8c6;font-weight: bold 
}
+.breeze-ui-check-translation-completeness-r4 { fill: #68a0b3;font-weight: bold 
}
+.breeze-ui-check-translation-completeness-r5 { fill: #868887 }
+.breeze-ui-check-translation-completeness-r6 { fill: #98a84b;font-weight: bold 
}
+.breeze-ui-check-translation-completeness-r7 { fill: #8d7b39 }
+    </style>
+
+    <defs>
+    <clipPath id="breeze-ui-check-translation-completeness-clip-terminal">
+      <rect x="0" y="0" width="1463.0" height="365.0" />
+    </clipPath>
+    <clipPath id="breeze-ui-check-translation-completeness-line-0">
+    <rect x="0" y="1.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-1">
+    <rect x="0" y="25.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-2">
+    <rect x="0" y="50.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-3">
+    <rect x="0" y="74.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-4">
+    <rect x="0" y="99.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-5">
+    <rect x="0" y="123.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-6">
+    <rect x="0" y="147.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-7">
+    <rect x="0" y="172.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-8">
+    <rect x="0" y="196.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-9">
+    <rect x="0" y="221.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-10">
+    <rect x="0" y="245.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-11">
+    <rect x="0" y="269.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-12">
+    <rect x="0" y="294.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ui-check-translation-completeness-line-13">
+    <rect x="0" y="318.7" width="1464" height="24.65"/>
+            </clipPath>
+    </defs>
+
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="414" rx="8"/><text 
class="breeze-ui-check-translation-completeness-title" fill="#c5c8c6" 
text-anchor="middle" x="740" 
y="27">Command:&#160;ui&#160;check-translation-completeness</text>
+            <g transform="translate(26,22)">
+            <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+            <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+            <circle cx="44" cy="0" r="7" fill="#28c840"/>
+            </g>
+        
+    <g transform="translate(9, 41)" 
clip-path="url(#breeze-ui-check-translation-completeness-clip-terminal)">
+    
+    <g class="breeze-ui-check-translation-completeness-matrix">
+    <text class="breeze-ui-check-translation-completeness-r1" x="1464" y="20" 
textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-0)">
+</text><text class="breeze-ui-check-translation-completeness-r2" x="12.2" 
y="44.4" textLength="73.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-1)">Usage:</text><text
 class="breeze-ui-check-translation-completeness-r3" x="97.6" y="44.4" 
textLength="488" 
clip-path="url(#breeze-ui-check-translation-completeness-line-1)">breeze&#160;ui&#160;check-translation-completeness</text><text
 class="breeze-ui-check-translation-completeness-r1" x="597.8" y="44.4" 
textLength="12.2" c [...]
+</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" 
y="68.8" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-2)">
+</text><text class="breeze-ui-check-translation-completeness-r1" x="12.2" 
y="93.2" textLength="463.6" 
clip-path="url(#breeze-ui-check-translation-completeness-line-3)">Check&#160;completeness&#160;of&#160;UI&#160;translations.</text><text
 class="breeze-ui-check-translation-completeness-r1" x="1464" y="93.2" 
textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-3)">
+</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" 
y="117.6" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-4)">
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="142" 
textLength="24.4" 
clip-path="url(#breeze-ui-check-translation-completeness-line-5)">╭─</text><text
 class="breeze-ui-check-translation-completeness-r5" x="24.4" y="142" 
textLength="256.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-5)">&#160;Translation&#160;options&#160;</text><text
 class="breeze-ui-check-translation-completeness-r5" x="280.6" y="142" 
textLength="1159" clip-path="url(#breeze- [...]
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="166.4" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-6)">│</text><text 
class="breeze-ui-check-translation-completeness-r4" x="24.4" y="166.4" 
textLength="122" 
clip-path="url(#breeze-ui-check-translation-completeness-line-6)">--language</text><text
 class="breeze-ui-check-translation-completeness-r6" x="219.6" y="166.4" 
textLength="24.4" clip-path="url(#breeze-ui-check-translation-co [...]
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="190.8" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-7)">│</text><text 
class="breeze-ui-check-translation-completeness-r4" x="24.4" y="190.8" 
textLength="158.6" 
clip-path="url(#breeze-ui-check-translation-completeness-line-7)">--add-missing</text><text
 class="breeze-ui-check-translation-completeness-r1" x="268.4" y="190.8" 
textLength="1122.4" clip-path="url(#breeze-ui-check-transla [...]
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="215.2" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-8)">│</text><text 
class="breeze-ui-check-translation-completeness-r4" x="24.4" y="215.2" 
textLength="170.8" 
clip-path="url(#breeze-ui-check-translation-completeness-line-8)">--remove-extra</text><text
 class="breeze-ui-check-translation-completeness-r1" x="268.4" y="215.2" 
textLength="1000.4" clip-path="url(#breeze-ui-check-transl [...]
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="239.6" textLength="1464" 
clip-path="url(#breeze-ui-check-translation-completeness-line-9)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ui-check-translation-completeness-r1" x="1464" y="239.6" 
textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-9)">
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="264" 
textLength="24.4" 
clip-path="url(#breeze-ui-check-translation-completeness-line-10)">╭─</text><text
 class="breeze-ui-check-translation-completeness-r5" x="24.4" y="264" 
textLength="195.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-10)">&#160;Common&#160;options&#160;</text><text
 class="breeze-ui-check-translation-completeness-r5" x="219.6" y="264" 
textLength="1220" clip-path="url(#breeze-ui- [...]
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="288.4" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-11)">│</text><text
 class="breeze-ui-check-translation-completeness-r4" x="24.4" y="288.4" 
textLength="109.8" 
clip-path="url(#breeze-ui-check-translation-completeness-line-11)">--verbose</text><text
 class="breeze-ui-check-translation-completeness-r6" x="158.6" y="288.4" 
textLength="24.4" clip-path="url(#breeze-ui-check-translation [...]
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="312.8" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-12)">│</text><text
 class="breeze-ui-check-translation-completeness-r4" x="24.4" y="312.8" 
textLength="109.8" 
clip-path="url(#breeze-ui-check-translation-completeness-line-12)">--dry-run</text><text
 class="breeze-ui-check-translation-completeness-r6" x="158.6" y="312.8" 
textLength="24.4" clip-path="url(#breeze-ui-check-translation [...]
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="337.2" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-13)">│</text><text
 class="breeze-ui-check-translation-completeness-r4" x="24.4" y="337.2" 
textLength="73.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-13)">--help</text><text
 class="breeze-ui-check-translation-completeness-r6" x="158.6" y="337.2" 
textLength="24.4" clip-path="url(#breeze-ui-check-translation-com [...]
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="361.6" textLength="1464" 
clip-path="url(#breeze-ui-check-translation-completeness-line-14)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ui-check-translation-completeness-r1" x="1464" y="361.6" 
textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-14)">
+</text>
+    </g>
+    </g>
+</svg>
diff --git a/dev/breeze/doc/images/output_ui_check-translation-completeness.txt 
b/dev/breeze/doc/images/output_ui_check-translation-completeness.txt
new file mode 100644
index 00000000000..1c09634e8b5
--- /dev/null
+++ b/dev/breeze/doc/images/output_ui_check-translation-completeness.txt
@@ -0,0 +1 @@
+9cb14c5898ad3622ce8112dcca284dea
diff --git a/dev/breeze/src/airflow_breeze/breeze.py 
b/dev/breeze/src/airflow_breeze/breeze.py
index b26dcdd77e3..0b5c1d23977 100755
--- a/dev/breeze/src/airflow_breeze/breeze.py
+++ b/dev/breeze/src/airflow_breeze/breeze.py
@@ -42,6 +42,7 @@ from airflow_breeze.commands import 
release_management_validation  # noqa: E402,
 from airflow_breeze.commands.sbom_commands import sbom  # noqa: E402
 from airflow_breeze.commands.setup_commands import setup  # noqa: E402
 from airflow_breeze.commands.testing_commands import group_for_testing  # 
noqa: E402
+from airflow_breeze.commands.ui_commands import ui_group  # noqa: E402
 
 main.add_command(group_for_testing)
 main.add_command(kubernetes_group)
@@ -51,6 +52,7 @@ main.add_command(prod_image)
 main.add_command(setup)
 main.add_command(release_management)
 main.add_command(sbom)
+main.add_command(ui_group)
 main.add_command(workflow_run)
 
 if __name__ == "__main__":
diff --git a/dev/i18n/check_translations_completeness.py 
b/dev/breeze/src/airflow_breeze/commands/ui_commands.py
old mode 100755
new mode 100644
similarity index 86%
rename from dev/i18n/check_translations_completeness.py
rename to dev/breeze/src/airflow_breeze/commands/ui_commands.py
index 5972087b9ff..f6f75002604
--- a/dev/i18n/check_translations_completeness.py
+++ b/dev/breeze/src/airflow_breeze/commands/ui_commands.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python3
 # Licensed to the Apache Software Foundation (ASF) under one
 # or more contributor license agreements.  See the NOTICE file
 # distributed with this work for additional information
@@ -15,35 +14,57 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-# /// script
-# requires-python = ">=3.10"
-# dependencies = [
-#   "rich>=13.6.0",
-#   "rich-click",
-# ]
-# ///
 from __future__ import annotations
 
 import json
-import os
+import re
 import sys
 from collections import defaultdict
 from pathlib import Path
 from typing import Any, NamedTuple
 
-import rich_click as click
-from rich import print
-from rich.console import Console
-from rich.panel import Panel
-from rich.table import Table
+import click
 
-click.rich_click.MAX_WIDTH = 120
-click.rich_click.USE_RICH_MARKUP = True
+from airflow_breeze.commands.common_options import option_dry_run, 
option_verbose
+from airflow_breeze.utils.click_utils import BreezeGroup
+from airflow_breeze.utils.console import get_console
+from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
+
+LOCALES_DIR = AIRFLOW_ROOT_PATH / "airflow-core" / "src" / "airflow" / "ui" / 
"public" / "i18n" / "locales"
+
+
+def natural_sort_key(text: str) -> tuple:
+    """
+    Create a sort key that matches eslint-plugin-jsonc behavior with natural: 
true.
+
+    This mimics JavaScript's localeCompare with numeric: true, which:
+    1. Does case-insensitive comparison character-by-character
+    2. Uses original case as tiebreaker when characters are equal (ignoring 
case)
+    3. Handles numbers naturally (2 < 10)
+
+    For each character position, we create a tuple: (lowercase_char, 
original_char)
+    This ensures case-insensitive primary sort with case-sensitive tiebreaker.
+    """
+
+    def char_key(c: str) -> tuple:
+        if c.isdigit():
+            return 0, int(c), c  # Numbers sort before letters
+        return 1, c.lower(), c  # Lowercase for primary, original for 
tiebreaker
+
+    # Split on numbers to handle natural number ordering
+    parts: list[tuple[int, tuple[tuple[Any, ...], ...] | int]] = []
+    for part in re.split(r"(\d+)", text):
+        if not part:
+            continue
+        if part.isdigit():
+            # For numeric parts, use integer value
+            parts.append((0, int(part)))
+        else:
+            # For text parts, convert each character
+            parts.append((1, tuple(char_key(c) for c in part)))
+
+    return tuple(parts)
 
-LOCALES_DIR = (
-    Path(__file__).parents[2] / "airflow-core" / "src" / "airflow" / "ui" / 
"public" / "i18n" / "locales"
-)
 
 MOST_COMMON_PLURAL_SUFFIXES = ["_one", "_other"]
 # Plural suffixes per language (expand as needed). The actual suffixes depend 
on the language
@@ -118,10 +139,11 @@ def get_plural_base(key: str, suffixes: list[str]) -> str 
| None:
     return None
 
 
-def expand_plural_keys(keys: set[str], lang: str, console: Console) -> 
set[str]:
+def expand_plural_keys(keys: set[str], lang: str) -> set[str]:
     """
     For a set of keys, expand all plural bases to include all required 
suffixes for the language.
     """
+    console = get_console()
     suffixes = PLURAL_SUFFIXES.get(lang)
     if not suffixes:
         console.print(
@@ -169,7 +191,6 @@ def flatten_keys(d: dict, prefix: str = "") -> list[str]:
 
 def compare_keys(
     locale_files: list[LocaleFiles],
-    console,
 ) -> tuple[dict[str, LocaleSummary], dict[str, dict[str, int]]]:
     """
     Compare all non-English locales with English locale only.
@@ -193,14 +214,12 @@ def compare_keys(
                     data = load_json(path)
                     keys = set(flatten_keys(data))
                 except Exception as e:
-                    print(f"Error loading {path}: {e}")
+                    get_console().print(f"Error loading {path}: {e}")
             key_sets.append(LocaleKeySet(locale=lf.locale, keys=keys))
         keys_by_locale = {ks.locale: ks.keys for ks in key_sets}
         en_keys = keys_by_locale.get("en", set()) or set()
         # Expand English keys for all required plural forms in each language
-        expanded_en_keys = {
-            lang: expand_plural_keys(en_keys, lang, console) for lang in 
keys_by_locale.keys()
-        }
+        expanded_en_keys = {lang: expand_plural_keys(en_keys, lang) for lang 
in keys_by_locale.keys()}
         missing_keys: dict[str, list[str]] = {}
         extra_keys: dict[str, list[str]] = {}
         missing_counts[filename] = {}
@@ -221,12 +240,13 @@ def compare_keys(
     return summary, missing_counts
 
 
-def print_locale_file_table(
-    locale_files: list[LocaleFiles], console: Console, language: str | None = 
None
-) -> None:
+def print_locale_file_table(locale_files: list[LocaleFiles], language: str | 
None = None) -> None:
     if language and language == "en":
         return
+    console = get_console()
     console.print("[bold underline]Locales and their files:[/bold underline]", 
style="cyan")
+    from rich.table import Table
+
     table = Table(show_header=True, header_style="bold magenta")
     table.add_column("Locale")
     table.add_column("Files")
@@ -238,11 +258,10 @@ def print_locale_file_table(
     console.print(table)
 
 
-def print_file_set_differences(
-    locale_files: list[LocaleFiles], console: Console, language: str | None = 
None
-) -> bool:
+def print_file_set_differences(locale_files: list[LocaleFiles], language: str 
| None = None) -> bool:
     if language and language == "en":
         return False
+    console = get_console()
     filtered = (
         locale_files if language is None else [lf for lf in locale_files if 
lf.locale in (language, "en")]
     )
@@ -266,11 +285,11 @@ def print_file_set_differences(
     return found_difference
 
 
-def print_language_summary(
-    locale_files: list[LocaleFiles], summary: dict[str, LocaleSummary], 
console: Console
-) -> bool:
+def print_language_summary(locale_files: list[LocaleFiles], summary: dict[str, 
LocaleSummary]) -> bool:
     found_difference = False
-    missing_in_en: dict[str, dict[str, set[str]]] = {}
+    console = get_console()
+    from rich.panel import Panel
+
     for lf in sorted(locale_files):
         locale = lf.locale
         file_missing: dict[str, list[str]] = {}
@@ -282,9 +301,6 @@ def print_language_summary(
                 file_missing[filename] = missing_keys
             if extra_keys:
                 file_extra[filename] = extra_keys
-                if locale != "en":
-                    for k in extra_keys:
-                        missing_in_en.setdefault(filename, {}).setdefault(k, 
set()).add(locale)
         if file_missing or file_extra:
             if locale == "en":
                 continue
@@ -306,18 +322,6 @@ def print_language_summary(
     return found_difference
 
 
-def get_outdated_entries_for_language(
-    summary: dict[str, LocaleSummary], language: str
-) -> dict[str, list[str]]:
-    """Return a dict of filename -> list of outdated/old keys for the given 
language (present in other languages, missing in the given language)."""
-    outdated: dict[str, list[str]] = {}
-    for filename, diff in summary.items():
-        missing = diff.missing_keys.get(language, [])
-        if missing:
-            outdated[filename] = list(missing)
-    return outdated
-
-
 def count_todos(obj) -> int:
     """Count TODO: translate entries in a dict or list."""
     if isinstance(obj, dict):
@@ -329,7 +333,10 @@ def count_todos(obj) -> int:
     return 0
 
 
-def print_translation_progress(console, locale_files, missing_counts, summary):
+def print_translation_progress(locale_files, missing_counts, summary):
+    console = get_console()
+    from rich.table import Table
+
     tables = defaultdict(lambda: Table(show_lines=True))
     all_files = set()
     coverage_per_language = {}  # Collect total coverage per language
@@ -394,7 +401,6 @@ def print_translation_progress(console, locale_files, 
missing_counts, summary):
                 file_extra = len(summary.get(filename, LocaleSummary({}, 
{})).extra_keys.get(lang, []))
                 file_todos = 0
                 file_translated = 0
-                file_actual_translated = 0
                 file_coverage_percent = 0
                 complete_percent = 0
                 style = "red"
@@ -415,15 +421,6 @@ def print_translation_progress(console, locale_files, 
missing_counts, summary):
             total_translated += file_translated
             total_total += file_total
 
-        # check missing translation files
-        en_root = LOCALES_DIR / "en"
-        if diffs := set(os.listdir(en_root)) - set(all_files):
-            for diff in diffs:
-                with open(en_root / diff) as f:
-                    en_data = json.load(f)
-                file_total = sum(1 for _ in flatten_keys(en_data))
-                table.add_row(diff, str(file_total), "0", str(file_total), 
"0", "0%", "0%", "0%", style="red")
-
         # Calculate totals for this language
         total_coverage_percent = 100 * total_translated / total_total if 
total_total else 100
         total_actual_translated = total_translated - total_todos
@@ -448,117 +445,14 @@ def print_translation_progress(console, locale_files, 
missing_counts, summary):
     return has_todos, coverage_per_language
 
 
[email protected]()
[email protected](
-    "--language", "-l", default=None, help="Show summary for a single language 
(e.g. en, de, pl, etc.)"
-)
[email protected](
-    "--add-missing",
-    is_flag=True,
-    default=False,
-    help="Add missing translations for all languages except English, prefixed 
with 'TODO: translate:'.",
-)
[email protected](
-    "--remove-extra",
-    is_flag=True,
-    default=False,
-    help="Remove extra translations that are present in the language but 
missing in English.",
-)
-def cli(language: str | None = None, add_missing: bool = False, remove_extra: 
bool = False):
-    locale_files = get_locale_files()
-    console = Console(force_terminal=True, color_system="auto")
-    print_locale_file_table(locale_files, console, language)
-    found_difference = print_file_set_differences(locale_files, console, 
language)
-    summary, missing_counts = compare_keys(locale_files, console)
-    console.print("\n[bold underline]Summary of differences by language:[/bold 
underline]", style="cyan")
-    if add_missing and language != "en":
-        # Loop through all languages except 'en' and add missing translations
-        if language:
-            language_files = [lf for lf in locale_files if lf.locale == 
language]
-        else:
-            language_files = [lf for lf in locale_files if lf.locale != "en"]
-        for lf in language_files:
-            filtered_summary = {}
-            for filename, diff in summary.items():
-                filtered_summary[filename] = LocaleSummary(
-                    missing_keys={lf.locale: diff.missing_keys.get(lf.locale, 
[])},
-                    extra_keys={lf.locale: diff.extra_keys.get(lf.locale, [])},
-                )
-            add_missing_translations(lf.locale, filtered_summary, console)
-        # After adding, re-run the summary for all languages
-        summary, missing_counts = compare_keys(get_locale_files(), console)
-    if remove_extra and language != "en":
-        # Loop through all languages except 'en' and remove extra translations
-        if language:
-            language_files = [lf for lf in locale_files if lf.locale == 
language]
-        else:
-            language_files = [lf for lf in locale_files if lf.locale != "en"]
-        for lf in language_files:
-            filtered_summary = {}
-            for filename, diff in summary.items():
-                filtered_summary[filename] = LocaleSummary(
-                    missing_keys={lf.locale: diff.missing_keys.get(lf.locale, 
[])},
-                    extra_keys={lf.locale: diff.extra_keys.get(lf.locale, [])},
-                )
-            remove_extra_translations(lf.locale, filtered_summary, console)
-        # After removing, re-run the summary for all languages
-        summary, missing_counts = compare_keys(get_locale_files(), console)
-    if language:
-        locales = [lf.locale for lf in locale_files]
-        if language not in locales:
-            console.print(f"[red]Language '{language}' not found among 
locales: {', '.join(locales)}[/red]")
-            sys.exit(2)
-        if language == "en":
-            console.print("[bold red]Cannot check completeness for English 
language![/bold red]")
-            sys.exit(2)
-        else:
-            filtered_summary = {}
-            for filename, diff in summary.items():
-                filtered_summary[filename] = LocaleSummary(
-                    missing_keys={language: diff.missing_keys.get(language, 
[])},
-                    extra_keys={language: diff.extra_keys.get(language, [])},
-                )
-            lang_diff = print_language_summary(
-                [lf for lf in locale_files if lf.locale == language], 
filtered_summary, console
-            )
-            found_difference = found_difference or lang_diff
-    else:
-        lang_diff = print_language_summary(locale_files, summary, console)
-        found_difference = found_difference or lang_diff
-    has_todos, coverage_per_language = print_translation_progress(
-        console,
-        [lf for lf in locale_files if language is None or lf.locale == 
language],
-        missing_counts,
-        summary,
-    )
-    if not found_difference and not has_todos:
-        console.print("\n[green]All translations are complete![/green]\n\n")
-    else:
-        console.print("\n[red]Some translations are not complete![/red]\n\n")
-        # Print summary of total coverage per language
-        if coverage_per_language:
-            summary_table = Table(show_header=True, header_style="bold 
magenta")
-            summary_table.title = "Total Coverage per Language"
-            summary_table.add_column("Language", style="cyan")
-            summary_table.add_column("Coverage", style="green")
-            for lang, coverage in sorted(coverage_per_language.items()):
-                if coverage >= 95:
-                    coverage_str = f"[bold green]{coverage:.1f}%[/bold green]"
-                elif coverage > 80:
-                    coverage_str = f"[bold yellow]{coverage:.1f}%[/bold 
yellow]"
-                else:
-                    coverage_str = f"[red]{coverage:.1f}%[/red]"
-                summary_table.add_row(lang, coverage_str)
-            console.print(summary_table)
-
-
-def add_missing_translations(language: str, summary: dict[str, LocaleSummary], 
console: Console):
+def add_missing_translations(language: str, summary: dict[str, LocaleSummary]):
     """
     Add missing translations for the selected language.
 
     It does it by copying them from English and prefixing with 'TODO: 
translate:'.
     Ensures all required plural forms for the language are added.
     """
+    console = get_console()
     suffixes = PLURAL_SUFFIXES.get(language, ["_one", "_other"])
     for filename, diff in summary.items():
         missing_keys = set(diff.missing_keys.get(language, []))
@@ -573,8 +467,7 @@ def add_missing_translations(language: str, summary: 
dict[str, LocaleSummary], c
             continue
         try:
             lang_data = load_json(lang_path)
-        except Exception as e:
-            console.print(f"[yellow]Failed to load {language} file {language}: 
{e}[/yellow]")
+        except Exception:
             lang_data = {}  # Start with an empty dict if the file doesn't 
exist
 
         # Helper to recursively add missing keys, including plural forms
@@ -603,12 +496,12 @@ def add_missing_translations(language: str, summary: 
dict[str, LocaleSummary], c
 
         add_keys(en_data, lang_data)
 
-        def natural_key_sort(obj):
+        def sort_dict_keys(obj):
             if isinstance(obj, dict):
-                return {k: natural_key_sort(obj[k]) for k in sorted(obj, 
key=lambda x: (x.lower(), x))}
+                return {k: sort_dict_keys(obj[k]) for k in sorted(obj.keys(), 
key=natural_sort_key)}
             return obj
 
-        lang_data = natural_key_sort(lang_data)
+        lang_data = sort_dict_keys(lang_data)
         lang_path.parent.mkdir(parents=True, exist_ok=True)
         with open(lang_path, "w", encoding="utf-8") as f:
             json.dump(lang_data, f, ensure_ascii=False, indent=2)
@@ -616,12 +509,13 @@ def add_missing_translations(language: str, summary: 
dict[str, LocaleSummary], c
         console.print(f"[green]Added missing translations to 
{lang_path}[/green]")
 
 
-def remove_extra_translations(language: str, summary: dict[str, 
LocaleSummary], console: Console):
+def remove_extra_translations(language: str, summary: dict[str, 
LocaleSummary]):
     """
     Remove extra translations for the selected language.
 
     Removes keys that are present in the language file but missing in the 
English file.
     """
+    console = get_console()
     for filename, diff in summary.items():
         extra_keys = set(diff.extra_keys.get(language, []))
         if not extra_keys:
@@ -650,17 +544,133 @@ def remove_extra_translations(language: str, summary: 
dict[str, LocaleSummary],
 
         remove_keys(lang_data)
 
-        def natural_key_sort(obj):
+        def sort_dict_keys(obj):
             if isinstance(obj, dict):
-                return {k: natural_key_sort(obj[k]) for k in sorted(obj)}
+                return {k: sort_dict_keys(obj[k]) for k in sorted(obj.keys(), 
key=natural_sort_key)}
             return obj
 
-        lang_data = natural_key_sort(lang_data)
+        lang_data = sort_dict_keys(lang_data)
         with open(lang_path, "w", encoding="utf-8") as f:
             json.dump(lang_data, f, ensure_ascii=False, indent=2)
             f.write("\n")  # Ensure newline at the end of the file
         console.print(f"[green]Removed {len(extra_keys)} extra translations 
from {lang_path}[/green]")
 
 
-if __name__ == "__main__":
-    cli()
[email protected](cls=BreezeGroup, name="ui", help="Tools for UI development and 
maintenance")
+def ui_group():
+    pass
+
+
+@ui_group.command(
+    name="check-translation-completeness",
+    help="Check completeness of UI translations.",
+)
[email protected](
+    "--language",
+    "-l",
+    default=None,
+    help="Show summary for a single language (e.g. en, de, pl, etc.)",
+)
[email protected](
+    "--add-missing",
+    is_flag=True,
+    default=False,
+    help="Add missing translations for all languages except English, prefixed 
with 'TODO: translate:'.",
+)
[email protected](
+    "--remove-extra",
+    is_flag=True,
+    default=False,
+    help="Remove extra translations that are present in the language but 
missing in English.",
+)
+@option_verbose
+@option_dry_run
+def check_translation_completeness(
+    language: str | None = None, add_missing: bool = False, remove_extra: bool 
= False
+):
+    locale_files = get_locale_files()
+    console = get_console()
+    print_locale_file_table(locale_files, language)
+    found_difference = print_file_set_differences(locale_files, language)
+    summary, missing_counts = compare_keys(locale_files)
+    console.print("\n[bold underline]Summary of differences by language:[/bold 
underline]", style="cyan")
+    if add_missing and language != "en":
+        # Loop through all languages except 'en' and add missing translations
+        if language:
+            language_files = [lf for lf in locale_files if lf.locale == 
language]
+        else:
+            language_files = [lf for lf in locale_files if lf.locale != "en"]
+        for lf in language_files:
+            filtered_summary = {}
+            for filename, diff in summary.items():
+                filtered_summary[filename] = LocaleSummary(
+                    missing_keys={lf.locale: diff.missing_keys.get(lf.locale, 
[])},
+                    extra_keys={lf.locale: diff.extra_keys.get(lf.locale, [])},
+                )
+            add_missing_translations(lf.locale, filtered_summary)
+        # After adding, re-run the summary for all languages
+        summary, missing_counts = compare_keys(get_locale_files())
+    if remove_extra and language != "en":
+        # Loop through all languages except 'en' and remove extra translations
+        if language:
+            language_files = [lf for lf in locale_files if lf.locale == 
language]
+        else:
+            language_files = [lf for lf in locale_files if lf.locale != "en"]
+        for lf in language_files:
+            filtered_summary = {}
+            for filename, diff in summary.items():
+                filtered_summary[filename] = LocaleSummary(
+                    missing_keys={lf.locale: diff.missing_keys.get(lf.locale, 
[])},
+                    extra_keys={lf.locale: diff.extra_keys.get(lf.locale, [])},
+                )
+            remove_extra_translations(lf.locale, filtered_summary)
+        # After removing, re-run the summary for all languages
+        summary, missing_counts = compare_keys(get_locale_files())
+    if language:
+        locales = [lf.locale for lf in locale_files]
+        if language not in locales:
+            console.print(f"[red]Language '{language}' not found among 
locales: {', '.join(locales)}[/red]")
+            sys.exit(2)
+        if language == "en":
+            console.print("[bold red]Cannot check completeness for English 
language![/bold red]")
+            sys.exit(2)
+        else:
+            filtered_summary = {}
+            for filename, diff in summary.items():
+                filtered_summary[filename] = LocaleSummary(
+                    missing_keys={language: diff.missing_keys.get(language, 
[])},
+                    extra_keys={language: diff.extra_keys.get(language, [])},
+                )
+            lang_diff = print_language_summary(
+                [lf for lf in locale_files if lf.locale == language], 
filtered_summary
+            )
+            found_difference = found_difference or lang_diff
+    else:
+        lang_diff = print_language_summary(locale_files, summary)
+        found_difference = found_difference or lang_diff
+    has_todos, coverage_per_language = print_translation_progress(
+        [lf for lf in locale_files if language is None or lf.locale == 
language],
+        missing_counts,
+        summary,
+    )
+    if not found_difference and not has_todos:
+        console.print("\n[green]All translations are complete![/green]\n\n")
+    else:
+        console.print("\n[red]Some translations are not complete![/red]\n\n")
+        # Print summary of total coverage per language
+        if coverage_per_language:
+            from rich.table import Table
+
+            summary_table = Table(show_header=True, header_style="bold 
magenta")
+            summary_table.title = "Total Coverage per Language"
+            summary_table.add_column("Language", style="cyan")
+            summary_table.add_column("Coverage", style="green")
+            for lang, coverage in sorted(coverage_per_language.items()):
+                if coverage >= 95:
+                    coverage_str = f"[bold green]{coverage:.1f}%[/bold green]"
+                elif coverage > 80:
+                    coverage_str = f"[bold yellow]{coverage:.1f}%[/bold 
yellow]"
+                else:
+                    coverage_str = f"[red]{coverage:.1f}%[/red]"
+                summary_table.add_row(lang, coverage_str)
+            console.print(summary_table)
diff --git a/dev/breeze/src/airflow_breeze/commands/ui_commands_config.py 
b/dev/breeze/src/airflow_breeze/commands/ui_commands_config.py
new file mode 100644
index 00000000000..ea5b19a6808
--- /dev/null
+++ b/dev/breeze/src/airflow_breeze/commands/ui_commands_config.py
@@ -0,0 +1,37 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+UI_COMMANDS: dict[str, str | list[str]] = {
+    "name": "UI commands",
+    "commands": [
+        "check-translation-completeness",
+    ],
+}
+
+UI_PARAMETERS: dict[str, list[dict[str, str | list[str]]]] = {
+    "breeze ui check-translation-completeness": [
+        {
+            "name": "Translation options",
+            "options": [
+                "--language",
+                "--add-missing",
+                "--remove-extra",
+            ],
+        },
+    ],
+}
diff --git a/dev/breeze/src/airflow_breeze/configure_rich_click.py 
b/dev/breeze/src/airflow_breeze/configure_rich_click.py
index 3ac1a200a41..9d0e47e3f95 100644
--- a/dev/breeze/src/airflow_breeze/configure_rich_click.py
+++ b/dev/breeze/src/airflow_breeze/configure_rich_click.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 from airflow_breeze.commands.sbom_commands_config import SBOM_COMMANDS, 
SBOM_PARAMETERS
+from airflow_breeze.commands.ui_commands_config import UI_COMMANDS, 
UI_PARAMETERS
 from airflow_breeze.commands.workflow_commands_config import 
WORKFLOW_RUN_COMMANDS, WORKFLOW_RUN_PARAMETERS
 
 from airflow_breeze.utils import recording  # isort:skip  # noqa: F401
@@ -80,6 +81,7 @@ else:
         **RELEASE_MANAGEMENT_PARAMETERS,
         **SBOM_PARAMETERS,
         **WORKFLOW_RUN_PARAMETERS,
+        **UI_PARAMETERS,
     }
     click.rich_click.COMMAND_GROUPS = {
         "breeze": [
@@ -97,8 +99,16 @@ else:
                 "commands": ["release-management", "sbom", "workflow-run"],
             },
             {
-                "name": "Other commands",
-                "commands": ["setup", "ci"],
+                "name": "CI commands",
+                "commands": ["ci"],
+            },
+            {
+                "name": "UI commands",
+                "commands": ["ui"],
+            },
+            {
+                "name": "Setup commands",
+                "commands": ["setup"],
             },
         ],
         "breeze testing": TESTING_COMMANDS,
@@ -121,4 +131,5 @@ else:
         "breeze sbom": [SBOM_COMMANDS],
         "breeze ci": [CI_COMMANDS],
         "breeze workflow-run": [WORKFLOW_RUN_COMMANDS],
+        "breeze ui": [UI_COMMANDS],
     }
diff --git a/dev/breeze/tests/test_ui_commands.py 
b/dev/breeze/tests/test_ui_commands.py
new file mode 100644
index 00000000000..843d985ed02
--- /dev/null
+++ b/dev/breeze/tests/test_ui_commands.py
@@ -0,0 +1,347 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+
+from airflow_breeze.commands.ui_commands import (
+    LocaleFiles,
+    LocaleKeySet,
+    LocaleSummary,
+    compare_keys,
+    expand_plural_keys,
+    flatten_keys,
+    get_plural_base,
+)
+
+
+class TestPluralHandling:
+    def test_get_plural_base_with_suffix(self):
+        suffixes = ["_one", "_other"]
+        assert get_plural_base("message_one", suffixes) == "message"
+        assert get_plural_base("message_other", suffixes) == "message"
+
+    def test_get_plural_base_without_suffix(self):
+        suffixes = ["_one", "_other"]
+        assert get_plural_base("message", suffixes) is None
+
+    def test_get_plural_base_with_complex_suffixes(self):
+        suffixes = ["_zero", "_one", "_two", "_few", "_many", "_other"]
+        assert get_plural_base("item_zero", suffixes) == "item"
+        assert get_plural_base("item_many", suffixes) == "item"
+
+    def test_expand_plural_keys_english(self):
+        keys = {"message_one", "message_other", "simple"}
+        expanded = expand_plural_keys(keys, "en")
+        # Should include both _one and _other forms for "message"
+        assert "message_one" in expanded
+        assert "message_other" in expanded
+        assert "simple" in expanded
+
+    def test_expand_plural_keys_polish(self):
+        keys = {"message_one"}
+        expanded = expand_plural_keys(keys, "pl")
+        # Polish has 4 forms: _one, _few, _many, _other
+        assert "message_one" in expanded
+        assert "message_few" in expanded
+        assert "message_many" in expanded
+        assert "message_other" in expanded
+
+
+class TestFlattenKeys:
+    def test_flatten_simple_dict(self):
+        data = {"key1": "value1", "key2": "value2"}
+        keys = flatten_keys(data)
+        assert set(keys) == {"key1", "key2"}
+
+    def test_flatten_nested_dict(self):
+        data = {"parent": {"child1": "value1", "child2": "value2"}}
+        keys = flatten_keys(data)
+        assert set(keys) == {"parent.child1", "parent.child2"}
+
+    def test_flatten_deeply_nested_dict(self):
+        data = {"level1": {"level2": {"level3": "value"}}}
+        keys = flatten_keys(data)
+        assert keys == ["level1.level2.level3"]
+
+    def test_flatten_mixed_dict(self):
+        data = {"simple": "value", "nested": {"key": "value2"}}
+        keys = flatten_keys(data)
+        assert set(keys) == {"simple", "nested.key"}
+
+
+class TestCompareKeys:
+    def test_compare_keys_identical(self, tmp_path):
+        # Create temporary locale files
+        en_dir = tmp_path / "en"
+        en_dir.mkdir()
+        de_dir = tmp_path / "de"
+        de_dir.mkdir()
+
+        test_data = {"greeting": "Hello", "farewell": "Goodbye"}
+
+        (en_dir / "test.json").write_text(json.dumps(test_data))
+        (de_dir / "test.json").write_text(json.dumps(test_data))
+
+        # Mock LOCALES_DIR temporarily
+        import airflow_breeze.commands.ui_commands as ui_commands
+
+        original_locales_dir = ui_commands.LOCALES_DIR
+        ui_commands.LOCALES_DIR = tmp_path
+
+        try:
+            locale_files = [
+                LocaleFiles(locale="en", files=["test.json"]),
+                LocaleFiles(locale="de", files=["test.json"]),
+            ]
+            summary, missing_counts = compare_keys(locale_files)
+
+            assert "test.json" in summary
+            assert summary["test.json"].missing_keys.get("de", []) == []
+            assert summary["test.json"].extra_keys.get("de", []) == []
+        finally:
+            ui_commands.LOCALES_DIR = original_locales_dir
+
+    def test_compare_keys_with_missing(self, tmp_path):
+        en_dir = tmp_path / "en"
+        en_dir.mkdir()
+        de_dir = tmp_path / "de"
+        de_dir.mkdir()
+
+        en_data = {"greeting": "Hello", "farewell": "Goodbye"}
+        de_data = {"greeting": "Hallo"}
+
+        (en_dir / "test.json").write_text(json.dumps(en_data))
+        (de_dir / "test.json").write_text(json.dumps(de_data))
+
+        import airflow_breeze.commands.ui_commands as ui_commands
+
+        original_locales_dir = ui_commands.LOCALES_DIR
+        ui_commands.LOCALES_DIR = tmp_path
+
+        try:
+            locale_files = [
+                LocaleFiles(locale="en", files=["test.json"]),
+                LocaleFiles(locale="de", files=["test.json"]),
+            ]
+            summary, missing_counts = compare_keys(locale_files)
+
+            assert "test.json" in summary
+            assert "farewell" in summary["test.json"].missing_keys.get("de", 
[])
+            assert missing_counts["test.json"]["de"] == 1
+        finally:
+            ui_commands.LOCALES_DIR = original_locales_dir
+
+    def test_compare_keys_with_extra(self, tmp_path):
+        en_dir = tmp_path / "en"
+        en_dir.mkdir()
+        de_dir = tmp_path / "de"
+        de_dir.mkdir()
+
+        en_data = {"greeting": "Hello"}
+        de_data = {"greeting": "Hallo", "extra": "Extra"}
+
+        (en_dir / "test.json").write_text(json.dumps(en_data))
+        (de_dir / "test.json").write_text(json.dumps(de_data))
+
+        import airflow_breeze.commands.ui_commands as ui_commands
+
+        original_locales_dir = ui_commands.LOCALES_DIR
+        ui_commands.LOCALES_DIR = tmp_path
+
+        try:
+            locale_files = [
+                LocaleFiles(locale="en", files=["test.json"]),
+                LocaleFiles(locale="de", files=["test.json"]),
+            ]
+            summary, missing_counts = compare_keys(locale_files)
+
+            assert "test.json" in summary
+            assert "extra" in summary["test.json"].extra_keys.get("de", [])
+        finally:
+            ui_commands.LOCALES_DIR = original_locales_dir
+
+
+class TestLocaleSummary:
+    def test_locale_summary_creation(self):
+        summary = LocaleSummary(missing_keys={"de": ["key1", "key2"]}, 
extra_keys={"de": ["key3"]})
+        assert summary.missing_keys == {"de": ["key1", "key2"]}
+        assert summary.extra_keys == {"de": ["key3"]}
+
+
+class TestLocaleFiles:
+    def test_locale_files_creation(self):
+        lf = LocaleFiles(locale="en", files=["test.json", "common.json"])
+        assert lf.locale == "en"
+        assert len(lf.files) == 2
+
+
+class TestLocaleKeySet:
+    def test_locale_key_set_with_keys(self):
+        lks = LocaleKeySet(locale="en", keys={"key1", "key2"})
+        assert lks.locale == "en"
+        assert lks.keys == {"key1", "key2"}
+
+    def test_locale_key_set_without_keys(self):
+        lks = LocaleKeySet(locale="de", keys=None)
+        assert lks.locale == "de"
+        assert lks.keys is None
+
+
+class TestCountTodos:
+    def test_count_todos_in_string(self):
+        from airflow_breeze.commands.ui_commands import count_todos
+
+        assert count_todos("TODO: translate: Hello") == 1
+        assert count_todos("Hello") == 0
+
+    def test_count_todos_in_dict(self):
+        from airflow_breeze.commands.ui_commands import count_todos
+
+        data = {
+            "key1": "TODO: translate: Hello",
+            "key2": "Already translated",
+            "key3": "TODO: translate: Goodbye",
+        }
+        assert count_todos(data) == 2
+
+    def test_count_todos_nested(self):
+        from airflow_breeze.commands.ui_commands import count_todos
+
+        data = {
+            "parent": {
+                "child1": "TODO: translate: Hello",
+                "child2": "TODO: translate: World",
+            },
+            "simple": "No TODO",
+        }
+        assert count_todos(data) == 2
+
+
+class TestAddMissingTranslations:
+    def test_add_missing_translations(self, tmp_path):
+        from airflow_breeze.commands.ui_commands import 
add_missing_translations
+
+        en_dir = tmp_path / "en"
+        en_dir.mkdir()
+        de_dir = tmp_path / "de"
+        de_dir.mkdir()
+
+        en_data = {"greeting": "Hello", "farewell": "Goodbye"}
+        de_data = {"greeting": "Hallo"}
+
+        (en_dir / "test.json").write_text(json.dumps(en_data))
+        (de_dir / "test.json").write_text(json.dumps(de_data))
+
+        import airflow_breeze.commands.ui_commands as ui_commands
+
+        original_locales_dir = ui_commands.LOCALES_DIR
+        ui_commands.LOCALES_DIR = tmp_path
+
+        try:
+            summary = LocaleSummary(
+                missing_keys={"de": ["farewell"]},
+                extra_keys={"de": []},
+            )
+            add_missing_translations("de", {"test.json": summary})
+
+            # Check that the file was updated
+            de_data_updated = json.loads((de_dir / "test.json").read_text())
+            assert "farewell" in de_data_updated
+            assert de_data_updated["farewell"].startswith("TODO: translate:")
+        finally:
+            ui_commands.LOCALES_DIR = original_locales_dir
+
+
+class TestRemoveExtraTranslations:
+    def test_remove_extra_translations(self, tmp_path):
+        from airflow_breeze.commands.ui_commands import 
remove_extra_translations
+
+        de_dir = tmp_path / "de"
+        de_dir.mkdir()
+
+        de_data = {"greeting": "Hallo", "extra": "Extra Key"}
+        (de_dir / "test.json").write_text(json.dumps(de_data))
+
+        import airflow_breeze.commands.ui_commands as ui_commands
+
+        original_locales_dir = ui_commands.LOCALES_DIR
+        ui_commands.LOCALES_DIR = tmp_path
+
+        try:
+            summary = LocaleSummary(
+                missing_keys={"de": []},
+                extra_keys={"de": ["extra"]},
+            )
+            remove_extra_translations("de", {"test.json": summary})
+
+            # Check that the extra key was removed
+            de_data_updated = json.loads((de_dir / "test.json").read_text())
+            assert "extra" not in de_data_updated
+            assert "greeting" in de_data_updated
+        finally:
+            ui_commands.LOCALES_DIR = original_locales_dir
+
+
+class TestNaturalSorting:
+    def test_natural_sort_matches_eslint(self, tmp_path):
+        """Test that keys are sorted like eslint-plugin-jsonc with natural: 
true (case-insensitive with case-sensitive tiebreaker)."""
+        from airflow_breeze.commands.ui_commands import 
add_missing_translations
+
+        en_dir = tmp_path / "en"
+        en_dir.mkdir()
+        de_dir = tmp_path / "de"
+        de_dir.mkdir()
+
+        # Create English data with mixed-case keys to test sorting behavior
+        # This tests the specific cases that differ between ASCII and natural 
sort
+        en_data = {
+            "assetEvent_few": "1",
+            "asset_few": "2",
+            "parseDuration": "3",
+            "parsedAt": "4",
+            "Zebra": "5",
+            "apple": "6",
+        }
+        de_data = {}
+
+        (en_dir / "test.json").write_text(json.dumps(en_data))
+        (de_dir / "test.json").write_text(json.dumps(de_data))
+
+        import airflow_breeze.commands.ui_commands as ui_commands
+
+        original_locales_dir = ui_commands.LOCALES_DIR
+        ui_commands.LOCALES_DIR = tmp_path
+
+        try:
+            summary = LocaleSummary(
+                missing_keys={"de": list(en_data.keys())},
+                extra_keys={"de": []},
+            )
+            add_missing_translations("de", {"test.json": summary})
+
+            # Check that keys are sorted using natural sort (case-insensitive 
with case-sensitive tiebreaker)
+            de_data_updated = json.loads((de_dir / "test.json").read_text())
+            keys = list(de_data_updated.keys())
+
+            # Expected order matches eslint-plugin-jsonc with natural: true:
+            # - "apple" < "asset_few" < "assetEvent_few" (case-insensitive: 
apple < asset < assetevent)
+            # - "parseDuration" < "parsedAt" (case-insensitive equal at 
"parse", then D < d at position 5)
+            # - "Zebra" at the end (case-insensitive: z comes last)
+            assert keys == ["apple", "asset_few", "assetEvent_few", 
"parseDuration", "parsedAt", "Zebra"]
+        finally:
+            ui_commands.LOCALES_DIR = original_locales_dir
diff --git a/devel-common/src/docs/utils/conf_constants.py 
b/devel-common/src/docs/utils/conf_constants.py
index 5a5b0e7f672..1b8285460e8 100644
--- a/devel-common/src/docs/utils/conf_constants.py
+++ b/devel-common/src/docs/utils/conf_constants.py
@@ -339,7 +339,6 @@ BASIC_AUTOAPI_IGNORE_PATTERNS = [
     "*/tests/system/__init__.py",
     "*/tests/system/*/tests/*",
     "*/tests/system/example_empty.py",
-    "*/check_translations_completeness.py",
 ]
 
 IGNORE_PATTERNS_RECOGNITION = re.compile(r"\[AutoAPI\] .* Ignoring \s 
(?P<path>/[\w/.]*)", re.VERBOSE)

Reply via email to