From 391ac1f87404088c89aeb5fbaa73ec46eedb0255 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 14 Jun 2026 15:50:59 +0200 Subject: [PATCH 01/17] refac --- .../Admin/CreateConnectionModal.svelte | 4 +- .../Admin/EditConnectionModal.svelte | 2 +- .../src/lib/components/SetupWizard.svelte | 319 ++++++++++++++++++ cptr/frontend/src/lib/i18n/locales/en.json | 29 +- cptr/frontend/src/routes/+layout.svelte | 20 ++ 5 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 cptr/frontend/src/lib/components/SetupWizard.svelte diff --git a/cptr/frontend/src/lib/components/Admin/CreateConnectionModal.svelte b/cptr/frontend/src/lib/components/Admin/CreateConnectionModal.svelte index 2f94378..a4fbfba 100644 --- a/cptr/frontend/src/lib/components/Admin/CreateConnectionModal.svelte +++ b/cptr/frontend/src/lib/components/Admin/CreateConnectionModal.svelte @@ -15,7 +15,7 @@ let { onclose, oncreated }: Props = $props(); let formName = $state(''); - let formProvider = $state<'anthropic' | 'openai'>('anthropic'); + let formProvider = $state<'openai' | 'anthropic'>('openai'); let formApiType = $state<'chat_completions' | 'responses'>('chat_completions'); let formBaseUrl = $state(''); let formApiKey = $state(''); @@ -94,8 +94,8 @@ bind:value={formProvider} class="block w-full bg-transparent text-[13px] text-gray-700 dark:text-gray-300 outline-none py-0.5 cursor-pointer" > - + diff --git a/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte b/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte index ae57bee..c54d31e 100644 --- a/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte +++ b/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte @@ -130,8 +130,8 @@ bind:value={formProvider} class="block w-full bg-transparent text-[13px] text-gray-700 dark:text-gray-300 outline-none py-0.5 cursor-pointer" > - + diff --git a/cptr/frontend/src/lib/components/SetupWizard.svelte b/cptr/frontend/src/lib/components/SetupWizard.svelte new file mode 100644 index 0000000..55f5bc1 --- /dev/null +++ b/cptr/frontend/src/lib/components/SetupWizard.svelte @@ -0,0 +1,319 @@ + + +
+
+ +
+ {#each Array(totalSteps) as _, i} +
+ {/each} +
+ + {#if step === 0} + +
+

+ {$t('onboarding.welcomeTitle')} +

+

+ {$t('onboarding.welcomeDesc')} +

+ + +
+ {:else if step === 1} + +
+

+ {$t('onboarding.openFolder')} +

+

+ {$t('onboarding.openFolderDesc')} +

+ + {#if selectedPath} +

+ {selectedPath} +

+ {/if} + + + +
+ +
+
+ {:else if step === 2} + + +
{ if (e.key === 'Enter' && apiKey.trim()) connectAi(); }}> +

+ {$t('onboarding.connectAi')} +

+

+ {$t('onboarding.connectAiDesc')} +

+ + {#if aiConnected} +

+ {config.name} connected +

+ + {:else} +
+

Provider

+ +
+ + {#if provider === 'openai'} +
+

API Type

+ +
+ {/if} + +
+

{$t('connections.baseUrl')}

+ + + +
+ +
+

{$t('connections.apiKey')}

+ +
+ +

+ {$t('onboarding.keyStaysLocal')} +

+ + + +
+ +
+ {/if} +
+ {:else if step === 3} + +
+

+ {$t('onboarding.ready')} +

+

+ {$t('onboarding.readyDesc')} +

+ +
+

+ {$t('onboarding.tipSearch', { shortcut: searchShortcut })} +

+

+ {$t('onboarding.tipTerminal')} +

+

+ {$t('onboarding.tipMobile')} +

+
+ + +
+ {/if} +
+
+ +{#if showPicker} + { + showPicker = false; + const params = new URLSearchParams(window.location.search); + const wsPath = params.get('workspace'); + if (wsPath) { + selectedPath = wsPath; + const url = new URL(window.location.href); + url.searchParams.delete('workspace'); + window.history.replaceState({}, '', url.toString()); + next(); + } + }} + /> +{/if} + + diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 5f446bc..58378dc 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -70,7 +70,7 @@ "files.date": "Date", "files.folder": "folder", "files.file": "file", - "files.clickToPreview": "{{process}} — click to preview", + "files.clickToPreview": "{{process}} - click to preview", "files.showHidden": "Show dotfiles", "files.hideHidden": "Hide dotfiles", "files.failedToLoad": "Failed to load directory", @@ -723,6 +723,31 @@ "admin.contextCompaction": "Context compaction", "admin.compactTokenThreshold": "Token threshold", "admin.compactTokenThresholdUnit": "tokens", - "admin.compactTokenThresholdHint": "Older messages are summarized when estimated context exceeds this limit. Default: 80,000." + "admin.compactTokenThresholdHint": "Older messages are summarized when estimated context exceeds this limit. Default: 80,000.", + + "onboarding.welcomeTitle": "Your computer, from anywhere", + "onboarding.welcomeDesc": "Code, manage, and control your machine from any device.", + "onboarding.getStarted": "Get started →", + "onboarding.openFolder": "Open a folder", + "onboarding.openFolderDesc": "Pick a project or folder to work with.", + "onboarding.skip": "Skip", + "onboarding.connectAi": "Connect AI", + "onboarding.connectAiDesc": "Add an API key to unlock chat, code editing, and automations.", + "onboarding.connectAiSkip": "I'll do this later", + "onboarding.keyStaysLocal": "Your key stays on this machine.", + "onboarding.connect": "Connect →", + "onboarding.ready": "You're all set", + "onboarding.readyDesc": "A few things to try:", + "onboarding.tipSearch": "Search for anything with {{shortcut}}", + "onboarding.tipTerminal": "Open a terminal right in the browser", + "onboarding.tipMobile": "Works on your phone, same URL", + "onboarding.startUsing": "Start →", + "onboarding.takeTour": "Take the tour", + "onboarding.suggestion1": "Explain this project", + "onboarding.suggestion2": "Find all TODOs", + "onboarding.suggestion3": "What files are here?", + "onboarding.suggestion4": "Help me write tests", + "onboarding.connectAiNudge": "Connect an AI provider to start chatting.", + "onboarding.goToConnections": "Settings → Connections" } diff --git a/cptr/frontend/src/routes/+layout.svelte b/cptr/frontend/src/routes/+layout.svelte index 9573a42..c424f64 100644 --- a/cptr/frontend/src/routes/+layout.svelte +++ b/cptr/frontend/src/routes/+layout.svelte @@ -43,10 +43,12 @@ import { t } from '$lib/i18n'; import { refreshChatState, bindGlobalChatListener } from '$lib/stores/chat'; import { refreshAudioState } from '$lib/stores/audio'; + import SetupWizard from '$lib/components/SetupWizard.svelte'; let { children } = $props(); let showSettings = $state(false); let showUpdateToast = $state(false); + let showSetup = $state(false); // Auth state type AuthState = 'checking' | 'needs_setup' | 'needs_login' | 'authenticated'; @@ -117,6 +119,20 @@ } }); + // Check ?setup=true param for admin setup wizard + $effect(() => { + if (!$stateLoaded) return; + if (authState !== 'authenticated') return; + + const params = new URLSearchParams(window.location.search); + if (params.get('setup') === 'true' && $session?.role === 'admin') { + showSetup = true; + const url = new URL(window.location.href); + url.searchParams.delete('setup'); + window.history.replaceState({}, '', url.toString()); + } + }); + async function checkAuth() { try { const params = new URLSearchParams(window.location.search); @@ -167,6 +183,7 @@ } async function handleAuth() { + const wasSetup = authState === 'needs_setup'; try { const auth = await getSession(); if (auth.authenticated) { @@ -181,6 +198,7 @@ initState(); refreshChatState(); refreshAudioState(); + if (wasSetup) showSetup = true; return; } } catch {} @@ -299,6 +317,8 @@ token={startupToken} onauth={handleAuth} /> +{:else if $stateLoaded && showSetup} + { showSetup = false; }} /> {:else if $stateLoaded}
Date: Sun, 14 Jun 2026 16:23:28 +0200 Subject: [PATCH 02/17] refac --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4dfd91a..6a841b7 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Bring your own API key. Works with OpenAI, Anthropic, Ollama, or any OpenAI-comp | 🔌 **Tool servers** | Connect external tools via MCP or OpenAPI. | | 🧠 **Context compaction** | Long conversations are automatically summarised to stay fast. | +Already have a favourite terminal agent? Claude Code, Codex, Gemini CLI, Cursor, Grok, OpenCode, Kilo Code, and Pi all plug straight in. Use the subscription you already pay for. + ## Messaging bots Connect the AI to your chat apps. Full tool access, streaming responses, conversations synced back to the web UI. From 4b17b3073111a7f0b2717aabebab7941ccf46ad2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 14 Jun 2026 16:24:35 +0200 Subject: [PATCH 03/17] refac --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6a841b7..b68892e 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ cptr run --host 0.0.0.0 | | | |---|---| | 📁 **File browser** | Navigate, create, rename, upload, drag and drop. Icons by type, sizes at a glance. | -| ⌨️ **Terminal** | Full PTY-backed shell in the browser. Anything you'd run at your desk. | +| ⌨️ **Terminal** | Full shell in the browser. Run your tools, your scripts, or your favourite coding agent. | | 🔀 **Git** | Stage, commit, diff, branch, push. Visual changes view. No command line required. | | ✏️ **Editor** | Syntax-highlighted editing with tabs. Open multiple files side by side. | | 📂 **Workspaces** | Multiple projects, one instance. Switch without losing your place. | From 7938f73e5783389eb21d915f03a55a66e8e40158 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 14 Jun 2026 21:26:40 +0200 Subject: [PATCH 04/17] refac --- cptr/app.py | 7 + cptr/frontend/src/lib/apis/admin.ts | 7 +- .../lib/components/Admin/ToolServers.svelte | 159 ++++++++++++------ cptr/frontend/src/lib/i18n/locales/en.json | 6 + cptr/routers/admin.py | 40 ++++- cptr/utils/mcp/client.py | 46 ++++- cptr/utils/mcp/stdio_manager.py | 89 ++++++++++ cptr/utils/tools.py | 77 +++++++-- 8 files changed, 368 insertions(+), 63 deletions(-) create mode 100644 cptr/utils/mcp/stdio_manager.py diff --git a/cptr/app.py b/cptr/app.py index 4c09e0c..4ba9d82 100644 --- a/cptr/app.py +++ b/cptr/app.py @@ -80,6 +80,13 @@ async def shutdown(): await shutdown_browser() except Exception: pass + # Clean up stdio MCP server processes + try: + from cptr.utils.mcp.stdio_manager import stdio_manager + + await stdio_manager.disconnect_all() + except Exception: + pass # Auth middleware diff --git a/cptr/frontend/src/lib/apis/admin.ts b/cptr/frontend/src/lib/apis/admin.ts index f3c71b8..4a6869c 100644 --- a/cptr/frontend/src/lib/apis/admin.ts +++ b/cptr/frontend/src/lib/apis/admin.ts @@ -133,7 +133,7 @@ export const updateModelConfig = ( export interface ToolServer { id: string; - type: 'openapi' | 'mcp'; + type: 'openapi' | 'mcp' | 'mcp_stdio'; url: string; path: string; auth_type: string; @@ -142,6 +142,11 @@ export interface ToolServer { description: string; headers: Record | null; enabled: boolean; + // Stdio MCP fields + command?: string; + args?: string[]; + env?: Record | null; + cwd?: string | null; } export const listToolServers = async (): Promise => { diff --git a/cptr/frontend/src/lib/components/Admin/ToolServers.svelte b/cptr/frontend/src/lib/components/Admin/ToolServers.svelte index 4c45f06..34a31b2 100644 --- a/cptr/frontend/src/lib/components/Admin/ToolServers.svelte +++ b/cptr/frontend/src/lib/components/Admin/ToolServers.svelte @@ -23,7 +23,7 @@ // Form state let formId = $state(''); - let formType = $state<'openapi' | 'mcp'>('openapi'); + let formType = $state<'openapi' | 'mcp' | 'mcp_stdio'>('openapi'); let formUrl = $state(''); let formPath = $state('openapi.json'); let formAuthType = $state('bearer'); @@ -31,6 +31,10 @@ let formName = $state(''); let formDescription = $state(''); let formHeaders = $state(''); + // Stdio fields + let formCommand = $state(''); + let formArgs = $state(''); + let formCwd = $state(''); let saving = $state(false); let verifying = $state(false); @@ -59,6 +63,9 @@ formName = ''; formDescription = ''; formHeaders = ''; + formCommand = ''; + formArgs = ''; + formCwd = ''; verifyResult = null; showModal = true; @@ -75,13 +82,25 @@ formName = s.name; formDescription = s.description; formHeaders = s.headers ? JSON.stringify(s.headers, null, 2) : ''; + formCommand = s.command || ''; + formArgs = (s.args || []).join(' '); + formCwd = s.cwd || ''; verifyResult = null; showModal = true; } async function handleSubmit() { - if (!formId.trim() || !formUrl.trim()) { + const isStdio = formType === 'mcp_stdio'; + if (!formId.trim()) { + toast.error($t('toolServers.fieldsRequired')); + return; + } + if (isStdio && !formCommand.trim()) { + toast.error($t('toolServers.commandRequired')); + return; + } + if (!isStdio && !formUrl.trim()) { toast.error($t('toolServers.fieldsRequired')); return; } @@ -110,6 +129,7 @@ name: formName.trim() || (() => { + if (formType === 'mcp_stdio') return formCommand.trim().split('/').pop() || 'stdio'; try { return new URL(formUrl.trim()).hostname; } catch { @@ -117,7 +137,10 @@ } })(), description: formDescription.trim(), - headers: parsedHeaders || null + headers: parsedHeaders || null, + command: formCommand.trim(), + args: formArgs.trim() ? formArgs.trim().split(/\s+/) : [], + cwd: formCwd.trim() || null }; if (editServer) { if (formKey.trim()) data.key = formKey.trim(); @@ -205,17 +228,17 @@ > - {s.type === 'mcp' ? 'MCP' : 'API'} + {s.type === 'mcp' ? 'MCP' : s.type === 'mcp_stdio' ? 'STDIO' : 'API'} - {s.name || s.url} + {s.name || s.command || s.url} +
@@ -305,20 +329,59 @@ class="block w-full bg-transparent text-[13px] text-gray-700 dark:text-gray-300 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none py-0.5" /> - - - + + {#if formType !== 'mcp_stdio'} + + + {/if} + + + {#if formType === 'mcp_stdio'} + + + + + + + {/if} {#if formType === 'openapi'} @@ -335,35 +398,37 @@ /> {/if} - -
-
- - -
- {#if formAuthType === 'bearer'} -
+ + {#if formType !== 'mcp_stdio'} +
+
{$t('toolServers.auth')} - +
- {/if} -
+ {#if formAuthType === 'bearer'} +
+ + +
+ {/if} +
+ {/if}
-

+

+ {$t('about.share')} + {#each shareLinks as link, i} + {#if i > 0} + · + {/if} + {link.label} + {/each} + · + +
+ +

{$t('about.createdBy')}

diff --git a/cptr/frontend/src/lib/i18n/locales/de.json b/cptr/frontend/src/lib/i18n/locales/de.json index 1c6d64a..d6d55b0 100644 --- a/cptr/frontend/src/lib/i18n/locales/de.json +++ b/cptr/frontend/src/lib/i18n/locales/de.json @@ -316,6 +316,9 @@ "about.licenseName": "Open Use Lizenz", "about.copyright": "Copyright © 2026 Open WebUI Inc. Alle Rechte vorbehalten.", "about.updateAvailable": "v{{version}} verfügbar", + "about.share": "Teilen", + "about.copyLink": "Link kopieren", + "about.copied": "Kopiert!", "connections.failedToUpdate": "Verbindung konnte nicht aktualisiert werden", "keyboard.newFile": "Eine neue leere Datei öffnen", "keyboard.newTerminal": "Eine neue Terminalsitzung öffnen", diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 38f9afe..6b03df5 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -377,6 +377,9 @@ "about.licenseName": "Open Use License", "about.copyright": "Copyright © 2026 Open WebUI Inc. All rights reserved.", "about.updateAvailable": "v{{version}} available", + "about.share": "Share", + "about.copyLink": "Copy link", + "about.copied": "Copied!", "automations.title": "Automations", "automations.filter": "Filter...", diff --git a/cptr/frontend/src/lib/i18n/locales/es.json b/cptr/frontend/src/lib/i18n/locales/es.json index 1c5f019..6c5b0b1 100644 --- a/cptr/frontend/src/lib/i18n/locales/es.json +++ b/cptr/frontend/src/lib/i18n/locales/es.json @@ -316,6 +316,9 @@ "about.licenseName": "Licencia de Uso Abierto", "about.copyright": "Copyright © 2026 Open WebUI Inc. Todos los derechos reservados.", "about.updateAvailable": "v{{version}} disponible", + "about.share": "Compartir", + "about.copyLink": "Copiar enlace", + "about.copied": "¡Copiado!", "connections.failedToUpdate": "Error al actualizar conexión", "keyboard.newFile": "Abrir un archivo nuevo vacío", "keyboard.newTerminal": "Abrir una nueva sesión de terminal", diff --git a/cptr/frontend/src/lib/i18n/locales/fr.json b/cptr/frontend/src/lib/i18n/locales/fr.json index d603668..6bf7a9e 100644 --- a/cptr/frontend/src/lib/i18n/locales/fr.json +++ b/cptr/frontend/src/lib/i18n/locales/fr.json @@ -315,6 +315,9 @@ "about.licenseName": "Licence d'utilisation ouverte", "about.copyright": "Copyright © 2026 Open WebUI Inc. Tous droits réservés.", "about.updateAvailable": "v{{version}} disponible", + "about.share": "Partager", + "about.copyLink": "Copier le lien", + "about.copied": "Copié !", "connections.failedToUpdate": "Échec de la mise à jour de la connexion", "keyboard.newFile": "Ouvrir un nouveau fichier vide", "keyboard.newTerminal": "Ouvrir une nouvelle session de terminal", diff --git a/cptr/frontend/src/lib/i18n/locales/ja.json b/cptr/frontend/src/lib/i18n/locales/ja.json index 9fe58b4..e704ec1 100644 --- a/cptr/frontend/src/lib/i18n/locales/ja.json +++ b/cptr/frontend/src/lib/i18n/locales/ja.json @@ -316,6 +316,9 @@ "about.licenseName": "オープンユースライセンス", "about.copyright": "Copyright © 2026 Open WebUI Inc. All rights reserved.", "about.updateAvailable": "v{{version}} が利用可能", + "about.share": "共有", + "about.copyLink": "リンクをコピー", + "about.copied": "コピーしました!", "connections.failedToUpdate": "接続の更新に失敗しました", "keyboard.newFile": "新しい空のファイルを開く", "keyboard.newTerminal": "新しいターミナルセッションを開く", diff --git a/cptr/frontend/src/lib/i18n/locales/ko.json b/cptr/frontend/src/lib/i18n/locales/ko.json index d63d07e..929f0cd 100644 --- a/cptr/frontend/src/lib/i18n/locales/ko.json +++ b/cptr/frontend/src/lib/i18n/locales/ko.json @@ -316,6 +316,9 @@ "about.licenseName": "오픈 사용 라이선스", "about.copyright": "Copyright © 2026 Open WebUI Inc. All rights reserved.", "about.updateAvailable": "v{{version}} 사용 가능", + "about.share": "공유", + "about.copyLink": "링크 복사", + "about.copied": "복사됨!", "connections.failedToUpdate": "연결 업데이트에 실패했습니다", "keyboard.newFile": "새 빈 파일 열기", "keyboard.newTerminal": "새 터미널 세션 열기", diff --git a/cptr/frontend/src/lib/i18n/locales/pt-BR.json b/cptr/frontend/src/lib/i18n/locales/pt-BR.json index 933ec3f..41a4a2d 100644 --- a/cptr/frontend/src/lib/i18n/locales/pt-BR.json +++ b/cptr/frontend/src/lib/i18n/locales/pt-BR.json @@ -316,6 +316,9 @@ "about.licenseName": "Licença de Uso Aberto", "about.copyright": "Copyright © 2026 Open WebUI Inc. Todos os direitos reservados.", "about.updateAvailable": "v{{version}} disponível", + "about.share": "Compartilhar", + "about.copyLink": "Copiar link", + "about.copied": "Copiado!", "connections.failedToUpdate": "Falha ao atualizar conexão", "keyboard.newFile": "Abrir um novo arquivo vazio", "keyboard.newTerminal": "Abrir uma nova sessão de terminal", diff --git a/cptr/frontend/src/lib/i18n/locales/ru.json b/cptr/frontend/src/lib/i18n/locales/ru.json index 4c54a0d..b9df36d 100644 --- a/cptr/frontend/src/lib/i18n/locales/ru.json +++ b/cptr/frontend/src/lib/i18n/locales/ru.json @@ -316,6 +316,9 @@ "about.licenseName": "Открытая лицензия", "about.copyright": "Copyright © 2026 Open WebUI Inc. Все права защищены.", "about.updateAvailable": "v{{version}} доступна", + "about.share": "Поделиться", + "about.copyLink": "Копировать ссылку", + "about.copied": "Скопировано!", "connections.failedToUpdate": "Не удалось обновить подключение", "keyboard.newFile": "Открыть новый пустой файл", "keyboard.newTerminal": "Открыть новую сессию терминала", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-CN.json b/cptr/frontend/src/lib/i18n/locales/zh-CN.json index 4826b84..cd2d6ab 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-CN.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-CN.json @@ -316,6 +316,9 @@ "about.licenseName": "开放使用许可", "about.copyright": "Copyright © 2026 Open WebUI Inc. 保留所有权利。", "about.updateAvailable": "v{{version}} 可用", + "about.share": "分享", + "about.copyLink": "复制链接", + "about.copied": "已复制!", "connections.failedToUpdate": "更新连接失败", "keyboard.newFile": "打开新的空白文件", "keyboard.newTerminal": "打开新的终端会话", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-TW.json b/cptr/frontend/src/lib/i18n/locales/zh-TW.json index c146e48..31ed45b 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-TW.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-TW.json @@ -316,6 +316,9 @@ "about.licenseName": "開放使用許可", "about.copyright": "Copyright © 2026 Open WebUI Inc. 保留所有權利。", "about.updateAvailable": "v{{version}} 可用", + "about.share": "分享", + "about.copyLink": "複製連結", + "about.copied": "已複製!", "connections.failedToUpdate": "更新連線失敗", "keyboard.newFile": "開啟新的空白檔案", "keyboard.newTerminal": "開啟新的終端機工作階段", From a9835d70a4239777588bd886478ad56c4cd03f49 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 08:24:45 +0200 Subject: [PATCH 06/17] refac --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b68892e..c66d989 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,26 @@ cptr run Or with [uv](https://docs.astral.sh/uv/): `uvx cptr@latest run` -Opens in your browser. From other devices: +Opens in your browser at `http://localhost:8000`. + +### Access from your phone + +Same Wi-Fi? Bind to all interfaces: ```bash cptr run --host 0.0.0.0 ``` +Open `http://:8000` on your phone. + +Not on the same network? Use a tunnel: + +- **[Tailscale](https://tailscale.com)** creates a private mesh network between your devices. Recommended. +- **[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)** gives you a permanent URL through Cloudflare's edge. +- **[ngrok](https://ngrok.com)** gives you a public URL in one command. + +Or skip networking entirely and connect a [messaging bot](#messaging-bots) instead. + ## What you get | | | @@ -37,6 +51,7 @@ cptr run --host 0.0.0.0 | ⌨️ **Terminal** | Full shell in the browser. Run your tools, your scripts, or your favourite coding agent. | | 🔀 **Git** | Stage, commit, diff, branch, push. Visual changes view. No command line required. | | ✏️ **Editor** | Syntax-highlighted editing with tabs. Open multiple files side by side. | +| 🗂️ **Tabs** | Open terminals, files, chats, and tools in separate tabs. Rearrange or split your layout. | | 📂 **Workspaces** | Multiple projects, one instance. Switch without losing your place. | | 🔍 **Search** | Find files by name, search across file contents and chat history. ⌘K to find anything. | | 📱 **Mobile-first** | Not a desktop UI made smaller. Built for the screen in your pocket. | From d21ac8ac977fe566250e17f5aa31cf8123a6fbdd Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 10:05:17 +0200 Subject: [PATCH 07/17] refac --- cptr/utils/documents.py | 292 ++++++++++++++++++++++++++++++++++++++++ cptr/utils/tools.py | 181 +++++++++++++++++++------ pyproject.toml | 1 + 3 files changed, 434 insertions(+), 40 deletions(-) create mode 100644 cptr/utils/documents.py diff --git a/cptr/utils/documents.py b/cptr/utils/documents.py new file mode 100644 index 0000000..637015d --- /dev/null +++ b/cptr/utils/documents.py @@ -0,0 +1,292 @@ +"""Document text extraction utilities. + +Extracts readable text from binary document formats so LLMs can +consume their content. Each ``extract_*`` function takes a file path +and returns the document's text as a plain string. + +All imports are lazy — missing libraries produce a clear ImportError +with install instructions, not a crash. +""" + +import os +import zipfile + + +def extract_pdf(file_path: str) -> str: + """Extract text from a PDF file.""" + try: + from pypdf import PdfReader + except ImportError: + raise ImportError("pip install pypdf") + + reader = PdfReader(file_path) + return "\n".join(page.extract_text() or "" for page in reader.pages) + + +def extract_docx(file_path: str) -> str: + """Extract text from a Word (.docx) file.""" + try: + from docx import Document as DocxDocument + except ImportError: + raise ImportError("pip install python-docx") + + doc = DocxDocument(file_path) + parts = [] + for para in doc.paragraphs: + parts.append(para.text) + for table in doc.tables: + for row in table.rows: + parts.append("\t".join(cell.text for cell in row.cells)) + return "\n".join(parts) + + +def extract_xlsx(file_path: str) -> str: + """Extract text from an Excel (.xlsx) file.""" + try: + from openpyxl import load_workbook + except ImportError: + raise ImportError("pip install openpyxl") + + wb = load_workbook(file_path, read_only=True, data_only=True) + parts = [] + for sheet in wb.worksheets: + parts.append(f"--- {sheet.title} ---") + for row in sheet.iter_rows(values_only=True): + parts.append("\t".join(str(c) if c is not None else "" for c in row)) + wb.close() + return "\n".join(parts) + + +def extract_pptx(file_path: str) -> str: + """Extract text from a PowerPoint (.pptx) file.""" + try: + from pptx import Presentation + except ImportError: + raise ImportError("pip install python-pptx") + + prs = Presentation(file_path) + parts = [] + for i, slide in enumerate(prs.slides, 1): + parts.append(f"--- Slide {i} ---") + for shape in slide.shapes: + if shape.has_text_frame: + parts.append(shape.text_frame.text) + return "\n".join(parts) + + +def extract_rtf(file_path: str) -> str: + """Extract text from a Rich Text Format (.rtf) file.""" + try: + from striprtf.striprtf import rtf_to_text + except ImportError: + raise ImportError("pip install striprtf") + + with open(file_path, "rb") as f: + raw = f.read() + return rtf_to_text(raw.decode("utf-8", errors="replace")) + + +def extract_xls(file_path: str) -> str: + """Extract text from a legacy Excel (.xls) file.""" + try: + import xlrd + except ImportError: + raise ImportError("pip install xlrd") + + wb = xlrd.open_workbook(file_path) + parts = [] + for sheet in wb.sheets(): + parts.append(f"--- {sheet.name} ---") + for row_idx in range(sheet.nrows): + parts.append("\t".join( + str(sheet.cell_value(row_idx, col_idx)) + for col_idx in range(sheet.ncols) + )) + return "\n".join(parts) + + +def extract_odt(file_path: str) -> str: + """Extract text from an OpenDocument Text (.odt) file.""" + try: + from lxml import etree + except ImportError: + raise ImportError("pip install lxml") + + with zipfile.ZipFile(file_path) as zf: + with zf.open("content.xml") as f: + tree = etree.parse(f) + ns = "urn:oasis:names:tc:opendocument:xmlns:text:1.0" + return "\n".join( + "".join(p.itertext()) + for p in tree.iter(f"{{{ns}}}p") + ) + + +def extract_ods(file_path: str) -> str: + """Extract text from an OpenDocument Spreadsheet (.ods) file.""" + try: + from lxml import etree + except ImportError: + raise ImportError("pip install lxml") + + with zipfile.ZipFile(file_path) as zf: + with zf.open("content.xml") as f: + tree = etree.parse(f) + ns_table = "urn:oasis:names:tc:opendocument:xmlns:table:1.0" + ns_text = "urn:oasis:names:tc:opendocument:xmlns:text:1.0" + parts = [] + for table in tree.iter(f"{{{ns_table}}}table"): + name = table.get(f"{{{ns_table}}}name", "Sheet") + parts.append(f"--- {name} ---") + for row in table.iter(f"{{{ns_table}}}table-row"): + cells = [] + for cell in row.iter(f"{{{ns_table}}}table-cell"): + cell_text = " ".join( + "".join(p.itertext()) + for p in cell.iter(f"{{{ns_text}}}p") + ) + cells.append(cell_text) + parts.append("\t".join(cells)) + return "\n".join(parts) + + +def extract_odp(file_path: str) -> str: + """Extract text from an OpenDocument Presentation (.odp) file.""" + try: + from lxml import etree + except ImportError: + raise ImportError("pip install lxml") + + with zipfile.ZipFile(file_path) as zf: + with zf.open("content.xml") as f: + tree = etree.parse(f) + ns_draw = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" + ns_text = "urn:oasis:names:tc:opendocument:xmlns:text:1.0" + parts = [] + for i, page in enumerate(tree.iter(f"{{{ns_draw}}}page"), 1): + parts.append(f"--- Slide {i} ---") + for p in page.iter(f"{{{ns_text}}}p"): + text = "".join(p.itertext()).strip() + if text: + parts.append(text) + return "\n".join(parts) + + +def extract_epub(file_path: str) -> str: + """Extract text from an EPUB e-book.""" + try: + from lxml import etree + except ImportError: + raise ImportError("pip install lxml") + + parts = [] + with zipfile.ZipFile(file_path) as zf: + # Parse the container to find the root file + with zf.open("META-INF/container.xml") as cf: + container = etree.parse(cf) + ns_container = "urn:oasis:names:tc:opendocument:xmlns:container" + rootfile = container.find(f".//{{{ns_container}}}rootfile") + if rootfile is None: + rootfile = container.xpath("//*[local-name()='rootfile']") + rootfile = rootfile[0] if rootfile else None + + if rootfile is not None: + opf_path = rootfile.get("full-path", "") + opf_dir = opf_path.rsplit("/", 1)[0] + "/" if "/" in opf_path else "" + with zf.open(opf_path) as opf_file: + opf = etree.parse(opf_file) + spine_ids = [ + item.get("idref") + for item in opf.xpath("//*[local-name()='itemref']") + ] + manifest = { + item.get("id"): item.get("href") + for item in opf.xpath("//*[local-name()='item']") + } + for idref in spine_ids: + href = manifest.get(idref, "") + item_path = opf_dir + href if not href.startswith("/") else href.lstrip("/") + try: + with zf.open(item_path) as html_file: + html_tree = etree.parse(html_file, etree.HTMLParser()) + body = html_tree.find(".//body") + if body is not None: + text = "".join(body.itertext()) + parts.append(text.strip()) + except (KeyError, etree.XMLSyntaxError): + continue + else: + for name in zf.namelist(): + if name.endswith((".html", ".xhtml", ".htm")): + try: + with zf.open(name) as html_file: + html_tree = etree.parse(html_file, etree.HTMLParser()) + body = html_tree.find(".//body") + if body is not None: + text = "".join(body.itertext()) + parts.append(text.strip()) + except etree.XMLSyntaxError: + continue + return "\n\n".join(parts) + + +def extract_eml(file_path: str) -> str: + """Extract text from an email message (.eml).""" + import email + from email import policy + + with open(file_path, "rb") as f: + msg = email.message_from_binary_file(f, policy=policy.default) + parts = [] + for header in ("From", "To", "Cc", "Date", "Subject"): + val = msg.get(header) + if val: + parts.append(f"{header}: {val}") + parts.append("") # blank line after headers + body = msg.get_body(preferencelist=("plain", "html")) + if body: + content = body.get_content() + if body.get_content_type() == "text/html": + try: + from lxml import etree + tree = etree.HTML(content) + content = "".join(tree.itertext()) if tree is not None else content + except ImportError: + pass # Fall back to raw HTML text + parts.append(content) + return "\n".join(parts) + + +# MIME type / extension → extractor mapping. +# Each entry: (mime_type_or_None, file_extension_or_None, extractor) +EXTRACTORS: list[tuple[str | None, str | None, callable]] = [ + ("application/pdf", None, extract_pdf), + ("application/vnd.openxmlformats-officedocument.wordprocessingml.document", None, extract_docx), + ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", None, extract_xlsx), + ("application/vnd.openxmlformats-officedocument.presentationml.presentation", None, extract_pptx), + ("application/rtf", ".rtf", extract_rtf), + ("application/vnd.ms-excel", ".xls", extract_xls), + ("application/vnd.oasis.opendocument.text", ".odt", extract_odt), + ("application/vnd.oasis.opendocument.spreadsheet", ".ods", extract_ods), + ("application/vnd.oasis.opendocument.presentation", ".odp", extract_odp), + ("application/epub+zip", ".epub", extract_epub), + ("message/rfc822", ".eml", extract_eml), +] + + +def extract_by_path(file_path: str) -> str | None: + """Try all extractors for a file by MIME type / extension. + + Returns extracted text, or None if no extractor matches. + Raises ImportError with install instructions if the matching + extractor's library is missing. + """ + import mimetypes + + mime, _ = mimetypes.guess_type(file_path) + ext = os.path.splitext(file_path)[1].lower() + + for ext_mime, ext_suffix, extractor in EXTRACTORS: + if (ext_mime and mime == ext_mime) or (ext_suffix and ext == ext_suffix): + return extractor(file_path) + return None diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index bd70591..e7c39e1 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -17,6 +17,7 @@ import json import os import re +import time import uuid from pathlib import Path from typing import Callable, Optional, Pattern, get_type_hints @@ -30,8 +31,21 @@ async def _collect_bg_output(task_id: str, proc: asyncio.subprocess.Process): - """Collect output from a background process into the task buffer.""" + """Collect output from a background process into memory + JSONL log.""" + task = _bg_tasks.get(task_id) + log_path = task.get("log_path") if task else None + log_file = None + try: + if log_path: + Path(log_path).parent.mkdir(parents=True, exist_ok=True) + log_file = open(log_path, "a", encoding="utf-8") + log_file.write( + json.dumps({"type": "start", "command": task["command"], + "ts": time.time()}) + "\n" + ) + log_file.flush() + while True: chunk = await proc.stdout.read(4096) if not chunk: @@ -39,15 +53,28 @@ async def _collect_bg_output(task_id: str, proc: asyncio.subprocess.Process): task = _bg_tasks.get(task_id) if task: task["output"].extend(chunk) - # Cap buffer at 256KB + # Cap in-memory buffer at 256KB if len(task["output"]) > 256 * 1024: - task["output"] = task["output"][-256 * 1024 :] + task["output"] = task["output"][-256 * 1024:] + if log_file: + log_file.write( + json.dumps({"type": "output", + "data": chunk.decode(errors="replace"), + "ts": time.time()}) + "\n" + ) + log_file.flush() except Exception: pass finally: task = _bg_tasks.get(task_id) if task: task["done"] = True + if log_file: + log_file.write( + json.dumps({"type": "end", "exit_code": proc.returncode, + "ts": time.time()}) + "\n" + ) + log_file.close() # ── Helper ────────────────────────────────────────────────── @@ -187,7 +214,37 @@ def _read(): if size > 500_000: return f"Error: file too large ({size} bytes, max 500KB)" - lines = full.read_text(errors="replace").splitlines() + # Try strict text decoding first + try: + content = full.read_text(errors="strict") + except (UnicodeDecodeError, ValueError): + # Binary file — try document extraction (PDF, DOCX, XLSX, etc.) + try: + from cptr.utils.documents import extract_by_path + + text = extract_by_path(str(full)) + if text: + lines = text.splitlines() + total = len(lines) + if start_line > 0 or end_line > 0: + s = max(1, start_line) - 1 + e = min(total, end_line) if end_line > 0 else total + selected = lines[s:e] + numbered = [f"{i + s + 1}: {line}" for i, line in enumerate(selected)] + return f"File: {path} | Lines {s + 1}-{e} of {total}\n" + "\n".join(numbered) + capped = lines[:800] + numbered = [f"{i + 1}: {line}" for i, line in enumerate(capped)] + header = f"File: {path} | Total lines: {total}" + if total > 800: + header += " (showing first 800)" + return header + "\n" + "\n".join(numbered) + except ImportError as e: + return f"Error: reading {full.suffix} files requires: {e}" + except Exception as e: + return f"Error: failed to extract text from {path}: {e}" + return f"Error: binary file ({full.suffix}), cannot read as text" + + lines = content.splitlines() total = len(lines) if start_line > 0 or end_line > 0: @@ -628,21 +685,23 @@ def _apply(): async def run_command( command: str, cwd: str = ".", - timeout: int = 30, - background: bool = False, + wait: int = 10, *, workspace: str, ) -> str: - """Run a shell command. Use background=true for long-running processes. + """Run a shell command. Returns a task_id for status checks and input. :param command: The shell command to execute. :param cwd: Working directory relative to workspace root. - :param timeout: Timeout in seconds (max 300, ignored if background). - :param background: Run in background and return a task_id for status checks. + :param wait: Seconds to wait for output before returning (0-30). The command continues running regardless. """ work_dir = _resolve_path(cwd, workspace) if not work_dir.is_dir(): return f"Error: not a directory: {cwd}" + active = sum(1 for t in _bg_tasks.values() if not t.get("done")) + if active >= _BG_TASK_LIMIT: + return f"Error: too many running tasks ({active}/{_BG_TASK_LIMIT}). Kill one first." + env = {**os.environ, "PAGER": "cat", "GIT_PAGER": "cat"} try: @@ -650,43 +709,52 @@ async def run_command( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + stdin=asyncio.subprocess.PIPE, cwd=str(work_dir), env=env, ) - - if background: - active = sum(1 for t in _bg_tasks.values() if not t.get("done")) - if active >= _BG_TASK_LIMIT: - proc.kill() - return ( - f"Error: too many background tasks ({active}/{_BG_TASK_LIMIT}). Kill one first." - ) - - task_id = uuid.uuid4().hex[:8] - _bg_tasks[task_id] = { - "proc": proc, - "output": bytearray(), - "command": command, - "done": False, - } - asyncio.create_task(_collect_bg_output(task_id, proc)) - return f"Background task started: {task_id}\nCommand: {command}\nUse check_task('{task_id}') to see output or kill_task('{task_id}') to stop it." - - timeout = min(max(timeout, 5), 300) - stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) - output = stdout.decode(errors="replace").strip() - output = _truncate_output(output, max_chars=CHAT_TOOL_COMMAND_MAX_CHARS) - - if proc.returncode != 0: - return f"Exit code {proc.returncode}\n{output}" - return output or "(no output)" - - except asyncio.TimeoutError: - proc.kill() - return f"Error: command timed out after {timeout}s. Consider using background=true for long commands." except Exception as e: return f"Error: {e}" + task_id = uuid.uuid4().hex[:8] + log_dir = Path(workspace) / ".cptr" / "task_logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / f"{task_id}.jsonl" + + _bg_tasks[task_id] = { + "proc": proc, + "output": bytearray(), + "command": command, + "done": False, + "log_path": str(log_path), + } + collect_task = asyncio.create_task(_collect_bg_output(task_id, proc)) + + # Wait for quick commands to finish inline + wait = min(max(wait, 0), 30) + if wait > 0: + try: + await asyncio.wait_for(asyncio.shield(collect_task), timeout=wait) + except asyncio.TimeoutError: + pass + + task = _bg_tasks.get(task_id) + output = task["output"].decode(errors="replace") if task else "" + output = _truncate_output(output, max_chars=CHAT_TOOL_COMMAND_MAX_CHARS) + done = task.get("done", False) if task else True + exit_code = proc.returncode + + if done: + status = f"exited (code {exit_code})" + else: + status = "running" + + return ( + f"Task {task_id}: {status}\n" + f"Command: {command}\n" + f"---\n{output}" + ) + async def check_task(task_id: str, *, workspace: str) -> str: """Check status and recent output of a background task. @@ -732,6 +800,38 @@ async def kill_task(task_id: str, *, workspace: str) -> str: return f"Killed task {task_id}" +async def send_input(task_id: str, input: str, *, workspace: str) -> str: + """Send input to a running task's stdin. Use for interactive prompts, REPLs, or control characters. + :param task_id: The task ID returned by run_command. + :param input: Text to send. Use \\n for Enter, \\x03 for Ctrl-C, \\x04 for Ctrl-D. + """ + task = _bg_tasks.get(task_id) + if not task: + available = list(_bg_tasks.keys()) + return f"Error: no task '{task_id}'. Active: {available or 'none'}" + + proc = task["proc"] + if proc.returncode is not None: + return f"Error: task {task_id} already exited (code {proc.returncode})" + + if proc.stdin is None: + return f"Error: task {task_id} has no stdin" + + # LLMs emit literal "\n" — convert to real characters + try: + text = input.encode("raw_unicode_escape").decode("unicode_escape") + except (UnicodeDecodeError, ValueError): + text = input + + try: + proc.stdin.write(text.encode()) + await proc.stdin.drain() + except (BrokenPipeError, ConnectionResetError, OSError): + return f"Error: stdin closed for task {task_id}" + + return f"Sent {len(text)} bytes to task {task_id}" + + async def web_search(query: str, *, workspace: str) -> str: """Search the web for information. Returns summaries with source URLs. :param query: The search query. @@ -1191,6 +1291,7 @@ async def browser_evaluate(javascript: str, *, __context__: dict) -> str: "multi_edit_file": {"fn": multi_edit_file, "auto": False}, "write_file": {"fn": write_file, "auto": False}, "run_command": {"fn": run_command, "auto": False}, + "send_input": {"fn": send_input, "auto": False}, "kill_task": {"fn": kill_task, "auto": False}, "create_automation": {"fn": create_automation, "auto": False}, "update_automation": {"fn": update_automation, "auto": False}, diff --git a/pyproject.toml b/pyproject.toml index 9c623fa..5726f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ [project.optional-dependencies] pam = ["python-pam>=2.0"] mcp = ["mcp>=1.8"] +docs = ["pypdf>=4.0", "python-docx>=1.0", "openpyxl>=3.1"] [dependency-groups] dev = [ From 29ec493961164d0d4ec962fbed69aa37c2377919 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 10:11:08 +0200 Subject: [PATCH 08/17] refac --- cptr/utils/tools.py | 240 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 186 insertions(+), 54 deletions(-) diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index e7c39e1..c208fd6 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -23,55 +23,174 @@ from typing import Callable, Optional, Pattern, get_type_hints from cptr.env import CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS +try: + import fcntl + import pty + import signal + import struct + import subprocess + import termios + + _PTY_AVAILABLE = True +except ImportError: + import signal + import subprocess + + _PTY_AVAILABLE = False # Windows + # ── Background task state ─────────────────────────────────── -_bg_tasks: dict[str, dict] = {} # task_id → {proc, output, command, done} +_bg_tasks: dict[str, dict] = {} +# task_id → { +# "master_fd": int | None, PTY mode (Unix) — read/write through this fd +# "proc": Popen | Process, The child process handle +# "output": bytearray, In-memory ring buffer (256KB cap) +# "command": str, +# "done": bool, +# "exit_code": int | None, +# "log_path": str, +# } _BG_TASK_LIMIT = 5 +_MAX_LOG_SIZE = 50 * 1024 * 1024 # 50MB — rotate when exceeded + + +def _spawn_pty(command: str, cwd: str, env: dict) -> tuple: + """Spawn a command under a PTY (Unix only). Returns (proc, master_fd).""" + master_fd, slave_fd = pty.openpty() + try: + fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + proc = subprocess.Popen( + command, + shell=True, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=cwd, + env=env, + start_new_session=True, + ) + except Exception: + os.close(slave_fd) + os.close(master_fd) + raise + os.close(slave_fd) + return proc, master_fd + + +def _kill_process_group(pid: int, force: bool = False) -> None: + """Send signal to the child's entire process group. + + SIGTERM for graceful shutdown (default), SIGKILL for force. + Falls back to signalling just the leader if the group is gone. + """ + sig = signal.SIGKILL if force else signal.SIGTERM + try: + os.killpg(pid, sig) + except (ProcessLookupError, PermissionError): + try: + os.kill(pid, sig) + except ProcessLookupError: + pass + + +def _rotate_log(log_path: str, log_file) -> tuple: + """Keep the newest half of the log file. Returns new (file, bytes_written).""" + log_file.flush() + log_file.close() + + with open(log_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + keep = lines[len(lines) // 2:] + + with open(log_path, "w", encoding="utf-8") as f: + f.write(json.dumps({"type": "log_rotated", "ts": time.time()}) + "\n") + for line in keep: + f.write(line) + + new_file = open(log_path, "a", encoding="utf-8") + new_size = sum(len(l.encode("utf-8", errors="replace")) for l in keep) + return new_file, new_size -async def _collect_bg_output(task_id: str, proc: asyncio.subprocess.Process): - """Collect output from a background process into memory + JSONL log.""" +async def _collect_bg_output(task_id: str): + """Read output from a background process (PTY or pipe) into memory + JSONL log.""" task = _bg_tasks.get(task_id) - log_path = task.get("log_path") if task else None + if not task: + return + + master_fd = task.get("master_fd") + proc = task["proc"] + log_path = task.get("log_path") log_file = None + log_bytes = 0 try: if log_path: Path(log_path).parent.mkdir(parents=True, exist_ok=True) log_file = open(log_path, "a", encoding="utf-8") - log_file.write( - json.dumps({"type": "start", "command": task["command"], - "ts": time.time()}) + "\n" - ) + entry = json.dumps({"type": "start", "command": task["command"], + "pid": proc.pid, "ts": time.time()}) + "\n" + log_file.write(entry) log_file.flush() + log_bytes += len(entry.encode("utf-8", errors="replace")) + + loop = asyncio.get_event_loop() while True: - chunk = await proc.stdout.read(4096) - if not chunk: - break + # Read from PTY fd (Unix) or subprocess pipe (Windows fallback) + if master_fd is not None: + try: + chunk = await loop.run_in_executor(None, os.read, master_fd, 4096) + if not chunk: + break + except OSError: + break # EIO when child exits + else: + chunk = await proc.stdout.read(4096) + if not chunk: + break + task = _bg_tasks.get(task_id) if task: task["output"].extend(chunk) - # Cap in-memory buffer at 256KB if len(task["output"]) > 256 * 1024: task["output"] = task["output"][-256 * 1024:] + if log_file: - log_file.write( - json.dumps({"type": "output", - "data": chunk.decode(errors="replace"), - "ts": time.time()}) + "\n" - ) + entry = json.dumps({"type": "output", + "data": chunk.decode(errors="replace"), + "ts": time.time()}) + "\n" + entry_size = len(entry.encode("utf-8", errors="replace")) + if log_bytes + entry_size > _MAX_LOG_SIZE: + log_file, log_bytes = _rotate_log(log_path, log_file) + log_file.write(entry) log_file.flush() + log_bytes += entry_size except Exception: pass finally: + # Wait for the process to finish and collect exit code task = _bg_tasks.get(task_id) + if master_fd is not None: + exit_code = await loop.run_in_executor(None, proc.wait) + try: + os.close(master_fd) + except OSError: + pass + else: + await proc.wait() + exit_code = proc.returncode + if task: task["done"] = True + task["exit_code"] = exit_code + task["master_fd"] = None # fd is closed + if log_file: log_file.write( - json.dumps({"type": "end", "exit_code": proc.returncode, + json.dumps({"type": "end", "exit_code": exit_code, "ts": time.time()}) + "\n" ) log_file.close() @@ -703,16 +822,20 @@ async def run_command( return f"Error: too many running tasks ({active}/{_BG_TASK_LIMIT}). Kill one first." env = {**os.environ, "PAGER": "cat", "GIT_PAGER": "cat"} + master_fd = None try: - proc = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - stdin=asyncio.subprocess.PIPE, - cwd=str(work_dir), - env=env, - ) + if _PTY_AVAILABLE: + proc, master_fd = _spawn_pty(command, str(work_dir), env) + else: + proc = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + stdin=asyncio.subprocess.PIPE, + cwd=str(work_dir), + env=env, + ) except Exception as e: return f"Error: {e}" @@ -722,13 +845,15 @@ async def run_command( log_path = log_dir / f"{task_id}.jsonl" _bg_tasks[task_id] = { + "master_fd": master_fd, "proc": proc, "output": bytearray(), "command": command, "done": False, + "exit_code": None, "log_path": str(log_path), } - collect_task = asyncio.create_task(_collect_bg_output(task_id, proc)) + collect_task = asyncio.create_task(_collect_bg_output(task_id)) # Wait for quick commands to finish inline wait = min(max(wait, 0), 30) @@ -742,7 +867,7 @@ async def run_command( output = task["output"].decode(errors="replace") if task else "" output = _truncate_output(output, max_chars=CHAT_TOOL_COMMAND_MAX_CHARS) done = task.get("done", False) if task else True - exit_code = proc.returncode + exit_code = task.get("exit_code") if task else None if done: status = f"exited (code {exit_code})" @@ -758,46 +883,45 @@ async def run_command( async def check_task(task_id: str, *, workspace: str) -> str: """Check status and recent output of a background task. - :param task_id: The task ID returned by run_command with background=true. + :param task_id: The task ID returned by run_command. """ task = _bg_tasks.get(task_id) if not task: available = list(_bg_tasks.keys()) return f"Error: no task with id '{task_id}'. Active tasks: {available or 'none'}" - proc = task["proc"] output = task["output"].decode(errors="replace") output = _truncate_output(output, max_chars=CHAT_TOOL_COMMAND_MAX_CHARS) - done = task.get("done", False) or proc.returncode is not None - if done: - status = f"exited (code {proc.returncode})" + if task.get("done", False): + status = f"exited (code {task.get('exit_code')})" else: status = "running" return f"Task {task_id}: {status}\nCommand: {task['command']}\n---\n{output}" -async def kill_task(task_id: str, *, workspace: str) -> str: - """Kill a running background task. +async def kill_task(task_id: str, force: bool = False, *, workspace: str) -> str: + """Terminate a running task. Sends SIGTERM for graceful shutdown by default. :param task_id: The task ID to kill. + :param force: Send SIGKILL instead of SIGTERM for immediate termination. """ task = _bg_tasks.get(task_id) if not task: available = list(_bg_tasks.keys()) return f"Error: no task with id '{task_id}'. Active tasks: {available or 'none'}" - proc = task["proc"] - if proc.returncode is not None: + if task.get("done", False): + exit_code = task.get("exit_code") _bg_tasks.pop(task_id, None) - return f"Task {task_id} already finished (code {proc.returncode})" + return f"Task {task_id} already finished (code {exit_code})" - try: - proc.kill() - except ProcessLookupError: - pass + proc = task["proc"] + _kill_process_group(proc.pid, force=force) _bg_tasks.pop(task_id, None) - return f"Killed task {task_id}" + + action = "Killed" if force else "Terminated" + return f"{action} task {task_id}" async def send_input(task_id: str, input: str, *, workspace: str) -> str: @@ -810,12 +934,8 @@ async def send_input(task_id: str, input: str, *, workspace: str) -> str: available = list(_bg_tasks.keys()) return f"Error: no task '{task_id}'. Active: {available or 'none'}" - proc = task["proc"] - if proc.returncode is not None: - return f"Error: task {task_id} already exited (code {proc.returncode})" - - if proc.stdin is None: - return f"Error: task {task_id} has no stdin" + if task.get("done", False): + return f"Error: task {task_id} already exited (code {task.get('exit_code')})" # LLMs emit literal "\n" — convert to real characters try: @@ -823,11 +943,23 @@ async def send_input(task_id: str, input: str, *, workspace: str) -> str: except (UnicodeDecodeError, ValueError): text = input - try: - proc.stdin.write(text.encode()) - await proc.stdin.drain() - except (BrokenPipeError, ConnectionResetError, OSError): - return f"Error: stdin closed for task {task_id}" + master_fd = task.get("master_fd") + if master_fd is not None: + # PTY mode: write to master fd + try: + os.write(master_fd, text.encode()) + except OSError: + return f"Error: PTY closed for task {task_id}" + else: + # Pipe mode fallback + proc = task["proc"] + if proc.stdin is None: + return f"Error: task {task_id} has no stdin" + try: + proc.stdin.write(text.encode()) + await proc.stdin.drain() + except (BrokenPipeError, ConnectionResetError, OSError): + return f"Error: stdin closed for task {task_id}" return f"Sent {len(text)} bytes to task {task_id}" From 93fb3400b56c8b6818b13c99280337316421a2c0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 10:18:47 +0200 Subject: [PATCH 09/17] refac --- cptr/utils/tools.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index c208fd6..7237d5d 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -155,6 +155,7 @@ async def _collect_bg_output(task_id: str): task = _bg_tasks.get(task_id) if task: task["output"].extend(chunk) + task["total_bytes"] += len(chunk) if len(task["output"]) > 256 * 1024: task["output"] = task["output"][-256 * 1024:] @@ -585,8 +586,9 @@ async def create_file( :param overwrite: Set to true to overwrite an existing file. :param artifact_type: Set to 'implementation_plan' to present a plan for user review before coding. """ - # Artifact-only: save to .cptr/artifacts/ (same location as create_artifact) - if artifact_type and not path: + # Artifact mode: save to .cptr/artifacts/ (same location as create_artifact) + # When artifact_type is set, path is ignored. + if artifact_type: from datetime import datetime, timezone ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") @@ -848,6 +850,7 @@ async def run_command( "master_fd": master_fd, "proc": proc, "output": bytearray(), + "total_bytes": 0, "command": command, "done": False, "exit_code": None, @@ -868,6 +871,7 @@ async def run_command( output = _truncate_output(output, max_chars=CHAT_TOOL_COMMAND_MAX_CHARS) done = task.get("done", False) if task else True exit_code = task.get("exit_code") if task else None + next_offset = task.get("total_bytes", 0) if task else 0 if done: status = f"exited (code {exit_code})" @@ -877,28 +881,43 @@ async def run_command( return ( f"Task {task_id}: {status}\n" f"Command: {command}\n" + f"next_offset: {next_offset}\n" f"---\n{output}" ) -async def check_task(task_id: str, *, workspace: str) -> str: +async def check_task(task_id: str, offset: int = 0, *, workspace: str) -> str: """Check status and recent output of a background task. :param task_id: The task ID returned by run_command. + :param offset: Byte offset from previous check. Pass next_offset from the last response to get only new output. """ task = _bg_tasks.get(task_id) if not task: available = list(_bg_tasks.keys()) return f"Error: no task with id '{task_id}'. Active tasks: {available or 'none'}" - output = task["output"].decode(errors="replace") + buf = task["output"] + total = task.get("total_bytes", 0) + buf_start = total - len(buf) # byte offset of first byte in buffer + + if offset <= buf_start: + # Requested offset is before buffer start (old output was trimmed) + raw = buf + else: + # Slice to only return new output since offset + skip = offset - buf_start + raw = buf[skip:] + + output = raw.decode(errors="replace") output = _truncate_output(output, max_chars=CHAT_TOOL_COMMAND_MAX_CHARS) + next_offset = total if task.get("done", False): status = f"exited (code {task.get('exit_code')})" else: status = "running" - return f"Task {task_id}: {status}\nCommand: {task['command']}\n---\n{output}" + return f"Task {task_id}: {status}\nCommand: {task['command']}\nnext_offset: {next_offset}\n---\n{output}" async def kill_task(task_id: str, force: bool = False, *, workspace: str) -> str: From 02dc82f10a8b0b54a3b54219ce034305a8884614 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 10:57:31 +0200 Subject: [PATCH 10/17] refac --- cptr/env.py | 8 ++++++++ cptr/utils/tools.py | 49 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/cptr/env.py b/cptr/env.py index dc178f3..cc3cf26 100644 --- a/cptr/env.py +++ b/cptr/env.py @@ -29,6 +29,14 @@ CHAT_TOOL_COMMAND_MAX_CHARS = int(os.environ.get("CHAT_TOOL_COMMAND_MAX_CHARS", "8000")) CHAT_COMPACT_TOKEN_THRESHOLD = int(os.environ.get("CHAT_COMPACT_TOKEN_THRESHOLD", "80000")) +# ── Execute timeout ───────────────────────────────────────── +# Default wait (seconds) for run_command / check_task when the caller +# doesn't pass an explicit wait value. None = return immediately. +EXECUTE_TIMEOUT: float | None = None +_execute_timeout = os.environ.get("CPTR_EXECUTE_TIMEOUT") +if _execute_timeout is not None: + EXECUTE_TIMEOUT = float(_execute_timeout) + # ── AI stream settings ────────────────────────────────────── STREAM_CONNECT_TIMEOUT_SECONDS = float(os.environ.get("CPTR_STREAM_CONNECT_TIMEOUT", "30")) STREAM_READ_TIMEOUT_SECONDS = float(os.environ.get("CPTR_STREAM_READ_TIMEOUT", "300")) diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index 7237d5d..9bd0e84 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -21,7 +21,7 @@ import uuid from pathlib import Path from typing import Callable, Optional, Pattern, get_type_hints -from cptr.env import CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS +from cptr.env import CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS, EXECUTE_TIMEOUT try: import fcntl @@ -806,14 +806,14 @@ def _apply(): async def run_command( command: str, cwd: str = ".", - wait: int = 10, + wait: Optional[int] = None, *, workspace: str, ) -> str: """Run a shell command. Returns a task_id for status checks and input. :param command: The shell command to execute. :param cwd: Working directory relative to workspace root. - :param wait: Seconds to wait for output before returning (0-30). The command continues running regardless. + :param wait: Seconds to wait for the command to finish before returning (max 120). Returns early if done sooner. Null returns immediately. Use 30-60 for installs and builds, 5-10 for quick commands, null or 0 for long-lived servers. """ work_dir = _resolve_path(cwd, workspace) if not work_dir.is_dir(): @@ -855,14 +855,17 @@ async def run_command( "done": False, "exit_code": None, "log_path": str(log_path), + "log_task": None, } - collect_task = asyncio.create_task(_collect_bg_output(task_id)) + log_task = asyncio.create_task(_collect_bg_output(task_id)) + _bg_tasks[task_id]["log_task"] = log_task - # Wait for quick commands to finish inline - wait = min(max(wait, 0), 30) - if wait > 0: + # Wait for the command to finish inline (matches open-terminal behaviour) + if wait is None and EXECUTE_TIMEOUT: + wait = EXECUTE_TIMEOUT + if wait is not None and wait > 0: try: - await asyncio.wait_for(asyncio.shield(collect_task), timeout=wait) + await asyncio.wait_for(asyncio.shield(log_task), timeout=min(wait, 120)) except asyncio.TimeoutError: pass @@ -886,16 +889,28 @@ async def run_command( ) -async def check_task(task_id: str, offset: int = 0, *, workspace: str) -> str: +async def check_task(task_id: str, offset: int = 0, wait: Optional[int] = None, *, workspace: str) -> str: """Check status and recent output of a background task. :param task_id: The task ID returned by run_command. :param offset: Byte offset from previous check. Pass next_offset from the last response to get only new output. + :param wait: Seconds to wait for the task to finish before returning (max 120). Returns early if done sooner. Null returns immediately. """ task = _bg_tasks.get(task_id) if not task: available = list(_bg_tasks.keys()) return f"Error: no task with id '{task_id}'. Active tasks: {available or 'none'}" + # Optionally wait for the task to finish + if wait is None and EXECUTE_TIMEOUT: + wait = EXECUTE_TIMEOUT + if wait is not None and wait > 0 and not task.get("done"): + collect = task.get("log_task") + if collect and not collect.done(): + try: + await asyncio.wait_for(asyncio.shield(collect), timeout=min(wait, 120)) + except asyncio.TimeoutError: + pass + buf = task["output"] total = task.get("total_bytes", 0) buf_start = total - len(buf) # byte offset of first byte in buffer @@ -1815,6 +1830,16 @@ async def _execute_external_tool(name: str, args: dict) -> str: _TYPE_MAP = {str: "string", int: "integer", bool: "boolean", float: "number"} +def _unwrap_optional(hint): + """If hint is Optional[X] (Union[X, None]), return X.""" + args = getattr(hint, '__args__', None) + if args and type(None) in args: + real = [a for a in args if a is not type(None)] + if len(real) == 1: + return real[0] + return hint + + def _parse_param_descriptions(docstring: str) -> dict[str, str]: """Extract :param name: description lines from docstring.""" descs: dict[str, str] = {} @@ -1843,10 +1868,14 @@ def _fn_to_schema(name: str, fn) -> dict: # Skip injected context params (keyword-only, never exposed to LLM) if param.kind == inspect.Parameter.KEYWORD_ONLY: continue - ptype = _TYPE_MAP.get(hints.get(pname), "string") # type: ignore[arg-type] + raw_hint = hints.get(pname) + hint = _unwrap_optional(raw_hint) if raw_hint else raw_hint + ptype = _TYPE_MAP.get(hint, "string") # type: ignore[arg-type] prop: dict = {"type": ptype} if pname in param_descs: prop["description"] = param_descs[pname] + if param.default is not inspect.Parameter.empty: + prop["default"] = param.default properties[pname] = prop # Positional with no default → required if param.default is inspect.Parameter.empty: From 0c412f0d702269ca4110969f0503ce8c28046293 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 11:03:46 +0200 Subject: [PATCH 11/17] refac --- cptr/utils/tools.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index 9bd0e84..d822014 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -813,7 +813,7 @@ async def run_command( """Run a shell command. Returns a task_id for status checks and input. :param command: The shell command to execute. :param cwd: Working directory relative to workspace root. - :param wait: Seconds to wait for the command to finish before returning (max 120). Returns early if done sooner. Null returns immediately. Use 30-60 for installs and builds, 5-10 for quick commands, null or 0 for long-lived servers. + :param wait: Seconds to wait for the command to finish before returning (max 300). Returns early if done sooner. Null returns immediately. Use 30-60 for installs and builds, 5-10 for quick commands, null or 0 for long-lived servers. """ work_dir = _resolve_path(cwd, workspace) if not work_dir.is_dir(): @@ -865,7 +865,7 @@ async def run_command( wait = EXECUTE_TIMEOUT if wait is not None and wait > 0: try: - await asyncio.wait_for(asyncio.shield(log_task), timeout=min(wait, 120)) + await asyncio.wait_for(asyncio.shield(log_task), timeout=min(wait, 300)) except asyncio.TimeoutError: pass @@ -893,7 +893,7 @@ async def check_task(task_id: str, offset: int = 0, wait: Optional[int] = None, """Check status and recent output of a background task. :param task_id: The task ID returned by run_command. :param offset: Byte offset from previous check. Pass next_offset from the last response to get only new output. - :param wait: Seconds to wait for the task to finish before returning (max 120). Returns early if done sooner. Null returns immediately. + :param wait: Seconds to wait for the task to finish before returning (max 300). Returns early if done sooner. Null returns immediately. """ task = _bg_tasks.get(task_id) if not task: @@ -907,7 +907,7 @@ async def check_task(task_id: str, offset: int = 0, wait: Optional[int] = None, collect = task.get("log_task") if collect and not collect.done(): try: - await asyncio.wait_for(asyncio.shield(collect), timeout=min(wait, 120)) + await asyncio.wait_for(asyncio.shield(collect), timeout=min(wait, 300)) except asyncio.TimeoutError: pass From ed11c3cd6c3254bd7796f267b2bcaab6e130077b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 11:41:18 +0200 Subject: [PATCH 12/17] refac --- cptr/frontend/src/lib/apis/git.ts | 8 +- .../frontend/src/lib/components/GitBar.svelte | 45 ++++- cptr/frontend/src/lib/i18n/locales/en.json | 4 + cptr/routers/git.py | 15 +- cptr/utils/ai.py | 9 + cptr/utils/chat_task.py | 66 ++++--- cptr/utils/git.py | 30 ++- uv.lock | 176 +++++++++++++++++- 8 files changed, 325 insertions(+), 28 deletions(-) diff --git a/cptr/frontend/src/lib/apis/git.ts b/cptr/frontend/src/lib/apis/git.ts index b40a2b3..382bf18 100644 --- a/cptr/frontend/src/lib/apis/git.ts +++ b/cptr/frontend/src/lib/apis/git.ts @@ -59,7 +59,13 @@ export const gitCommit = (root: string, message: string) => export const gitPull = (root: string) => fetchJSON('/api/git/pull', jsonBody({ root })); -export const gitPush = (root: string) => fetchJSON('/api/git/push', jsonBody({ root })); +export const gitPush = ( + root: string, + { force = false, set_upstream = false, branch }: { force?: boolean; set_upstream?: boolean; branch?: string } = {} +) => fetchJSON('/api/git/push', jsonBody({ root, force, set_upstream, branch })); + +export const gitUncommit = (root: string) => + fetchJSON('/api/git/uncommit', jsonBody({ root })); export const createGitBranch = (root: string, name: string) => fetchJSON('/api/git/branch', jsonBody({ root, name })); diff --git a/cptr/frontend/src/lib/components/GitBar.svelte b/cptr/frontend/src/lib/components/GitBar.svelte index 8cf4ad2..e76e764 100644 --- a/cptr/frontend/src/lib/components/GitBar.svelte +++ b/cptr/frontend/src/lib/components/GitBar.svelte @@ -11,6 +11,7 @@ gitCommit, gitPull, gitPush, + gitUncommit, checkoutBranch, createGitBranch } from '$lib/apis/git'; @@ -58,6 +59,7 @@ let gitStatus = $derived(gitStatusStore.status as { is_repo: boolean; branch: string; + upstream: string; ahead: number; behind: number; files: GitFile[]; @@ -193,6 +195,18 @@ await refresh(); } + async function doUncommit() { + loading = true; + try { + const d = await gitUncommit(workspacePath); + flash($t('git.uncommitted')); + } catch { + flash('Nothing to uncommit'); + } + loading = false; + await refresh(); + } + async function doPull() { loading = true; const d = await gitPull(workspacePath); @@ -203,7 +217,11 @@ async function doPush() { loading = true; - const d = await gitPush(workspacePath); + const needsPublish = !gitStatus?.upstream; + const d = await gitPush(workspacePath, { + set_upstream: needsPublish, + branch: needsPublish ? gitStatus?.branch : undefined + }); flash(d.ok ? $t('git.pushed') : d.message); loading = false; await refresh(); @@ -315,6 +333,8 @@ icon: 'upload', action: doPush }; + if (!gitStatus.upstream) + return { label: $t('git.publish'), icon: 'upload', action: doPush }; return { label: $t('git.fetch'), icon: 'refresh', action: doPull }; }); @@ -713,6 +733,29 @@ {$t('git.commitToBranch', { branch: gitStatus.branch })} {/if} + + + {#if gitStatus.ahead > 0 || !gitStatus.upstream} +
+ + +
+ {/if} {:else} diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 6b03df5..43cef57 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -132,11 +132,15 @@ "git.fetch": "Fetch", "git.pull": "Pull", "git.push": "Push", + "git.publish": "Publish", "git.pullCount": "Pull ↓{{count}}", "git.pushCount": "Push ↑{{count}}", "git.committed": "Committed", "git.pulled": "Pulled", "git.pushed": "Pushed", + "git.uncommit": "Undo", + "git.uncommitted": "Commit undone", + "git.uncommitTooltip": "Undo last commit (keep changes staged)", "git.discarded": "Discarded", "git.copiedPath": "Copied path", "git.copiedRelativePath": "Copied relative path", diff --git a/cptr/routers/git.py b/cptr/routers/git.py index 232319a..8320686 100644 --- a/cptr/routers/git.py +++ b/cptr/routers/git.py @@ -30,6 +30,7 @@ stash_pop, stash_save, status, + uncommit, unstage, ) @@ -150,6 +151,8 @@ class RootRequest(BaseModel): class PushRequest(BaseModel): root: str force: bool = False + set_upstream: bool = False + branch: Optional[str] = None class StashSaveRequest(BaseModel): @@ -244,7 +247,17 @@ async def git_pull(body: RootRequest): async def git_push(body: PushRequest): """Push to remote.""" try: - return await push(body.root, body.force) + return await push(body.root, body.force, body.set_upstream, body.branch) + except GitError as e: + _handle_git_error(e) + + +@router.post("/uncommit") +async def git_uncommit(body: RootRequest): + """Undo the last commit, moving changes back to staging.""" + await _require_repo(body.root) + try: + return await uncommit(body.root) except GitError as e: _handle_git_error(e) diff --git a/cptr/utils/ai.py b/cptr/utils/ai.py index 7845eb2..dd1fe81 100644 --- a/cptr/utils/ai.py +++ b/cptr/utils/ai.py @@ -500,6 +500,9 @@ def _to_responses_input(messages: list[dict], instructions: str) -> list[dict]: } ) elif role == "assistant" and m.get("tool_calls"): + # Emit reasoning items before function calls (required by reasoning models) + for ri in m.get("reasoning_items", []): + items.append(ri) for tc in m["tool_calls"]: args = tc["function"].get("arguments", "{}") call_id = tc.get("id", "") @@ -604,6 +607,12 @@ async def stream_openai_responses( "name": item["name"], "arguments": json.loads(item["arguments"]), } + elif item["type"] == "reasoning": + # Reasoning items must be round-tripped for reasoning models + yield { + "type": "reasoning", + "item": item, + } elif etype == "response.failed": error = event.get("response", {}).get("error", {}) diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 6924782..aad0e81 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -597,6 +597,7 @@ async def _load_message_history(chat_id: str, message_id: str) -> tuple[list[dic # Reconstruct tool calls from output items for the provider if m.output: tool_calls = [] + reasoning_items = [] for item in m.output: if item.get("type") == "function_call" and item.get("status") == "completed": tc = { @@ -611,8 +612,12 @@ async def _load_message_history(chat_id: str, message_id: str) -> tuple[list[dic if item.get("fc_id"): tc["fc_id"] = item["fc_id"] tool_calls.append(tc) + elif item.get("type") == "reasoning": + reasoning_items.append(item) if tool_calls: entry["tool_calls"] = tool_calls + if reasoning_items: + entry["reasoning_items"] = reasoning_items # Add tool results as separate messages for item in m.output: @@ -644,7 +649,10 @@ def _parse_image_data_uri(result: str) -> tuple[str, str] | None: return None -def _append_tool_to_messages(messages: list[dict], event: dict, result: str, provider: str): +def _append_tool_to_messages( + messages: list[dict], event: dict, result: str, provider: str, + reasoning_items: list[dict] | None = None, +): """Append a tool call + result to the message history for the next API call.""" # Check for image result before truncation (data URI is large but needed) image = _parse_image_data_uri(result) @@ -656,23 +664,25 @@ def _append_tool_to_messages(messages: list[dict], event: dict, result: str, pro result = result[:half] + "\n\n...(truncated)...\n\n" + result[-half:] # Add assistant message with tool_call - messages.append( - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": event["call_id"], - "fc_id": event.get("id", ""), - "type": "function", - "function": { - "name": event["name"], - "arguments": json.dumps(event["arguments"]), - }, - } - ], - } - ) + assistant_msg = { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": event["call_id"], + "fc_id": event.get("id", ""), + "type": "function", + "function": { + "name": event["name"], + "arguments": json.dumps(event["arguments"]), + }, + } + ], + } + # Attach reasoning items for round-tripping (Responses API reasoning models) + if reasoning_items: + assistant_msg["reasoning_items"] = reasoning_items + messages.append(assistant_msg) if image: # Structured multimodal content — provider converters handle the @@ -1029,6 +1039,7 @@ def _sync_state(): restart = False pending_calls: list[dict] = [] # Collect tool calls from this response + pending_reasoning: list[dict] = [] # Reasoning items for round-tripping async for event in stream: if event["type"] == "text_delta": @@ -1041,6 +1052,10 @@ def _sync_state(): # Collect tool call — don't execute yet pending_calls.append(event) + elif event["type"] == "reasoning": + # Collect reasoning items for round-tripping with tool calls + pending_reasoning.append(event["item"]) + elif event["type"] == "usage": usage = {k: v for k, v in event.items() if k != "type"} if "total_tokens" not in usage: @@ -1102,6 +1117,9 @@ def _sync_state(): if needs_approval: # First non-auto tool stops the loop for approval tc = needs_approval + # Store reasoning items before function_call for round-tripping + for ri in pending_reasoning: + output_items.append(ri) item = { "type": "function_call", "id": str(uuid.uuid4()), @@ -1125,7 +1143,9 @@ def _sync_state(): await emit(done=True) return - # All calls are auto-approved — build UI items + # All calls are auto-approved — store reasoning + build UI items + for ri in pending_reasoning: + output_items.append(ri) call_items: list[tuple[dict, dict]] = [] # (event, ui_item) for tc in pending_calls: item = { @@ -1176,7 +1196,9 @@ def _sync_state(): await emit(output=artifact_item) _sync_state() - _append_tool_to_messages(messages, tc, result, provider) + # Only attach reasoning to the first tool call message + ri = pending_reasoning if idx == other_indices[0] else None + _append_tool_to_messages(messages, tc, result, provider, reasoning_items=ri) new_messages_since += 2 # Execute delegate_task calls concurrently, emit each as it completes @@ -1212,7 +1234,9 @@ def _sync_state(): await emit(output=item) await emit(output=result_item) _sync_state() - _append_tool_to_messages(messages, tc, result, provider) + # Attach reasoning to first delegate call if no sequential calls consumed it + ri = pending_reasoning if not other_indices and idx == delegate_indices[0] else None + _append_tool_to_messages(messages, tc, result, provider, reasoning_items=ri) new_messages_since += 2 # Persist after all tool calls diff --git a/cptr/utils/git.py b/cptr/utils/git.py index 05d6681..2668c7f 100644 --- a/cptr/utils/git.py +++ b/cptr/utils/git.py @@ -420,15 +420,41 @@ async def pull(root: str) -> dict[str, Any]: return {"ok": code == 0, "message": (out + err).strip()} -async def push(root: str, force: bool = False) -> dict[str, Any]: - """Push to remote.""" +async def push( + root: str, + force: bool = False, + set_upstream: bool = False, + branch: str | None = None, + remote: str = "origin", +) -> dict[str, Any]: + """Push to remote. Use *set_upstream* for first-time branch publish.""" args = ["push"] + if set_upstream: + args.extend(["-u", remote, branch or "HEAD"]) if force: args.append("--force-with-lease") code, out, err = await _run(*args, cwd=root, check=False) return {"ok": code == 0, "message": (out + err).strip()} +async def uncommit(root: str) -> dict[str, str]: + """Undo the last commit, moving its changes back to the staging area. + + Uses ``git reset --soft HEAD~1``. + """ + # Grab info about the commit we're about to undo + _, log_out, _ = await _run( + "log", "-1", "--format=%H%x00%h%x00%s", cwd=root, check=False + ) + parts = log_out.strip().split("\x00") + undone_hash = parts[1] if len(parts) >= 2 else "" + undone_msg = parts[2] if len(parts) >= 3 else "" + + await _run("reset", "--soft", "HEAD~1", cwd=root) + + return {"hash": undone_hash, "message": undone_msg} + + async def stash_list(root: str) -> list[dict[str, str]]: """List stashes.""" _, out, _ = await _run("stash", "list", "--format=%gd%x00%s", cwd=root, check=False) diff --git a/uv.lock b/uv.lock index 947aa7c..4bd3d59 100644 --- a/uv.lock +++ b/uv.lock @@ -265,7 +265,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.4.1" +version = "0.4.3" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -285,6 +285,11 @@ dependencies = [ ] [package.optional-dependencies] +docs = [ + { name = "openpyxl" }, + { name = "pypdf" }, + { name = "python-docx" }, +] mcp = [ { name = "mcp" }, ] @@ -307,8 +312,11 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.128.8" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.8" }, + { name = "openpyxl", marker = "extra == 'docs'", specifier = ">=3.1" }, { name = "pyjwt", specifier = ">=2.8" }, + { name = "pypdf", marker = "extra == 'docs'", specifier = ">=4.0" }, { name = "python-dateutil", specifier = ">=2.8" }, + { name = "python-docx", marker = "extra == 'docs'", specifier = ">=1.0" }, { name = "python-pam", marker = "extra == 'pam'", specifier = ">=2.0" }, { name = "python-socketio", extras = ["asgi"], specifier = ">=5.11" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0" }, @@ -402,12 +410,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604 }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } wheels = [ @@ -789,6 +806,124 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, ] +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60", size = 8541124 }, + { url = "https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d", size = 4602783 }, + { url = "https://files.pythonhosted.org/packages/4a/a4/65c63ca98bd129f6cff7b8c2fa48953ab058cc6005b541354e7dd54d8000/lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea", size = 5002687 }, + { url = "https://files.pythonhosted.org/packages/96/1d/ab7a5c4b5a394d98a94e2d0fc67bab8297597426770dd4978370fbdaf531/lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074", size = 5155099 }, + { url = "https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30", size = 5057225 }, + { url = "https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315", size = 5287643 }, + { url = "https://files.pythonhosted.org/packages/eb/d6/b619717f918fd76747448fdbaee0e769edbc70e659b5b5d0112b7020b7a3/lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1", size = 5412445 }, + { url = "https://files.pythonhosted.org/packages/c6/80/12bc5390ac0a3edeb579d9535e5049a5dda663438728e179d52fb319c33a/lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206", size = 4770864 }, + { url = "https://files.pythonhosted.org/packages/0b/59/6500c09da3137f54f020e908d81cfc5ee3e8888e908fd380207afad7c2e6/lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067", size = 5359594 }, + { url = "https://files.pythonhosted.org/packages/f2/9b/f64b4cc6b7ebcf75d95af3cde934d254b5f2f10d4163928d838d86b6eb48/lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a", size = 5107713 }, + { url = "https://files.pythonhosted.org/packages/16/19/c7388ad5d3a72315d2832dc1458cbf4f2af7f2b990b606ff4876efd04511/lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa", size = 4803973 }, + { url = "https://files.pythonhosted.org/packages/3f/22/76197f0bbf165f0b9e75be59be4997e5259cde973f12f098c1b54c7f5d60/lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383", size = 5349925 }, + { url = "https://files.pythonhosted.org/packages/24/52/d2a0cfeccb9bcdc47c7ee05cdae5d69b48c9acf20997790a6338bb0d0b3b/lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1", size = 5309825 }, + { url = "https://files.pythonhosted.org/packages/19/4a/b30944266776c2f49749ef2445aa7e78898194134b80ad776386f61b56ae/lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a", size = 3598402 }, + { url = "https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5", size = 4021295 }, + { url = "https://files.pythonhosted.org/packages/d0/5f/26a4dd0e12b9456ff7b12a21af5b491eb6629680d1edd73f4140fd386bcf/lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485", size = 3667717 }, + { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461 }, + { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375 }, + { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654 }, + { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921 }, + { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456 }, + { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776 }, + { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945 }, + { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237 }, + { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904 }, + { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225 }, + { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721 }, + { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549 }, + { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877 }, + { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072 }, + { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469 }, + { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640 }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821 }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252 }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746 }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723 }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557 }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036 }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367 }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171 }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874 }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492 }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232 }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023 }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773 }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088 }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995 }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382 }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255 }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610 }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780 }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006 }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139 }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329 }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564 }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467 }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304 }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607 }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168 }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487 }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231 }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450 }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874 }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987 }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276 }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903 }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869 }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490 }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146 }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866 }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022 }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695 }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642 }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338 }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528 }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730 }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530 }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670 }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485 }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635 }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681 }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229 }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191 }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202 }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497 }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991 }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545 }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736 }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291 }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822 }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923 }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843 }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515 }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511 }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206 }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404 }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769 }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936 }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296 }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598 }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845 }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345 }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350 }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223 }, + { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127 }, + { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769 }, + { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163 }, + { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945 }, + { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664 }, + { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989 }, +] + [[package]] name = "mako" version = "1.3.12" @@ -952,6 +1087,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 }, +] + [[package]] name = "pycparser" version = "2.23" @@ -1143,6 +1290,18 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pypdf" +version = "6.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/0a/48fe05c6bb3aa4bb4d2a4079a383d33c0dfec1edf613a642f07d8b8b5c2e/pypdf-6.13.2.tar.gz", hash = "sha256:5a96a17dbdfbf9c2ab24c0a13fa0aba182be22ba6f283098712c16fc242f509f", size = 6479250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/17/378943705992f74e451a06de3401ce68e3213763c81e44d0614559c45599/pypdf-6.13.2-py3-none-any.whl", hash = "sha256:6eeb9e57693f29d41bd01255d02660cbbb41fd7fc818a982677389a35e4f2083", size = 346555 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1155,6 +1314,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, ] +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987 }, +] + [[package]] name = "python-dotenv" version = "1.2.1" From b6ec72dd186403a2dbc5f2a8335a428da1797625 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 11:53:21 +0200 Subject: [PATCH 13/17] refac --- cptr/frontend/src/lib/apis/index.ts | 2 +- .../frontend/src/lib/components/GitBar.svelte | 110 ++++++++++++------ cptr/frontend/src/lib/i18n/locales/en.json | 2 + cptr/utils/git.py | 18 ++- 4 files changed, 96 insertions(+), 36 deletions(-) diff --git a/cptr/frontend/src/lib/apis/index.ts b/cptr/frontend/src/lib/apis/index.ts index 77c1994..c4d81d5 100644 --- a/cptr/frontend/src/lib/apis/index.ts +++ b/cptr/frontend/src/lib/apis/index.ts @@ -23,7 +23,7 @@ export async function fetchJSON(path: string, init?: RequestInit): const res = await fetchHandler(path, init); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new ApiError(res.status, data.error || res.statusText); + throw new ApiError(res.status, data.detail || data.error || res.statusText); } return res.json(); } diff --git a/cptr/frontend/src/lib/components/GitBar.svelte b/cptr/frontend/src/lib/components/GitBar.svelte index e76e764..491421c 100644 --- a/cptr/frontend/src/lib/components/GitBar.svelte +++ b/cptr/frontend/src/lib/components/GitBar.svelte @@ -65,6 +65,10 @@ files: GitFile[]; } | null); + // Branch has never been pushed to remote + const needsPublish = $derived(!gitStatus?.upstream); + const unpushedCount = $derived(needsPublish ? (commits?.length ?? 0) : (gitStatus?.ahead ?? 0)); + // Clear stale selection when file is no longer in the changed list $effect(() => { if (view === 'changes' && selectedFile) { @@ -200,8 +204,9 @@ try { const d = await gitUncommit(workspacePath); flash($t('git.uncommitted')); - } catch { - flash('Nothing to uncommit'); + switchView('changes'); + } catch (e) { + flash(e instanceof Error ? e.message : 'Failed to undo commit'); } loading = false; await refresh(); @@ -217,7 +222,6 @@ async function doPush() { loading = true; - const needsPublish = !gitStatus?.upstream; const d = await gitPush(workspacePath, { set_upstream: needsPublish, branch: needsPublish ? gitStatus?.branch : undefined @@ -255,6 +259,7 @@ // Context menu let contextMenu = $state<{ file: GitFile; anchor: HTMLElement } | null>(null); + let commitMenu = $state<{ commit: Commit; isLatest: boolean; anchor: HTMLElement } | null>(null); function openFileMenu(e: MouseEvent, file: GitFile) { e.stopPropagation(); @@ -265,6 +270,15 @@ contextMenu = null; } + function openCommitMenu(e: MouseEvent, commit: Commit, isLatest: boolean) { + e.stopPropagation(); + commitMenu = { commit, isLatest, anchor: e.currentTarget as HTMLElement }; + } + + function closeCommitMenu() { + commitMenu = null; + } + async function discardFile(path: string) { await discardChanges(workspacePath, [path]); if (selectedFile === path) { @@ -733,47 +747,47 @@ {$t('git.commitToBranch', { branch: gitStatus.branch })} {/if} - - - {#if gitStatus.ahead > 0 || !gitStatus.upstream} -
- - -
- {/if} {:else}
- {#each commits as c} + {#each commits as c, i} + {#if i === unpushedCount && unpushedCount > 0} +
+ + + {unpushedCount} unpushed {unpushedCount === 1 ? 'commit' : 'commits'} + +
+ {/if} {/each} {#if !commits.length} @@ -898,6 +912,36 @@ /> {/if} +{#if commitMenu} + { + doUncommit(); + closeCommitMenu(); + } + } + ] + : []), + { + label: $t('git.copyCommitHash'), + icon: 'copy', + onclick: () => { + navigator.clipboard.writeText(commitMenu!.commit.hash); + flash($t('git.copiedPath')); + closeCommitMenu(); + } + } + ]} + onclose={closeCommitMenu} + /> +{/if} +