diff --git a/codetide/__init__.py b/codetide/__init__.py index 913c0d7..8674046 100644 --- a/codetide/__init__.py +++ b/codetide/__init__.py @@ -577,4 +577,19 @@ def get( as_string=as_string, as_list_str=as_string_list, preloaded_files=requested_files - ) \ No newline at end of file + ) + + def _as_file_paths(self, code_identifiers: Union[str, List[str]])->List[str]: + if isinstance(code_identifiers, str): + code_identifiers = [code_identifiers] + + as_file_paths = [] + for code_identifier in code_identifiers: + if self.rootpath / code_identifier in self.files: + as_file_paths.append(code_identifier) + elif element := self.codebase.cached_elements.get(code_identifier): + as_file_paths.append(element.file_path) + else: ### covers new files + as_file_paths.append(element) + + return as_file_paths diff --git a/codetide/agents/tide/agent.py b/codetide/agents/tide/agent.py index 16bd3c9..ece0e67 100644 --- a/codetide/agents/tide/agent.py +++ b/codetide/agents/tide/agent.py @@ -1,6 +1,6 @@ from functools import partial from codetide import CodeTide -from ...mcp.tools.patch_code import file_exists, open_file, process_patch, remove_file, write_file +from ...mcp.tools.patch_code import file_exists, open_file, process_patch, remove_file, write_file, parse_patch_blocks from ...core.defaults import DEFAULT_ENCODING, DEFAULT_STORAGE_PATH from ...autocomplete import AutoComplete from .models import Steps @@ -8,7 +8,7 @@ AGENT_TIDE_SYSTEM_PROMPT, GET_CODE_IDENTIFIERS_SYSTEM_PROMPT, REJECT_PATCH_FEEDBACK_TEMPLATE, STAGED_DIFFS_TEMPLATE, STEPS_SYSTEM_PROMPT, WRITE_PATCH_SYSTEM_PROMPT ) -from .utils import delete_file, parse_commit_blocks, parse_patch_blocks, parse_steps_markdown, trim_to_patch_section +from .utils import delete_file, parse_blocks, parse_steps_markdown, trim_to_patch_section from .consts import AGENT_TIDE_ASCII_ART try: @@ -49,6 +49,10 @@ class AgentTide(BaseModel): changed_paths :List[str]=Field(default_factory=list) request_human_confirmation :bool=False + contextIdentifiers :Optional[List[str]]=None + modifyIdentifiers :Optional[List[str]]=None + reasoning :Optional[str]=None + _skip_context_retrieval :bool=False _last_code_identifers :Optional[Set[str]]=set() _last_code_context :Optional[str] = None @@ -104,14 +108,29 @@ async def agent_loop(self, codeIdentifiers :Optional[List[str]]=None): ) if codeIdentifiers is None and not self._skip_context_retrieval: - codeIdentifiers = await self.llm.acomplete( + context_response = await self.llm.acomplete( self.history, system_prompt=[GET_CODE_IDENTIFIERS_SYSTEM_PROMPT.format(DATE=TODAY)], prefix_prompt=repo_tree, - stream=False, - json_output=True + stream=False + # json_output=True ) + contextIdentifiers = parse_blocks(context_response, block_word="Context Identifiers", multiple=False) + modifyIdentifiers = parse_blocks(context_response, block_word="Modify Identifiers", multiple=False) + + reasoning = context_response.split("*** Begin") + if not reasoning: + reasoning = [context_response] + self.reasoning = reasoning[0].strip() + + self.contextIdentifiers = contextIdentifiers.splitlines() if isinstance(contextIdentifiers, str) else None + self.modifyIdentifiers = modifyIdentifiers.splitlines() if isinstance(modifyIdentifiers, str) else None + codeIdentifiers = self.contextIdentifiers or [] + + if self.modifyIdentifiers: + codeIdentifiers.extend(self.tide._as_file_paths(self.modifyIdentifiers)) + codeContext = None if codeIdentifiers: autocomplete = AutoComplete(self.tide.cached_ids) @@ -144,7 +163,7 @@ async def agent_loop(self, codeIdentifiers :Optional[List[str]]=None): if not self.request_human_confirmation: self.approve() - commitMessage = parse_commit_blocks(response, multiple=False) + commitMessage = parse_blocks(response, multiple=False, block_word="Commit") if commitMessage: self.commit(commitMessage) diff --git a/codetide/agents/tide/models.py b/codetide/agents/tide/models.py index 62be8d2..84c03a0 100644 --- a/codetide/agents/tide/models.py +++ b/codetide/agents/tide/models.py @@ -1,6 +1,5 @@ - +from typing import Callable, Dict, List, Optional from pydantic import BaseModel, RootModel -from typing import Dict, List, Optional STEP_INSTRUCTION_TEMPLATE = """ ## Step {step}: @@ -23,6 +22,7 @@ class Step(BaseModel): description :str instructions :str context_identifiers :Optional[List[str]]=None + modify_identifiers: Optional[List[str]]=None def as_instruction(self)->str: return STEP_INSTRUCTION_TEMPLATE.format( @@ -31,6 +31,14 @@ def as_instruction(self)->str: instructions=self.instructions ) + def get_code_identifiers(self, validate_identifiers_fn :Callable)->Optional[List[str]]: + code_identifiers = self.context_identifiers or [] + + if self.modify_identifiers: + code_identifiers.extend(validate_identifiers_fn(code_identifiers)) + + return None or code_identifiers + class Steps(RootModel): root :List[Step] diff --git a/codetide/agents/tide/prompts.py b/codetide/agents/tide/prompts.py index b44d7ac..37cb23f 100644 --- a/codetide/agents/tide/prompts.py +++ b/codetide/agents/tide/prompts.py @@ -61,34 +61,45 @@ **Instructions:** 1. Carefully read and interpret the user's request, identifying any references to files, modules, submodules, or code elements—either explicit or implied. -2. **Prioritize returning fully qualified code identifiers** (such as functions, classes, methods, variables, or attributes) that are directly related to the user's request or are elements of interest. The identifier format must use dot notation to represent the path-like structure, e.g., `module.submodule.Class.method` or `module.function`, without file extensions. -3. Only include full file paths (relative to the repository root) if: +2. **Segregate identifiers into two categories:** + - **Context Identifiers:** Code elements (functions, classes, methods, variables, attributes, or file paths) that are required to understand, reference, or provide context for the requested change, but are not themselves expected to be modified. + - **Modify Identifiers:** Code elements (functions, classes, methods, variables, attributes, or file paths) that are likely to require direct modification to fulfill the user's request. +3. **Prioritize returning fully qualified code identifiers** (using dot notation, e.g., `module.submodule.Class.method`), without file extensions. Only include file paths (relative to the repository root) if: - The user explicitly requests file-level operations (such as adding, deleting, or renaming files), or - No valid or relevant code identifiers can be determined for the request. 4. If the user refers to a file by name or path and the request is about code elements within that file, extract and include the relevant code identifiers from that file instead of the file path, unless the user specifically asks for the file path. -5. If fulfilling the request would likely depend on additional symbols or files—based on naming, structure, required context from other files/modules, or conventional design patterns—include those code identifiers as well. +5. If fulfilling the request would likely depend on additional symbols or files—based on naming, structure, required context from other files/modules, or conventional design patterns—include those code identifiers as context identifiers. 6. Only include identifiers or paths that are present in the provided tree structure. Never fabricate or guess paths or names that do not exist. -7. If no relevant code identifiers or file paths can be confidently identified, return an empty list. +7. If no relevant code identifiers or file paths can be confidently identified, leave the relevant section(s) empty - without any contents or lines, not even the word empty. --- -**Output Format (Strict JSON Only):** +**Output Format:** -Return a JSON array of strings. Each string must be: -- A fully qualified code identifier using dot notation (e.g., `module.submodule.Class.method`), without file extensions, or -- A valid file path relative to the repository root (only if explicitly required or no code identifiers are available). +Your response must include: -Your output must be a pure JSON list of strings. Do **not** include any explanation, comments, or formatting outside the JSON block. +1. A brief explanation (1-3 sentences) describing your reasoning and search process for selecting the identifiers. +2. The following delimited sections, each containing a newline-separated list of identifiers (or left empty if none): + +*** Begin Context Identifiers + +*** End Context Identifiers + +*** Begin Modify Identifiers + +*** End Modify Identifiers + +Do **not** include any additional commentary, formatting, or output outside these sections. --- **Evaluation Criteria:** -- You must identify all code identifiers directly referenced or implied in the user request, prioritizing them over file paths. +- You must identify all code identifiers directly referenced or implied in the user request, and correctly categorize them as context or modify identifiers. - You must include any internal code elements that are clearly involved or required for the task. - You must consider logical dependencies that may need to be modified together (e.g., helper modules, config files, related class methods). - You must consider files that can be relevant as context to complete the user request, but only include their paths if code identifiers are not available or explicitly requested. -- You must return a clean and complete list of all relevant code identifiers and, if necessary, file paths. +- You must return a clean and complete list of all relevant code identifiers and, if necessary, file paths, in the correct section. - Do not over-include; be minimal but thorough. Return only what is truly required. """ @@ -288,12 +299,16 @@ 1. **step_description** **instructions**: precise instructions of the task to be implemented in this step **context_identifiers**: - - fully qualified code identifiers or file paths (as taken from the repo_tree) that this step touches, depends on, or must update + - fully qualified code identifiers or file paths (as taken from the repo_tree) that this step depends on for context (read/reference only) + **modify_identifiers**: + - fully qualified code identifiers or file paths (as taken from the repo_tree) that this step will directly modify or update --- 2. **next_step_description** **instructions**: ... **context_identifiers**: - ... + **modify_identifiers**: + - ... --- ... *** End Steps @@ -308,7 +323,7 @@ 4. **Granularity:** Break complex requirements into logical sub-steps. Order them so dependencies are respected (e.g., setup → implementation → validation → integration). -5. **Traceability:** Each step’s `context_identifiers` must clearly tie that step to specific code areas; this enables downstream mapping to actual implementation targets. +5. **Traceability:** Each step's `context_identifiers` and `modify_identifiers` must clearly tie that step to specific code areas; this enables downstream mapping to actual implementation targets. 6. **Single-Responsibility per Step:** Aim for each numbered step to encapsulate a coherent unit of work. Avoid mixing unrelated concerns in one step. @@ -316,14 +331,9 @@ 8. **Testing & Validation:** Where appropriate, include in steps the need for testing, how to validate success, and any edge cases to cover. -9. **Failure Modes & Corrections:** If the user’s request implies potential pitfalls (e.g., backward compatibility, race conditions, security), surface those in early steps or in the comments and include remediation as part of the plan. +9. **Failure Modes & Corrections:** If the use's request implies potential pitfalls (e.g., backward compatibility, race conditions, security), surface those in early steps or in the comments and include remediation as part of the plan. 10. **Succinctness of Format:** Strictly adhere to the step formatting with separators (`---`) and the beginning/end markers. Do not add extraneous numbering or narrative outside the prescribed structure. - ---- - -`repo_tree` -{REPO_TREE} """ CMD_TRIGGER_PLANNING_STEPS = """ @@ -343,18 +353,17 @@ CMD_COMMIT_PROMPT = """ Generate a conventional commit message that summarizes the work done since the previous commit. -The message should have a clear subject line and a body explaining the problem solved and the implementation approach. - -Important Instructions: -Place the commit message inside exactly this format: -*** Begin Commit -[commit message] -*** End Commit - -You may include additional comments about the changes made outside of this block +**Instructions:** -If no diffs for staged files are provided in the context, reply that there's nothing to commit +1. First, write a body (before the commit block) that explains the problem solved and the implementation approach. This should be clear, concise, and provide context for the change. +2. Then, place the commit subject line (only) inside the commit block, using this format: + *** Begin Commit + [subject line only, up to 3 lines, straight to the point and descriptive of the broad changes] + *** End Commit +3. The subject line should follow the conventional commit format with a clear type/scope prefix, and summarize the broad changes made. Do not include the body or any explanation inside the commit block—only the subject line. +4. You may include additional comments about the changes made outside of this block, if needed. +5. If no diffs for staged files are provided in the context, reply that there's nothing to commit.context, reply that there's nothing to commit The commit message should follow conventional commit format with a clear type/scope prefix """ diff --git a/codetide/agents/tide/ui/app.py b/codetide/agents/tide/ui/app.py index 568ec56..dc16624 100644 --- a/codetide/agents/tide/ui/app.py +++ b/codetide/agents/tide/ui/app.py @@ -37,6 +37,7 @@ import asyncio import json import yaml +import time @cl.password_auth_callback def auth(): @@ -172,6 +173,7 @@ async def on_execute_steps(action :cl.Action): latest_step_message :cl.Message = cl.user_session.get("latest_step_message") if latest_step_message and latest_step_message.id == action.payload.get("msg_id"): await latest_step_message.remove_actions() + await latest_step_message.send() # close message ? if agent_tide_ui.current_step is None: task_list = cl.TaskList("Steps") @@ -213,7 +215,7 @@ async def on_execute_steps(action :cl.Action): author="Agent Tide" ).send() - await agent_loop(step_instructions_msg, codeIdentifiers=step.context_identifiers, agent_tide_ui=agent_tide_ui) + await agent_loop(step_instructions_msg, codeIdentifiers=step.get_code_identifiers(agent_tide_ui.agent_tide.tide._as_file_paths), agent_tide_ui=agent_tide_ui) task_list.status = f"Waiting feedback on step {current_task_idx}" await task_list.send() @@ -225,6 +227,7 @@ async def on_stop_steps(action :cl.Action): latest_step_message :cl.Message = cl.user_session.get("latest_step_message") if latest_step_message and latest_step_message.id == action.payload.get("msg_id"): await latest_step_message.remove_actions() + await latest_step_message.send() # close message ? task_list = cl.user_session.get("StepsTaskList") if task_list: @@ -262,9 +265,45 @@ async def on_inspect_context(action :cl.Action): await inspect_msg.send() +async def send_reasoning_msg(loading_msg :cl.message, context_msg :cl.Message, agent_tide_ui :AgentTideUi, st :float)->bool: + await loading_msg.remove() + + context_data = { + key: value for key in ["contextIdentifiers", "modifyIdentifiers"] + if (value := getattr(agent_tide_ui.agent_tide, key, None)) + } + context_msg.elements.append( + cl.CustomElement( + name="ReasoningMessage", + props={ + "reasoning": agent_tide_ui.agent_tide.reasoning, + "data": context_data, + "title": f"Thought for {time.time()-st:.2f} seconds", + "defaultExpanded": False, + "showControls": False + } + ) + ) + await context_msg.send() + return True @cl.on_message async def agent_loop(message: Optional[cl.Message]=None, codeIdentifiers: Optional[list] = None, agent_tide_ui :Optional[AgentTideUi]=None): + + loading_msg = await cl.Message( + content="", + elements=[ + cl.CustomElement( + name="LoadingMessage", + props={ + "messages": ["Working", "Syncing CodeTide", "Thinking", "Looking for context"], + "interval": 1500, # 1.5 seconds between messages + "showIcon": True + } + ) + ] + ).send() + if agent_tide_ui is None: agent_tide_ui = await loadAgentTideUi() @@ -278,7 +317,8 @@ async def agent_loop(message: Optional[cl.Message]=None, codeIdentifiers: Option chat_history.append({"role": "user", "content": message.content}) await agent_tide_ui.add_to_history(message.content) - + + context_msg = cl.Message(content="", author="AgentTide") msg = cl.Message(content="", author="Agent Tide") async with cl.Step("ApplyPatch", type="tool") as diff_step: await diff_step.remove() @@ -311,11 +351,17 @@ async def agent_loop(message: Optional[cl.Message]=None, codeIdentifiers: Option global_fallback_msg=msg ) + st = time.time() + is_reasonig_sent = False async for chunk in run_concurrent_tasks(agent_tide_ui, codeIdentifiers): if chunk == STREAM_START_TOKEN: + is_reasonig_sent = await send_reasoning_msg(loading_msg, context_msg, agent_tide_ui, st) continue - if chunk == STREAM_END_TOKEN: + elif not is_reasonig_sent: + is_reasonig_sent = await send_reasoning_msg(loading_msg, context_msg, agent_tide_ui, st) + + elif chunk == STREAM_END_TOKEN: # Handle any remaining content await stream_processor.finalize() break diff --git a/codetide/agents/tide/ui/public/elements/HtmlRenderer.jsx b/codetide/agents/tide/ui/public/elements/HtmlRenderer.jsx new file mode 100644 index 0000000..22e29de --- /dev/null +++ b/codetide/agents/tide/ui/public/elements/HtmlRenderer.jsx @@ -0,0 +1,71 @@ +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { X, RefreshCw } from "lucide-react" + +export default function HtmlRenderer() { + // Function to sanitize HTML (basic approach) + const sanitizeHtml = (html) => { + // Create a temporary div to parse the HTML + const temp = document.createElement('div'); + temp.innerHTML = html; + + // Remove potentially dangerous elements + const dangerousElements = temp.querySelectorAll('script, object, embed, iframe'); + dangerousElements.forEach(el => el.remove()); + + return temp.innerHTML; + }; + + const handleRefresh = () => { + // Re-render the component by updating props + updateElement(props); + }; + + const handleRemove = () => { + deleteElement(); + }; + + // Get the HTML content from props + const htmlContent = props.html || props.content || '

No HTML content provided

'; + const title = props.title || 'HTML Content'; + const showControls = props.showControls !== false; // Default to true + + return ( +
+ {showControls && ( +
+

{title}

+
+ + +
+
+ )} + + + +
+ + +
+ ); +} \ No newline at end of file diff --git a/codetide/agents/tide/ui/public/elements/LoadingMessage.jsx b/codetide/agents/tide/ui/public/elements/LoadingMessage.jsx new file mode 100644 index 0000000..aeba6f4 --- /dev/null +++ b/codetide/agents/tide/ui/public/elements/LoadingMessage.jsx @@ -0,0 +1,81 @@ +import React, { useState, useEffect } from 'react'; + +const LoadingMessage = ({ messages = ['Working...', 'Syncing CodeTide', 'Thinking...', 'Looking for context'], interval = 3000 }) => { + const [currentIndex, setCurrentIndex] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setCurrentIndex((prevIndex) => (prevIndex + 1) % messages.length); + }, interval); + + return () => clearInterval(timer); + }, [messages.length, interval]); + + /* TODO update styles and fonts to match current - increase size a bit*/ + const loadingWaveStyle = { + display: 'inline-flex', + alignItems: 'center', + gap: '8px', + padding: '8px 12px', + background: 'rgba(255, 255, 255, 0.05)', + border: '1px solid rgba(255, 255, 255, 0.1)', + borderRadius: '20px', + backdropFilter: 'blur(10px)', + color: '#e0e0e0', + fontSize: '12px', + fontWeight: '500', + letterSpacing: '0.3px', + minHeight: '20px', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif' + }; + + const waveContainerStyle = { + display: 'flex', + gap: '2px' + }; + + const waveDotStyle = { + width: '3px', + height: '3px', + background: '#00d4ff', + borderRadius: '50%', + animation: 'wave 1.2s ease-in-out infinite' + }; + + const loadingTextStyle = { + minWidth: '120px', + transition: 'opacity 0.3s ease-in-out' + }; + + return ( + <> + +
+
+
+
+
+
+
+
+ {messages[currentIndex]} +
+
+ + ); +}; + +export default LoadingMessage; \ No newline at end of file diff --git a/codetide/agents/tide/ui/public/elements/ReasoningMessage.jsx b/codetide/agents/tide/ui/public/elements/ReasoningMessage.jsx new file mode 100644 index 0000000..0dbcedb --- /dev/null +++ b/codetide/agents/tide/ui/public/elements/ReasoningMessage.jsx @@ -0,0 +1,100 @@ +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { ChevronDown, ChevronRight } from "lucide-react" +import { useState } from "react" + +export default function ReasoningMessage() { + const [isExpanded, setIsExpanded] = useState(props.defaultExpanded !== false); + + const toggleExpanded = () => { + setIsExpanded(!isExpanded); + }; + + // Get data from props + const reasoning = props.reasoning || "No reasoning provided"; + const data = props.data || {}; + const title = props.title || "Analysis Result"; + const summaryText = props.summaryText || "Click to view details"; + + // Convert data object to array and take first two entries + const dataEntries = Object.entries(data).slice(0, 2); + + // Generate HTML content + const generateHtml = () => { + let html = ` +
+
+
+ ${reasoning} +
`; + + // Add key-value entries if they exist + if (dataEntries.length > 0) { + html += `
`; + + dataEntries.forEach(([key, value], index) => { + html += ` +
+
${key}
+
+ ${typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)} +
+
`; + }); + + html += `
`; + } + + html += ` +
+
`; + + return html; + }; + + return ( +
+
+

{title}

+ + + + + + +

{summaryText}

+
+
+
+
+ + + + {isExpanded && ( +
+ )} + + + + +
+ ); +} \ No newline at end of file diff --git a/codetide/agents/tide/utils.py b/codetide/agents/tide/utils.py index 7fca8cc..5116532 100644 --- a/codetide/agents/tide/utils.py +++ b/codetide/agents/tide/utils.py @@ -21,7 +21,7 @@ async def trim_to_patch_section(filename): lines_to_keep.append(line) # Include the begin marker elif '*** End Patch' in line: lines_to_keep.append(line) # Include the end marker - break # Stop after end marker + capturing = False # Stop capturing but continue processing elif capturing: lines_to_keep.append(line) @@ -34,47 +34,35 @@ async def trim_to_patch_section(filename): except FileNotFoundError: pass -def parse_patch_blocks(text: str, multiple: bool = True) -> Union[str, List[str], None]: - """ - Extract content between *** Begin Patch and *** End Patch markers (inclusive), - ensuring that both markers are at zero indentation (start of line, no leading spaces). - - Args: - text: Full input text containing one or more patch blocks. - multiple: If True, return a list of all patch blocks. If False, return the first match. - Returns: - A string (single patch), list of strings (multiple patches), or None if not found. +def parse_blocks(text: str, block_word: str = "Commit", multiple: bool = True) -> Union[str, List[str], None]: """ - - pattern = r"(?m)^(\*\*\* Begin Patch[\s\S]*?^\*\*\* End Patch)$" - matches = re.findall(pattern, text) - - if not matches: - return None - - return matches if multiple else matches[0] - -def parse_commit_blocks(text: str, multiple: bool = True) -> Union[str, List[str], None]: - """ - Extract content between *** Begin Commit and *** End Commit markers (exclusive), + Extract content between *** Begin and *** End markers (exclusive), ensuring that both markers are at zero indentation (start of line, no leading spaces). Args: - text: Full input text containing one or more commit blocks. - multiple: If True, return a list of all commit blocks. If False, return the first match. + text: Full input text containing one or more blocks. + block_word: The word to use in the block markers (e.g., "Commit", "Section", "Code"). + multiple: If True, return a list of all blocks. If False, return the first match. Returns: - A string (single commit), list of strings (multiple commits), or None if not found. + A string (single block), list of strings (multiple blocks), or None if not found. """ - pattern = r"(?m)^\*\*\* Begin Commit\n([\s\S]*?)^\*\*\* End Commit$" + # Escape the block_word to handle any special regex characters + escaped_word = re.escape(block_word) + + # Create pattern with the parameterized block word + pattern = rf"(?m)^\*\*\* Begin {escaped_word}\n([\s\S]*?)^\*\*\* End {escaped_word}$" matches = re.findall(pattern, text) if not matches: return None - return matches if multiple else matches[0].strip() + if multiple: + return [match.strip() for match in matches] + else: + return matches[0].strip() def parse_steps_markdown(md: str): steps = [] @@ -103,15 +91,21 @@ def parse_steps_markdown(md: str): instructions = instructions_match.group(1).strip() if instructions_match else "" # Match context identifiers - context_match = re.search(r"\*\*context_identifiers\*\*:\s*(.*)", raw_step, re.DOTALL) + context_match = re.search(r"\*\*context_identifiers\*\*:\s*(.*?)(?=\*\*modify_identifiers\*\*:)", raw_step, re.DOTALL) context_block = context_match.group(1).strip() if context_match else "" context_identifiers = re.findall(r"- (.+)", context_block) + # Match modifying identifiers + modify_match = re.search(r"\*\*modify_identifiers\*\*:\s*(.*)", raw_step, re.DOTALL) + modify_match = modify_match.group(1).strip() if modify_match else "" + modify_identifiers = re.findall(r"- (.+)", modify_match) + steps.append({ "step": step_num, "description": description, "instructions": instructions, - "context_identifiers": context_identifiers + "context_identifiers": [identifier.strip() for identifier in context_identifiers], + "modify_identifiers": [identifier.strip() for identifier in modify_identifiers] }) return steps diff --git a/codetide/core/models.py b/codetide/core/models.py index 122d008..d1505aa 100644 --- a/codetide/core/models.py +++ b/codetide/core/models.py @@ -539,7 +539,13 @@ def from_list_of_elements(cls, class CodeBase(BaseModel): """Root model representing complete codebase with file hierarchy and caching.""" root: List[CodeFileModel] = Field(default_factory=list) - _cached_elements :Dict[str, Any] = dict() + _cached_elements :Dict[str, Union[CodeFileModel, ClassDefinition, FunctionDefinition, VariableDeclaration, ImportStatement]] = dict() + + @property + def cached_elements(self)->Dict[str, Union[CodeFileModel, ClassDefinition, FunctionDefinition, VariableDeclaration, ImportStatement]]: + if not self._cached_elements: + self._build_cached_elements() + return self._cached_elements def _build_cached_elements(self, force_update :bool=False): """Builds cache of all elements with unique IDs across entire codebase.""" diff --git a/codetide/mcp/tools/patch_code/__init__.py b/codetide/mcp/tools/patch_code/__init__.py index 813bbdb..fe47591 100644 --- a/codetide/mcp/tools/patch_code/__init__.py +++ b/codetide/mcp/tools/patch_code/__init__.py @@ -5,8 +5,93 @@ from typing import Dict, Optional, Tuple, List, Callable, Union import pathlib +import re import os +def parse_patch_blocks(text: str, multiple: bool = True) -> Union[str, List[str], None]: + """ + Extract content between *** Begin Patch and *** End Patch markers (inclusive), + ensuring that both markers are at zero indentation (start of line, no leading spaces). + + If only one identifier is present: + - If only "Begin Patch" exists: returns from Begin Patch to end of text + - If only "End Patch" exists: returns from start of text to End Patch + + Args: + text: Full input text containing one or more patch blocks. + multiple: If True, return a list of all patch blocks. If False, return the first match. + + Returns: + A string (single patch), list of strings (multiple patches), or None if not found. + """ + + # First, try to find complete blocks (both Begin and End markers) + complete_pattern = r"(?m)^(\*\*\* Begin Patch[\s\S]*?^\*\*\* End Patch)$" + complete_matches = re.findall(complete_pattern, text) + + # If we found complete matches, return them (preserving original behavior) + if complete_matches: + return complete_matches if multiple else complete_matches[0] + + # If no complete matches, look for partial identifiers + begin_pattern = r"(?m)^(\*\*\* Begin Patch).*$" + end_pattern = r"(?m)^(\*\*\* End Patch).*$" + + begin_matches = list(re.finditer(begin_pattern, text)) + end_matches = list(re.finditer(end_pattern, text)) + + partial_matches = [] + + # Handle cases with only Begin markers (from Begin to end of text) + if begin_matches and not end_matches: + for match in begin_matches: + start_pos = match.start() + partial_content = text[start_pos:] + partial_matches.append(partial_content) + + # Handle cases with only End markers (from start of text to End) + elif end_matches and not begin_matches: + for match in end_matches: + end_pos = match.end() + partial_content = text[:end_pos] + partial_matches.append(partial_content) + + # Handle mixed cases (some begins without ends, some ends without begins) + elif begin_matches or end_matches: + # Get all Begin positions + begin_positions = [m.start() for m in begin_matches] + end_positions = [m.end() for m in end_matches] + + # For each Begin, try to find corresponding End + for begin_pos in begin_positions: + corresponding_end = None + for end_pos in end_positions: + if end_pos > begin_pos: + corresponding_end = end_pos + break + + if corresponding_end: + # Complete pair found + partial_content = text[begin_pos:corresponding_end] + else: + # Begin without End - go to end of text + partial_content = text[begin_pos:] + + partial_matches.append(partial_content) + + # Handle orphaned End markers (Ends that don't have corresponding Begins) + for end_pos in end_positions: + has_corresponding_begin = any(begin_pos < end_pos for begin_pos in begin_positions) + if not has_corresponding_begin: + # End without Begin - from start of text + partial_content = text[:end_pos] + partial_matches.append(partial_content) + + if not partial_matches: + return None + + return partial_matches if multiple else partial_matches[0] + # --------------------------------------------------------------------------- # # User-facing API # --------------------------------------------------------------------------- # @@ -115,36 +200,46 @@ def process_patch( if not os.path.exists(patch_path): raise DiffError("Patch path {patch_path} does not exist.") + ### TODO might need to update this to process multiple patches in line + if root_path is not None: root_path = pathlib.Path(root_path) # Normalize line endings before processing - text = open_fn(patch_path) - - # FIX: Check for existence of files to be added before parsing. - paths_to_add = identify_files_added(text) - for p in paths_to_add: - if root_path is not None: - p = str(root_path / p) - if exists_fn(p): - raise DiffError(f"Add File Error - file already exists: {p}") - - paths_needed = identify_files_needed(text) - - # Load files with normalized line endings - orig_files = {} - for path in paths_needed: - if root_path is not None: - path = str(root_path / path) - orig_files[path] = open_fn(path) - - patch, _fuzz = text_to_patch(text, orig_files, rootpath=root_path) - commit = patch_to_commit(patch, orig_files) - - apply_commit(commit, write_fn, remove_fn, exists_fn) + patches_text = open_fn(patch_path) + print(f"{patches_text=}") + patches = parse_patch_blocks(patches_text)#or [""] + print(f"{patches=}") + + all_paths_needed = [] + for text in patches: + # FIX: Check for existence of files to be added before parsing. + paths_to_add = identify_files_added(text) + for p in paths_to_add: + if root_path is not None: + p = str(root_path / p) + if exists_fn(p): + raise DiffError(f"Add File Error - file already exists: {p}") + + paths_needed = identify_files_needed(text) + all_paths_needed.extend(paths_to_add) + all_paths_needed.extend(paths_needed) + + # Load files with normalized line endings + orig_files = {} + for path in paths_needed: + if root_path is not None: + path = str(root_path / path) + orig_files[path] = open_fn(path) + + patch, _fuzz = text_to_patch(text, orig_files, rootpath=root_path) + commit = patch_to_commit(patch, orig_files) + + apply_commit(commit, write_fn, remove_fn, exists_fn) + + remove_fn(patch_path) - remove_fn(patch_path) - return paths_needed + return all_paths_needed # --------------------------------------------------------------------------- # # Default FS wrappers diff --git a/examples/hf_demo_space/app.py b/examples/hf_demo_space/app.py index c39892c..bc3bc69 100644 --- a/examples/hf_demo_space/app.py +++ b/examples/hf_demo_space/app.py @@ -9,6 +9,7 @@ from codetide.agents.tide.ui.stream_processor import StreamProcessor, MarkerConfig from codetide.agents.tide.ui.utils import run_concurrent_tasks from codetide.agents.tide.ui.agent_tide_ui import AgentTideUi +from codetide.agents.tide.ui.app import send_reasoning_msg from codetide.core.defaults import DEFAULT_ENCODING from codetide.core.logs import logger from codetide.agents.tide.models import Step @@ -30,12 +31,13 @@ import json import stat import yaml +import time import os DEFAULT_SESSIONS_WORKSPACE = Path(os.getcwd()) / "sessions" @cl.on_chat_start -async def start_chatr(): +async def start_chat(): session_id = ulid() cl.user_session.set("session_id", session_id) await cl.context.emitter.set_commands(AgentTideUi.commands) @@ -199,6 +201,7 @@ async def on_execute_steps(action :cl.Action): latest_step_message :cl.Message = cl.user_session.get("latest_step_message") if latest_step_message and latest_step_message.id == action.payload.get("msg_id"): await latest_step_message.remove_actions() + await latest_step_message.send() # close message ? if agent_tide_ui.current_step is None: task_list = cl.TaskList("Steps") @@ -240,7 +243,7 @@ async def on_execute_steps(action :cl.Action): author="Agent Tide" ).send() - await agent_loop(step_instructions_msg, codeIdentifiers=step.context_identifiers, agent_tide_ui=agent_tide_ui) + await agent_loop(step_instructions_msg, codeIdentifiers=step.get_code_identifiers(agent_tide_ui.agent_tide.tide._as_file_paths), agent_tide_ui=agent_tide_ui) task_list.status = f"Waiting feedback on step {current_task_idx}" await task_list.send() @@ -252,6 +255,7 @@ async def on_stop_steps(action :cl.Action): latest_step_message :cl.Message = cl.user_session.get("latest_step_message") if latest_step_message and latest_step_message.id == action.payload.get("msg_id"): await latest_step_message.remove_actions() + await latest_step_message.send() # close message ? task_list = cl.user_session.get("StepsTaskList") if task_list: @@ -296,6 +300,21 @@ async def on_inspect_context(action :cl.Action): @cl.on_message async def agent_loop(message: Optional[cl.Message]=None, codeIdentifiers: Optional[list] = None, agent_tide_ui :Optional[AgentTideUi]=None): + + loading_msg = await cl.Message( + content="", + elements=[ + cl.CustomElement( + name="LoadingMessage", + props={ + "messages": ["Working", "Syncing CodeTide", "Thinking", "Looking for context"], + "interval": 1500, # 1.5 seconds between messages + "showIcon": True + } + ) + ] + ).send() + if agent_tide_ui is None: agent_tide_ui = cl.user_session.get("AgentTideUi") @@ -310,9 +329,8 @@ async def agent_loop(message: Optional[cl.Message]=None, codeIdentifiers: Option chat_history.append({"role": "user", "content": message.content}) await agent_tide_ui.add_to_history(message.content) - + context_msg = cl.Message(content="", author="AgentTide") msg = cl.Message(content="", author="Agent Tide") - async with cl.Step("ApplyPatch", type="tool") as diff_step: await diff_step.remove() @@ -344,11 +362,17 @@ async def agent_loop(message: Optional[cl.Message]=None, codeIdentifiers: Option global_fallback_msg=msg ) + st = time.time() + is_reasonig_sent = False async for chunk in run_concurrent_tasks(agent_tide_ui, codeIdentifiers): if chunk == STREAM_START_TOKEN: + is_reasonig_sent = await send_reasoning_msg(loading_msg, context_msg, agent_tide_ui, st) continue - if chunk == STREAM_END_TOKEN: + elif not is_reasonig_sent: + is_reasonig_sent = await send_reasoning_msg(loading_msg, context_msg, agent_tide_ui, st) + + elif chunk == STREAM_END_TOKEN: # Handle any remaining content await stream_processor.finalize() break diff --git a/tests/agents/tide/test_utils_parse_steps_markdown.py b/tests/agents/tide/test_utils_parse_steps_markdown.py index 2da869d..b7e51c6 100644 --- a/tests/agents/tide/test_utils_parse_steps_markdown.py +++ b/tests/agents/tide/test_utils_parse_steps_markdown.py @@ -12,6 +12,9 @@ def generate_steps_md(n): **context_identifiers**: - module.{i} - path/to/file_{i}.py + **modify_identifiers**: + - output/result_{i}.py + - config/settings_{i}.json ---""") steps_md.append("*** End Steps") return "\n".join(steps_md) @@ -25,23 +28,36 @@ def test_parse_multiple_steps(): **context_identifiers**: - db.init_schema - config/db.yaml + **modify_identifiers**: + - database/schema.sql + - config/database.ini --- 1. **Load seed data** **instructions**: Populate tables with initial records from seed_data.json **context_identifiers**: - db.load_seed - data/seed_data.json + **modify_identifiers**: + - database/populated.db + - logs/seed_import.log --- *** End Steps """ steps = parse_steps_markdown(md) + assert len(steps) == 2 + + # Test step 0 assert steps[0]["step"] == 0 assert steps[0]["description"] == "Initialize database" assert "SQLite database" in steps[0]["instructions"] assert steps[0]["context_identifiers"] == ["db.init_schema", "config/db.yaml"] + assert steps[0]["modify_identifiers"] == ["database/schema.sql", "config/database.ini"] + + # Test step 1 assert steps[1]["step"] == 1 assert steps[1]["context_identifiers"][1] == "data/seed_data.json" + assert steps[1]["modify_identifiers"] == ["database/populated.db", "logs/seed_import.log"] def test_single_step(): md = """ @@ -50,6 +66,8 @@ def test_single_step(): **instructions**: Perform all required tasks in a single step. **context_identifiers**: - utils.main_handler + **modify_identifiers**: + - output/final_result.txt --- *** End Steps """ @@ -59,27 +77,62 @@ def test_single_step(): assert steps[0]["description"] == "Do everything" assert "single step" in steps[0]["instructions"] assert steps[0]["context_identifiers"] == ["utils.main_handler"] + assert steps[0]["modify_identifiers"] == ["output/final_result.txt"] -def test_missing_context_identifiers(): +def test_empty_identifiers(): md = """ *** Begin Steps - 0. **No context** + 0. **No identifiers** **instructions**: Just explain something here **context_identifiers**: + **modify_identifiers**: --- *** End Steps """ steps = parse_steps_markdown(md) assert len(steps) == 1 assert steps[0]["context_identifiers"] == [] + assert steps[0]["modify_identifiers"] == [] + +def test_partial_empty_identifiers(): + md = """ + *** Begin Steps + 0. **Only context** + **instructions**: Has context but no modify identifiers + **context_identifiers**: + - some.module + - some/file.py + **modify_identifiers**: + --- + 1. **Only modify** + **instructions**: Has modify but no context identifiers + **context_identifiers**: + **modify_identifiers**: + - output/result.json + - logs/activity.log + --- + *** End Steps + """ + steps = parse_steps_markdown(md) + assert len(steps) == 2 + + # Step with only context + assert steps[0]["context_identifiers"] == ["some.module", "some/file.py"] + assert steps[0]["modify_identifiers"] == [] + + # Step with only modify + assert steps[1]["context_identifiers"] == [] + assert steps[1]["modify_identifiers"] == ["output/result.json", "logs/activity.log"] def test_malformed_but_parsable_step(): md = """ *** Begin Steps 0. **Incomplete step** - **instructions**: This has no context identifiers + **instructions**: This has empty identifier lines **context_identifiers**: - + **modify_identifiers**: + - --- *** End Steps """ @@ -87,8 +140,9 @@ def test_malformed_but_parsable_step(): assert len(steps) == 1 assert steps[0]["description"] == "Incomplete step" assert steps[0]["context_identifiers"] == [] + assert steps[0]["modify_identifiers"] == [] -def test_multiple_hyphen_indented_contexts(): +def test_multiple_hyphen_indented_identifiers(): md = """ *** Begin Steps 0. **Handle multi-line** @@ -97,12 +151,40 @@ def test_multiple_hyphen_indented_contexts(): - module.first - module.second - module.third + **modify_identifiers**: + - output/first.py + - output/second.py + - output/third.py + - logs/process.log --- *** End Steps """ steps = parse_steps_markdown(md) assert len(steps) == 1 assert steps[0]["context_identifiers"] == ["module.first", "module.second", "module.third"] + assert steps[0]["modify_identifiers"] == ["output/first.py", "output/second.py", "output/third.py", "logs/process.log"] + +def test_mixed_whitespace_handling(): + md = """ + *** Begin Steps + 0. **Whitespace test** + **instructions**: Test various whitespace scenarios + **context_identifiers**: + - module.with.spaces + - indented.module + - normal.module + **modify_identifiers**: + - output/spaced.file + - output/indented.file + - output/normal.file + --- + *** End Steps + """ + steps = parse_steps_markdown(md) + assert len(steps) == 1 + # Should strip whitespace from identifier names + assert steps[0]["context_identifiers"] == ["module.with.spaces", "indented.module", "normal.module"] + assert steps[0]["modify_identifiers"] == ["output/spaced.file", "output/indented.file", "output/normal.file"] @pytest.mark.parametrize("count", [5, 10, 50]) def test_large_number_of_steps(count): @@ -116,3 +198,48 @@ def test_large_number_of_steps(count): assert step["description"] == f"Step {i} description" assert f"task {i}" in step["instructions"] assert step["context_identifiers"] == [f"module.{i}", f"path/to/file_{i}.py"] + assert step["modify_identifiers"] == [f"output/result_{i}.py", f"config/settings_{i}.json"] + +def test_complex_real_world_example(): + """Test with a more realistic example that might be seen in practice.""" + md = """ + *** Begin Steps + 0. **Setup authentication system** + **instructions**: Create user authentication with JWT tokens and bcrypt password hashing + **context_identifiers**: + - auth.models.User + - auth.utils.jwt_helper + - config/security.yaml + - requirements.txt + **modify_identifiers**: + - auth/models.py + - auth/views.py + - auth/serializers.py + - config/settings.py + --- + 1. **Implement API endpoints** + **instructions**: Create REST API endpoints for user registration, login, and profile management + **context_identifiers**: + - auth.models.User + - api.base_views + - docs/api_spec.md + **modify_identifiers**: + - api/auth_views.py + - api/urls.py + - tests/test_auth_api.py + --- + *** End Steps + """ + steps = parse_steps_markdown(md) + assert len(steps) == 2 + + # Verify complex step structure + assert steps[0]["description"] == "Setup authentication system" + assert "JWT tokens" in steps[0]["instructions"] + assert len(steps[0]["context_identifiers"]) == 4 + assert len(steps[0]["modify_identifiers"]) == 4 + + assert steps[1]["description"] == "Implement API endpoints" + assert "REST API" in steps[1]["instructions"] + assert len(steps[1]["context_identifiers"]) == 3 + assert len(steps[1]["modify_identifiers"]) == 3 \ No newline at end of file