Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ jobs:
uv run coverage erase
uv run coverage run -m pytest
uv run coverage report
uv run coverage xml
- name: Upload coverage data
if: always()
uses: actions/upload-artifact@v7
Expand All @@ -66,6 +67,11 @@ jobs:
include-hidden-files: true
if-no-files-found: error
retention-days: 7
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v6
with:
files: coverage.xml
fail_ci_if_error: false

build:
name: Build & audit package
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ build/
.pytest_cache/
.tox/
.coverage
coverage.xml
htmlcov/
.mypy_cache/
.ruff_cache/
Expand Down
23 changes: 23 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Mirrors the lint job in .github/workflows/ci.yml (ruff check, ruff format,
# mypy) so contributors get the same checks locally before pushing.
repos:
- repo: local
hooks:
- id: ruff-check
name: ruff check
entry: uv run ruff check .
language: system
types: [python]
pass_filenames: false
- id: ruff-format
name: ruff format
entry: uv run ruff format --check .
language: system
types: [python]
pass_filenames: false
- id: mypy
name: mypy type check
entry: uv run mypy gcode
language: system
types: [python]
pass_filenames: false
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ Look for issues labeled [`good first issue`](https://github.com/shauryagangrade/
- Write clean, readable code with descriptive variable names.
- Ensure all tests pass before opening a PR.

## Pre-commit Hooks

Install the hooks once so lint, formatting, and type checks run before every
commit (they mirror the CI lint job):

```bash
uv run pre-commit install
```

Run them on the whole tree any time with:

```bash
uv run pre-commit run --all-files
```

---

## PR Guidelines
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# GCode

![Build](https://github.com/shauryagangrade/GCode/actions/workflows/ci.yml/badge.svg)
[![codecov](https://codecov.io/gh/shauryagangrade/GCode/branch/main/graph/badge.svg)](https://codecov.io/gh/shauryagangrade/GCode)
[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
[![PyPI version](https://img.shields.io/pypi/v/gcode.svg)](https://pypi.org/project/gcode/)
[![Stars](https://img.shields.io/github/stars/shauryagangrade/GCode?style=social)](https://github.com/shauryagangrade/GCode)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dev = [
"coverage>=7",
"mypy>=1.13",
"pip-audit>=2.7",
"pre-commit>=4.0",
"pytest>=8",
"ruff>=0.9",
]
Expand Down
152 changes: 151 additions & 1 deletion tests/test_ui.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from gcode.ui import RichUI
from unittest.mock import Mock, patch

from gcode.ui import RichUI, _summarize_tool, _truncate
from rich.console import Console


Expand All @@ -13,3 +15,151 @@ def test_goodbye_renders_logo_and_session():
assert "Thanks for coding with GCode." in output
assert "session" in output.lower()
assert "demo-session" in output


# -- _truncate -------------------------------------------------------------


def test_truncate_short_text_unchanged():
assert _truncate("hello world", 50) == "hello world"


def test_truncate_normalizes_whitespace():
assert _truncate("a b\n\tc", 50) == "a b c"


def test_truncate_long_text_appends_ellipsis():
out = _truncate("x" * 100, 80)
assert out == "x" * 80 + "…"


def test_truncate_exact_limit_no_ellipsis():
assert _truncate("x" * 80, 80) == "x" * 80


def test_truncate_empty_string():
assert _truncate("", 10) == ""


def test_truncate_non_string_input():
assert _truncate(42, 10) == "42"


# -- _summarize_tool -------------------------------------------------------


def test_summarize_tool_execute_bash_command():
assert _summarize_tool("execute_bash", {"command": "ls -la"}) == "ls -la"


def test_summarize_tool_uses_path_when_present():
assert _summarize_tool("read_file", {"path": "src/main.py"}) == "src/main.py"


def test_summarize_tool_non_string_path_becomes_empty():
assert _summarize_tool("read_file", {"path": 123}) == ""


def test_summarize_tool_path_wins_over_other_args():
assert _summarize_tool("grep", {"pattern": "foo", "path": "src"}) == "src"


def test_summarize_tool_empty_args():
assert _summarize_tool("noop", {}) == ""


def test_summarize_tool_truncates_long_values():
out = _summarize_tool("edit_file", {"path": "x" * 500})
assert out == "x" * 120 + "…"


# -- _show_slash_menu ------------------------------------------------------


def _mock_select(return_value):
sel = Mock()
sel.ask.return_value = return_value
return sel


def test_slash_menu_returns_selected_command():
with patch(
"gcode.ui.questionary.select",
return_value=_mock_select("/help — Show available commands"),
) as select_mock:
ui = RichUI()
assert ui._show_slash_menu() == "/help"

choices = select_mock.call_args.kwargs["choices"]
assert choices[0].startswith("/help")


def test_slash_menu_cancel_returns_empty():
with patch("gcode.ui.questionary.select", return_value=_mock_select(None)):
ui = RichUI()
assert ui._show_slash_menu() == ""


def test_slash_menu_keyboard_interrupt_returns_empty():
sel = Mock()
sel.ask.side_effect = KeyboardInterrupt
with patch("gcode.ui.questionary.select", return_value=sel):
ui = RichUI()
assert ui._show_slash_menu() == ""


# -- streaming refresh handler ---------------------------------------------


class _FakeLive:
"""Minimal stand-in for rich.live.Live that records updates."""

def __init__(self):
self.updates = []
self.stopped = False

def start(self):
pass

def update(self, content):
self.updates.append(content)

def stop(self):
self.stopped = True


def test_token_renders_markdown_after_threshold(monkeypatch):
from gcode import ui as ui_module

fake = _FakeLive()
monkeypatch.setattr(ui_module, "Live", lambda *args, **kwargs: fake)

ui = RichUI()
ui.assistant_start()

# Below the 80-char re-render threshold: no update yet.
ui.token("x" * 40)
assert fake.updates == []

# Crossing the threshold triggers one Markdown update.
ui.token("y" * 50) # total 90 >= 80
assert len(fake.updates) == 1

# Ending the stream renders the final text and stops the live region.
ui.assistant_end()
assert fake.stopped
assert len(fake.updates) == 2


# -- tool display ----------------------------------------------------------


def test_tool_start_renders_summary():
ui = RichUI()
ui.console = Console(record=True, width=120)

ui.tool_start("execute_bash", {"command": "ls"})

output = ui.console.export_text()
assert "execute_bash" in output
assert "ls" in output
Loading