From 6c6cd5052a157b658f50e04ca7c350a00c2dbd60 Mon Sep 17 00:00:00 2001
From: HsiangNianian <i@jyunko.cn>
Date: Fri, 16 Jan 2026 14:17:17 +0800
Subject: feat: add assistant view and configuration editor components

Introduced a new AssistantView component for enhanced interaction with the AI assistant, allowing users to send messages and receive responses. Implemented a ConfigEditorModal for editing configuration files with JSON validation and history management. Updated the App component to integrate these new features, improving user experience and functionality in managing AI settings.
---
 ui/src/components/ConfigEditorModal.svelte | 364 +++++++++++++++++++++++++++++
 1 file changed, 364 insertions(+)
 create mode 100644 ui/src/components/ConfigEditorModal.svelte

(limited to 'ui/src/components/ConfigEditorModal.svelte')

diff --git a/ui/src/components/ConfigEditorModal.svelte b/ui/src/components/ConfigEditorModal.svelte
new file mode 100644
index 0000000..87a7d67
--- /dev/null
+++ b/ui/src/components/ConfigEditorModal.svelte
@@ -0,0 +1,364 @@
+<script lang="ts">
+  import { settingsState } from "../stores/settings.svelte";
+  import { Save, X, FileJson, AlertCircle, Undo, Redo, Settings } from "lucide-svelte";
+  import Prism from 'prismjs';
+  import 'prismjs/components/prism-json';
+  import 'prismjs/themes/prism-tomorrow.css';
+
+  let content = $state(settingsState.rawConfigContent);
+  let isSaving = $state(false);
+  let localError = $state("");
+  
+  let textareaRef: HTMLTextAreaElement | undefined = $state();
+  let preRef: HTMLPreElement | undefined = $state();
+  let lineNumbersRef: HTMLDivElement | undefined = $state();
+
+  // History State
+  let history = $state([settingsState.rawConfigContent]);
+  let historyIndex = $state(0);
+  let debounceTimer: ReturnType<typeof setTimeout> | undefined;
+
+  // Editor Settings
+  let showLineNumbers = $state(localStorage.getItem('editor_showLineNumbers') !== 'false');
+  let showStatusBar = $state(localStorage.getItem('editor_showStatusBar') !== 'false');
+  let showSettings = $state(false);
+
+  // Cursor Status
+  let cursorLine = $state(1);
+  let cursorCol = $state(1);
+  
+  let lines = $derived(content.split('\n'));
+
+  $effect(() => {
+     localStorage.setItem('editor_showLineNumbers', String(showLineNumbers));
+     localStorage.setItem('editor_showStatusBar', String(showStatusBar));
+  });
+
+  // Cleanup timer on destroy
+  $effect(() => {
+    return () => {
+        if (debounceTimer) clearTimeout(debounceTimer);
+    };
+  });
+
+  // Initial validation
+  $effect(() => {
+    validate(content);
+  });
+
+  function validate(text: string) {
+    try {
+        JSON.parse(text);
+        localError = "";
+    } catch (e: any) {
+        localError = e.message;
+    }
+  }
+
+  function pushHistory(newContent: string, immediate = false) {
+      if (debounceTimer) clearTimeout(debounceTimer);
+      
+      const commit = () => {
+          if (newContent === history[historyIndex]) return;
+          const next = history.slice(0, historyIndex + 1);
+          next.push(newContent);
+          history = next;
+          historyIndex = next.length - 1;
+      };
+
+      if (immediate) {
+          commit();
+      } else {
+          debounceTimer = setTimeout(commit, 500);
+      }
+  }
+
+  function handleUndo() {
+      if (historyIndex > 0) {
+          historyIndex--;
+          content = history[historyIndex];
+          validate(content);
+      }
+  }
+
+  function handleRedo() {
+      if (historyIndex < history.length - 1) {
+          historyIndex++;
+          content = history[historyIndex];
+          validate(content);
+      }
+  }
+
+  function updateCursor() {
+      if (!textareaRef) return;
+      const pos = textareaRef.selectionStart;
+      const text = textareaRef.value.substring(0, pos);
+      const lines = text.split('\n');
+      cursorLine = lines.length;
+      cursorCol = lines[lines.length - 1].length + 1;
+  }
+
+  function handleInput(e: Event) {
+      const target = e.target as HTMLTextAreaElement;
+      content = target.value;
+      validate(content);
+      pushHistory(content);
+      updateCursor();
+  }
+
+  function handleScroll() {
+      if (textareaRef) {
+          if (preRef) {
+              preRef.scrollTop = textareaRef.scrollTop;
+              preRef.scrollLeft = textareaRef.scrollLeft;
+          }
+          if (lineNumbersRef) {
+              lineNumbersRef.scrollTop = textareaRef.scrollTop;
+          }
+      }
+  }
+
+  let highlightedCode = $derived(
+      Prism.highlight(content, Prism.languages.json, 'json') + '\n'
+  );
+
+  async function handleSave(close = false) {
+    if (localError) return;
+    isSaving = true;
+    await settingsState.saveRawConfig(content, close);
+    isSaving = false;
+  }
+
+  function handleKeydown(e: KeyboardEvent) {
+    // Save
+    if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      handleSave(false); // Keep open on shortcut save
+    } 
+    // Undo
+    else if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
+        e.preventDefault();
+        handleUndo();
+    }
+    // Redo (Ctrl+Shift+Z or Ctrl+Y)
+    else if (
+        (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey) ||
+        (e.key === 'y' && (e.ctrlKey || e.metaKey))
+    ) {
+        e.preventDefault();
+        handleRedo();
+    }
+    // Close
+    else if (e.key === 'Escape') {
+      settingsState.closeConfigEditor();
+    } 
+    // Tab
+    else if (e.key === 'Tab') {
+      e.preventDefault();
+      const target = e.target as HTMLTextAreaElement;
+      const start = target.selectionStart;
+      const end = target.selectionEnd;
+      
+      pushHistory(content, true);
+
+      const newContent = content.substring(0, start) + "  " + content.substring(end);
+      content = newContent;
+      
+      pushHistory(content, true);
+
+      setTimeout(() => {
+          target.selectionStart = target.selectionEnd = start + 2;
+          updateCursor();
+      }, 0);
+      validate(content);
+    }
+  }
+</script>
+
+<div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70 animate-in fade-in duration-200">
+  <div 
+    class="bg-[#1d1f21] rounded-xl border border-zinc-700 shadow-2xl w-[900px] max-w-[95vw] h-[85vh] flex flex-col overflow-hidden"
+    role="dialog"
+    aria-modal="true"
+  >
+    <!-- Header -->
+    <div class="flex items-center justify-between p-4 border-b border-zinc-700 bg-[#1d1f21] z-20 relative">
+      <div class="flex items-center gap-3">
+        <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+          <FileJson size={20} />
+        </div>
+        <div class="flex flex-col">
+            <h3 class="text-lg font-bold text-white leading-none">Configuration Editor</h3>
+            <span class="text-[10px] text-zinc-500 font-mono mt-1 break-all">{settingsState.configFilePath}</span>
+        </div>
+      </div>
+      <div class="flex items-center gap-2">
+          <!-- Undo/Redo Buttons -->
+          <div class="flex items-center bg-zinc-800 rounded-lg p-0.5 mr-2 border border-zinc-700">
+              <button 
+                onclick={handleUndo} 
+                disabled={historyIndex === 0}
+                class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
+                title="Undo (Ctrl+Z)"
+              >
+                  <Undo size={16} />
+              </button>
+              <button 
+                onclick={handleRedo} 
+                disabled={historyIndex === history.length - 1}
+                class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
+                title="Redo (Ctrl+Y)"
+              >
+                  <Redo size={16} />
+              </button>
+          </div>
+
+          <!-- Settings Toggle -->
+          <div class="relative">
+              <button 
+                onclick={() => showSettings = !showSettings}
+                class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg {showSettings ? 'bg-white/10 text-white' : ''}"
+                title="Editor Settings"
+              >
+                <Settings size={20} />
+              </button>
+              
+              {#if showSettings}
+                <div class="absolute right-0 top-full mt-2 w-48 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl p-2 z-50 flex flex-col gap-1">
+                    <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
+                        <input type="checkbox" bind:checked={showLineNumbers} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
+                        <span class="text-sm text-zinc-300">Line Numbers</span>
+                    </label>
+                    <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
+                        <input type="checkbox" bind:checked={showStatusBar} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
+                        <span class="text-sm text-zinc-300">Cursor Status</span>
+                    </label>
+                </div>
+              {/if}
+          </div>
+
+          <button
+            onclick={() => settingsState.closeConfigEditor()}
+            class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg"
+            title="Close (Esc)"
+          >
+            <X size={20} />
+          </button>
+      </div>
+    </div>
+
+    <!-- Error Banner -->
+    {#if localError || settingsState.configEditorError}
+        <div class="bg-red-500/10 border-b border-red-500/20 p-3 flex items-start gap-3 animate-in slide-in-from-top-2 z-10 relative">
+            <AlertCircle size={16} class="text-red-400 mt-0.5 shrink-0" />
+            <p class="text-xs text-red-300 font-mono whitespace-pre-wrap">{localError || settingsState.configEditorError}</p>
+        </div>
+    {/if}
+
+    <!-- Editor Body (Flex row for line numbers + code) -->
+    <div class="flex-1 flex overflow-hidden relative bg-[#1d1f21]">
+        <!-- Line Numbers -->
+        {#if showLineNumbers}
+            <div 
+                bind:this={lineNumbersRef}
+                class="pt-4 pb-4 pr-3 pl-2 text-right text-zinc-600 bg-[#1d1f21] border-r border-zinc-700/50 font-mono select-none overflow-hidden min-w-[3rem]"
+                aria-hidden="true"
+            >
+                {#each lines as _, i}
+                    <div class="leading-[20px] text-[13px]">{i + 1}</div>
+                {/each}
+            </div>
+        {/if}
+
+        <!-- Code Area -->
+        <div class="flex-1 relative overflow-hidden group">
+            <!-- Highlighted Code (Background) -->
+            <pre 
+                bind:this={preRef}
+                aria-hidden="true"
+                class="absolute inset-0 w-full h-full p-4 m-0 bg-transparent pointer-events-none overflow-hidden whitespace-pre font-mono text-sm leading-relaxed"
+            ><code class="language-json">{@html highlightedCode}</code></pre>
+
+            <!-- Textarea (Foreground) -->
+            <textarea
+                bind:this={textareaRef}
+                bind:value={content}
+                oninput={handleInput}
+                onkeydown={handleKeydown}
+                onscroll={handleScroll}
+                onmouseup={updateCursor}
+                onkeyup={updateCursor}
+                onclick={() => showSettings = false}
+                class="absolute inset-0 w-full h-full p-4 bg-transparent text-transparent caret-white font-mono text-sm leading-relaxed resize-none focus:outline-none whitespace-pre overflow-auto z-10 selection:bg-indigo-500/30"
+                spellcheck="false"
+                autocorrect="off"
+                autocapitalize="off"
+            ></textarea>
+        </div>
+    </div>
+
+    <!-- Footer -->
+    <div class="p-3 border-t border-zinc-700 bg-[#1d1f21] flex justify-between items-center z-20 relative">
+      <div class="text-xs text-zinc-500 flex gap-4 items-center">
+        {#if showStatusBar}
+            <div class="flex gap-3 font-mono border-r border-zinc-700 pr-4 mr-1">
+                <span>Ln {cursorLine}</span>
+                <span>Col {cursorCol}</span>
+            </div>
+        {/if}
+        <span class="hidden sm:inline"><span class="bg-white/10 px-1.5 py-0.5 rounded text-zinc-300">Ctrl+S</span> save</span>
+      </div>
+      <div class="flex gap-3">
+        <button
+          onclick={() => settingsState.closeConfigEditor()}
+          class="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-medium transition-colors"
+        >
+          Cancel
+        </button>
+        <button
+          onclick={() => handleSave(false)}
+          disabled={isSaving || !!localError}
+          class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
+          title={localError ? "Fix errors before saving" : "Save changes"}
+        >
+          {#if isSaving}
+            <div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
+            Saving...
+          {:else}
+            <Save size={16} />
+            Save
+          {/if}
+        </button>
+      </div>
+    </div>
+  </div>
+</div>
+
+<style>
+  /* Ensure exact font match */
+  pre, textarea {
+      font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
+      font-size: 13px !important;
+      line-height: 20px !important;
+      letter-spacing: 0px !important;
+      tab-size: 2;
+  }
+  
+  /* Hide scrollbar for pre but keep it functional for textarea */
+  pre::-webkit-scrollbar {
+    display: none;
+  }
+
+  /* Override Prism background and font weights for alignment */
+  :global(pre[class*="language-"]), :global(code[class*="language-"]) {
+      background: transparent !important;
+      text-shadow: none !important;
+      box-shadow: none !important;
+  }
+
+  /* CRITICAL: Force normal weight to match textarea */
+  :global(.token) {
+      font-weight: normal !important;
+      font-style: normal !important;
+  }
+</style>
-- 
cgit v1.2.3-70-g09d2


From cfa49be141ec3edd01d524b5230c7405b47dfd69 Mon Sep 17 00:00:00 2001
From: HsiangNianian <i@jyunko.cn>
Date: Fri, 16 Jan 2026 15:30:19 +0800
Subject: chore: add dev dependency for prek in pyproject.toml and uv.lock

Included the 'prek' package as a development dependency in both pyproject.toml and uv.lock, specifying version 0.2.28 to enhance development capabilities.
---
 pyproject.toml                             |  5 +++++
 ui/src/components/ConfigEditorModal.svelte |  9 +++++++--
 uv.lock                                    | 32 ++++++++++++++++++++++++++++++
 3 files changed, 44 insertions(+), 2 deletions(-)

(limited to 'ui/src/components/ConfigEditorModal.svelte')

diff --git a/pyproject.toml b/pyproject.toml
index 2aec266..10e6bbf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,3 +18,8 @@ path = "_version.py"
 [tool.hatch.build.targets.wheel]
 packages = ["."]
 only-include = ["_version.py"]
+
+[dependency-groups]
+dev = [
+    "prek>=0.2.28",
+]
diff --git a/ui/src/components/ConfigEditorModal.svelte b/ui/src/components/ConfigEditorModal.svelte
index 87a7d67..dd866ee 100644
--- a/ui/src/components/ConfigEditorModal.svelte
+++ b/ui/src/components/ConfigEditorModal.svelte
@@ -13,6 +13,12 @@
   let preRef: HTMLPreElement | undefined = $state();
   let lineNumbersRef: HTMLDivElement | undefined = $state();
 
+  // Textarea attributes that TypeScript doesn't recognize but are valid HTML
+  const textareaAttrs = {
+    autocorrect: "off",
+    autocapitalize: "off"
+  } as Record<string, string>;
+
   // History State
   let history = $state([settingsState.rawConfigContent]);
   let historyIndex = $state(0);
@@ -291,8 +297,7 @@
                 onclick={() => showSettings = false}
                 class="absolute inset-0 w-full h-full p-4 bg-transparent text-transparent caret-white font-mono text-sm leading-relaxed resize-none focus:outline-none whitespace-pre overflow-auto z-10 selection:bg-indigo-500/30"
                 spellcheck="false"
-                autocorrect="off"
-                autocapitalize="off"
+                {...textareaAttrs}
             ></textarea>
         </div>
     </div>
diff --git a/uv.lock b/uv.lock
index b7a6bec..15d682d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -27,9 +27,17 @@ dependencies = [
     { name = "pre-commit" },
 ]
 
+[package.dev-dependencies]
+dev = [
+    { name = "prek" },
+]
+
 [package.metadata]
 requires-dist = [{ name = "pre-commit", specifier = ">=4.5.1" }]
 
+[package.metadata.requires-dev]
+dev = [{ name = "prek", specifier = ">=0.2.28" }]
+
 [[package]]
 name = "filelock"
 version = "3.20.3"
@@ -82,6 +90,30 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437 },
 ]
 
+[[package]]
+name = "prek"
+version = "0.2.28"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/d7/dda8a6b7819bdb9d1f54fa046911e80974d862bacba75a3539b43a9bb858/prek-0.2.28.tar.gz", hash = "sha256:ac54f58cad26e617a5c5459b705ff1cbaaa41640db03d8d35e39645aca1b82cf", size = 283945 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7e/8c/2e18867e31d06dfa4974bf31c4e597dc1d2b3671b3c04d85f1f6a32ebc74/prek-0.2.28-py3-none-linux_armv6l.whl", hash = "sha256:1705c0bd035379cb5f1d03c19481821363d72d7923303fe8c84fd8cc7c6c3318", size = 4802811 },
+    { url = "https://files.pythonhosted.org/packages/26/fa/6c6d0b0d8b2f21301da2bb3441f22232ed5a8cba1b63eeb18244d2192a2e/prek-0.2.28-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:67677c08767c278335b31ebcbf00c1c73e14eadd99a0d8537dfb43c54482673a", size = 4904156 },
+    { url = "https://files.pythonhosted.org/packages/fc/5a/aa071ef1c2e6c3f58b50d9138676c96dd6de2323a44e1a3e56e18d25c382/prek-0.2.28-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffac92215058ea6ba954a8e3f978dcd2a5e89922a318fcb7035fb9c0ab4de395", size = 4630803 },
+    { url = "https://files.pythonhosted.org/packages/77/dc/66498e805a0bb17820de0c3575d75b202c66045a9bfeeff9305d9bedd126/prek-0.2.28-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:413c3da1c9b252b3fd5113f4a04c2dead3c793b0ec56fc55294bb797ba7ca056", size = 4826037 },
+    { url = "https://files.pythonhosted.org/packages/27/ad/99cccc9283c7b34cd92356fcb301a2b1c25a8b65dc34b86c671b0f8e29d8/prek-0.2.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e99639bb68b70704e7b3f7f1a51cb23527c4dbd4b8a01dfccaa70f26f8f6c58b", size = 4723658 },
+    { url = "https://files.pythonhosted.org/packages/53/13/ce3edc2dda7b65485612e08ab038b8dd1ef7b10a94b0193f527b19a5e246/prek-0.2.28-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33c4a1b2a8a76581476ae327d6d4f1b0af6af42a90d436e21e45c72cb1081b81", size = 5044611 },
+    { url = "https://files.pythonhosted.org/packages/48/47/6405d7ad7959d9b57d56fec9a1b4b2e00abeb955084dd45d100fb50a8377/prek-0.2.28-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6236dda18152fc56b9f802ce2914fbb2d19f9891595d552228c7894004b3332f", size = 5511371 },
+    { url = "https://files.pythonhosted.org/packages/92/cc/108c227fae40268ece36b80e5649037f1a816518e9b6d585d128b263df79/prek-0.2.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6506709d9a52ee431d48b249b8b5fb93597a9511eb260ee85d868750425057f", size = 5099352 },
+    { url = "https://files.pythonhosted.org/packages/12/d6/156ad3996d3a078a1bc2c0839b8681634216a494dcb298b8751beb28b327/prek-0.2.28-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7cc920c12613440c105a767dc19acf048e8a05342ba38b48450d673bea33bd62", size = 4834340 },
+    { url = "https://files.pythonhosted.org/packages/f4/06/c632d4c4bb9c63d25bcc26149f99c715206a40e414fb6b80e7f800ae2e2d/prek-0.2.28-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:322a1922e6d2fcb2a4c487e01b2613856dc3206269bdc317ad28770704159e63", size = 4844870 },
+    { url = "https://files.pythonhosted.org/packages/ba/03/763f62d292399ee962e2583e7bc3fd2f8ee2609813c89cc10ec89a39204c/prek-0.2.28-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:a07baefe3562371368135eac3c8b9cdb831bac17b83cb1b6a8f5688050918e6c", size = 4709011 },
+    { url = "https://files.pythonhosted.org/packages/e1/62/49397d1a5c2aaf5e7a8c0644be901ee97934a8a2cac0052652d01b7c6585/prek-0.2.28-py3-none-musllinux_1_1_i686.whl", hash = "sha256:17e95cab33067365028ffc1d4ab6c80c6c150f88e352d7c64bdc15e0570778f6", size = 4928435 },
+    { url = "https://files.pythonhosted.org/packages/5e/e8/8ec73b5bb3fb9d5daf77f181cc46c541bd476075c7613f9b4c9c953925cc/prek-0.2.28-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:bc5272e2e8d81438a3cd2785c3b14b6e73dcb8e61b8a2b7ce6e693d57f7181ac", size = 5209880 },
+    { url = "https://files.pythonhosted.org/packages/f3/52/a784a52bf72619bdfaafdbb6c964bda404b377213e7dc786f9ad6024501c/prek-0.2.28-py3-none-win32.whl", hash = "sha256:74657a1663191fb5f09f15873704c2d2640095ce56277d36860bbd08ba7eea94", size = 4622786 },
+    { url = "https://files.pythonhosted.org/packages/d0/b8/ec6aafefeb05ef3a0bfcc044d036890f2b732b90ed1426acbf1e33289a44/prek-0.2.28-py3-none-win_amd64.whl", hash = "sha256:c350046d623362db65e2be1455ef1c5a96ea476e235445752fa946a705f1c1c9", size = 5316389 },
+    { url = "https://files.pythonhosted.org/packages/df/3f/9d4aba92cb9199cad0b056de8292a78dcca1dc1f6a6a34550799f19bde3d/prek-0.2.28-py3-none-win_arm64.whl", hash = "sha256:81db6ba7e5cf1d5ceec7d3b04e01aded32b8db8f1238ad812ac6ebc0bd35f141", size = 4974627 },
+]
+
 [[package]]
 name = "pyyaml"
 version = "6.0.3"
-- 
cgit v1.2.3-70-g09d2

