From 10507c0cef08ae9ded9bc9dec71aeedeb3aea2d8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 31 Jul 2026 17:46:13 -0400 Subject: [PATCH 1/3] refac --- .../lib/components/Settings/Keyboard.svelte | 8 +- .../src/lib/components/chat/ChatPanel.svelte | 31 ++- cptr/frontend/src/lib/i18n/locales/de.json | 6 + cptr/frontend/src/lib/i18n/locales/en.json | 6 + cptr/frontend/src/lib/i18n/locales/es.json | 6 + cptr/frontend/src/lib/i18n/locales/fr.json | 6 + cptr/frontend/src/lib/i18n/locales/ja.json | 6 + cptr/frontend/src/lib/i18n/locales/ko.json | 6 + cptr/frontend/src/lib/i18n/locales/pt-BR.json | 6 + cptr/frontend/src/lib/i18n/locales/ru.json | 6 + cptr/frontend/src/lib/i18n/locales/zh-CN.json | 6 + cptr/frontend/src/lib/i18n/locales/zh-TW.json | 6 + cptr/frontend/src/lib/stores/chat.ts | 21 ++ cptr/frontend/src/lib/stores/keybindings.ts | 16 ++ cptr/frontend/src/routes/+layout.svelte | 15 +- cptr/routers/chat.py | 107 ++++------ cptr/utils/chat_task.py | 186 ++++++++++++++---- 17 files changed, 322 insertions(+), 122 deletions(-) diff --git a/cptr/frontend/src/lib/components/Settings/Keyboard.svelte b/cptr/frontend/src/lib/components/Settings/Keyboard.svelte index b6517f8c..cee8325f 100644 --- a/cptr/frontend/src/lib/components/Settings/Keyboard.svelte +++ b/cptr/frontend/src/lib/components/Settings/Keyboard.svelte @@ -21,9 +21,13 @@ nextTab: $t('keyboard.nextTab'), prevTab: $t('keyboard.prevTab'), quickOpen: $t('keyboard.quickOpen'), + searchAll: $t('keyboard.searchAll'), openSettings: $t('keyboard.openSettings'), toggleSplit: $t('keyboard.toggleSplit'), - toggleSidebar: $t('keyboard.toggleSidebar') + toggleSidebar: $t('keyboard.toggleSidebar'), + approveToolCall: $t('keyboard.approveToolCall'), + rejectToolCall: $t('keyboard.rejectToolCall'), + voiceMemo: $t('keyboard.voiceMemo') }); /** Translated action labels for display. */ @@ -40,6 +44,8 @@ openSettings: $t('keyboard.action.openSettings'), toggleSplit: $t('keyboard.action.toggleSplit'), toggleSidebar: $t('keyboard.action.toggleSidebar'), + approveToolCall: $t('keyboard.action.approveToolCall'), + rejectToolCall: $t('keyboard.action.rejectToolCall'), voiceMemo: $t('keyboard.action.voiceMemo') }); diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index 457d1fd7..6e38f08f 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -30,7 +30,8 @@ streamingChatTabs, registerStreamingChat, unregisterStreamingChat, - updateChatStatuses + updateChatStatuses, + registerToolApprovalShortcutHandler } from '$lib/stores/chat'; import { socketStore } from '$lib/stores/socket.svelte'; import { onMount, onDestroy, tick } from 'svelte'; @@ -763,7 +764,7 @@ } refreshCommandSessions(); commandSessionsTimer = setInterval(refreshCommandSessions, 5000); - window.addEventListener('cptr:inspect-command-session', handleInspectCommandSession); + window.addEventListener('computer:inspectCommandSession', handleInspectCommandSession); const offChat = socketStore.on('events:chat', handleSocketEvent); const offConnect = socketStore.on('connect', handleReconnect); @@ -779,7 +780,7 @@ unbindSocketListeners = null; if (commandSessionsTimer) clearInterval(commandSessionsTimer); commandSessionsTimer = null; - window.removeEventListener('cptr:inspect-command-session', handleInspectCommandSession); + window.removeEventListener('computer:inspectCommandSession', handleInspectCommandSession); if (landingRefreshTimer) clearTimeout(landingRefreshTimer); if (taskClearTimer) clearTimeout(taskClearTimer); // Don't clear streamingChatTabs here -- the global listener in @@ -821,6 +822,11 @@ return () => unregisterStreamingChat(chatId!, tabId!); }); + $effect(() => { + if (!active) return; + return registerToolApprovalShortcutHandler(handleApproveToolCallShortcut); + }); + // ── Persist read state separately from visibility tracking ── $effect(() => { @@ -1265,7 +1271,8 @@ item.type === 'function_call' && item.call_id === callId && item.status === 'pending' ); if (call) { - call.status = approved ? 'running' : 'rejected'; + call.status = approved ? 'queued' : 'rejected'; + if (approved) call.approved = true; allMessages = [...allMessages]; // trigger reactivity } } @@ -1277,6 +1284,22 @@ }); } + function handleApproveToolCallShortcut(approved: boolean) { + for (const { msg } of [...activePath].reverse()) { + const item = msg.output?.find( + (output: any) => + output.type === 'function_call' && + output.name !== 'ask_user' && + output.status === 'pending' + ); + if (!item) continue; + + handleApprove(msg.id, item.call_id, approved); + return true; + } + return false; + } + function handleAskUserAnswer( messageId: string, callId: string, diff --git a/cptr/frontend/src/lib/i18n/locales/de.json b/cptr/frontend/src/lib/i18n/locales/de.json index 7815cd8a..284c0920 100644 --- a/cptr/frontend/src/lib/i18n/locales/de.json +++ b/cptr/frontend/src/lib/i18n/locales/de.json @@ -513,9 +513,13 @@ "keyboard.nextTab": "Zum nächsten Tab wechseln", "keyboard.prevTab": "Zum vorherigen Tab wechseln", "keyboard.quickOpen": "Dateien schnell suchen und öffnen", + "keyboard.searchAll": "Chats und Dateien durchsuchen", "keyboard.openSettings": "Einstellungen öffnen", "keyboard.toggleSplit": "Geteilte Ansicht umschalten", "keyboard.toggleSidebar": "Seitenleiste ein-/ausblenden", + "keyboard.approveToolCall": "Ausstehenden Tool-Aufruf genehmigen", + "keyboard.rejectToolCall": "Ausstehenden Tool-Aufruf ablehnen", + "keyboard.voiceMemo": "Sprachnotiz-Rekorder öffnen", "changelog.whatsNew": "Neuigkeiten", "changelog.releaseNotes": "Versionshinweise", "changelog.loadError": "Versionshinweise konnten nicht geladen werden.", @@ -826,6 +830,8 @@ "keyboard.action.openSettings": "Einstellungen öffnen", "keyboard.action.toggleSplit": "Teilung umschalten", "keyboard.action.toggleSidebar": "Seitenleiste umschalten", + "keyboard.action.approveToolCall": "Tool-Aufruf genehmigen", + "keyboard.action.rejectToolCall": "Tool-Aufruf ablehnen", "keyboard.action.voiceMemo": "Sprachnotiz", "keyboard.conflict": "Auch belegt von {{action}}", "chat.history.justNow": "Gerade eben", diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 29ebdc56..389b4d27 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -540,9 +540,13 @@ "keyboard.nextTab": "Switch to the next tab", "keyboard.prevTab": "Switch to the previous tab", "keyboard.quickOpen": "Search and open files quickly", + "keyboard.searchAll": "Search across chats and files", "keyboard.openSettings": "Open the settings panel", "keyboard.toggleSplit": "Toggle split editor view", "keyboard.toggleSidebar": "Show or hide the sidebar", + "keyboard.approveToolCall": "Approve the pending tool call", + "keyboard.rejectToolCall": "Reject the pending tool call", + "keyboard.voiceMemo": "Open voice memo recorder", "changelog.whatsNew": "What's New", "changelog.releaseNotes": "Release Notes", "changelog.loadError": "Couldn't load release notes.", @@ -863,6 +867,8 @@ "keyboard.action.openSettings": "Open Settings", "keyboard.action.toggleSplit": "Toggle Split", "keyboard.action.toggleSidebar": "Toggle Sidebar", + "keyboard.action.approveToolCall": "Approve Tool Call", + "keyboard.action.rejectToolCall": "Reject Tool Call", "keyboard.action.voiceMemo": "Voice Memo", "keyboard.conflict": "Also bound to {{action}}", "chat.history.justNow": "Just now", diff --git a/cptr/frontend/src/lib/i18n/locales/es.json b/cptr/frontend/src/lib/i18n/locales/es.json index f535ab3c..d3a56a85 100644 --- a/cptr/frontend/src/lib/i18n/locales/es.json +++ b/cptr/frontend/src/lib/i18n/locales/es.json @@ -513,9 +513,13 @@ "keyboard.nextTab": "Cambiar a la siguiente pestaña", "keyboard.prevTab": "Cambiar a la pestaña anterior", "keyboard.quickOpen": "Buscar y abrir archivos rápidamente", + "keyboard.searchAll": "Buscar en chats y archivos", "keyboard.openSettings": "Abrir el panel de ajustes", "keyboard.toggleSplit": "Alternar vista dividida del editor", "keyboard.toggleSidebar": "Mostrar u ocultar la barra lateral", + "keyboard.approveToolCall": "Aprobar la llamada de herramienta pendiente", + "keyboard.rejectToolCall": "Rechazar la llamada de herramienta pendiente", + "keyboard.voiceMemo": "Abrir grabadora de nota de voz", "changelog.whatsNew": "Novedades", "changelog.releaseNotes": "Notas de la versión", "changelog.loadError": "No se pudieron cargar las notas de la versión.", @@ -826,6 +830,8 @@ "keyboard.action.openSettings": "Abrir ajustes", "keyboard.action.toggleSplit": "Alternar división", "keyboard.action.toggleSidebar": "Alternar barra lateral", + "keyboard.action.approveToolCall": "Aprobar herramienta", + "keyboard.action.rejectToolCall": "Rechazar herramienta", "keyboard.action.voiceMemo": "Nota de voz", "keyboard.conflict": "También asignado a {{action}}", "chat.history.justNow": "Ahora mismo", diff --git a/cptr/frontend/src/lib/i18n/locales/fr.json b/cptr/frontend/src/lib/i18n/locales/fr.json index a9fd9028..41bf8085 100644 --- a/cptr/frontend/src/lib/i18n/locales/fr.json +++ b/cptr/frontend/src/lib/i18n/locales/fr.json @@ -513,9 +513,13 @@ "keyboard.nextTab": "Passer à l'onglet suivant", "keyboard.prevTab": "Passer à l'onglet précédent", "keyboard.quickOpen": "Rechercher et ouvrir des fichiers rapidement", + "keyboard.searchAll": "Rechercher dans les chats et fichiers", "keyboard.openSettings": "Ouvrir le panneau des paramètres", "keyboard.toggleSplit": "Basculer la vue divisée de l'éditeur", "keyboard.toggleSidebar": "Afficher ou masquer la barre latérale", + "keyboard.approveToolCall": "Approuver l'appel d'outil en attente", + "keyboard.rejectToolCall": "Rejeter l'appel d'outil en attente", + "keyboard.voiceMemo": "Ouvrir l'enregistreur de note vocale", "changelog.whatsNew": "Nouveautés", "changelog.releaseNotes": "Notes de version", "changelog.loadError": "Impossible de charger les notes de version.", @@ -826,6 +830,8 @@ "keyboard.action.openSettings": "Ouvrir les paramètres", "keyboard.action.toggleSplit": "Basculer la division", "keyboard.action.toggleSidebar": "Basculer la barre latérale", + "keyboard.action.approveToolCall": "Approuver l'outil", + "keyboard.action.rejectToolCall": "Rejeter l'outil", "keyboard.action.voiceMemo": "Note vocale", "keyboard.conflict": "Également lié à {{action}}", "chat.history.justNow": "À l'instant", diff --git a/cptr/frontend/src/lib/i18n/locales/ja.json b/cptr/frontend/src/lib/i18n/locales/ja.json index 8b7a0a8c..3583f619 100644 --- a/cptr/frontend/src/lib/i18n/locales/ja.json +++ b/cptr/frontend/src/lib/i18n/locales/ja.json @@ -513,9 +513,13 @@ "keyboard.nextTab": "次のタブに切り替える", "keyboard.prevTab": "前のタブに切り替える", "keyboard.quickOpen": "ファイルをすばやく検索して開く", + "keyboard.searchAll": "チャットとファイルを検索", "keyboard.openSettings": "設定パネルを開く", "keyboard.toggleSplit": "エディタの分割表示を切替", "keyboard.toggleSidebar": "サイドバーの表示/非表示", + "keyboard.approveToolCall": "保留中のツール呼び出しを承認", + "keyboard.rejectToolCall": "保留中のツール呼び出しを拒否", + "keyboard.voiceMemo": "音声メモ録音を開く", "changelog.whatsNew": "新機能", "changelog.releaseNotes": "リリースノート", "changelog.loadError": "リリースノートを読み込めませんでした。", @@ -826,6 +830,8 @@ "keyboard.action.openSettings": "設定を開く", "keyboard.action.toggleSplit": "分割を切替", "keyboard.action.toggleSidebar": "サイドバーを切替", + "keyboard.action.approveToolCall": "ツールを承認", + "keyboard.action.rejectToolCall": "ツールを拒否", "keyboard.action.voiceMemo": "音声メモ", "keyboard.conflict": "{{action}}にも割り当て済み", "chat.history.justNow": "たった今", diff --git a/cptr/frontend/src/lib/i18n/locales/ko.json b/cptr/frontend/src/lib/i18n/locales/ko.json index 2cb6f35f..85a5fe7c 100644 --- a/cptr/frontend/src/lib/i18n/locales/ko.json +++ b/cptr/frontend/src/lib/i18n/locales/ko.json @@ -513,9 +513,13 @@ "keyboard.nextTab": "다음 탭으로 전환", "keyboard.prevTab": "이전 탭으로 전환", "keyboard.quickOpen": "파일을 빠르게 검색하고 열기", + "keyboard.searchAll": "채팅과 파일 검색", "keyboard.openSettings": "설정 패널 열기", "keyboard.toggleSplit": "편집기 분할 보기 전환", "keyboard.toggleSidebar": "사이드바 표시/숨기기", + "keyboard.approveToolCall": "대기 중인 도구 호출 승인", + "keyboard.rejectToolCall": "대기 중인 도구 호출 거부", + "keyboard.voiceMemo": "음성 메모 녹음기 열기", "changelog.whatsNew": "새로운 기능", "changelog.releaseNotes": "릴리스 노트", "changelog.loadError": "릴리스 노트를 불러올 수 없습니다.", @@ -826,6 +830,8 @@ "keyboard.action.openSettings": "설정 열기", "keyboard.action.toggleSplit": "분할 전환", "keyboard.action.toggleSidebar": "사이드바 전환", + "keyboard.action.approveToolCall": "도구 승인", + "keyboard.action.rejectToolCall": "도구 거부", "keyboard.action.voiceMemo": "음성 메모", "keyboard.conflict": "{{action}}에도 할당됨", "chat.history.justNow": "방금", diff --git a/cptr/frontend/src/lib/i18n/locales/pt-BR.json b/cptr/frontend/src/lib/i18n/locales/pt-BR.json index 2dac8af1..60b9510c 100644 --- a/cptr/frontend/src/lib/i18n/locales/pt-BR.json +++ b/cptr/frontend/src/lib/i18n/locales/pt-BR.json @@ -513,9 +513,13 @@ "keyboard.nextTab": "Mudar para a próxima aba", "keyboard.prevTab": "Mudar para a aba anterior", "keyboard.quickOpen": "Pesquisar e abrir arquivos rapidamente", + "keyboard.searchAll": "Pesquisar em chats e arquivos", "keyboard.openSettings": "Abrir o painel de configurações", "keyboard.toggleSplit": "Alternar visualização dividida do editor", "keyboard.toggleSidebar": "Mostrar ou ocultar a barra lateral", + "keyboard.approveToolCall": "Aprovar a chamada de ferramenta pendente", + "keyboard.rejectToolCall": "Rejeitar a chamada de ferramenta pendente", + "keyboard.voiceMemo": "Abrir gravador de nota de voz", "changelog.whatsNew": "Novidades", "changelog.releaseNotes": "Notas da versão", "changelog.loadError": "Não foi possível carregar as notas da versão.", @@ -826,6 +830,8 @@ "keyboard.action.openSettings": "Abrir configurações", "keyboard.action.toggleSplit": "Alternar divisão", "keyboard.action.toggleSidebar": "Alternar barra lateral", + "keyboard.action.approveToolCall": "Aprovar ferramenta", + "keyboard.action.rejectToolCall": "Rejeitar ferramenta", "keyboard.action.voiceMemo": "Nota de voz", "keyboard.conflict": "Também atribuído a {{action}}", "chat.history.justNow": "Agora", diff --git a/cptr/frontend/src/lib/i18n/locales/ru.json b/cptr/frontend/src/lib/i18n/locales/ru.json index 98cb5427..ba4d17cb 100644 --- a/cptr/frontend/src/lib/i18n/locales/ru.json +++ b/cptr/frontend/src/lib/i18n/locales/ru.json @@ -513,9 +513,13 @@ "keyboard.nextTab": "Перейти к следующей вкладке", "keyboard.prevTab": "Перейти к предыдущей вкладке", "keyboard.quickOpen": "Быстрый поиск и открытие файлов", + "keyboard.searchAll": "Поиск в чатах и файлах", "keyboard.openSettings": "Открыть панель настроек", "keyboard.toggleSplit": "Переключить разделённый вид редактора", "keyboard.toggleSidebar": "Показать или скрыть боковую панель", + "keyboard.approveToolCall": "Одобрить ожидающий вызов инструмента", + "keyboard.rejectToolCall": "Отклонить ожидающий вызов инструмента", + "keyboard.voiceMemo": "Открыть запись голосовой заметки", "changelog.whatsNew": "Что нового", "changelog.releaseNotes": "Примечания к выпуску", "changelog.loadError": "Не удалось загрузить примечания к выпуску.", @@ -826,6 +830,8 @@ "keyboard.action.openSettings": "Открыть настройки", "keyboard.action.toggleSplit": "Переключить разделение", "keyboard.action.toggleSidebar": "Переключить боковую панель", + "keyboard.action.approveToolCall": "Одобрить инструмент", + "keyboard.action.rejectToolCall": "Отклонить инструмент", "keyboard.action.voiceMemo": "Голосовая заметка", "keyboard.conflict": "Также назначено на {{action}}", "chat.history.justNow": "Только что", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-CN.json b/cptr/frontend/src/lib/i18n/locales/zh-CN.json index 6e750780..6d31f14c 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-CN.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-CN.json @@ -513,9 +513,13 @@ "keyboard.nextTab": "切换到下一个标签页", "keyboard.prevTab": "切换到上一个标签页", "keyboard.quickOpen": "快速搜索并打开文件", + "keyboard.searchAll": "搜索聊天和文件", "keyboard.openSettings": "打开设置面板", "keyboard.toggleSplit": "切换编辑器分割视图", "keyboard.toggleSidebar": "显示或隐藏侧边栏", + "keyboard.approveToolCall": "批准待处理的工具调用", + "keyboard.rejectToolCall": "拒绝待处理的工具调用", + "keyboard.voiceMemo": "打开语音备忘录录制器", "changelog.whatsNew": "新功能", "changelog.releaseNotes": "发行说明", "changelog.loadError": "无法加载发行说明。", @@ -826,6 +830,8 @@ "keyboard.action.openSettings": "打开设置", "keyboard.action.toggleSplit": "切换分割", "keyboard.action.toggleSidebar": "切换侧边栏", + "keyboard.action.approveToolCall": "批准工具调用", + "keyboard.action.rejectToolCall": "拒绝工具调用", "keyboard.action.voiceMemo": "语音备忘录", "keyboard.conflict": "同时绑定到 {{action}}", "chat.history.justNow": "刚刚", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-TW.json b/cptr/frontend/src/lib/i18n/locales/zh-TW.json index 18d5866d..540677e2 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-TW.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-TW.json @@ -513,9 +513,13 @@ "keyboard.nextTab": "切換到下一個分頁", "keyboard.prevTab": "切換到上一個分頁", "keyboard.quickOpen": "快速搜尋並開啟檔案", + "keyboard.searchAll": "搜尋聊天和檔案", "keyboard.openSettings": "開啟設定面板", "keyboard.toggleSplit": "切換編輯器分割檢視", "keyboard.toggleSidebar": "顯示或隱藏側邊欄", + "keyboard.approveToolCall": "核准待處理的工具呼叫", + "keyboard.rejectToolCall": "拒絕待處理的工具呼叫", + "keyboard.voiceMemo": "開啟語音備忘錄錄製器", "changelog.whatsNew": "最新消息", "changelog.releaseNotes": "發行說明", "changelog.loadError": "無法載入發行說明。", @@ -826,6 +830,8 @@ "keyboard.action.openSettings": "開啟設定", "keyboard.action.toggleSplit": "切換分割", "keyboard.action.toggleSidebar": "切換側邊欄", + "keyboard.action.approveToolCall": "核准工具呼叫", + "keyboard.action.rejectToolCall": "拒絕工具呼叫", "keyboard.action.voiceMemo": "語音備忘錄", "keyboard.conflict": "同時繫結到 {{action}}", "chat.history.justNow": "剛才", diff --git a/cptr/frontend/src/lib/stores/chat.ts b/cptr/frontend/src/lib/stores/chat.ts index 04d289ef..7f22992a 100644 --- a/cptr/frontend/src/lib/stores/chat.ts +++ b/cptr/frontend/src/lib/stores/chat.ts @@ -106,6 +106,27 @@ export function unregisterStreamingChat(chatId: string, tabId: string) { if (tabIds.size === 0) chatToTabs.delete(chatId); } +type ToolApprovalShortcutHandler = (approved: boolean) => boolean; + +let toolApprovalShortcutHandler: ToolApprovalShortcutHandler | null = null; + +export function registerToolApprovalShortcutHandler( + handler: ToolApprovalShortcutHandler +): () => void { + toolApprovalShortcutHandler = handler; + return () => { + if (toolApprovalShortcutHandler === handler) toolApprovalShortcutHandler = null; + }; +} + +export function approveActiveToolCallShortcut(): boolean { + return toolApprovalShortcutHandler?.(true) ?? false; +} + +export function rejectActiveToolCallShortcut(): boolean { + return toolApprovalShortcutHandler?.(false) ?? false; +} + /** * Bind a global socket listener that clears streamingChatTabs when a * chat's "done" event arrives -- even if the ChatPanel is not mounted. diff --git a/cptr/frontend/src/lib/stores/keybindings.ts b/cptr/frontend/src/lib/stores/keybindings.ts index 3851d8b6..f2aa22bd 100644 --- a/cptr/frontend/src/lib/stores/keybindings.ts +++ b/cptr/frontend/src/lib/stores/keybindings.ts @@ -41,6 +41,8 @@ export const ACTION_IDS = [ 'openSettings', 'toggleSplit', 'toggleSidebar', + 'approveToolCall', + 'rejectToolCall', 'voiceMemo' ] as const; @@ -61,6 +63,8 @@ export const ACTION_LABELS: Record = { openSettings: 'Open Settings', toggleSplit: 'Toggle Split', toggleSidebar: 'Toggle Sidebar', + approveToolCall: 'Approve Tool Call', + rejectToolCall: 'Reject Tool Call', voiceMemo: 'Voice Memo' }; @@ -79,6 +83,8 @@ export const DEFAULT_KEYBINDINGS: Record = { openSettings: 'Cmd+.', toggleSplit: 'Cmd+\\', toggleSidebar: 'Cmd+Shift+S', + approveToolCall: 'Cmd+Shift+Enter', + rejectToolCall: 'Cmd+Shift+Backspace', voiceMemo: 'Cmd+Shift+M' }; @@ -243,6 +249,8 @@ export function executeAction( toggleSettings?: () => void; toggleSearch?: () => void; toggleVoiceMemo?: () => void; + approveToolCall?: () => boolean; + rejectToolCall?: () => boolean; } ): boolean { const dispatchHomeAction = ( @@ -341,6 +349,14 @@ export function executeAction( sidebarOpen.update((v) => !v); return true; + case 'approveToolCall': { + return callbacks?.approveToolCall?.() ?? false; + } + + case 'rejectToolCall': { + return callbacks?.rejectToolCall?.() ?? false; + } + case 'voiceMemo': callbacks?.toggleVoiceMemo?.(); return true; diff --git a/cptr/frontend/src/routes/+layout.svelte b/cptr/frontend/src/routes/+layout.svelte index de498be9..41396086 100644 --- a/cptr/frontend/src/routes/+layout.svelte +++ b/cptr/frontend/src/routes/+layout.svelte @@ -42,7 +42,12 @@ import { fetchJSON } from '$lib/apis'; import { gitStatusStore } from '$lib/stores/gitStatus.svelte'; import { t } from '$lib/i18n'; - import { refreshChatState, bindGlobalChatListener } from '$lib/stores/chat'; + import { + refreshChatState, + bindGlobalChatListener, + approveActiveToolCallShortcut, + rejectActiveToolCallShortcut + } from '$lib/stores/chat'; import { refreshAudioState } from '$lib/stores/audio'; import SetupWizard from '$lib/components/SetupWizard.svelte'; @@ -331,8 +336,7 @@ function handleKeydown(e: KeyboardEvent) { const action = matchKeybinding(e); if (!action) return; - e.preventDefault(); - executeAction(action, { + const handled = executeAction(action, { toggleQuickOpen: () => { showSearch.update((v) => !v); }, @@ -348,8 +352,11 @@ if (get(voiceMemosEnabled)) showVoiceMemo.update((v) => !v); }); }); - } + }, + approveToolCall: approveActiveToolCallShortcut, + rejectToolCall: rejectActiveToolCallShortcut }); + if (handled) e.preventDefault(); } // Chat events belong to the authenticated user, not a workspace. diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index fb9d7492..a4cf1e8e 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -491,9 +491,7 @@ async def _get_chat_context_usage(chat, model_id: str | None = None) -> dict | N tokens += estimate_messages_tokens( [{"role": m.role, "content": m.content or ""} for m in trailing_messages] ) - return build_context_usage( - tokens, threshold=compact_token_threshold - ) + return build_context_usage(tokens, threshold=compact_token_threshold) return estimate_context_usage(messages, system, threshold=compact_token_threshold) @@ -1069,84 +1067,45 @@ async def approve_tool(chat_id: str, message_id: str, body: ApproveRequest, requ if not call: raise HTTPException(400, "no pending tool call with that call_id") - if body.approved: - # Execute the tool - from cptr.utils.tools import execute_tool - - model_id = msg.model or "" - result = await execute_tool( - call["name"], - call.get("arguments", {}), - { - "workspace": chat.meta.get("workspace", ""), - "user_id": user_id, - "model_id": model_id, - "chat_id": chat_id, - "message_id": message_id, - "call_id": body.call_id, - }, - ) - call["status"] = "completed" - output.append( - { - "type": "function_call_output", - "call_id": body.call_id, - "output": result, - } - ) - - # Emit artifact card if the tool produced an artifact - from cptr.utils.chat_task import build_artifact_item - - artifact_item = build_artifact_item(call["name"], call.get("arguments", {}), result) - if artifact_item: - output.append(artifact_item) - from cptr.socket.main import emit_to_user - - await emit_to_user( - user_id, - {"chat_id": chat_id, "message_id": message_id, "output": artifact_item}, - ) - - if call["name"] == "display_file": - try: - file_item = json.loads(result) - except (json.JSONDecodeError, TypeError): - file_item = None - if isinstance(file_item, dict) and file_item.get("type") == "file": - output.append(file_item) - from cptr.socket.main import emit_to_user - - await emit_to_user( - user_id, - {"chat_id": chat_id, "message_id": message_id, "output": file_item}, - ) + model_id = msg.model or "" + workspace = chat.meta.get("workspace", "") if chat.meta else "" + from cptr.socket.main import emit_to_user + if body.approved: + call["approved"] = True + call["status"] = "queued" await ChatMessage.update(message_id, output=output, done=False) - # Resolve model target and continue - from cptr.utils.model_targets import resolve_model_target + await emit_to_user(user_id, {"chat_id": chat_id, "message_id": message_id, "output": call}) + else: + call["status"] = "rejected" + result_item = { + "type": "function_call_output", + "call_id": body.call_id, + "output": "Error: tool call rejected by user.", + } + output.append(result_item) + await ChatMessage.update(message_id, output=output, done=False) + await emit_to_user(user_id, {"chat_id": chat_id, "message_id": message_id, "output": call}) + await emit_to_user( + user_id, + {"chat_id": chat_id, "message_id": message_id, "output": result_item}, + ) - target = await resolve_model_target(model_id, request.app.state) - workspace = chat.meta.get("workspace", "") if chat.meta else "" + # Resolve model target and continue the saved tool-call queue. + from cptr.utils.model_targets import resolve_model_target - from cptr.utils.chat_task import start_task + target = await resolve_model_target(model_id, request.app.state) - start_task( - message_id=message_id, - chat_id=chat_id, - user_id=user_id, - workspace=workspace, - target=target, - ) - else: - call["status"] = "rejected" - await ChatMessage.update(message_id, output=output, done=True) - # Process pending inputs since this chat is now idle. - from cptr.utils.chat_task import process_pending_chat_inputs + from cptr.utils.chat_task import start_task - workspace = chat.meta.get("workspace", "") if chat.meta else "" - await process_pending_chat_inputs(chat_id, user_id, workspace) + start_task( + message_id=message_id, + chat_id=chat_id, + user_id=user_id, + workspace=workspace, + target=target, + ) return {"ok": True} diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index a84589bc..15c580c6 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -785,7 +785,9 @@ async def generate_chat_title( # ── Message history ───────────────────────────────────────── -def _output_items_to_messages(output_items: list[dict], message_id: str | None = None) -> list[dict]: +def _output_items_to_messages( + output_items: list[dict], message_id: str | None = None +) -> list[dict]: """Convert ordered persisted output items into model-visible messages.""" native_agent_call_ids = { item["call_id"] @@ -847,7 +849,7 @@ def flush_pending() -> None: ): continue pending_reasoning_items.append(item) - elif itype == "function_call" and item.get("status") == "completed": + elif itype == "function_call" and item.get("status") in {"completed", "rejected"}: call_id = item.get("call_id") if not call_id or call_id in native_agent_call_ids: continue @@ -1140,6 +1142,7 @@ def _scrub_incomplete_items(output_items: list[dict]) -> None: if item.get("type") == "function_call" and item.get("status") in ( "in_progress", "pending", + "queued", ): item["status"] = "failed" @@ -1897,6 +1900,122 @@ async def _finish_reasoning_item(): if "tool_approval_mode" not in chat_params and "auto_approve_tools" in chat_params: approval_mode = "full" if chat_params["auto_approve_tools"] else "auto" + async def run_queued_tool_calls(tool_ctx: dict) -> str: + """Run queued tool calls until approval is needed or the queue is empty. + + Returns: "idle", "completed", or "approval_required". + """ + processed_any = False + for item in output_items: + if item.get("type") != "function_call": + continue + if item.get("name") == ASK_USER_NAME or _is_native_agent_tool_item(item): + continue + if item.get("status") in {"completed", "rejected", "failed"}: + continue + call_id = item.get("call_id") + if call_id and any( + result.get("type") == "function_call_output" + and result.get("call_id") == call_id + for result in output_items + ): + item["status"] = "completed" + continue + + name = item.get("name", "") + tool = ALL_TOOLS.get(name) + needs_approval = not ( + approval_mode == "full" or (approval_mode == "auto" and tool and tool["auto"]) + ) + if needs_approval and not item.get("approved"): + item["status"] = "pending" + await _save_message( + "pending approval", + content=content, + output=output_items, + done=False, + ) + await emit(output=item) + _task_state.pop(message_id, None) + await emit( + done=True, + status="approval_required", + title=chat_obj.title if chat_obj else None, + tool_name=name, + workspace=workspace, + workspace_name=workspace.rstrip("/").rsplit("/", 1)[-1] + if workspace + else "", + ) + return "approval_required" + + item["status"] = "in_progress" + await emit(output=item) + _sync_state() + await _save_message("tool call in progress", content=content, output=output_items) + + arguments = item.get("arguments") or {} + if name == "create_artifact": + args = dict(arguments) + args.pop("workspace", None) + result = await create_artifact(**args, workspace=workspace) + else: + result = await execute_tool( + name, + arguments, + {**tool_ctx, "call_id": item["call_id"]}, + ) + + result_item = { + "type": "function_call_output", + "call_id": item["call_id"], + "output": result, + } + output_items.append(result_item) + item["status"] = "completed" + await emit(output=item) + await emit(output=result_item) + _sync_state() + + artifact_item = build_artifact_item(name, arguments, result) + if artifact_item: + output_items.append(artifact_item) + await emit(output=artifact_item) + _sync_state() + + if name == "display_file": + try: + file_item = json.loads(result) + except (json.JSONDecodeError, TypeError): + file_item = None + if isinstance(file_item, dict) and file_item.get("type") == "file": + output_items.append(file_item) + await emit(output=file_item) + _sync_state() + + await _save_message("tool call complete", content=content, output=output_items) + processed_any = True + + return "completed" if processed_any else "idle" + + tool_ctx = { + "workspace": workspace, + "user_id": user_id, + "model_id": model, + "full_model_id": ((chat_obj.meta or {}).get("last_model") if chat_obj else None) + or model, + "chat_id": chat_id, + "message_id": message_id, + "connection": connection, + "builtin_tools": builtin_tools, + } + + resumed_calls = await run_queued_tool_calls(tool_ctx) + if resumed_calls == "approval_required": + return + if resumed_calls == "completed": + messages, loaded_summary = await _load_message_history(chat_id, message_id) + last_usage: dict | None = None # real usage from last API call new_messages_since: int = 0 # messages appended since last API call @@ -2186,18 +2305,6 @@ async def _finish_reasoning_item(): ) flushed_item = _flush_text() - tool_ctx = { - "workspace": workspace, - "user_id": user_id, - "model_id": model, - "full_model_id": ((chat_obj.meta or {}).get("last_model") if chat_obj else None) - or model, - "chat_id": chat_id, - "message_id": message_id, - "connection": connection, - "builtin_tools": builtin_tools, - } - ask_user_calls = [tc for tc in pending_calls if tc["name"] == ASK_USER_NAME] if ask_user_calls: tc = ask_user_calls[0] @@ -2319,39 +2426,40 @@ async def auto_answer(): break if needs_approval: - # First non-auto tool stops the loop for approval - tc = needs_approval - item = { - "type": "function_call", - "id": str(uuid.uuid4()), - "call_id": tc["call_id"], - "fc_id": tc.get("id", ""), - "name": tc["name"], - "arguments": tc["arguments"], - "status": "pending", - } - output_items.append(item) + call_items = [] + for tc in pending_calls: + item = { + "type": "function_call", + "id": str(uuid.uuid4()), + "call_id": tc["call_id"], + "fc_id": tc.get("id", ""), + "name": tc["name"], + "arguments": tc["arguments"], + "status": "queued", + } + output_items.append(item) + call_items.append(item) + await _save_message( - "pending approval", + "queued tool calls", content=content, output=output_items, done=False, ) if flushed_item: await emit(output=flushed_item) + for item in call_items: await emit(output=item) - _task_state.pop(message_id, None) - await emit( - done=True, - status="approval_required", - title=chat_obj.title if chat_obj else None, - tool_name=tc["name"], - workspace=workspace, - workspace_name=workspace.rstrip("/").rsplit("/", 1)[-1] - if workspace - else "", - ) - return + + queue_result = await run_queued_tool_calls(tool_ctx) + if queue_result == "approval_required": + return + if queue_result == "completed": + messages, loaded_summary = await _load_message_history(chat_id, message_id) + new_messages_since = 0 + restart = True + continue + raise RuntimeError("tool-call queue did not make progress") # All calls are auto-approved — build UI items call_items: list[tuple[dict, dict]] = [] # (event, ui_item) From 44a2273feff939730745a8e953097fd8627ad497 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 31 Jul 2026 17:51:08 -0400 Subject: [PATCH 2/3] refac --- cptr/routers/gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cptr/routers/gateway.py b/cptr/routers/gateway.py index 92fc613b..48769805 100644 --- a/cptr/routers/gateway.py +++ b/cptr/routers/gateway.py @@ -732,7 +732,7 @@ async def _ensure_chat( title = "Open WebUI Chat" if messages: first_user = next( - (m.get("content", "")[:50] for m in messages if m.get("role") == "user"), + (message_text(m)[:50] for m in messages if m.get("role") == "user"), None, ) if first_user: From cf6929b57ced47fb01b6cec07919e30f94040d49 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 31 Jul 2026 17:51:32 -0400 Subject: [PATCH 3/3] refac --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd7b460..39411b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.18] - 2026-07-31 + +### Added + +- ⌨️ **Approve or reject tool requests from the keyboard.** Tool approval shortcuts are now available in Keyboard settings, so you can keep a chat moving without reaching for the mouse. + +### Fixed + +- 🧰 **Tool requests continue more smoothly.** When a chat asks for permission to use tools, Computer now keeps the request in order, picks up where it left off after your choice, and handles rejected tools more cleanly. +- 💬 **Chat shortcuts stay out of the way.** Shortcuts that only apply to an active tool request now leave the rest of the app alone when there is nothing to approve or reject. +- 💬 **Open WebUI chat titles are more reliable.** New chats started through Open WebUI now get cleaner titles from the first message. + ## [0.9.17] - 2026-07-31 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 04967c4f..61425b37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.9.17" +version = "0.9.18" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md" diff --git a/uv.lock b/uv.lock index 7dd155a3..f2203de8 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.9.17" +version = "0.9.18" source = { editable = "." } dependencies = [ { name = "aiosqlite" },