diff --git a/.cursor/rules/watcher-knowledge-graph.mdc b/.cursor/rules/watcher-knowledge-graph.mdc new file mode 100644 index 0000000..42b7600 --- /dev/null +++ b/.cursor/rules/watcher-knowledge-graph.mdc @@ -0,0 +1,29 @@ +--- +description: CodeGenome knowledge graph and MCP Context +alwaysApply: true +--- + +# CodeGenome MCP Integration + +You are operating within a repository analyzed by CodeGenome, an architectural knowledge graph tool. This project contains a `.genome/` directory. + +## Core Directives + +1. **Mandatory MCP Usage**: When `.genome/watcher.db` exists, you MUST use CodeGenome MCP access for all codebase, architecture, dependency, or symbol queries whenever it is available. +2. **Access Order**: First use native CodeGenome MCP tools exposed in your context. If those tools are missing, you MAY try a local MCP HTTP endpoint such as `http://127.0.0.1:7331/mcp` when the user has started it or configured it. Treat this as MCP transport access, not as an arbitrary application HTTP API. +3. **Prefer Graph over Grep**: Use graph-backed MCP tools instead of raw file searching (`grep`) or reading entire files blindly. The graph provides semantic understanding. +4. **Fallback Gracefully**: If native MCP tools are missing and HTTP MCP access is unavailable, incompatible, or returns empty data, tell the user exactly what failed and what to configure. Then, if needed, read `.genome/graph.json` or `.genome/exports/*.md` before resorting to standard text searches. +5. **Keep Context Fresh**: After modifying code files, remind the user to run `codegenome analyze` or start the live evolution server `codegenome evolve --live` to keep the graph updated. + +## Available MCP Capabilities + +- **Discovery**: `search_nodes` (find symbols) +- **Relationships**: `get_neighbors` (imports, callers, callees) +- **Architecture**: `get_entry_points`, `get_dead_code`, `get_circular_deps`, `get_god_nodes` +- **Metrics**: `get_complexity`, `get_churn`, `get_graph` (summary statistics) +- **Evolution**: `get_timeline`, `get_changes` (architectural diffs) + +## Constraints & Behaviors + +- Only read `.genome/graph.json` or `.genome/exports/*.md` directly if native MCP tools and local HTTP MCP transport are unavailable or fail to surface enough context. +- Verify your MCP usage by monitoring tool call success. If native tools are missing, try the configured local HTTP MCP endpoint when possible. If both native and HTTP MCP access fail, politely ask the user to configure their editor's MCP settings to run `codegenome mcp-start` (stdio) or start the server with `codegenome mcp-start --transport http`. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..b512202 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,24 @@ +# CodeGenome Knowledge Graph (MCP) + +You are operating within a repository analyzed by CodeGenome, an architectural knowledge graph tool. This project contains a `.genome/` directory. + +## Core Directives + +1. **Mandatory MCP Usage**: When `.genome/watcher.db` exists, you MUST use CodeGenome MCP access for all codebase, architecture, dependency, or symbol queries whenever it is available. +2. **Access Order**: First use native CodeGenome MCP tools exposed in your context. If those tools are missing, you MAY try a local MCP HTTP endpoint such as `http://127.0.0.1:7331/mcp` when the user has started it or configured it. Treat this as MCP transport access, not as an arbitrary application HTTP API. +3. **Prefer Graph over Grep**: Use graph-backed MCP tools instead of raw file searching (`grep`) or reading entire files blindly. The graph provides semantic understanding. +4. **Fallback Gracefully**: If native MCP tools are missing and HTTP MCP access is unavailable, incompatible, or returns empty data, tell the user exactly what failed and what to configure. Then, if needed, read `.genome/graph.json` or `.genome/exports/*.md` before resorting to standard text searches. +5. **Keep Context Fresh**: After modifying code files, remind the user to run `codegenome analyze` or start the live evolution server `codegenome evolve --live` to keep the graph updated. + +## Available MCP Capabilities + +- **Discovery**: `search_nodes` (find symbols) +- **Relationships**: `get_neighbors` (imports, callers, callees) +- **Architecture**: `get_entry_points`, `get_dead_code`, `get_circular_deps`, `get_god_nodes` +- **Metrics**: `get_complexity`, `get_churn`, `get_graph` (summary statistics) +- **Evolution**: `get_timeline`, `get_changes` (architectural diffs) + +## Constraints & Behaviors + +- Only read `.genome/graph.json` or `.genome/exports/*.md` directly if native MCP tools and local HTTP MCP transport are unavailable or fail to surface enough context. +- Verify your MCP usage by monitoring tool call success. If native tools are missing, try the configured local HTTP MCP endpoint when possible. If both native and HTTP MCP access fail, politely ask the user to configure their editor's MCP settings to run `codegenome mcp-start` (stdio) or start the server with `codegenome mcp-start --transport http`. diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml new file mode 100644 index 0000000..f2ca8b0 --- /dev/null +++ b/.github/workflows/compatibility.yml @@ -0,0 +1,41 @@ +name: Compatibility + +on: + push: + branches: ["main"] + pull_request: + +jobs: + install-and-parser-smoke: + name: "${{ matrix.os }} / py${{ matrix.python-version }}" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14] + python-version: ["3.11", "3.12", "3.13"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Upgrade build tooling + run: python -m pip install --upgrade pip setuptools wheel + + - name: Install package and test dependencies + run: | + python -m pip install . + python -m pip install pytest + + - name: Parser test suite + run: python -m pytest tests/test_parser.py -q + + - name: CLI smoke test + run: | + codegenome --help + python -c "import codegenome; print(codegenome.__version__)" diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..7bc07ec --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Environment-dependent path to Maven home directory +/mavenHomeManager.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/codegenome.iml b/.idea/codegenome.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/codegenome.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..a1757ae --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..628afae --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..f338140 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 0000000..b512202 --- /dev/null +++ b/.windsurfrules @@ -0,0 +1,24 @@ +# CodeGenome Knowledge Graph (MCP) + +You are operating within a repository analyzed by CodeGenome, an architectural knowledge graph tool. This project contains a `.genome/` directory. + +## Core Directives + +1. **Mandatory MCP Usage**: When `.genome/watcher.db` exists, you MUST use CodeGenome MCP access for all codebase, architecture, dependency, or symbol queries whenever it is available. +2. **Access Order**: First use native CodeGenome MCP tools exposed in your context. If those tools are missing, you MAY try a local MCP HTTP endpoint such as `http://127.0.0.1:7331/mcp` when the user has started it or configured it. Treat this as MCP transport access, not as an arbitrary application HTTP API. +3. **Prefer Graph over Grep**: Use graph-backed MCP tools instead of raw file searching (`grep`) or reading entire files blindly. The graph provides semantic understanding. +4. **Fallback Gracefully**: If native MCP tools are missing and HTTP MCP access is unavailable, incompatible, or returns empty data, tell the user exactly what failed and what to configure. Then, if needed, read `.genome/graph.json` or `.genome/exports/*.md` before resorting to standard text searches. +5. **Keep Context Fresh**: After modifying code files, remind the user to run `codegenome analyze` or start the live evolution server `codegenome evolve --live` to keep the graph updated. + +## Available MCP Capabilities + +- **Discovery**: `search_nodes` (find symbols) +- **Relationships**: `get_neighbors` (imports, callers, callees) +- **Architecture**: `get_entry_points`, `get_dead_code`, `get_circular_deps`, `get_god_nodes` +- **Metrics**: `get_complexity`, `get_churn`, `get_graph` (summary statistics) +- **Evolution**: `get_timeline`, `get_changes` (architectural diffs) + +## Constraints & Behaviors + +- Only read `.genome/graph.json` or `.genome/exports/*.md` directly if native MCP tools and local HTTP MCP transport are unavailable or fail to surface enough context. +- Verify your MCP usage by monitoring tool call success. If native tools are missing, try the configured local HTTP MCP endpoint when possible. If both native and HTTP MCP access fail, politely ask the user to configure their editor's MCP settings to run `codegenome mcp-start` (stdio) or start the server with `codegenome mcp-start --transport http`. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b512202 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# CodeGenome Knowledge Graph (MCP) + +You are operating within a repository analyzed by CodeGenome, an architectural knowledge graph tool. This project contains a `.genome/` directory. + +## Core Directives + +1. **Mandatory MCP Usage**: When `.genome/watcher.db` exists, you MUST use CodeGenome MCP access for all codebase, architecture, dependency, or symbol queries whenever it is available. +2. **Access Order**: First use native CodeGenome MCP tools exposed in your context. If those tools are missing, you MAY try a local MCP HTTP endpoint such as `http://127.0.0.1:7331/mcp` when the user has started it or configured it. Treat this as MCP transport access, not as an arbitrary application HTTP API. +3. **Prefer Graph over Grep**: Use graph-backed MCP tools instead of raw file searching (`grep`) or reading entire files blindly. The graph provides semantic understanding. +4. **Fallback Gracefully**: If native MCP tools are missing and HTTP MCP access is unavailable, incompatible, or returns empty data, tell the user exactly what failed and what to configure. Then, if needed, read `.genome/graph.json` or `.genome/exports/*.md` before resorting to standard text searches. +5. **Keep Context Fresh**: After modifying code files, remind the user to run `codegenome analyze` or start the live evolution server `codegenome evolve --live` to keep the graph updated. + +## Available MCP Capabilities + +- **Discovery**: `search_nodes` (find symbols) +- **Relationships**: `get_neighbors` (imports, callers, callees) +- **Architecture**: `get_entry_points`, `get_dead_code`, `get_circular_deps`, `get_god_nodes` +- **Metrics**: `get_complexity`, `get_churn`, `get_graph` (summary statistics) +- **Evolution**: `get_timeline`, `get_changes` (architectural diffs) + +## Constraints & Behaviors + +- Only read `.genome/graph.json` or `.genome/exports/*.md` directly if native MCP tools and local HTTP MCP transport are unavailable or fail to surface enough context. +- Verify your MCP usage by monitoring tool call success. If native tools are missing, try the configured local HTTP MCP endpoint when possible. If both native and HTTP MCP access fail, politely ask the user to configure their editor's MCP settings to run `codegenome mcp-start` (stdio) or start the server with `codegenome mcp-start --transport http`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e770a..df3988d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Copyable TUI console outputs** — users can now select text in the log panes and press `Ctrl+C` to copy it to the clipboard. - **LAN live graph sharing** — `codegenome evolve --live --lan` binds HTTP and WebSocket to `0.0.0.0` so other devices on the same network can open the live graph. The CLI prints a shareable LAN URL (for example `http://192.168.1.42:8000/graph.html?live=1`). - **TUI MCP HTTP mode controls** — the dashboard now includes separate **Start MCP HTTP (Local)** and **Start MCP HTTP (LAN)** buttons so users can intentionally choose localhost-only or LAN exposure. - **Git-aware file filtering** — the scanner respects workspace `.gitignore` and `.genomeignore` files, including nested ignore files in subdirectories, negation rules (`!pattern`), and anchored patterns. @@ -33,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Agent rules no longer reference misleading HTTP endpoints that caused agents to `curl` the server instead of using MCP transport. - MCP server keeps localhost-only behavior by default and now requires an explicit remote HTTP opt-in (`--allow-remote-http`) for non-loopback hosts. - Release lint blockers (unused imports and test lint violations) were resolved so full lint/test/build gates pass before upload. +- Tree-sitter dependency constraints now support Python 3.12+ installations (including macOS Apple Silicon) while preserving legacy pins for Python 3.11 compatibility (fixes [#1](https://github.com/Ogro-Projukti/codegenome/issues/1)). +- Updated tree-sitter `Parser` initialization to support both legacy and modern (`>=0.23`) API signatures without breaking runtime. ### Documentation diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5f6e7b9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "codegenome", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/pyproject.toml b/pyproject.toml index b281dd9..dc9c188 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,12 +24,18 @@ classifiers = [ "Topic :: Software Development :: Quality Assurance", ] dependencies = [ - "tree-sitter==0.21.3", - "tree-sitter-python==0.21.0", - "tree-sitter-javascript==0.21.0", - "tree-sitter-typescript==0.21.0", - "tree-sitter-go==0.21.0", - "tree-sitter-rust==0.21.2", + "tree-sitter==0.21.3; python_version < '3.12'", + "tree-sitter>=0.23,<0.26; python_version >= '3.12'", + "tree-sitter-python==0.21.0; python_version < '3.12'", + "tree-sitter-python>=0.23,<0.26; python_version >= '3.12'", + "tree-sitter-javascript==0.21.0; python_version < '3.12'", + "tree-sitter-javascript>=0.23,<0.26; python_version >= '3.12'", + "tree-sitter-typescript==0.21.0; python_version < '3.12'", + "tree-sitter-typescript>=0.23,<0.26; python_version >= '3.12'", + "tree-sitter-go==0.21.0; python_version < '3.12'", + "tree-sitter-go>=0.23,<0.26; python_version >= '3.12'", + "tree-sitter-rust==0.21.2; python_version < '3.12'", + "tree-sitter-rust>=0.23,<0.26; python_version >= '3.12'", "watchdog", "fastmcp", "leidenalg", diff --git a/requirements.txt b/requirements.txt index 5d7ae1f..cdb7500 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,15 @@ -tree-sitter==0.21.3 -tree-sitter-python==0.21.0 -tree-sitter-javascript==0.21.0 -tree-sitter-typescript==0.21.0 -tree-sitter-go==0.21.0 -tree-sitter-rust==0.21.2 +tree-sitter==0.21.3; python_version < "3.12" +tree-sitter>=0.23,<0.26; python_version >= "3.12" +tree-sitter-python==0.21.0; python_version < "3.12" +tree-sitter-python>=0.23,<0.26; python_version >= "3.12" +tree-sitter-javascript==0.21.0; python_version < "3.12" +tree-sitter-javascript>=0.23,<0.26; python_version >= "3.12" +tree-sitter-typescript==0.21.0; python_version < "3.12" +tree-sitter-typescript>=0.23,<0.26; python_version >= "3.12" +tree-sitter-go==0.21.0; python_version < "3.12" +tree-sitter-go>=0.23,<0.26; python_version >= "3.12" +tree-sitter-rust==0.21.2; python_version < "3.12" +tree-sitter-rust>=0.23,<0.26; python_version >= "3.12" networkx>=3.2,<4 watchdog>=4.0,<5 fastmcp>=2.0,<3 diff --git a/src/codegenome/parser.py b/src/codegenome/parser.py index 071d2e5..4223995 100644 --- a/src/codegenome/parser.py +++ b/src/codegenome/parser.py @@ -214,12 +214,22 @@ def _load_languages() -> dict[str, Language]: for key, module_name, attr_name, lang_name in specs: try: module = __import__(module_name) - languages[key] = Language(getattr(module, attr_name)(), lang_name) + languages[key] = _build_language(module, attr_name, lang_name) except Exception as exc: # pragma: no cover - optional grammars logger.warning("Failed to load tree-sitter grammar %s: %s", key, exc) return languages +def _build_language(module: object, attr_name: str, lang_name: str) -> Language: + """Create a Language object across tree-sitter API variants.""" + language_capsule = getattr(module, attr_name)() + try: + return Language(language_capsule, lang_name) + except TypeError: + # Newer tree-sitter releases accept only the grammar capsule. + return Language(language_capsule) + + class SourceParser: """Parse source files and extract symbols, imports, calls, and inheritance. @@ -232,8 +242,11 @@ def __init__(self) -> None: self._languages = _load_languages() self._parsers: dict[str, Parser] = {} for key, language in self._languages.items(): - parser = Parser() - parser.set_language(language) + try: + parser = Parser(language) + except TypeError: + parser = Parser() + parser.set_language(language) self._parsers[key] = parser def detect_language(self, path: Path | str) -> str | None: diff --git a/src/codegenome/tui.py b/src/codegenome/tui.py index 45eefd8..48d4b6d 100644 --- a/src/codegenome/tui.py +++ b/src/codegenome/tui.py @@ -9,9 +9,23 @@ from pathlib import Path from typing import Literal +from textual import events +from textual.actions import SkipAction from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical -from textual.widgets import Button, ContentSwitcher, Footer, Header, Input, Label, RichLog, Static, TabbedContent, TabPane +from textual.selection import Selection +from textual.widgets import ( + Button, + ContentSwitcher, + Footer, + Header, + Input, + Label, + RichLog, + Static, + TabbedContent, + TabPane, +) from textual.worker import Worker, WorkerState, get_current_worker from codegenome.workspace_info import ( @@ -38,12 +52,43 @@ class ActiveProcess: channel: LogChannel +class ReadOnlyRichLog(RichLog): + """Console log output: selectable/copyable, not keyboard-editable.""" + + ALLOW_SELECT = True + + def get_selection(self, selection: Selection) -> tuple[str, str] | None: + """Return plain text for the selected region.""" + if not self.lines: + return None + text = "\n".join(line.text for line in self.lines) + extracted = selection.extract(text) + if not extracted: + return None + return extracted, "\n" + + def selection_updated(self, selection: Selection | None) -> None: + self._line_cache.clear() + self.refresh() + + def on_key(self, event: events.Key) -> None: + """Ignore printable keys so log panes cannot be edited.""" + if event.character and event.character.isprintable(): + event.prevent_default() + event.stop() + + class CodeGenomeTUI(App): """A Textual app for managing CodeGenome.""" + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._subprocesses: set[asyncio.subprocess.Process] = set() + self.active_processes: list[ActiveProcess] = [] + BINDINGS = [ ("ctrl+q", "quit_app", "Quit"), - ("ctrl+c", "quit_app", "Quit"), + ("ctrl+c", "copy_log_text", "Copy"), ] CSS = """ @@ -116,7 +161,7 @@ class CodeGenomeTUI(App): margin-bottom: 1; } - .info-panel RichLog { + .info-panel ReadOnlyRichLog { height: 1fr; min-height: 6; } @@ -170,27 +215,57 @@ class CodeGenomeTUI(App): layout: vertical; } - .log-pane RichLog { + .log-pane ReadOnlyRichLog { height: 1fr; width: 1fr; border: solid $surface-lighten-1; } - #tab-analyze RichLog { + #tab-analyze ReadOnlyRichLog { border: solid cyan; } - #tab-mcp RichLog { + #tab-mcp ReadOnlyRichLog { border: solid green; } - #tab-evolve RichLog { + #tab-evolve ReadOnlyRichLog { border: solid magenta; } - #tab-general RichLog { + #tab-general ReadOnlyRichLog { border: solid white; } + + .panel-header { + height: 1; + layout: horizontal; + align: left middle; + margin-bottom: 1; + } + + .panel-header Label { + width: 1fr; + margin: 0; + padding: 0; + height: auto; + } + + .copy-btn { + height: 1; + min-width: 8; + border: none; + padding: 0 1; + margin: 0; + background: $surface-lighten-1; + color: $text; + text-style: bold; + } + + .copy-btn:hover { + background: $primary; + color: $text; + } """ LOG_IDS: dict[LogChannel, str] = { @@ -228,7 +303,9 @@ def compose(self) -> ComposeResult: with Vertical(classes="set-workspace-panel"): yield Label("[bold]Set Workspace[/bold]") yield Label("Enter the path to your project root:") - yield Input(value=".", id="workspace-input", placeholder="Enter path to workspace...") + yield Input( + value=".", id="workspace-input", placeholder="Enter path to workspace..." + ) with Horizontal(classes="page-actions"): yield Button("Set", id="btn-set-workspace", variant="primary") yield Button("Quit", id="btn-quit", variant="default") @@ -238,14 +315,38 @@ def compose(self) -> ComposeResult: yield Static(id="workspace-scan-status", markup=True) with Horizontal(id="workspace-info-panels"): with Vertical(classes="info-panel"): - yield Label("[bold cyan]Tracked Folders[/bold cyan]") - yield RichLog(id="info-folders", markup=True, highlight=False, wrap=True) + with Horizontal(classes="panel-header"): + yield Label("[bold cyan]Tracked Folders[/bold cyan]") + yield Button( + "Copy", id="btn-copy-folders", variant="default", classes="copy-btn" + ) + yield ReadOnlyRichLog( + id="info-folders", markup=True, highlight=False, wrap=True + ) with Vertical(classes="info-panel"): - yield Label("[bold cyan]File Extensions[/bold cyan]") - yield RichLog(id="info-extensions", markup=True, highlight=False, wrap=True) + with Horizontal(classes="panel-header"): + yield Label("[bold cyan]File Extensions[/bold cyan]") + yield Button( + "Copy", + id="btn-copy-extensions", + variant="default", + classes="copy-btn", + ) + yield ReadOnlyRichLog( + id="info-extensions", markup=True, highlight=False, wrap=True + ) with Vertical(classes="info-panel"): - yield Label("[bold cyan].gitignore Files[/bold cyan]") - yield RichLog(id="info-gitignore", markup=True, highlight=False, wrap=True) + with Horizontal(classes="panel-header"): + yield Label("[bold cyan].gitignore Files[/bold cyan]") + yield Button( + "Copy", + id="btn-copy-gitignore", + variant="default", + classes="copy-btn", + ) + yield ReadOnlyRichLog( + id="info-gitignore", markup=True, highlight=False, wrap=True + ) with Horizontal(classes="page-actions"): yield Button("Back", id="btn-back-to-set", variant="default") yield Button("Continue", id="btn-continue", variant="primary", disabled=True) @@ -260,26 +361,62 @@ def compose(self) -> ComposeResult: yield Button("Analyze", id="btn-analyze", variant="primary") yield Button("Export", id="btn-export", variant="primary") yield Button("Generate AI Rules", id="btn-rules", variant="primary") - yield Button("Start MCP HTTP (Local)", id="btn-mcp-local", variant="success") + yield Button( + "Start MCP HTTP (Local)", id="btn-mcp-local", variant="success" + ) yield Button("Start MCP HTTP (LAN)", id="btn-mcp-lan", variant="warning") with Horizontal(classes="command-row"): - yield Button("Live Evolve (Local)", id="btn-evolve-local", variant="success") + yield Button( + "Live Evolve (Local)", id="btn-evolve-local", variant="success" + ) yield Button("Live Evolve (LAN)", id="btn-evolve-lan", variant="success") with Container(id="log-container"): with TabbedContent(initial="tab-analyze"): with TabPane("Analyze", id="tab-analyze"): with Vertical(classes="log-pane"): - yield RichLog(id="log-analyze", markup=True, highlight=True) + with Horizontal(classes="panel-header"): + yield Label("[bold cyan]Analyze Log[/bold cyan]") + yield Button( + "Copy", + id="btn-copy-analyze", + variant="default", + classes="copy-btn", + ) + yield ReadOnlyRichLog(id="log-analyze", markup=True, highlight=True) with TabPane("MCP Server", id="tab-mcp"): with Vertical(classes="log-pane"): - yield RichLog(id="log-mcp", markup=True, highlight=True) + with Horizontal(classes="panel-header"): + yield Label("[bold cyan]MCP Server Log[/bold cyan]") + yield Button( + "Copy", + id="btn-copy-mcp", + variant="default", + classes="copy-btn", + ) + yield ReadOnlyRichLog(id="log-mcp", markup=True, highlight=True) with TabPane("Live Evolve", id="tab-evolve"): with Vertical(classes="log-pane"): - yield RichLog(id="log-evolve", markup=True, highlight=True) + with Horizontal(classes="panel-header"): + yield Label("[bold cyan]Live Evolve Log[/bold cyan]") + yield Button( + "Copy", + id="btn-copy-evolve", + variant="default", + classes="copy-btn", + ) + yield ReadOnlyRichLog(id="log-evolve", markup=True, highlight=True) with TabPane("General", id="tab-general"): with Vertical(classes="log-pane"): - yield RichLog(id="log-general", markup=True, highlight=True) + with Horizontal(classes="panel-header"): + yield Label("[bold cyan]General Log[/bold cyan]") + yield Button( + "Copy", + id="btn-copy-general", + variant="default", + classes="copy-btn", + ) + yield ReadOnlyRichLog(id="log-general", markup=True, highlight=True) with Horizontal(classes="page-actions"): yield Button("Stop MCP Server", id="btn-stop-mcp", variant="error") @@ -294,23 +431,21 @@ def on_mount(self) -> None: self.pages = self.query_one(ContentSwitcher) self.workspace_input = self.query_one("#workspace-input", Input) self.workspace_scan_status = self.query_one("#workspace-scan-status", Static) - self.info_folders_log = self.query_one("#info-folders", RichLog) - self.info_extensions_log = self.query_one("#info-extensions", RichLog) - self.info_gitignore_log = self.query_one("#info-gitignore", RichLog) + self.info_folders_log = self.query_one("#info-folders", ReadOnlyRichLog) + self.info_extensions_log = self.query_one("#info-extensions", ReadOnlyRichLog) + self.info_gitignore_log = self.query_one("#info-gitignore", ReadOnlyRichLog) self.workspace_summary = self.query_one("#workspace-summary", Static) self.continue_button = self.query_one("#btn-continue", Button) self.set_workspace_button = self.query_one("#btn-set-workspace", Button) self.log_tabs = self.query_one(TabbedContent) - self.log_widgets: dict[LogChannel, RichLog] = { - channel: self.query_one(f"#{log_id}", RichLog) + self.log_widgets: dict[LogChannel, ReadOnlyRichLog] = { + channel: self.query_one(f"#{log_id}", ReadOnlyRichLog) for channel, log_id in self.LOG_IDS.items() } self.command_buttons: dict[str, Button] = { button_id: self.query_one(f"#{button_id}", Button) for button_id in self.COMMAND_BUTTON_IDS } - self.active_processes: list[ActiveProcess] = [] - self._subprocesses: set[asyncio.subprocess.Process] = set() self._workspace_path: str | None = None self._pending_workspace_info: WorkspaceInfo | None = None self._main_initialized = False @@ -348,8 +483,7 @@ def update_workspace_scan_panels(self, info: WorkspaceInfo) -> None: """Populate the three scan result panels from workspace info.""" if info.error: self.workspace_scan_status.update( - f"[bold]Root:[/bold] {info.root} " - f"[bold red]Error:[/bold red] {info.error}" + f"[bold]Root:[/bold] {info.root} [bold red]Error:[/bold red] {info.error}" ) else: dir_count = len(info.tracked_directories) @@ -399,7 +533,11 @@ async def _load_workspace_info(self, path: str) -> None: def background_refresh_workspace_info(self) -> None: """Periodically refresh workspace counts in the background.""" - if getattr(self, "_workspace_path", None) and getattr(self, "pages", None) and self.pages.current == PAGE_MAIN: + if ( + getattr(self, "_workspace_path", None) + and getattr(self, "pages", None) + and self.pages.current == PAGE_MAIN + ): self.run_worker( self._do_background_refresh(self._workspace_path), exclusive=True, @@ -412,7 +550,7 @@ async def _do_background_refresh(self, path: str) -> None: info = await asyncio.to_thread(collect_workspace_info, Path(path)) if worker.is_cancelled: return - + # Only update the summary bar to avoid flashing the UI self.workspace_summary.update(format_workspace_summary(info)) @@ -446,7 +584,9 @@ def enter_main_dashboard(self) -> None: self.show_page(PAGE_MAIN) if getattr(self, "_workspace_poll_timer", None) is None: - self._workspace_poll_timer = self.set_interval(5.0, self.background_refresh_workspace_info) + self._workspace_poll_timer = self.set_interval( + 5.0, self.background_refresh_workspace_info + ) if not self._main_initialized: self._main_initialized = True @@ -466,12 +606,43 @@ def focus_log_tab(self, channel: LogChannel) -> None: """Switch the visible tab to the given log channel.""" self.log_tabs.active = self.TAB_IDS[channel] + def copy_panel_output(self, log_widget: ReadOnlyRichLog, panel_name: str) -> None: + """Get the text from a ReadOnlyRichLog and copy it to clipboard.""" + text = "\n".join(line.text for line in log_widget.lines) + if not text: + self.notify(f"No content to copy in {panel_name}.", severity="warning") + return + self.copy_to_clipboard(text) + self.notify(f"Copied {panel_name} output to clipboard!") + def on_button_pressed(self, event: Button.Pressed) -> None: """Handle command button presses.""" button_id = event.button.id if button_id in self.COMMAND_BUTTON_IDS and event.button.disabled: return + if button_id == "btn-copy-folders": + self.copy_panel_output(self.info_folders_log, "Tracked Folders") + return + elif button_id == "btn-copy-extensions": + self.copy_panel_output(self.info_extensions_log, "File Extensions") + return + elif button_id == "btn-copy-gitignore": + self.copy_panel_output(self.info_gitignore_log, ".gitignore Files") + return + elif button_id == "btn-copy-analyze": + self.copy_panel_output(self.log_widgets["analyze"], "Analyze Log") + return + elif button_id == "btn-copy-mcp": + self.copy_panel_output(self.log_widgets["mcp"], "MCP Server Log") + return + elif button_id == "btn-copy-evolve": + self.copy_panel_output(self.log_widgets["evolve"], "Live Evolve Log") + return + elif button_id == "btn-copy-general": + self.copy_panel_output(self.log_widgets["general"], "General Log") + return + if button_id == "btn-set-workspace": self.refresh_workspace_info() return @@ -507,7 +678,16 @@ def on_button_pressed(self, event: Button.Pressed) -> None: ) elif button_id == "btn-mcp-local": self.run_command( - ["codegenome", "mcp-start", "--path", workspace, "--transport", "http", "--port", "7331"], + [ + "codegenome", + "mcp-start", + "--path", + workspace, + "--transport", + "http", + "--port", + "7331", + ], channel="mcp", is_background=True, ) @@ -752,11 +932,23 @@ async def _stop_processes( async def _cleanup_subprocesses(self) -> None: """Close every tracked subprocess and its pipes.""" - for process in list(self._subprocesses): - with suppress(Exception): - await asyncio.shield(self._close_subprocess(process)) - self._subprocesses.clear() - self.active_processes.clear() + if hasattr(self, "_subprocesses"): + for process in list(self._subprocesses): + with suppress(Exception): + await asyncio.shield(self._close_subprocess(process)) + self._subprocesses.clear() + if hasattr(self, "active_processes"): + self.active_processes.clear() + + def action_copy_log_text(self) -> None: + """Copy selected log text to the clipboard.""" + try: + self.screen.action_copy_text() + except SkipAction: + self.notify( + "Select text in a log panel first, then press Ctrl+C.", + severity="warning", + ) def action_quit_app(self) -> None: """Handle quit action from bindings.""" @@ -773,7 +965,7 @@ def quit_app(self) -> None: async def _shutdown_and_exit(self) -> None: """Terminate subprocesses, close pipes, then exit cleanly.""" - if self._subprocesses: + if getattr(self, "_subprocesses", None): self.write_log("general", "[yellow]Stopping running commands...[/yellow]") await self._cleanup_subprocesses() diff --git a/tests/test_parser.py b/tests/test_parser.py index ce32782..9ee060f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -3,9 +3,11 @@ from __future__ import annotations from pathlib import Path +from types import SimpleNamespace import pytest +from codegenome import parser as parser_module from codegenome.parser import SourceParser @@ -129,3 +131,33 @@ def test_parser_read_failure_is_graceful(parser: SourceParser, tmp_path: Path) - result = parser.parse_file(path) assert result is not None assert result.errors + + +def test_build_language_supports_legacy_signature(monkeypatch: pytest.MonkeyPatch) -> None: + capsule = object() + + class LegacyLanguage: + def __init__(self, received_capsule: object, name: str) -> None: + self.received_capsule = received_capsule + self.name = name + + module = SimpleNamespace(language=lambda: capsule) + monkeypatch.setattr(parser_module, "Language", LegacyLanguage) + + language = parser_module._build_language(module, "language", "python") + assert language.received_capsule is capsule + assert language.name == "python" + + +def test_build_language_supports_modern_signature(monkeypatch: pytest.MonkeyPatch) -> None: + capsule = object() + + class ModernLanguage: + def __init__(self, received_capsule: object) -> None: + self.received_capsule = received_capsule + + module = SimpleNamespace(language=lambda: capsule) + monkeypatch.setattr(parser_module, "Language", ModernLanguage) + + language = parser_module._build_language(module, "language", "python") + assert language.received_capsule is capsule diff --git a/tests/test_tui.py b/tests/test_tui.py index 9371a6a..30c7e65 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -4,7 +4,41 @@ import asyncio -from codegenome.tui import ActiveProcess, CodeGenomeTUI +from rich.segment import Segment + +from textual import events +from textual.geometry import Offset +from textual.selection import Selection +from textual.strip import Strip + +from codegenome.tui import ActiveProcess, CodeGenomeTUI, ReadOnlyRichLog + + +def test_bindings_use_ctrl_c_for_copy_not_quit() -> None: + binding_map = {key: action for key, action, _description in CodeGenomeTUI.BINDINGS} + assert binding_map["ctrl+c"] == "copy_log_text" + assert binding_map["ctrl+q"] == "quit_app" + + +def test_read_only_rich_log_get_selection_returns_plain_text() -> None: + log = ReadOnlyRichLog() + log.lines = [ + Strip([Segment("alpha")], 5), + Strip([Segment("beta")], 4), + ] + selection = Selection(Offset(0, 0), Offset(4, 1)) + result = log.get_selection(selection) + assert result is not None + text, ending = result + assert text == "alpha\nbeta" + assert ending == "\n" + + +def test_read_only_rich_log_blocks_printable_keys() -> None: + log = ReadOnlyRichLog() + event = events.Key(key="x", character="x") + log.on_key(event) + assert event._stop_propagation is True def test_command_button_ids_include_channel_specific_stop_buttons() -> None: @@ -63,3 +97,52 @@ def fake_focus(channel: str) -> None: assert logs == [("mcp", "[yellow]No active MCP server process to stop.[/yellow]")] assert focused == ["mcp"] + + +def test_copy_panel_output_copies_text_to_clipboard() -> None: + app = CodeGenomeTUI() + log = ReadOnlyRichLog() + log.lines = [ + Strip([Segment("line 1")], 6), + Strip([Segment("line 2")], 6), + ] + + copied: list[str] = [] + notifications: list[str] = [] + + def fake_copy_to_clipboard(text: str) -> None: + copied.append(text) + + def fake_notify(message: str, *, severity: str = "information") -> None: + notifications.append(message) + + app.copy_to_clipboard = fake_copy_to_clipboard # type: ignore[method-assign] + app.notify = fake_notify # type: ignore[method-assign] + + app.copy_panel_output(log, "Test Panel") + + assert copied == ["line 1\nline 2"] + assert notifications == ["Copied Test Panel output to clipboard!"] + + +def test_copy_panel_output_notifies_if_empty() -> None: + app = CodeGenomeTUI() + log = ReadOnlyRichLog() + log.lines = [] + + copied: list[str] = [] + notifications: list[str] = [] + + def fake_copy_to_clipboard(text: str) -> None: + copied.append(text) + + def fake_notify(message: str, *, severity: str = "information") -> None: + notifications.append(message) + + app.copy_to_clipboard = fake_copy_to_clipboard # type: ignore[method-assign] + app.notify = fake_notify # type: ignore[method-assign] + + app.copy_panel_output(log, "Test Panel") + + assert not copied + assert notifications == ["No content to copy in Test Panel."]