# Task
Please help me improve my gui for my llm code assitant that I have made. I have provide all the key files needed 

# Project-Context: ~/Context

## File context_tool/format.py

```python
#!/usr/bin/env python3
"""
context_tool.format

---

Refocus a large project-context dump for a specific task using an LLM.

## Key points

- No static relevance or dependency logic in code – the LLM decides what to keep.
- Prompt tells the LLM to annotate the file-tree and include only relevant files
  (omitting headers for files it skips).
- The script itself prepends and appends task headings, so structure is always
  correct even if the LLM misbehaves.

Example:
context-format full_context.md focused_context.md \
 -t "write unit tests for context_tool/format.py" -f
"""

from **future** import annotations

import argparse
import sys
from pathlib import Path

from .core import call_openrouter_api
from .constants import DEFAULT_MODEL

# ───────────────────────────── Prompt builder ────────────────────────────────

def construct_llm_prompt(original_context: str, task: str) -> str:
"""
Build a single instruction block for the LLM.

    The LLM must:
    1. Annotate every entry in the Project File Tree with a one-line description.
    2. Under ## File Contents, include *only* files needed for the task,
       their direct dependencies, and any related tests — with NO headers or
       placeholders for omitted files.
    3. Output nothing outside the mandated Markdown structure.
    """
    return f"""

You are an expert programmer. You will receive a comprehensive project context dump.

**Task:** {task}

Produce a single Markdown document with these rules:

1. Under `## Project File Tree`, keep the existing tree, but append a one-line
   parenthetical description after every file and directory describing its likely purpose.
2. Under `## File Contents`, include a `### File: \\`<path>\\`` block **only** for
   files genuinely needed to accomplish the task (plus direct dependencies and
   any related test files). **Do not include any header or placeholder** for
   files you omit — simply skip them.
3. Do **NOT** add commentary, prose, or scoring outside the defined Markdown sections.
4. The output **must** begin with `# Project Context Dump` and end with
   `## End of Context Dump`, preserving all original headers.

**Original Context**
--- START ORIGINAL CONTEXT ---
{original_context}
--- END ORIGINAL CONTEXT ---
""".strip()

# ──────────────────────────────── CLI ──────────────────────────────────────

def main(argv: list[str] | None = None) -> None: # noqa: D401
parser = argparse.ArgumentParser(
description="Refocus a project context dump for a specific LLM task.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"input_file",
type=Path,
help="Path to the full context Markdown (from context-collect).",
)
parser.add_argument(
"output_file", type=Path, help="Path to write the focused Markdown context."
)
parser.add_argument(
"-t", "--task", required=True, help="The task this refocused context is for."
)
parser.add_argument(
"-m", "--model", default=DEFAULT_MODEL, help="OpenRouter model ID to use."
)
parser.add_argument("--temperature", type=float, default=None)
parser.add_argument("--max-tokens", type=int, default=None)
parser.add_argument(
"-f", "--force", action="store_true", help="Overwrite output file if it exists."
)
args = parser.parse_args(argv)

    # ─── Validate paths ─────────────────────────────────────────────────────
    if not args.input_file.is_file():
        sys.exit(f"Error: input file not found: {args.input_file}")

    if args.output_file.exists() and not args.force:
        if (
            input(f"Output '{args.output_file}' exists — overwrite? (y/N): ").lower()
            != "y"
        ):
            print("Cancelled.", file=sys.stderr)
            sys.exit(0)

    # ─── Build prompt & call the LLM ────────────────────────────────────────
    raw_context = args.input_file.read_text(encoding="utf-8", errors="ignore")
    prompt = construct_llm_prompt(raw_context, args.task)

    try:
        body = call_openrouter_api(
            prompt,
            model=args.model,
            temperature=args.temperature,
            max_tokens=args.max_tokens,
        )
    except Exception as exc:
        sys.exit(f"LLM call failed: {exc}")

    # ─── Sanity-check start header ──────────────────────────────────────────
    if not body.lstrip().startswith("# Project Context Dump"):
        print(
            "Warning: LLM output did not start with '# Project Context Dump'.",
            file=sys.stderr,
        )

    # ─── Wrap with task headings ────────────────────────────────────────────
    final_md = (
        f"# Prompt/Task\n{args.task}\n\n"
        f"{body.strip()}\n\n"
        f"# Prompt/Task Reiteration\n{args.task}\n"
    )

    # ─── Write file ─────────────────────────────────────────────────────────
    args.output_file.parent.mkdir(parents=True, exist_ok=True)
    args.output_file.write_text(final_md, encoding="utf-8")
    print(f"Wrote focused context to: {args.output_file}", file=sys.stderr)

if **name** == "**main**": # pragma: no cover
main()
```

---

## File context_tool/format_processor.py

````python
import ast
import re
from pathlib import Path
from typing import Dict, List, Any, Union, Optional, Tuple

# Use ast.unparse (Python 3.9+). User is on 3.13.
try:
    from ast import unparse
except ImportError:
    # Fallback for older Python, though not strictly needed for OP
    try:
        from astor import to_source as unparse
        print("Warning: Using 'astor.to_source' as ast.unparse. Python 3.9+ with built-in ast.unparse is recommended.")
    except ImportError:
        raise ImportError("For Python < 3.9, please install 'astor' (pip install astor) for AST unparsing.")

# Attempt to import get_language_tag from the project structure
try:
    from .core import get_language_tag
except ImportError:
    # Fallback stub if direct import fails (e.g., running script directly)
    # This should ideally not be needed if run as part of the package.
    print("Warning: Could not import get_language_tag from .core. Using a limited stub.")
    _LANGUAGE_MAP_STUB = {
        ".py": "python", ".js": "javascript", ".ts": "typescript", ".java": "java",
        ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp", ".cs": "csharp",
        ".rb": "ruby", ".php": "php", ".go": "go", ".rs": "rust", ".swift": "swift",
        ".kt": "kotlin", ".html": "html", ".css": "css", ".json": "json",
        ".yaml": "yaml", ".xml": "xml", ".md": "markdown", ".txt": "text",
        ".sh": "bash", ".sql": "sql", "dockerfile": "dockerfile"
    }
    def get_language_tag(path: Path) -> str:
        name_lower = path.name.lower()
        if name_lower == "dockerfile": return _LANGUAGE_MAP_STUB.get("dockerfile", "text")
        return _LANGUAGE_MAP_STUB.get(path.suffix.lower(), "text")


class OriginalFileContent:
    def __init__(self, path_str: str, raw_content_lines: List[str]):
        self.path = Path(path_str)
        self.raw_content = "\n".join(raw_content_lines)
        self.language = get_language_tag(self.path)
        self._ast_tree: Optional[ast.AST] = None
        self._source_lines_for_ast: Optional[List[str]] = None


    def get_ast_tree(self) -> Optional[ast.AST]:
        if self.language != "python":
            return None
        if self._ast_tree is None:
            try:
                # Store lines for precise source extraction if needed, though unparse is primary
                self._source_lines_for_ast = self.raw_content.splitlines(keepends=True)
                self._ast_tree = ast.parse(self.raw_content, filename=str(self.path))
            except SyntaxError as e:
                print(f"Warning: Could not parse Python AST for {self.path}: {e}")
                return None
        return self._ast_tree

    def get_node_source(self, node: ast.AST) -> str:
        """Extracts source code for an AST node."""
        # ast.unparse is generally preferred for semantic equivalence.
        # It might reformat slightly but captures the node's code.
        return unparse(node)

def extract_scopes_from_python_file(
    file_obj: OriginalFileContent,
    requested_scopes: List[Dict[str, Any]]
) -> List[Tuple[str, str, str]]: # List of (scope_type_label, scope_name_label, source_code)
    if file_obj.language != "python":
        return [("error", "unsupported_language", f"Scope extraction not supported for {file_obj.language}")]

    tree = file_obj.get_ast_tree()
    if not tree:
        return [("error", "ast_parse_failed", f"Could not parse AST for {file_obj.path}")]

    extracted_code_parts: List[Tuple[str, str, str]] = []

    # Handle 'imports' type first, as it's a collection of top-level nodes
    import_scope_requested = any(rs.get("type") == "imports" and rs.get("name") == "all" for rs in requested_scopes)
    if import_scope_requested:
        imports_code = ""
        for node in tree.body: # Iterate top-level nodes
            if isinstance(node, (ast.Import, ast.ImportFrom)):
                imports_code += file_obj.get_node_source(node) + "\n"
        if imports_code:
            extracted_code_parts.append(("Imports", "all", imports_code.strip()))

    # Handle other types (function, class)
    for scope_req in requested_scopes:
        req_type = scope_req.get("type")
        req_name = scope_req.get("name")

        if req_type == "imports": # Already handled
            continue

        for node in tree.body: # Iterate top-level nodes
            if req_type == "function" and isinstance(node, ast.FunctionDef) and node.name == req_name:
                extracted_code_parts.append(("Function", req_name, file_obj.get_node_source(node)))
                break # Found and processed this top-level function

            elif req_type == "class" and isinstance(node, ast.ClassDef) and node.name == req_name:
                class_node: ast.ClassDef = node
                requested_members = scope_req.get("members")

                if requested_members and isinstance(requested_members, list):
                    # LLM wants specific members of this class
                    member_code_parts = []
                    for member_req_detail in requested_members:
                        member_type = member_req_detail.get("type")
                        member_name = member_req_detail.get("name")
                        if member_type == "method":
                            for class_item_node in class_node.body:
                                if isinstance(class_item_node, ast.FunctionDef) and class_item_node.name == member_name:
                                    method_source = file_obj.get_node_source(class_item_node)
                                    # Add indentation to the method source if unparse doesn't handle it well for snippet
                                    # For now, unparse should be okay for individual methods.
                                    member_code_parts.append((f"Method from {req_name}", member_name, method_source))
                                    break # Found this method
                    if member_code_parts:
                        extracted_code_parts.extend(member_code_parts)
                    else: # Requested members but none found/extracted, maybe take whole class as fallback?
                          # Or report that specific members weren't found.
                          # For now, if members requested but none extracted, we don't add the class.
                          # This can be changed to: if members key exists, take whole class as a simpler approach.
                        extracted_code_parts.append(("Class (members requested but not specifically extracted)", req_name, file_obj.get_node_source(class_node)))

                else: # No "members" key or members is not a list, LLM wants the whole class
                    extracted_code_parts.append(("Class", req_name, file_obj.get_node_source(class_node)))
                break # Found and processed this top-level class

    return extracted_code_parts


def parse_original_context_dump_raw_content(md_content_string: str) -> Dict[str, OriginalFileContent]:
    files_data: Dict[str, OriginalFileContent] = {}
    current_file_path_str: str | None = None
    current_file_lines: List[str] = []
    in_code_block = False
    # Regex to find "### File: `path/to/file.py`"
    file_header_pattern = re.compile(r"^### File: `(.*?)`")

    for line in md_content_string.splitlines():
        file_header_match = file_header_pattern.match(line)
        if file_header_match:
            # If we were processing a previous file, finalize it
            if current_file_path_str and current_file_lines:
                files_data[current_file_path_str] = OriginalFileContent(current_file_path_str, current_file_lines)

            current_file_path_str = file_header_match.group(1)
            current_file_lines = [] # Reset for the new file
            in_code_block = False # A file header means we are out of any previous code block
            continue

        if current_file_path_str: # Only process lines if we are "in" a file block context
            if line.strip().startswith("```") and not in_code_block:
                in_code_block = True # Start of a new code block
                # Potentially clear current_file_lines if there was non-code content before it
                # current_file_lines = []
                continue
            elif line.strip() == "```" and in_code_block:
                in_code_block = False # End of the current code block
                # The content for the file is now complete
                if current_file_path_str and current_file_lines:
                     files_data[current_file_path_str] = OriginalFileContent(current_file_path_str, current_file_lines)
                # current_file_lines = [] # Don't reset yet, might be more blocks or file ends.
                # The next "### File:" or end of input will finalize.
                continue

            if in_code_block:
                current_file_lines.append(line)

    # Process the last file if any content was collected for it
    if current_file_path_str and current_file_lines and current_file_path_str not in files_data:
        files_data[current_file_path_str] = OriginalFileContent(current_file_path_str, current_file_lines)

    return files_data


def build_focused_markdown_from_scopes(
    parsed_original_files_map: Dict[str, OriginalFileContent],
    llm_selections_data: Dict[str, Any],
    task_description: str
) -> str:
    focused_md_parts: List[str] = [f"# Prompt/Task\n{task_description}\n"]

    if "task_analysis" in llm_selections_data and llm_selections_data["task_analysis"]:
        focused_md_parts.append("## LLM Task Analysis")
        focused_md_parts.append(f"```text\n{llm_selections_data['task_analysis'].strip()}\n```\n")

    focused_md_parts.append("## Selected File Contents")

    llm_file_selections = llm_selections_data.get("selections", [])
    if not llm_file_selections:
        focused_md_parts.append("\n_The LLM did not select any specific file portions for this task._\n")
    else:
        for selection_info in llm_file_selections:
            file_path_str = selection_info.get("path")
            requested_scopes_list = selection_info.get("scopes")
            reason = selection_info.get("reason", "No specific reason provided.")

            if not file_path_str or not requested_scopes_list:
                # print(f"Warning: Skipping invalid selection entry: {selection_info}")
                continue

            original_file_obj = parsed_original_files_map.get(file_path_str)
            if not original_file_obj:
                focused_md_parts.append(f"\n### File: `{file_path_str}` (LLM Selection - Original Not Found)")
                focused_md_parts.append(f"_LLM Reason: {reason}_")
                focused_md_parts.append("```text\n(Original content for this path was not found in the input context dump)\n```")
                continue

            focused_md_parts.append(f"\n### File: `{file_path_str}`")
            if reason: focused_md_parts.append(f"_LLM Reason: {reason}_")

            file_specific_extracted_code = ""
            if original_file_obj.language == "python":
                extracted_codes_for_file = extract_scopes_from_python_file(original_file_obj, requested_scopes_list)
                if not extracted_codes_for_file:
                    file_specific_extracted_code = "// LLM selected scopes, but no code was extracted. Check scope names or AST parsing.\n"
                else:
                    for scope_cat_label, scope_id_label, code_text in extracted_codes_for_file:
                        if scope_cat_label == "error":
                            file_specific_extracted_code += f"// Error extracting scope '{scope_id_label}': {code_text}\n"
                        else:
                            # Add a comment indicating what this block is
                            file_specific_extracted_code += f"# --- {scope_cat_label}: {scope_id_label} ---\n{code_text.strip()}\n\n"
            else: # For non-python files, include the whole file if LLM selected it
                file_specific_extracted_code = original_file_obj.raw_content
                file_specific_extracted_code += "\n// Note: Whole file included as scope extraction is Python-specific."

            focused_md_parts.append(f"```{original_file_obj.language}\n{file_specific_extracted_code.strip()}\n```")

    focused_md_parts.append("\n## End of Focused Context")
    focused_md_parts.append(f"\n# Prompt/Task Reiteration\n{task_description}\n")
    return "\n".join(focused_md_parts)
````

---

## File context_tool/gui_pyside.py

```python


# context_tool/gui_pyside.py

import sys
import os
import requests
import json
from pathlib import Path
from PySide6.QtCore import Qt, QThread, Signal, QTimer, QProcess
from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout,
    QLabel, QPushButton, QTextEdit, QLineEdit, QFileDialog, QComboBox,
    QCheckBox, QMessageBox
)
from PySide6.QtGui import QFont, QTextCursor

# Attempt to import from local package structure
try:
    from .format_processor import (
        parse_original_context_dump_raw_content,
        build_focused_markdown_from_scopes
    )
except ImportError:
    # This might happen if running the script directly without package context
    # Or if format_processor.py is not in the right place / has issues
    print("CRITICAL: Could not import from .format_processor. Ensure it's in the context_tool directory.")
    # Define dummy functions to prevent immediate crash, but formatting tab will fail.
    def parse_original_context_dump_raw_content(*args): raise NotImplementedError("format_processor not loaded")
    def build_focused_markdown_from_scopes(*args): raise NotImplementedError("format_processor not loaded")


OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
OPENROUTER_CHAT_COMPLETIONS_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_MODEL_ID = "openai/gpt-3.5-turbo" # Changed to a common, usually reliable model for testing

def which_python():
    return sys.executable or "python"

class ModelFetchThread(QThread):
    modelsFetched = Signal(list, str)
    def run(self):
        try:
            resp = requests.get(OPENROUTER_MODELS_URL, timeout=20)
            resp.raise_for_status()
            data = resp.json()
            ids = []
            if "data" in data:
                for m in data["data"]:
                    arch = m.get("architecture", {})
                    context_length = m.get("context_length", 0)
                    if (arch.get("modality") == "text->text" or \
                        "text" in arch.get("input_modalities", [])) and context_length > 0 and \
                        m.get("id") and "instruct" in m.get("id").lower() or "chat" in m.get("id").lower() or "gpt" in m.get("id").lower() : # Heuristic for chat/instruct models
                         ids.append(m["id"])
            ids.sort()
            self.modelsFetched.emit(ids, "")
        except Exception as e:
            self.modelsFetched.emit([], f"Failed to fetch models: {e}")

class OpenRouterStreamWorker(QThread):
    new_token = Signal(str)
    stream_finished = Signal(str)
    stream_error = Signal(str)

    def __init__(self, api_key: str, model: str, prompt_messages: list, temperature: float | None, max_tokens: int | None):
        super().__init__()
        self.api_key = api_key
        self.model = model
        self.prompt_messages = prompt_messages
        self.temperature = temperature
        self.max_tokens = max_tokens
        self._is_cancelled = False
        self.current_request = None

    def run(self):
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
            "HTTP-Referer": "https://github.com/your-repo/context-tool", # Optional: good practice
            "X-Title": "ContextTool-GUI" # Optional: good practice
        }
        payload = {
            "model": self.model,
            "messages": self.prompt_messages,
            "stream": True,
            # Instruct LLM to output JSON for scope selection
            "response_format": {"type": "json_object"} if "gpt-" in self.model and ("1106" in self.model or "0125" in self.model or "turbo" in self.model or "4o" in self.model) else None
        }
        if payload["response_format"] is None: # Remove if not supported/set
            del payload["response_format"]

        if self.temperature is not None: payload["temperature"] = self.temperature
        if self.max_tokens is not None: payload["max_tokens"] = self.max_tokens

        buffer = ""
        full_response_content = ""
        try:
            with requests.post(OPENROUTER_CHAT_COMPLETIONS_URL, headers=headers, json=payload, stream=True, timeout=30) as r:
                self.current_request = r
                r.raise_for_status()
                for chunk in r.iter_content(chunk_size=None, decode_unicode=True):
                    if self._is_cancelled:
                        self.stream_error.emit("Stream cancelled by user.")
                        return
                    buffer += chunk
                    while True:
                        line_end = buffer.find('\n')
                        if line_end == -1: break
                        line = buffer[:line_end].strip()
                        buffer = buffer[line_end + 1:]
                        if line.startswith('data: '):
                            data_str = line[6:]
                            if data_str == '[DONE]':
                                self.stream_finished.emit(full_response_content)
                                return
                            try:
                                data_obj = json.loads(data_str)
                                content_delta = data_obj["choices"][0]["delta"].get("content")
                                if content_delta:
                                    self.new_token.emit(content_delta)
                                    full_response_content += content_delta
                            except (json.JSONDecodeError, KeyError) as e:
                                print(f"SSE parsing error: {e} on data: {data_str[:100]}")
                                pass # Ignore minor parsing errors if stream continues
            self.stream_finished.emit(full_response_content) # If loop finishes without [DONE]
        except requests.exceptions.RequestException as e:
            self.stream_error.emit(f"API Request Error: {e}")
        except Exception as e:
            self.stream_error.emit(f"Streaming Error: {type(e).__name__}: {e}")
        finally:
            self.current_request = None

    def cancel(self):
        self._is_cancelled = True
        if self.current_request:
            try: self.current_request.close()
            except Exception: pass

def construct_llm_scope_selection_prompt(original_context: str, task: str) -> str:
    # This is the prompt defined in the previous response that asks for JSON output
    # describing scopes (imports, functions, classes).
    return f"""
You are an expert programmer assisting with code analysis. You will receive a project context dump.
Your task is to identify the most relevant files and specific code structures (imports, functions, classes, methods) within those files for the given **Task**.

**Task:** {task}

**Input Context:**
The provided context dump includes a "Project File Tree" and "File Contents" with original source code.

**Output Instructions:**
You MUST output a single, valid JSON object. Do NOT add any commentary or text outside this JSON object.
The JSON object should follow this structure:

{{
  "task_analysis": "A brief (1-2 sentences) summary of your understanding of the task and your general approach to selecting relevant context.",
  "selections": [
    {{
      "path": "path/to/relevant/file.py", // Relative path as in the context dump
      "scopes": [
        // For each file, list the scopes to include.
        // Valid 'type' values: "imports", "function", "class".
        // For 'type: "imports"', 'name' should be "all".
        // For 'type: "function"', provide the 'name' of the function.
        // For 'type: "class"', provide the 'name' of the class.
        //   Optionally, for classes, include a "members" array to specify individual methods.
        //   Member 'type' can be "method". If "members" is omitted, the entire class is implied.
        {{ "type": "imports", "name": "all" }},
        {{ "type": "function", "name": "function_name_to_include" }},
        {{
          "type": "class",
          "name": "ClassName",
          "members": [
            {{ "type": "method", "name": "method_one" }},
            {{ "type": "method", "name": "another_method" }}
          ]
        }},
        {{ "type": "class", "name": "AnotherClassToIncludeFully" }}
      ],
      "reason": "Brief explanation why this file and these scopes are relevant."
    }}
    // ... more selections if needed
  ]
}}

Prioritize conciseness. Only include files and scopes that are directly relevant to completing the task.
If a file is listed in the tree but no part of it is relevant, do not include it in your "selections".
Ensure the output is ONLY the JSON object.

**Original Context:**
--- START ORIGINAL CONTEXT ---
{original_context}
--- END ORIGINAL CONTEXT ---

Now, provide the JSON output based on the task and the context:
""".strip()


class ContextToolMainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("LLM Project Context Tool")
        self.resize(900, 720) # Increased height a bit
        self.setMinimumSize(700, 500)

        self.tabs = QTabWidget()
        self.setCentralWidget(self.tabs)

        self.collect_tab = QWidget()
        self.format_tab = QWidget()
        self.tabs.addTab(self.collect_tab, "1. Collect Context")
        self.tabs.addTab(self.format_tab, "2. Format Context (Scope Selection)")

        self.init_collect_tab()
        self.init_format_tab()
        self._collect_process = None
        self.stream_worker = None
        self.accumulated_response = ""

        self.model_fetch_thread = ModelFetchThread()
        self.model_fetch_thread.modelsFetched.connect(self.on_models_fetched)
        self.model_fetch_thread.start()
        self.check_api_key()

    def check_api_key(self):
        if not os.getenv("OPENROUTER_API_KEY"):
            QTimer.singleShot(100, lambda: QMessageBox.warning(
                self, "API Key Missing",
                "OPENROUTER_API_KEY environment variable is not set. "
                "The 'Format Context' tab functionality will be disabled."
            ))

    def init_collect_tab(self):
        layout = QVBoxLayout(self.collect_tab)
        font_mono = QFont("Consolas, Menlo, monospace", 10)
        row1 = QHBoxLayout()
        row1.addWidget(QLabel("Project Root Dir:"))
        self.collect_project_root = QLineEdit(str(Path.cwd()))
        row1.addWidget(self.collect_project_root)
        btn_root = QPushButton("Browse…")
        btn_root.clicked.connect(self.pick_collect_project_root)
        row1.addWidget(btn_root)
        layout.addLayout(row1)
        row2 = QHBoxLayout()
        row2.addWidget(QLabel("Output Dir:"))
        self.collect_output_dir = QLineEdit(str(Path.cwd()))
        row2.addWidget(self.collect_output_dir)
        btn_out = QPushButton("Browse…")
        btn_out.clicked.connect(self.pick_collect_output_dir)
        row2.addWidget(btn_out)
        layout.addLayout(row2)
        row3 = QHBoxLayout()
        row3.addWidget(QLabel("Output Filename:"))
        self.collect_output_file = QLineEdit("project_llm_context.md")
        row3.addWidget(self.collect_output_file)
        layout.addLayout(row3)
        row4 = QHBoxLayout()
        self.collect_force = QCheckBox("Force overwrite")
        row4.addWidget(self.collect_force)
        row4.addStretch()
        layout.addLayout(row4)
        row5 = QHBoxLayout()
        self.collect_run_btn = QPushButton("Run Collection")
        self.collect_run_btn.clicked.connect(self.run_collect)
        row5.addWidget(self.collect_run_btn)
        self.collect_status = QLabel("Status: Idle")
        row5.addWidget(self.collect_status)
        row5.addStretch()
        layout.addLayout(row5)
        layout.addWidget(QLabel("Output:"))
        self.collect_log = QTextEdit()
        self.collect_log.setFont(font_mono)
        self.collect_log.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
        self.collect_log.setReadOnly(True)
        self.collect_log.setMinimumHeight(180)
        layout.addWidget(self.collect_log, stretch=1)

    def pick_collect_project_root(self):
        d = QFileDialog.getExistingDirectory(self, "Select Project Root", self.collect_project_root.text())
        if d: self.collect_project_root.setText(d)

    def pick_collect_output_dir(self):
        d = QFileDialog.getExistingDirectory(self, "Select Output Dir", self.collect_output_dir.text())
        if d: self.collect_output_dir.setText(d)

    def run_collect(self):
        root, out_dir, out_file = self.collect_project_root.text().strip(), self.collect_output_dir.text().strip(), self.collect_output_file.text().strip()
        if not (root and out_dir and out_file):
            self.show_error("Please fill in all required fields for collection.")
            return
        output_path = Path(out_dir) / out_file
        if output_path.exists() and not self.collect_force.isChecked():
            if QMessageBox.question(self, "Overwrite?", f"Output file '{output_path}' exists. Overwrite?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) != QMessageBox.StandardButton.Yes:
                return
        cmd = [which_python(), "-m", "context_tool.collect", root, "-O", out_dir, "-o", out_file]
        if self.collect_force.isChecked(): cmd.append("-f")
        self.start_qprocess(cmd, self.collect_log, self.collect_status, self.collect_run_btn)

    def init_format_tab(self):
        layout = QVBoxLayout(self.format_tab)
        font_mono = QFont("Consolas, Menlo, monospace", 10)
        row1 = QHBoxLayout()
        row1.addWidget(QLabel("Input Context File (from Collect):"))
        self.format_input_file = QLineEdit()
        row1.addWidget(self.format_input_file)
        btn_infile = QPushButton("Browse…")
        btn_infile.clicked.connect(self.pick_format_input_file)
        row1.addWidget(btn_infile)
        layout.addLayout(row1)
        row2 = QHBoxLayout()
        row2.addWidget(QLabel("Output Focused File:"))
        self.format_output_file = QLineEdit("focused_context.md")
        row2.addWidget(self.format_output_file)
        btn_outfile = QPushButton("Browse…")
        btn_outfile.clicked.connect(self.pick_format_output_file)
        row2.addWidget(btn_outfile)
        layout.addLayout(row2)
        layout.addWidget(QLabel("Target Task:"))
        self.format_task = QTextEdit()
        self.format_task.setPlaceholderText("e.g., Write unit tests for module X using functions A and B.")
        self.format_task.setMinimumHeight(60)
        layout.addWidget(self.format_task)
        row3 = QHBoxLayout()
        row3.addWidget(QLabel("Model:"))
        self.format_model = QComboBox()
        self.format_model.setEditable(False)
        self.format_model.setEnabled(False)
        self.format_model.addItem("Loading models…")
        row3.addWidget(self.format_model)
        layout.addLayout(row3)
        row4 = QHBoxLayout()
        self.format_force_overwrite_file = QCheckBox("Force overwrite output file")
        row4.addWidget(self.format_force_overwrite_file)
        row4.addStretch()
        layout.addLayout(row4)
        row5 = QHBoxLayout()
        self.format_run_btn = QPushButton("Run Formatting (Scope Select)")
        self.format_run_btn.clicked.connect(self.run_format_stream)
        row5.addWidget(self.format_run_btn)
        self.format_cancel_btn = QPushButton("Cancel Stream")
        self.format_cancel_btn.clicked.connect(self.cancel_format_stream)
        self.format_cancel_btn.setEnabled(False)
        row5.addWidget(self.format_cancel_btn)
        self.format_status = QLabel("Status: Idle")
        row5.addWidget(self.format_status)
        row5.addStretch()
        layout.addLayout(row5)
        layout.addWidget(QLabel("Live LLM Output (JSON Stream):"))
        self.format_log = QTextEdit()
        self.format_log.setFont(font_mono)
        self.format_log.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
        self.format_log.setReadOnly(True)
        self.format_log.setMinimumHeight(180)
        layout.addWidget(self.format_log, stretch=1)

    def pick_format_input_file(self):
        f, _ = QFileDialog.getOpenFileName(self, "Select Input Context File", filter="Markdown Files (*.md);;All Files (*)")
        if f:
            self.format_input_file.setText(f)
            p = Path(f)
            self.format_output_file.setText(str(p.parent / f"{p.stem}_focused_scopes.md"))

    def pick_format_output_file(self):
        current_path = self.format_output_file.text() or str(Path.cwd() / "focused_context.md")
        f, _ = QFileDialog.getSaveFileName(self, "Select Output File", current_path, filter="Markdown Files (*.md);;All Files (*)")
        if f: self.format_output_file.setText(f)

    def run_format_stream(self):
        api_key = os.getenv("OPENROUTER_API_KEY")
        if not api_key:
            self.show_error("OPENROUTER_API_KEY environment variable is not set.")
            return
        infile_str, outfile_str, task_str, model_str = self.format_input_file.text().strip(), self.format_output_file.text().strip(), self.format_task.toPlainText().strip(), self.format_model.currentText()
        if not (infile_str and outfile_str and task_str and model_str not in ["Loading models…", "Model fetch failed!", "No suitable models found."]):
            self.show_error("Please fill all fields (input, output, task) and select a model.")
            return
        infile_path, outfile_path = Path(infile_str), Path(outfile_str)
        if not infile_path.is_file():
            self.show_error(f"Input file not found: {infile_path}")
            return
        if outfile_path.exists() and not self.format_force_overwrite_file.isChecked():
            if QMessageBox.question(self, "Overwrite?", f"Output file '{outfile_path}' exists. Overwrite?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) != QMessageBox.StandardButton.Yes:
                return
        try:
            original_context_md = infile_path.read_text(encoding="utf-8")
        except Exception as e:
            self.show_error(f"Error reading input file '{infile_path}': {e}")
            return

        prompt = construct_llm_scope_selection_prompt(original_context_md, task_str)
        messages = [{"role": "user", "content": prompt}]

        self.format_log.clear()
        self.accumulated_response = ""
        self.format_status.setText("Status: Starting LLM scope selection stream...")
        self.set_format_ui_enabled(False)

        # TODO: Add temp/max_tokens from UI if desired
        self.stream_worker = OpenRouterStreamWorker(api_key, model_str, messages, temperature=0.2, max_tokens=2048) # Example temp
        self.stream_worker.new_token.connect(self.on_new_token_received)
        self.stream_worker.stream_finished.connect(self.on_llm_json_stream_finished)
        self.stream_worker.stream_error.connect(self.on_stream_error)
        self.stream_worker.finished.connect(self.on_worker_thread_actually_finished)
        self.stream_worker.start()

    def on_new_token_received(self, token: str):
        self.accumulated_response += token
        self.format_log.insertPlainText(token)
        self.format_log.moveCursor(QTextCursor.MoveOperation.End) # Corrected

    def on_llm_json_stream_finished(self, full_json_response_str: str):
        self.format_status.setText("Status: LLM JSON response received. Processing...")
        self.format_log.append("\n\n--- LLM JSON STREAM COMPLETE ---")
        llm_selections_data: Dict[str, Any]
        try:
            # Attempt to find the JSON block if the LLM added surrounding text
            # A simple heuristic: find first '{' and last '}'
            json_start = full_json_response_str.find('{')
            json_end = full_json_response_str.rfind('}')
            if json_start != -1 and json_end != -1 and json_end > json_start:
                json_to_parse = full_json_response_str[json_start : json_end+1]
            else:
                json_to_parse = full_json_response_str # Assume it's all JSON

            llm_selections_data = json.loads(json_to_parse)
        except json.JSONDecodeError as e:
            self.show_error(f"Failed to parse LLM JSON response: {e}\n\nReceived:\n{full_json_response_str[:1000]}...")
            self.format_status.setText("Status: Error parsing LLM JSON.")
            self.reset_format_ui_after_stream()
            return

        infile_path_str = self.format_input_file.text().strip()
        outfile_path_str = self.format_output_file.text().strip()
        task_str = self.format_task.toPlainText().strip()
        try:
            original_md_content = Path(infile_path_str).read_text(encoding="utf-8")
            parsed_original_map = parse_original_context_dump_raw_content(original_md_content)
            final_focused_md = build_focused_markdown_from_scopes(parsed_original_map, llm_selections_data, task_str)

            outfile_path = Path(outfile_path_str)
            outfile_path.parent.mkdir(parents=True, exist_ok=True)
            outfile_path.write_text(final_focused_md, encoding="utf-8")
            self.format_log.append(f"\n--- Focused context (scopes) built and saved to: {outfile_path} ---")
            self.format_status.setText(f"Status: Completed. Scopes saved to {outfile_path.name}")
        except Exception as e:
            self.show_error(f"Error processing LLM selections or writing output: {type(e).__name__}: {e}")
            self.format_status.setText("Status: Error during final processing.")
        self.reset_format_ui_after_stream()

    def on_stream_error(self, error_message: str):
        self.format_status.setText(f"Status: Error - {error_message}")
        self.format_log.append(f"\n\n--- STREAM ERROR: {error_message} ---")
        self.reset_format_ui_after_stream()

    def cancel_format_stream(self):
        if self.stream_worker and self.stream_worker.isRunning():
            self.format_status.setText("Status: Cancelling stream...")
            self.stream_worker.cancel()
            self.format_cancel_btn.setEnabled(False)

    def on_worker_thread_actually_finished(self):
        if self.stream_worker:
            self.stream_worker.deleteLater()
            self.stream_worker = None
        self.format_cancel_btn.setEnabled(False)
        if not self.format_run_btn.isEnabled():
             self.reset_format_ui_after_stream(set_status_idle=False) # Don't overwrite if already finished/errored

    def set_format_ui_enabled(self, enabled: bool):
        self.format_run_btn.setEnabled(enabled)
        self.format_cancel_btn.setEnabled(not enabled)
        self.format_input_file.setEnabled(enabled)
        self.format_output_file.setEnabled(enabled)
        self.format_task.setEnabled(enabled)
        if self.format_model.itemText(0) not in ["Loading models…", "Model fetch failed!", "No suitable models found."]:
            self.format_model.setEnabled(enabled)
        self.format_force_overwrite_file.setEnabled(enabled)

    def reset_format_ui_after_stream(self, set_status_idle=True):
        self.set_format_ui_enabled(True)
        if set_status_idle and "Error" not in self.format_status.text() and "Completed" not in self.format_status.text():
             self.format_status.setText("Status: Idle")
        if self.stream_worker:
            self.stream_worker.deleteLater() # Ensure cleanup
            self.stream_worker = None

    def on_models_fetched(self, ids, error):
        self.format_model.clear()
        if error:
            self.format_model.addItem("Model fetch failed!")
            self.show_error(f"Failed to fetch models: {error}")
        elif not ids:
            self.format_model.addItem("No suitable models found.")
        else:
            for mid in ids: self.format_model.addItem(mid)
            try:
                idx = ids.index(DEFAULT_MODEL_ID)
                self.format_model.setCurrentIndex(idx)
            except ValueError:
                if ids: self.format_model.setCurrentIndex(0)
        self.format_model.setEnabled(self.format_model.count() > 0 and "failed" not in self.format_model.itemText(0).lower() and "no suitable" not in self.format_model.itemText(0).lower())


    def start_qprocess(self, cmd, log_widget, status_label, btn):
        btn.setEnabled(False)
        status_label.setText("Running...")
        log_widget.clear()
        if self._collect_process and self._collect_process.state() == QProcess.ProcessState.Running:
            self._collect_process.kill()
            self._collect_process.waitForFinished(1000)

        self._collect_process = QProcess(self)
        self._collect_process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
        self._collect_process._log_widget, self._collect_process._status_label, self._collect_process._btn = log_widget, status_label, btn
        self._collect_process.readyReadStandardOutput.connect(lambda: self.on_qproc_output(self._collect_process._log_widget))
        self._collect_process.finished.connect(lambda code, st: self.on_qproc_finished(code, st, self._collect_process._status_label, self._collect_process._btn, self._collect_process._log_widget))
        try:
            self._collect_process.start(cmd[0], cmd[1:])
            if not self._collect_process.waitForStarted(3000):
                self.on_qproc_finished(-1, QProcess.ExitStatus.CrashExit, status_label, btn, log_widget, "Failed to start process.")
        except Exception as e:
             self.on_qproc_finished(-1, QProcess.ExitStatus.CrashExit, status_label, btn, log_widget, f"Error starting process: {e}")

    def on_qproc_output(self, log_widget):
        if self._collect_process:
            try:
                data = self._collect_process.readAllStandardOutput().data().decode(errors='replace')
                log_widget.insertPlainText(data)
                log_widget.moveCursor(QTextCursor.MoveOperation.End) # Corrected
            except Exception as e:
                print(f"Error reading QProcess output: {e}")

    def on_qproc_finished(self, exit_code, exit_status, status_label, btn, log_widget, error_msg=None):
        final_status_msg = f"Status: Done (Exit {exit_code})"
        if error_msg: final_status_msg = f"Status: Error - {error_msg}"
        elif exit_status == QProcess.ExitStatus.CrashExit: final_status_msg = f"Status: Process crashed (Code {exit_code})"

        status_label.setText(final_status_msg)
        btn.setEnabled(True)

        log_append_msg = ""
        if error_msg: log_append_msg = f"\n--- ERROR: {error_msg} ---"
        elif exit_status == QProcess.ExitStatus.CrashExit : log_append_msg = f"\n--- ERROR: Process crashed (Code {exit_code}) ---"
        elif exit_code != 0: log_append_msg = f"\n--- WARNING: Process exited with code {exit_code} ---"
        if log_append_msg: log_widget.append(log_append_msg)

        if self._collect_process:
            self._collect_process.deleteLater()
            self._collect_process = None

    def show_error(self, msg):
        QMessageBox.critical(self, "Error", msg)

    def closeEvent(self, event):
        if self.stream_worker and self.stream_worker.isRunning():
            self.stream_worker.cancel()
            self.stream_worker.wait(500)
        if self._collect_process and self._collect_process.state() == QProcess.ProcessState.Running:
            self._collect_process.kill()
            self._collect_process.waitForFinished(500)
        event.accept()

def main():
    app = QApplication(sys.argv)
    win = ContextToolMainWindow()
    win.show()
    sys.exit(app.exec())

if __name__ == "__main__":
    main()



```

---

## File context_tool/symbol_patch.py

````python

# context_tool/symbol_patch.py
"""
Symbol-level patching utilities for Context-Tool
===============================================

Goals
-----
* Precisely replace **functions / methods / classes** without touching
  unrelated code or relying on fragile line numbers.
* Minimise LLM context: ask it to return *only* the rewritten symbol bodies.
* Provide an ergonomic API + CLI so the rest of the project can call
  "index → prompt → patch → write-back" in a few lines.

Public API
----------
- `index_python_symbols(code: str) -> dict[str, tuple[int, int]]`
- `build_patch_prompt(...) -> str`
- `apply_symbol_patches(original_code: str, patches: dict[str, str]) -> str`
- `SymbolPatcher` orchestrator class
- CLI: ``python -m context_tool.symbol_patch --help``

Implementation notes
--------------------
* AST parsing (`ast` stdlib) gives start/end line numbers for each node.
* Replacements are validated: if the span lines changed since indexing, the
  patch is refused unless `force=True`.
* Thread-safe: no globals, pure functions.
* Python-only for now; future work: tree-sitter for polyglot support.
"""

from __future__ import annotations

import argparse
import json
import sys
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Tuple

import ast

from .core import call_openrouter_api  # Re-use your existing HTTP wrapper
from .constants import DEFAULT_MODEL

__all__ = [
    "SymbolSpan",
    "index_python_symbols",
    "build_patch_prompt",
    "apply_symbol_patches",
    "SymbolPatcher",
]


# ───────────────────────────── Data-models ────────────────────────────────


@dataclass(slots=True, frozen=True)
class SymbolSpan:
    """Start/end (inclusive) 0-based line numbers of a top-level symbol."""

    start: int
    end: int


# ───────────────────────────── Indexing ───────────────────────────────────


def _node_span(node: ast.AST) -> Tuple[int, int]:
    """
    Return (start, end) where both indices are 0-based and inclusive.
    `ast` nodes expose 1-based .lineno / .end_lineno.
    """
    if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
        raise ValueError("Node has no lineno metadata – requires Python 3.8+")
    # pyright: reportAttributeAccessIssue=false
    return node.lineno - 1, node.end_lineno - 1  # type: ignore[attr-defined]


def index_python_symbols(code: str) -> Dict[str, SymbolSpan]:
    """
    Parse *code* and return `{symbol_name: SymbolSpan}` for every
    top-level function, async function, or class.

    Sub-methods within classes **are not** indexed; the LLM patcher targets
    the whole class body when class-level edits are needed.
    """
    tree = ast.parse(code)
    result: Dict[str, SymbolSpan] = {}

    for node in ast.iter_child_nodes(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
            start, end = _node_span(node)
            result[node.name] = SymbolSpan(start, end)

    return result


# ───────────────────────────── Prompt builder ─────────────────────────────


_PROMPT_SCHEMA = textwrap.dedent(
    """
    ## Instructions ##
    You will receive:
       1. *task* – what to achieve
       2. *target_symbol* – name of a top-level function/class/method
       3. *full source code* for the file containing that symbol

    Return *only* valid Python code **for that symbol** (nothing else),
    implementing the task.  If no change is needed, reply exactly:
        NO_CHANGES

    Do **NOT** wrap the answer in markdown fences, and do not include any
    other commentary.
    """
).strip()


def build_patch_prompt(
    task: str,
    target_symbol: str,
    file_path: str,
    file_code: str,
    extra_context: str | None = None,
) -> str:
    """
    Assemble a concise prompt for the LLM to rewrite *target_symbol*.

    `extra_context` can be a project summary, dependency graph, etc.
    """
    parts: list[str] = [_PROMPT_SCHEMA, "", f"Task:\n{task.strip()}"]
    if extra_context:
        parts.extend(("\nProject context:\n", extra_context.strip()))
    parts.extend(
        (
            f"\nFile: {file_path}",
            f"Target symbol: {target_symbol}",
            "\n```python",
            file_code.strip(),
            "```",
        )
    )
    return "\n".join(parts)


# ───────────────────────────── Patching logic ─────────────────────────────


class PatchFailedError(RuntimeError):
    """Raised when code drift prevents a safe patch."""


def apply_symbol_patches(
    original_code: str,
    patches: Dict[str, str],
    *,
    force: bool = False,
) -> str:
    """
    Apply *patches* `{symbol_name: new_code}` to *original_code*.

    * If the text spanning the symbol has changed since it was indexed,
      and `force` is False, raises PatchFailedError.

    Returns the fully patched file as a single string.
    """
    symbol_map = index_python_symbols(original_code)
    missing = [s for s in patches.keys() if s not in symbol_map]
    if missing:
        raise PatchFailedError(f"Symbols not found: {', '.join(missing)}")

    lines = original_code.splitlines()

    # Sort by descending start index so earlier replacements don't invalidate
    # later spans.
    sorted_items = sorted(
        patches.items(), key=lambda kv: symbol_map[kv[0]].start, reverse=True
    )

    for symbol, new_code in sorted_items:
        span = symbol_map[symbol]
        before = "\n".join(lines[span.start : span.end + 1])
        if not force and symbol not in before:
            # Naïve drift detection: the old def/class header should still
            # contain the symbol name; otherwise code shifted too much.
            raise PatchFailedError(
                f"File changed around symbol '{symbol}'; re-index required."
            )

        replacement_lines = new_code.rstrip("\n").splitlines()
        lines = lines[: span.start] + replacement_lines + lines[span.end + 1 :]

    return "\n".join(lines) + ("\n" if original_code.endswith("\n") else "")


# ───────────────────────────── Orchestrator ───────────────────────────────


@dataclass(slots=True)
class PatchJob:
    """A single symbol rewrite request for the LLM."""

    file_path: Path
    symbol: str
    task: str
    extra_context: str | None = None
    temperature: float | None = None
    max_tokens: int | None = 1024


class SymbolPatcher:
    """
    High-level helper: run LLM calls and patch files in-place.

    Example
    -------
    >>> patcher = SymbolPatcher()
    >>> jobs = [
    ...     PatchJob(Path("example.py"), "add", "Add type hints"),
    ... ]
    >>> patcher.run(jobs)
    """

    def __init__(self, model: str = DEFAULT_MODEL) -> None:
        self.model = model

    # public ---------------------------------------------------------

    def run(self, jobs: List[PatchJob], *, dry_run: bool = False) -> None:
        """
        Execute *jobs*.  If *dry_run* is True, edits are printed but
        not written to disk.
        """
        for job in jobs:
            print(f"⏳  LLM patching {job.file_path}:{job.symbol} …")
            original_text = job.file_path.read_text(encoding="utf-8")
            prompt = build_patch_prompt(
                job.task,
                job.symbol,
                str(job.file_path),
                original_text,
                extra_context=job.extra_context,
            )
            try:
                new_code = call_openrouter_api(
                    prompt,
                    model=self.model,
                    temperature=job.temperature,
                    max_tokens=job.max_tokens,
                )
            except Exception as exc:
                print(f"❌  LLM call failed on {job.symbol}: {exc}", file=sys.stderr)
                continue

            if new_code.strip() == "NO_CHANGES":
                print(f"✅  No changes for {job.symbol}")
                continue

            try:
                patched = apply_symbol_patches(original_text, {job.symbol: new_code})
            except PatchFailedError as exc:
                print(f"❌  Patch failed: {exc}", file=sys.stderr)
                continue

            if dry_run:
                print(f"🔍  Dry-run diff for {job.file_path} (not saved):\n")
                _print_unified_diff(original_text, patched, job.file_path)
            else:
                job.file_path.write_text(patched, encoding="utf-8")
                print(f"💾  Patched {job.file_path}")

    # private --------------------------------------------------------

    def __repr__(self) -> str:  # pragma: no cover
        return f"<SymbolPatcher model={self.model!r}>"


# ───────────────────────────── CLI helper ──────────────────────────────────


def _print_unified_diff(old: str, new: str, path: Path) -> None:
    """Print a minimal unified diff to stdout."""
    import difflib

    diff = difflib.unified_diff(
        old.splitlines(),
        new.splitlines(),
        fromfile=str(path),
        tofile=str(path),
        lineterm="",
    )
    for line in diff:
        print(line)


def _cli() -> None:  # pragma: no cover, basic self-test CLI
    parser = argparse.ArgumentParser(
        description="Symbol-level LLM patcher (Python only).",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "file",
        type=Path,
        help="Path to the Python file to patch.",
    )
    parser.add_argument(
        "symbol",
        help="Target top-level function or class to rewrite.",
    )
    parser.add_argument(
        "-t",
        "--task",
        required=True,
        help="Task description to achieve.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Show diff but do not modify file.",
    )
    parser.add_argument(
        "-m",
        "--model",
        default=DEFAULT_MODEL,
        help="OpenRouter model ID.",
    )
    args = parser.parse_args()

    job = PatchJob(
        file_path=args.file,
        symbol=args.symbol,
        task=args.task,
    )
    patcher = SymbolPatcher(model=args.model)
    patcher.run([job], dry_run=args.dry_run)


if __name__ == "__main__":
    _cli()


````