From e50c6e93d3a90f4212864e07ba5a47da0132c5c4 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Jul 2026 23:50:20 -0400 Subject: [PATCH 01/12] refac --- cptr/utils/chat_task.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index ece9122..9eebd1d 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -812,7 +812,7 @@ async def _load_message_history(chat_id: str, message_id: str) -> tuple[list[dic # (truly empty placeholder on first run) if m.id == message_id and not m.done and not m.content and not m.output: continue - entry: dict = {"role": m.role, "content": m.content or ""} + entry: dict = {"id": m.id, "role": m.role, "content": m.content or ""} # Transform uploaded images into base64 multimodal blocks; inline text files if m.role == "user": @@ -970,6 +970,7 @@ async def _load_message_history(chat_id: str, message_id: str) -> tuple[list[dic # result from previous turn) before creating a new one. result.append(entry) entry = { + "id": m.id, "role": "assistant", "content": "", "tool_calls": matched_calls, @@ -979,6 +980,7 @@ async def _load_message_history(chat_id: str, message_id: str) -> tuple[list[dic for out in turn["outputs"]: result.append(entry) entry = { + "id": m.id, "role": "tool", "tool_call_id": out["call_id"], "content": _tool_result_for_model( @@ -2046,7 +2048,8 @@ async def _finish_reasoning_item(): api_type=api_type, ) - await ChatMessage.update(summary_message_id, chat_summary=summary) + checkpoint_message_id = keep_zone[0].get("id") or summary_message_id + await ChatMessage.update(checkpoint_message_id, chat_summary=summary) loaded_summary = summary # Append summary to system prompt (works for all providers) @@ -2077,7 +2080,7 @@ async def _finish_reasoning_item(): logger.info( "[task %s] compacted: checkpoint=%s dropped %d msgs, kept %d, summary=%d chars", message_id[:8], - summary_message_id[:8], + checkpoint_message_id[:8], len(drop_zone), len(keep_zone), len(summary), @@ -2114,6 +2117,7 @@ async def _finish_reasoning_item(): ], } ) + api_messages = [{k: v for k, v in m.items() if k != "id"} for m in api_messages] form_data = ChatCompletionForm( model=model, From fbc33282e44139c92804a86c450c0ae11d44e16b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 15 Jul 2026 15:22:26 -0400 Subject: [PATCH 02/12] refac --- cptr/frontend/src/lib/apis/chat.ts | 7 +- .../components/chat/AssistantMessage.svelte | 16 ++-- .../src/lib/components/chat/ChatPanel.svelte | 8 +- cptr/routers/chat.py | 25 +++--- cptr/routers/workspace.py | 45 +++++++--- cptr/utils/chat_task.py | 23 +++-- cptr/utils/context.py | 39 +++++++++ cptr/utils/gitignore.py | 83 +++++++++++++++++++ cptr/utils/tools.py | 31 ++++--- 9 files changed, 225 insertions(+), 52 deletions(-) create mode 100644 cptr/utils/gitignore.py diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index a5c8339..eb1e171 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -171,8 +171,11 @@ export const answerAskUser = ( export const cancelTask = (chatId: string, messageId: string) => fetchJSON(`/api/chats/${chatId}/messages/${messageId}/cancel`, { method: 'POST' }); -export const compactChat = (chatId: string, modelId: string) => - fetchJSON(`/api/chats/${chatId}/compact`, jsonBody({ model_id: modelId })); +export const compactChat = (chatId: string, modelId?: string | null) => + fetchJSON( + `/api/chats/${chatId}/compact`, + jsonBody({ model_id: modelId || null }) + ); export const updateCurrentMessage = (chatId: string, messageId: string) => fetchJSON<{ ok: boolean }>(`/api/chats/${chatId}/current`, jsonBody({ message_id: messageId })); diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte index f4e8f86..991be66 100644 --- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte @@ -754,7 +754,7 @@ onmouseenter={() => (showUsageTooltip = true)} onmouseleave={() => (showUsageTooltip = false)} aria-label={$t('chat.usageInfo')} - use:tooltip={$t('chat.usageInfo')} + use:tooltip={showUsageTooltip ? null : $t('chat.usageInfo')} >
{#each Object.entries(usage) as [key, value]}
- {formatUsageLabel(key)} - {formatUsageValue(key, value)} + {formatUsageLabel(key)} + {formatUsageValue(key, value)}
{/each}
@@ -794,7 +791,8 @@ class="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-[0.3125rem] border-l-transparent border-r-[0.3125rem] border-r-transparent - border-t-[0.3125rem] border-t-gray-900 dark:border-t-gray-800" + border-t-[0.3125rem]" + style="border-top-color: var(--app-bg);" > {/if} diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index 0b16f7e..1f64586 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -532,6 +532,7 @@ delta?: string; output?: any; tasks?: ChatTask[]; + context_usage?: ContextUsage | null; done?: boolean; error?: string; pending_inputs_processed?: boolean; @@ -574,6 +575,9 @@ chatTitle = data.title; updateTab(tabId, data.chat_id, data.title); } + if (data.context_usage) { + contextUsage = data.context_usage; + } // Follow-up state changed server-side: reload to see new transcript/generation state. if (data.pending_inputs_processed || data.async_subagent_pending) { @@ -1024,12 +1028,12 @@ toast.message($t('chat.compactNoChat')); return; } - if (!selectedModel || sending || streaming) return; + if (sending || streaming) return; sending = true; inputText = ''; const toastId = toast.loading($t('chat.compacting')); try { - const result = await apiCompactChat(chatId, selectedModel); + const result = await apiCompactChat(chatId, selectedModel || null); contextUsage = result.context_usage ?? contextUsage; if (result.compacted) { toast.success($t('chat.compactDone'), { id: toastId }); diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index aec71e8..9d98749 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -457,6 +457,7 @@ async def _get_chat_context_usage(chat, model_id: str | None = None) -> dict | N estimate_context_usage, estimate_messages_tokens, load_compact_token_threshold, + usage_context_tokens, ) messages, existing_summary = await _load_message_history(chat.id, message_id) @@ -470,9 +471,8 @@ async def _get_chat_context_usage(chat, model_id: str | None = None) -> dict | N usage_checkpoint = await _get_latest_usage_checkpoint(chat.id, message_id) if usage_checkpoint: trailing_messages, usage = usage_checkpoint - tokens = usage.get("input_tokens") - if isinstance(tokens, int) and tokens > 0: - tokens += usage.get("output_tokens", 0) + tokens = usage_context_tokens(usage) + if tokens > 0: if trailing_messages: tokens += estimate_messages_tokens( [{"role": m.role, "content": m.content or ""} for m in trailing_messages] @@ -495,6 +495,8 @@ async def _infer_chat_model(chat_id: str) -> str: async def _get_latest_usage_checkpoint( chat_id: str, message_id: str ) -> tuple[list[ChatMessage], dict] | None: + from cptr.utils.context import normalize_usage, usage_context_tokens + all_msgs = await ChatMessage.get_all_by_chat(chat_id) msg_map = {m.id: m for m in all_msgs} chain = [] @@ -508,8 +510,8 @@ async def _get_latest_usage_checkpoint( message = chain[index] if message.chat_summary: return None - usage = message.usage or {} - if message.role == "assistant" and usage.get("input_tokens"): + usage = normalize_usage(message.usage) + if message.role == "assistant" and usage_context_tokens(usage): return chain[index + 1 :], usage return None @@ -700,7 +702,7 @@ class SendMessageRequest(BaseModel): class CompactRequest(BaseModel): - model_id: str + model_id: Optional[str] = None @router.post("") @@ -864,13 +866,16 @@ async def compact_chat(chat_id: str, body: CompactRequest, request: Request): raise HTTPException(404, "chat not found") if await _chat_has_active_generation(chat_id): raise HTTPException(409, "wait for the current response to finish before compacting") + model_id = body.model_id or await _infer_chat_model(chat_id) + if not model_id: + raise HTTPException(400, "choose a model before compacting") message_id = await _get_context_leaf_message_id(chat) if not message_id: return {"ok": True, "compacted": False, "reason": "empty", "context_usage": None} current_msg = await ChatMessage.get_by_id(message_id) if not current_msg or not current_msg.parent_id: - usage = await _get_chat_context_usage(chat, body.model_id) + usage = await _get_chat_context_usage(chat, model_id) return {"ok": True, "compacted": False, "reason": "too_short", "context_usage": usage} from cptr.utils.model_targets import ( @@ -879,7 +884,7 @@ async def compact_chat(chat_id: str, body: CompactRequest, request: Request): resolve_model_target, ) - target = await resolve_model_target(body.model_id, request.app.state) + target = await resolve_model_target(model_id, request.app.state) if not isinstance(target, ApiModelTarget): target = await first_api_model_target(request.app.state) @@ -888,7 +893,7 @@ async def compact_chat(chat_id: str, body: CompactRequest, request: Request): messages, existing_summary = await _load_message_history(chat_id, current_msg.parent_id) if not messages: - usage = await _get_chat_context_usage(chat, body.model_id) + usage = await _get_chat_context_usage(chat, model_id) return {"ok": True, "compacted": False, "reason": "too_short", "context_usage": usage} connection = target.connection @@ -914,7 +919,7 @@ async def compact_chat(chat_id: str, body: CompactRequest, request: Request): from cptr.utils.chat_export import export_chat_to_file await export_chat_to_file(chat_id) - usage = await _get_chat_context_usage(chat, body.model_id) + usage = await _get_chat_context_usage(chat, model_id) return { "ok": True, "compacted": True, diff --git a/cptr/routers/workspace.py b/cptr/routers/workspace.py index 0438de4..37da275 100644 --- a/cptr/routers/workspace.py +++ b/cptr/routers/workspace.py @@ -18,6 +18,8 @@ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel +from cptr.utils.gitignore import is_gitignored, load_gitignore + router = APIRouter(prefix="/api/workspace/files", tags=["workspace-files"]) @@ -355,22 +357,35 @@ def _is_search_ignored(name: str) -> bool: return name in SEARCH_IGNORE_DIRS or name.endswith(".egg-info") -def _walk_match_entries(root: Path, show_hidden: bool): +def _walk_match_entries( + root: Path, + show_hidden: bool, + gitignore: tuple[Path, tuple] | None = None, +): """Yield visible files and directories without following symlinks.""" + if gitignore is None: + gitignore = load_gitignore(root) + ignore_base, ignore_patterns = gitignore + try: entries = sorted(root.iterdir(), key=lambda item: item.name.lower()) except (OSError, PermissionError): return for item in entries: - if _is_search_ignored(item.name) or (not show_hidden and item.name.startswith(".")): - continue try: + item_is_dir = item.is_dir() + if ( + _is_search_ignored(item.name) + or (not show_hidden and item.name.startswith(".")) + or is_gitignored(item, ignore_base, ignore_patterns, is_dir=item_is_dir) + ): + continue if item.is_symlink(): yield item, "file" - elif item.is_dir(): + elif item_is_dir: yield item, "directory" - yield from _walk_match_entries(item, show_hidden) + yield from _walk_match_entries(item, show_hidden, gitignore) elif item.is_file(): yield item, "file" except (OSError, PermissionError): @@ -408,7 +423,6 @@ def _content_matches_with_rg( "--column", "--max-count", str(MAX_CONTENT_MATCHES_PER_FILE + 1), - "--no-ignore", ] if show_hidden: args.append("--hidden") @@ -489,7 +503,9 @@ def find_file_matches( files = {path.resolve() for path, kind in entries if kind == "file" and not path.is_symlink()} content_result = _content_matches_with_rg(root, query, query_lower, show_hidden, files) content_matches, _ = ( - content_result if content_result is not None else _content_matches_with_python(files, query_lower) + content_result + if content_result is not None + else _content_matches_with_python(files, query_lower) ) matches: list[tuple[int, int, FileMatch]] = [] @@ -593,13 +609,19 @@ def walk_and_rank_files(root: Path, query: str, limit: int = 20) -> list[SearchR query_lower = query.strip().lower().replace("\\", "/") matches: list[tuple[int, int, SearchResult]] = [] max_collect = limit * 10 + ignore_base, ignore_patterns = load_gitignore(root) def walk(directory: Path, depth: int = 0): if depth > 8 or len(matches) >= max_collect: return try: for item in sorted(directory.iterdir(), key=lambda p: p.name.lower()): - if item.name in SEARCH_IGNORE_DIRS or item.name.startswith("."): + item_is_dir = item.is_dir() + if ( + item.name in SEARCH_IGNORE_DIRS + or item.name.startswith(".") + or is_gitignored(item, ignore_base, ignore_patterns, is_dir=item_is_dir) + ): continue if len(matches) >= max_collect: return @@ -619,7 +641,7 @@ def walk(directory: Path, depth: int = 0): SearchResult( path=str(item), name=item.name, - type="directory" if item.is_dir() else "file", + type="directory" if item_is_dir else "file", ), ) ) @@ -631,12 +653,12 @@ def walk(directory: Path, depth: int = 0): SearchResult( path=str(item), name=item.name, - type="directory" if item.is_dir() else "file", + type="directory" if item_is_dir else "file", ), ) ) - if item.is_dir(): + if item_is_dir: walk(item, depth + 1) except (PermissionError, OSError): pass @@ -646,7 +668,6 @@ def walk(directory: Path, depth: int = 0): return [m[2] for m in matches[:limit]] - # ── File management ────────────────────────────────────────────── diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 9eebd1d..f323cb5 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -14,7 +14,13 @@ from cptr.events import EVENTS, publish_event from cptr.env import CHAT_MAX_ITERATIONS, CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS -from cptr.utils.context import resolve_compact_token_threshold, should_compact +from cptr.utils.context import ( + build_context_usage, + normalize_usage, + resolve_compact_token_threshold, + should_compact, + usage_context_tokens, +) from cptr.utils.skills import ( bump_skill_view, discover_skills, @@ -754,7 +760,7 @@ async def generate_chat_title( # Do not overwrite a title manually set while this request was running. chat = await Chat.get_by_id(chat_id) - fallback = user_content[:50].strip() or "New Chat" + fallback = user_message[:50].strip() or "New Chat" if not chat or chat.title != fallback: return @@ -2192,13 +2198,16 @@ async def _finish_reasoning_item(): await emit(output=item) elif event["type"] == "usage": - usage = {k: v for k, v in event.items() if k != "type"} - if "total_tokens" not in usage: - usage["total_tokens"] = usage.get("input_tokens", 0) + usage.get( - "output_tokens", 0 - ) + usage = normalize_usage({k: v for k, v in event.items() if k != "type"}) last_usage = usage new_messages_since = 0 + tokens = usage_context_tokens(usage) + if tokens > 0: + await emit( + context_usage=build_context_usage( + tokens, threshold=compact_token_threshold, source="estimated" + ) + ) elif event["type"] == "done": # Stream ended. Usage may have arrived earlier, multiple times, or never. diff --git a/cptr/utils/context.py b/cptr/utils/context.py index 1a4e12c..ad04667 100644 --- a/cptr/utils/context.py +++ b/cptr/utils/context.py @@ -46,6 +46,7 @@ def should_compact( """ resolved_threshold = threshold or _get_threshold() + last_usage = normalize_usage(last_usage) if last_usage and last_usage.get("input_tokens"): # Real base from last API call + estimate only new additions base = last_usage["input_tokens"] + last_usage.get("output_tokens", 0) @@ -72,6 +73,36 @@ def build_context_usage(tokens: int, *, threshold: int | None = None, source: st } +def normalize_usage(usage: dict | None) -> dict | None: + """Return provider usage with cptr's canonical token field names.""" + if not usage: + return None + normalized = dict(usage) + input_tokens = _parse_nonnegative_int( + normalized.get("input_tokens", normalized.get("prompt_tokens")) + ) + output_tokens = _parse_nonnegative_int( + normalized.get("output_tokens", normalized.get("completion_tokens")) + ) + total_tokens = _parse_nonnegative_int(normalized.get("total_tokens")) + if total_tokens == 0: + total_tokens = input_tokens + output_tokens + normalized["input_tokens"] = input_tokens + normalized["output_tokens"] = output_tokens + normalized["total_tokens"] = total_tokens + return normalized + + +def usage_context_tokens(usage: dict | None) -> int: + """Best available context token count from normalized or provider usage.""" + usage = normalize_usage(usage) + if not usage: + return 0 + if usage.get("input_tokens"): + return usage["input_tokens"] + usage.get("output_tokens", 0) + return usage.get("total_tokens", 0) + + def estimate_context_usage( messages: list[dict], system_prompt: str, *, threshold: int | None = None ) -> dict: @@ -89,6 +120,14 @@ def _parse_positive_int(value: object) -> int | None: return parsed if parsed > 0 else None +def _parse_nonnegative_int(value: object) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return 0 + return max(0, parsed) + + def resolve_compact_token_threshold( model: str | None = None, *, diff --git a/cptr/utils/gitignore.py b/cptr/utils/gitignore.py new file mode 100644 index 0000000..033366d --- /dev/null +++ b/cptr/utils/gitignore.py @@ -0,0 +1,83 @@ +"""Small .gitignore matcher for fallback file walkers.""" + +from __future__ import annotations + +from fnmatch import fnmatch +from pathlib import Path + +GitignorePattern = tuple[str, bool, bool, bool] + + +def load_gitignore(root: Path) -> tuple[Path, tuple[GitignorePattern, ...]]: + """Load the nearest repo/root .gitignore patterns for a search root.""" + base = _ignore_base(root.resolve()) + gitignore = base / ".gitignore" + if not gitignore.is_file(): + return base, () + + patterns: list[GitignorePattern] = [] + for raw in gitignore.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("\\#") or line.startswith("\\!"): + line = line[1:] + negated = line.startswith("!") + if negated: + line = line[1:].strip() + directory_only = line.endswith("/") + pattern = line.strip("/") + if pattern: + patterns.append((pattern, negated, directory_only, "/" in pattern)) + return base, tuple(patterns) + + +def is_gitignored( + path: Path, + base: Path, + patterns: tuple[GitignorePattern, ...], + *, + is_dir: bool, +) -> bool: + if not patterns: + return False + try: + rel = path.resolve().relative_to(base).as_posix() + except ValueError: + return False + + ignored = False + name = path.name + for pattern, negated, directory_only, anchored in patterns: + if directory_only and not is_dir: + continue + if _matches_gitignore_pattern(pattern, rel, name, directory_only, anchored): + ignored = not negated + return ignored + + +def _ignore_base(root: Path) -> Path: + current = root if root.is_dir() else root.parent + fallback = current + for parent in (current, *current.parents): + if (parent / ".git").exists(): + return parent + if (parent / ".gitignore").is_file(): + fallback = parent + return fallback + + +def _matches_gitignore_pattern( + pattern: str, + rel: str, + name: str, + directory_only: bool, + anchored: bool, +) -> bool: + if anchored: + return ( + rel == pattern + or (directory_only and rel.startswith(f"{pattern}/")) + or fnmatch(rel, pattern) + ) + return any(fnmatch(part, pattern) for part in rel.split("/")) or fnmatch(name, pattern) diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index 0e8ec0a..52f39a0 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -20,7 +20,9 @@ import uuid from pathlib import Path from typing import Any, Literal, Optional, get_args, get_origin, get_type_hints + from cptr.env import CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS, EXECUTE_TIMEOUT +from cptr.utils.gitignore import is_gitignored, load_gitignore try: import fcntl @@ -716,13 +718,22 @@ def _read_text_for_search(fpath: Path) -> str | None: def _walk_and_search(): results = [] ignore = {".git", "node_modules", "__pycache__", ".venv", "venv"} + ignore_base, ignore_patterns = load_gitignore(full) q = query.lower() if case_insensitive else query for root, dirs, files in os.walk(full): - dirs[:] = [d for d in dirs if d not in ignore] + root_path = Path(root) + dirs[:] = [ + d + for d in dirs + if d not in ignore + and not is_gitignored(root_path / d, ignore_base, ignore_patterns, is_dir=True) + ] for fname in files: fpath = Path(root) / fname - if _is_dotenv(fpath): + if _is_dotenv(fpath) or is_gitignored( + fpath, ignore_base, ignore_patterns, is_dir=False + ): continue text = _read_text_for_search(fpath) if text is None: @@ -767,7 +778,7 @@ async def create_file( artifact_path = artifact_dir / f"{ts}_{artifact_type}.md" def _write_artifact(): - artifact_path.write_text(content) + artifact_path.write_text(content, encoding="utf-8") await asyncio.to_thread(_write_artifact) @@ -793,7 +804,7 @@ def _write_artifact(): def _write(): full.parent.mkdir(parents=True, exist_ok=True) - full.write_text(content) + full.write_text(content, encoding="utf-8") await asyncio.to_thread(_write) return f"Created {path} ({len(content)} bytes, {len(content.splitlines())} lines)" @@ -820,7 +831,7 @@ async def create_artifact( artifact_path = artifact_dir / f"{ts}_{artifact_type}.md" def _write(): - artifact_path.write_text(content) + artifact_path.write_text(content, encoding="utf-8") await asyncio.to_thread(_write) @@ -972,7 +983,7 @@ async def write_file(path: str, content: str, *, workspace: str) -> str: def _write(): full.parent.mkdir(parents=True, exist_ok=True) - full.write_text(content) + full.write_text(content, encoding="utf-8") await asyncio.to_thread(_write) return f"Wrote {len(content)} bytes to {path}" @@ -1043,7 +1054,7 @@ async def edit_file( return f"Error: file not found: {path}" def _edit(): - content = full.read_text(errors="replace") + content = full.read_text(encoding="utf-8", errors="replace") if start_line > 0 or end_line > 0: lines = content.splitlines(keepends=True) @@ -1075,7 +1086,7 @@ def _edit(): ) new_content = content.replace(target, replacement, 1) - full.write_text(new_content) + full.write_text(new_content, encoding="utf-8") return None # success sentinel result = await asyncio.to_thread(_edit) @@ -1115,7 +1126,7 @@ async def multi_edit_file( return "Error: edits must be a non-empty JSON array" def _apply(): - content = full.read_text(errors="replace") + content = full.read_text(encoding="utf-8", errors="replace") applied = 0 for i, edit in enumerate(edit_list): @@ -1138,7 +1149,7 @@ def _apply(): content = content.replace(target, replacement, 1) applied += 1 - full.write_text(content) + full.write_text(content, encoding="utf-8") return f"Applied {applied} edits to {path}" return await asyncio.to_thread(_apply) From 0db561b6c7057b022ce27d3ac278e66dfc555504 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 15 Jul 2026 15:31:27 -0400 Subject: [PATCH 03/12] refac --- cptr/env.py | 4 ++ cptr/frontend/package.json | 2 +- cptr/frontend/src/app.css | 4 +- .../src/lib/components/Admin/Workspace.svelte | 69 +++++++++++++++++++ .../src/lib/components/SettingsModal.svelte | 8 ++- cptr/frontend/src/lib/i18n/locales/en.json | 3 + cptr/routers/audio.py | 4 +- cptr/routers/git.py | 25 +++++-- cptr/utils/workspace.py | 33 +++++++++ 9 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 cptr/frontend/src/lib/components/Admin/Workspace.svelte diff --git a/cptr/env.py b/cptr/env.py index 5d856a5..14ad9d4 100644 --- a/cptr/env.py +++ b/cptr/env.py @@ -66,6 +66,10 @@ def _env_int(name: str, default: int) -> int: # Claude SDK stdout JSON buffer; chat/tool output caps apply later after parsing. CLAUDE_CODE_MAX_BUFFER_SIZE = _env_int("CPTR_CLAUDE_CODE_MAX_BUFFER_SIZE", 128 * 1024 * 1024) +# ── Workspace storage ─────────────────────────────────────── +WORKSPACE_AUTO_GITIGNORE_DOT_CPTR_ENV = os.environ.get("CPTR_AUTO_GITIGNORE_DOT_CPTR") +WORKSPACE_AUTO_GITIGNORE_DOT_CPTR = _env_bool("CPTR_AUTO_GITIGNORE_DOT_CPTR", "true") + # ── Execute timeout ───────────────────────────────────────── # Default wait (seconds) for run_command / check_task when the caller # doesn't pass an explicit wait value. None = return immediately. diff --git a/cptr/frontend/package.json b/cptr/frontend/package.json index 8c07701..60d9fa0 100644 --- a/cptr/frontend/package.json +++ b/cptr/frontend/package.json @@ -93,4 +93,4 @@ "tippy.js": "^6.3.7", "xlsx": "^0.18.5" } -} \ No newline at end of file +} diff --git a/cptr/frontend/src/app.css b/cptr/frontend/src/app.css index c29a90c..e03784f 100644 --- a/cptr/frontend/src/app.css +++ b/cptr/frontend/src/app.css @@ -29,8 +29,8 @@ --app-fg: #525252; --app-fg-muted: color-mix(in oklab, var(--app-fg) 62%, var(--app-bg)); --app-fg-subtle: color-mix(in oklab, var(--app-fg) 48%, var(--app-bg)); - --app-border: color-mix(in oklab, var(--app-fg) 7%, transparent); - --app-divider: color-mix(in oklab, var(--app-fg) 5%, transparent); + --app-border: color-mix(in oklab, var(--app-fg) 5%, transparent); + --app-divider: color-mix(in oklab, var(--app-fg) 3.5%, transparent); --app-hover: color-mix(in oklab, var(--app-fg) 6%, transparent); --app-active: color-mix(in oklab, var(--app-fg) 7%, transparent); --app-ui-font: var(--font-sans); diff --git a/cptr/frontend/src/lib/components/Admin/Workspace.svelte b/cptr/frontend/src/lib/components/Admin/Workspace.svelte new file mode 100644 index 0000000..a2859ee --- /dev/null +++ b/cptr/frontend/src/lib/components/Admin/Workspace.svelte @@ -0,0 +1,69 @@ + + +
+

{$t('admin.workspace')}

+ + {#if loading} +
+ {:else} +
+ +

+ {$t('admin.workspaceAutoGitignoreDotCptrHint')} +

+
+ +
+ +
+ {/if} +
diff --git a/cptr/frontend/src/lib/components/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte index 7d3c7d0..44154bb 100644 --- a/cptr/frontend/src/lib/components/SettingsModal.svelte +++ b/cptr/frontend/src/lib/components/SettingsModal.svelte @@ -21,6 +21,7 @@ import AdminWeb from './Admin/Web.svelte'; import ToolServers from './Admin/ToolServers.svelte'; import Subagents from './Admin/Subagents.svelte'; + import Workspace from './Admin/Workspace.svelte'; import { session } from '$lib/session'; import { t } from '$lib/i18n'; @@ -43,7 +44,8 @@ | 'images' | 'web' | 'toolservers' - | 'subagents'; + | 'subagents' + | 'workspace'; interface Props { onclose: () => void; @@ -71,6 +73,7 @@ 'web', 'toolservers', 'subagents', + 'workspace', 'memory', 'skills' ]; @@ -99,6 +102,7 @@ { id: 'web', label: $t('admin.web'), icon: 'globe' }, { id: 'toolservers', label: $t('admin.toolServers'), icon: 'plug' }, { id: 'subagents', label: $t('admin.subagents'), icon: 'user' }, + { id: 'workspace', label: $t('admin.workspace'), icon: 'folder' }, { id: 'memory', label: $t('settings.memory'), icon: 'brain' }, { id: 'skills', label: 'Skills', icon: 'spark' } ]); @@ -218,6 +222,8 @@ {:else if activeTab === 'subagents'} + {:else if activeTab === 'workspace'} + {/if} diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index ce65fc1..ad952ae 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -274,6 +274,9 @@ "admin.minCharsPassword": "Min 6 characters", "admin.connections": "Connections", "admin.agents": "Agents", + "admin.workspace": "Workspace", + "admin.workspaceAutoGitignoreDotCptr": "Add .cptr to .gitignore", + "admin.workspaceAutoGitignoreDotCptrHint": "Keeps local chats, memory, caches, and generated assets out of git by default. Turn this off if you want to track .cptr and manage exclusions inside .cptr/.gitignore.", "admin.agentsRefresh": "Refresh", "admin.agentsAddProfile": "Add profile", "admin.agentsDeleteProfile": "Delete", diff --git a/cptr/routers/audio.py b/cptr/routers/audio.py index e0ba52a..d63dc66 100644 --- a/cptr/routers/audio.py +++ b/cptr/routers/audio.py @@ -72,8 +72,8 @@ def _get_user(request: Request) -> str: def _workspace_audio_cache_dir(workspace: str | None, kind: str) -> Path | None: # Voice samples are project context, so they live under the workspace .cptr # folder and move with the project. That keeps STT and TTS artifacts available - # for reuse, debugging, and the local data flywheel while ensure_cptr_gitignored - # keeps them out of git. + # for reuse, debugging, and the local data flywheel. By default, + # ensure_cptr_gitignored keeps them out of git. if not workspace: return None ws = Path(workspace).expanduser().resolve() diff --git a/cptr/routers/git.py b/cptr/routers/git.py index 90319f0..23d2b71 100644 --- a/cptr/routers/git.py +++ b/cptr/routers/git.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel +from cptr.utils.json_parser import extract_json from cptr.utils.git import ( GitError, branches, @@ -153,6 +154,23 @@ class CommitRequest(BaseModel): The description should be one short paragraph explaining the meaningful change. Do not invent details.""" +def _parse_commit_message_response(text: str) -> tuple[str, str]: + message = extract_json(text) + summary = str(message.get("summary", "")).strip() if isinstance(message, dict) else "" + description = str(message.get("description", "")).strip() if isinstance(message, dict) else "" + if summary: + return summary.splitlines()[0][:72], description + + lines = [ + line.strip() + for line in text.strip().splitlines() + if line.strip() and not line.strip().startswith("```") + ] + if not lines: + return "", "" + return lines[0][:72], "\n".join(lines[1:]).strip() + + class CheckoutRequest(BaseModel): root: str branch: str @@ -260,7 +278,6 @@ async def generate_commit_message(body: CommitMessageRequest, request: Request): from cptr.utils.chat_task import _default_base_url from cptr.utils.config import _get_jwt_secret from cptr.utils.crypto import decrypt_key - from cptr.utils.json_parser import extract_json from cptr.utils.model_targets import ApiModelTarget, resolve_model_target from cptr.models import Config @@ -285,12 +302,10 @@ async def generate_commit_message(body: CommitMessageRequest, request: Request): ) except Exception as e: raise HTTPException(status_code=502, detail="Could not generate a commit message") from e - message = extract_json(text) - summary = str(message.get("summary", "")).strip().splitlines()[0] if isinstance(message, dict) else "" - description = str(message.get("description", "")).strip() if isinstance(message, dict) else "" + summary, description = _parse_commit_message_response(text) if not summary: raise HTTPException(status_code=502, detail="Could not generate a commit message") - return {"summary": summary[:72], "description": description} + return {"summary": summary, "description": description} @router.post("/checkout") diff --git a/cptr/utils/workspace.py b/cptr/utils/workspace.py index c71ae34..1d22624 100644 --- a/cptr/utils/workspace.py +++ b/cptr/utils/workspace.py @@ -4,9 +4,30 @@ from pathlib import Path +from cptr import env +from cptr.utils.config import load_config + + +def auto_gitignore_cptr_enabled() -> bool: + if env.WORKSPACE_AUTO_GITIGNORE_DOT_CPTR_ENV is not None: + return env.WORKSPACE_AUTO_GITIGNORE_DOT_CPTR + + config = load_config() + value = None + workspace = config.get("workspace", {}) + if isinstance(workspace, dict): + value = workspace.get("auto_gitignore_dot_cptr") + app_config = config.get("app_config", {}) + if isinstance(app_config, dict): + value = app_config.get("workspace.auto_gitignore_dot_cptr", value) + return _bool_config(value, default=True) + def ensure_cptr_gitignored(workspace: str | Path) -> None: """If workspace is a git repo, ensure .cptr is listed in .gitignore.""" + if not auto_gitignore_cptr_enabled(): + return + ws = Path(workspace) if not (ws / ".git").exists(): return @@ -26,3 +47,15 @@ def ensure_cptr_gitignored(workspace: str | Path) -> None: gitignore.write_text(content, encoding="utf-8") else: gitignore.write_text(f"{entry}\n", encoding="utf-8") + + +def _bool_config(value: object, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes", "on"}: + return True + if normalized in {"false", "0", "no", "off"}: + return False + return default From 355703f8a8de4e0648468e509d99d35c8a3e3cc6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 15 Jul 2026 15:34:51 -0400 Subject: [PATCH 04/12] refac --- cptr/utils/browser/cdp.py | 24 +++++++++++++++++++----- cptr/utils/tools.py | 15 ++++++++++++--- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/cptr/utils/browser/cdp.py b/cptr/utils/browser/cdp.py index ce2f16e..aed7c26 100644 --- a/cptr/utils/browser/cdp.py +++ b/cptr/utils/browser/cdp.py @@ -325,12 +325,26 @@ async def scroll(self, direction: str = "down", amount: int = 3) -> None: # ── Observation ──────────────────────────────────────── - async def screenshot(self) -> bytes: + async def screenshot(self, width: int | None = None, height: int | None = None) -> bytes: """Capture a screenshot of the current viewport. Returns PNG bytes.""" - result = await self._send( - "Page.captureScreenshot", {"format": "png", "quality": 80} - ) - return base64.b64decode(result["data"]) + if width is not None and height is not None: + await self._send( + "Emulation.setDeviceMetricsOverride", + { + "width": width, + "height": height, + "deviceScaleFactor": 1, + "mobile": False, + }, + ) + try: + result = await self._send( + "Page.captureScreenshot", {"format": "png", "quality": 80} + ) + return base64.b64decode(result["data"]) + finally: + if width is not None and height is not None: + await self._send("Emulation.clearDeviceMetricsOverride") async def get_text(self) -> str: """Extract visible text content from the page.""" diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index 52f39a0..112b0aa 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -1816,15 +1816,24 @@ async def browser_type(ref: str, text: str, *, __context__: dict) -> str: return await client.snapshot() -async def browser_screenshot(*, __context__: dict) -> str: - """Take a screenshot of the current browser page. Saves the image to the workspace.""" +async def browser_screenshot( + width: Optional[int] = None, height: Optional[int] = None, *, __context__: dict +) -> str: + """Take a screenshot of the current browser page. Saves the image to the workspace. + :param width: Optional screenshot viewport width in CSS pixels. + :param height: Optional screenshot viewport height in CSS pixels. + """ cfg = await _get_browser_config() if cfg.get("provider", "local") != "local": return "Error: browser_screenshot requires Local CDP provider." + if (width is None) != (height is None): + return "Error: browser_screenshot width and height must be provided together." + if width is not None and height is not None and (width <= 0 or height <= 0): + return "Error: browser_screenshot width and height must be positive integers." chat_id = __context__.get("chat_id", "default") client = await _get_cdp_session(chat_id) - png_bytes = await client.screenshot() + png_bytes = await client.screenshot(width=width, height=height) # Save to workspace workspace = __context__.get("workspace", ".") From 7158fb0d7656f2cab8e7819a2273ec6109f9a996 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 15 Jul 2026 15:36:23 -0400 Subject: [PATCH 05/12] refac --- .../src/lib/components/chat/UserMessage.svelte | 10 +++++----- .../src/lib/components/markdown/BlockRenderer.svelte | 2 +- .../src/lib/components/markdown/InlineRenderer.svelte | 2 ++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cptr/frontend/src/lib/components/chat/UserMessage.svelte b/cptr/frontend/src/lib/components/chat/UserMessage.svelte index ca01034..ce58ac7 100644 --- a/cptr/frontend/src/lib/components/chat/UserMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/UserMessage.svelte @@ -1,6 +1,7 @@