feat(tui): implement PermissionsScreen with diff view #1307

Merged
freemo merged 1 commits from feature/m8-tui-permissions-screen into master 2026-04-02 17:08:13 +00:00
6 changed files with 1604 additions and 0 deletions
@@ -0,0 +1,618 @@
"""Step definitions for tui_permissions_screen.feature.
Tests cover:
- DiffDisplayMode, FileChangeType, PermissionDecision enums
- PermissionRequest model (unified/side-by-side/context diffs)
- ToolPermissionRequest model (decisions, is_pending, is_allowed, is_rejected)
- PermissionRequestService (queue, decisions, session-scoped decisions)
- PermissionsScreen widget (load, navigate, cycle mode, allow/reject)
- Rendering helpers (_render_file_list, _render_diff_panel, _render_status_bar)
"""
from __future__ import annotations
from behave import given, then, when
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_permission_request(path: str, before: str, after: str):
from cleveragents.tui.permissions.models import FileChangeType, PermissionRequest
return PermissionRequest(
path=path,
change_type=FileChangeType.MODIFIED,
before_content=before,
after_content=after,
)
def _make_tool_request(request_id: str, tool_name: str, n_changes: int = 3):
from cleveragents.tui.permissions.models import (
FileChangeType,
PermissionRequest,
ToolPermissionRequest,
)
changes = [
PermissionRequest(
path=f"file{i}.py",
change_type=FileChangeType.MODIFIED,
before_content=f"old content {i}\nline2\n",
after_content=f"new content {i}\nline2\n",
)
for i in range(n_changes)
]
return ToolPermissionRequest(
request_id=request_id,
tool_name=tool_name,
resource_name="local/api-service",
changes=changes,
)
# ---------------------------------------------------------------------------
# DiffDisplayMode enum
# ---------------------------------------------------------------------------
@when("I inspect the DiffDisplayMode enum")
def step_inspect_diff_display_mode(context):
from cleveragents.tui.permissions.models import DiffDisplayMode
context._diff_display_mode = DiffDisplayMode
@then('DiffDisplayMode should have values "unified", "side_by_side", "context"')
def step_diff_display_mode_values(context):
from cleveragents.tui.permissions.models import DiffDisplayMode
assert DiffDisplayMode.UNIFIED == "unified"
assert DiffDisplayMode.SIDE_BY_SIDE == "side_by_side"
assert DiffDisplayMode.CONTEXT == "context"
# ---------------------------------------------------------------------------
# FileChangeType enum
# ---------------------------------------------------------------------------
@when("I inspect the FileChangeType enum")
def step_inspect_file_change_type(context):
from cleveragents.tui.permissions.models import FileChangeType
context._file_change_type = FileChangeType
@then('FileChangeType should have values "M", "A", "D"')
def step_file_change_type_values(context):
from cleveragents.tui.permissions.models import FileChangeType
assert FileChangeType.MODIFIED == "M"
assert FileChangeType.ADDED == "A"
assert FileChangeType.DELETED == "D"
# ---------------------------------------------------------------------------
# PermissionDecision enum
# ---------------------------------------------------------------------------
@when("I inspect the PermissionDecision enum")
def step_inspect_permission_decision(context):
from cleveragents.tui.permissions.models import PermissionDecision
context._permission_decision = PermissionDecision
@then(
'PermissionDecision should have values "allow_once", "allow_always", "reject_once", "reject_always"'
)
def step_permission_decision_values(context):
from cleveragents.tui.permissions.models import PermissionDecision
assert PermissionDecision.ALLOW_ONCE == "allow_once"
assert PermissionDecision.ALLOW_ALWAYS == "allow_always"
assert PermissionDecision.REJECT_ONCE == "reject_once"
assert PermissionDecision.REJECT_ALWAYS == "reject_always"
# ---------------------------------------------------------------------------
# PermissionRequest model
# ---------------------------------------------------------------------------
_BEFORE = "def login(self, request):\n return self.authenticate(request)\n"
_AFTER = (
"def login(self, request):\n"
" result = self.authenticate(request)\n"
" self.log_attempt(request, result)\n"
" return result\n"
)
@given('a PermissionRequest for "{path}" with before and after content')
def step_create_permission_request(context, path):
context._perm_request = _make_permission_request(path, _BEFORE, _AFTER)
@given('a PermissionRequest for "{path}" with identical before and after content')
def step_create_identical_permission_request(context, path):
same = "def foo():\n pass\n"
context._perm_request = _make_permission_request(path, same, same)
@when('I render the diff in "{mode}" mode')
def step_render_diff(context, mode):
from cleveragents.tui.permissions.models import DiffDisplayMode
mode_enum = DiffDisplayMode(mode)
context._rendered_diff = context._perm_request.render_diff(mode_enum)
@then('the rendered diff should contain "{text}"')
def step_rendered_diff_contains(context, text):
assert text in context._rendered_diff, (
f"Expected {text!r} in diff:\n{context._rendered_diff}"
)
@then("the rendered diff should be empty")
def step_rendered_diff_empty(context):
assert context._rendered_diff == "", (
f"Expected empty diff, got:\n{context._rendered_diff}"
)
@when("I call side_by_side_diff on the request")
def step_call_side_by_side_diff(context):
context._sbs_left, context._sbs_right = context._perm_request.side_by_side_diff()
@then("both sides should have the same number of lines")
def step_sbs_same_length(context):
assert len(context._sbs_left) == len(context._sbs_right), (
f"Left: {len(context._sbs_left)}, Right: {len(context._sbs_right)}"
)
# ---------------------------------------------------------------------------
# ToolPermissionRequest model
# ---------------------------------------------------------------------------
@given("a ToolPermissionRequest with no decision")
def step_create_tool_permission_request(context):
context._tool_request = _make_tool_request("req-0", "local/file-write")
context._original_request = context._tool_request
@then("the request should be pending")
def step_request_is_pending(context):
assert context._tool_request.is_pending
@then("the request should not be allowed")
def step_request_not_allowed(context):
assert not context._tool_request.is_allowed
@then("the request should not be rejected")
def step_request_not_rejected(context):
assert not context._tool_request.is_rejected
@when('I apply decision "{decision}" to the request')
def step_apply_decision(context, decision):
from cleveragents.tui.permissions.models import PermissionDecision
context._decided_request = context._tool_request.apply_decision(
PermissionDecision(decision)
)
# Update context._tool_request to the decided version for subsequent steps
context._tool_request = context._decided_request
@then("the request should be allowed")
def step_request_is_allowed(context):
assert context._tool_request.is_allowed
@then("the request should be rejected")
def step_request_is_rejected(context):
assert context._tool_request.is_rejected
@then("the request should not be pending")
def step_request_not_pending(context):
assert not context._tool_request.is_pending
@then("the original request should still be pending")
def step_original_request_still_pending(context):
assert context._original_request.is_pending
# ---------------------------------------------------------------------------
# PermissionRequestService
# ---------------------------------------------------------------------------
@given("a fresh PermissionRequestService")
def step_create_service(context):
from cleveragents.tui.permissions.service import PermissionRequestService
context._service = PermissionRequestService()
context._clear_result = None
context._decision_result = None
@when('I enqueue a request with id "{request_id}" for tool "{tool_name}"')
def step_enqueue_request(context, request_id, tool_name):
req = _make_tool_request(request_id, tool_name)
context._service.enqueue(req)
context._last_request_id = request_id
@when('I record decision "{decision}" for request "{request_id}"')
def step_record_decision(context, decision, request_id):
from cleveragents.tui.permissions.models import PermissionDecision
context._decision_result = context._service.record_decision(
request_id, PermissionDecision(decision)
)
@then("the service should have {count:d} pending request")
def step_service_pending_count_singular(context, count):
assert len(context._service.pending_requests()) == count, (
f"Expected {count} pending, got {len(context._service.pending_requests())}"
)
@then("the service should have {count:d} pending requests")
def step_service_pending_count(context, count):
assert len(context._service.pending_requests()) == count, (
f"Expected {count} pending, got {len(context._service.pending_requests())}"
)
@then("the service should have {count:d} total requests")
def step_service_total_count(context, count):
assert len(context._service.all_requests()) == count, (
f"Expected {count} total, got {len(context._service.all_requests())}"
)
@then('the request "{request_id}" should have decision "{decision}"')
def step_request_has_decision(context, request_id, decision):
req = context._service.get(request_id)
assert req is not None, f"Request {request_id!r} not found"
assert req.decision is not None
assert req.decision == decision, f"Expected {decision!r}, got {req.decision!r}"
@then('the session decision for "{tool_name}" should be None')
def step_session_decision_none(context, tool_name):
result = context._service.get_session_decision(tool_name)
assert result is None, f"Expected None, got {result!r}"
@then('the session decision for "{tool_name}" should be "{decision}"')
def step_session_decision_value(context, tool_name, decision):
result = context._service.get_session_decision(tool_name)
assert result == decision, f"Expected {decision!r}, got {result!r}"
@when('I clear the session decision for "{tool_name}"')
def step_clear_session_decision(context, tool_name):
context._clear_result = context._service.clear_session_decision(tool_name)
@then("the clear result should be False")
def step_clear_result_false(context):
assert context._clear_result is False
@then("the decision result should be None")
def step_decision_result_none(context):
assert context._decision_result is None
@then('getting request "{request_id}" should return None')
def step_get_request_none(context, request_id):
result = context._service.get(request_id)
assert result is None
@when("I clear all requests")
def step_clear_all(context):
context._service.clear_all()
# ---------------------------------------------------------------------------
# PermissionsScreen widget
# ---------------------------------------------------------------------------
@given("a fresh PermissionsScreen")
def step_create_permissions_screen(context):
from cleveragents.tui.permissions.screen import PermissionsScreen
context._screen = PermissionsScreen()
@when("I load a ToolPermissionRequest with {n:d} file changes")
def step_load_tool_request(context, n):
req = _make_tool_request("req-screen", "local/file-write", n_changes=n)
context._screen.load_request(req)
@then('the screen text should contain "{text}"')
def step_screen_text_contains(context, text):
assert text in context._screen._text, (
f"Expected {text!r} in screen text:\n{context._screen._text}"
)
@then("the screen text should be empty")
def step_screen_text_empty(context):
assert context._screen._text == "", (
f"Expected empty screen text, got:\n{context._screen._text}"
)
@then("the selected index should be {index:d}")
def step_selected_index(context, index):
assert context._screen.selected_index == index, (
f"Expected index {index}, got {context._screen.selected_index}"
)
@when("I navigate next on the screen")
def step_navigate_next(context):
context._screen.navigate_next()
@when("I navigate prev on the screen")
def step_navigate_prev(context):
context._screen.navigate_prev()
@then('the diff mode should be "{mode}"')
def step_diff_mode(context, mode):
assert context._screen.diff_mode == mode, (
f"Expected mode {mode!r}, got {context._screen.diff_mode!r}"
)
@when("I cycle the diff mode")
def step_cycle_diff_mode(context):
context._screen.cycle_diff_mode()
@when('I set the diff mode to "{mode}"')
def step_set_diff_mode(context, mode):
from cleveragents.tui.permissions.models import DiffDisplayMode
context._screen.set_diff_mode(DiffDisplayMode(mode))
@when("I press allow_once on the screen")
def step_press_allow_once(context):
context._screen.allow_once()
@when("I press allow_always on the screen")
def step_press_allow_always(context):
context._screen.allow_always()
@when("I press reject_once on the screen")
def step_press_reject_once(context):
context._screen.reject_once()
@when("I press reject_always on the screen")
def step_press_reject_always(context):
context._screen.reject_always()
@then('the screen decision should be "{decision}"')
def step_screen_decision(context, decision):
assert context._screen.decision == decision, (
f"Expected decision {decision!r}, got {context._screen.decision!r}"
)
@then("the screen decision should be None")
def step_screen_decision_none(context):
assert context._screen.decision is None, (
f"Expected None, got {context._screen.decision!r}"
)
@when("I clear the screen")
def step_clear_screen(context):
context._screen.clear()
# ---------------------------------------------------------------------------
# _next_diff_mode helper
# ---------------------------------------------------------------------------
@when('I call _next_diff_mode with "{mode}"')
def step_call_next_diff_mode(context, mode):
from cleveragents.tui.permissions.models import DiffDisplayMode
from cleveragents.tui.permissions.screen import _next_diff_mode
context._next_mode_result = _next_diff_mode(DiffDisplayMode(mode))
@then('the next diff mode result should be "{mode}"')
def step_next_mode_result(context, mode):
assert context._next_mode_result == mode, (
f"Expected {mode!r}, got {context._next_mode_result!r}"
)
# ---------------------------------------------------------------------------
# Rendering helpers
# ---------------------------------------------------------------------------
@when("I render a file list with {n:d} files and selected index {idx:d}")
def step_render_file_list(context, n, idx):
from cleveragents.tui.permissions.models import FileChangeType, PermissionRequest
from cleveragents.tui.permissions.screen import _render_file_list
changes = [
PermissionRequest(
path=f"file{i}.py",
change_type=FileChangeType.MODIFIED,
before_content="old\n",
after_content="new\n",
)
for i in range(n)
]
context._file_list_text = _render_file_list(changes, idx)
_ARROW = "\u276f" # heavy right-pointing angle quotation mark ornament (U+276F)
@then(f'the file list should contain "{_ARROW}" on the second entry')
def step_file_list_arrow_second(context):
lines = context._file_list_text.splitlines()
# lines[0] is the header "Files (N changes):"
# lines[1] is file0 (index 0), lines[2] is file1 (index 1)
assert _ARROW in lines[2], f"Expected arrow in second file entry, got: {lines[2]!r}"
@when("I render a diff panel with no change")
def step_render_diff_panel_none(context):
from cleveragents.tui.permissions.models import DiffDisplayMode
from cleveragents.tui.permissions.screen import _render_diff_panel
context._diff_panel_text = _render_diff_panel(None, DiffDisplayMode.UNIFIED)
@when("I render a diff panel with an identical-content change")
def step_render_diff_panel_identical(context):
from cleveragents.tui.permissions.models import DiffDisplayMode
from cleveragents.tui.permissions.screen import _render_diff_panel
change = _make_permission_request("src/same.py", "same\n", "same\n")
context._diff_panel_text = _render_diff_panel(change, DiffDisplayMode.UNIFIED)
@then('the diff panel should contain "{text}"')
def step_diff_panel_contains(context, text):
assert text in context._diff_panel_text, (
f"Expected {text!r} in diff panel:\n{context._diff_panel_text}"
)
@when('I render a status bar for tool "{tool_name}" and resource "{resource_name}"')
def step_render_status_bar(context, tool_name, resource_name):
from cleveragents.tui.permissions.models import DiffDisplayMode
from cleveragents.tui.permissions.screen import _render_status_bar
context._status_bar_text = _render_status_bar(
tool_name, resource_name, DiffDisplayMode.UNIFIED
)
@then('the status bar should contain "{text}"')
def step_status_bar_contains(context, text):
assert text in context._status_bar_text, (
f"Expected {text!r} in status bar:\n{context._status_bar_text}"
)
# ---------------------------------------------------------------------------
# PermissionsScreen property accessors
# ---------------------------------------------------------------------------
@then("the screen current_request should be None")
def step_screen_current_request_none(context):
assert context._screen.current_request is None
@then("the screen current_request should not be None")
def step_screen_current_request_not_none(context):
assert context._screen.current_request is not None
# ---------------------------------------------------------------------------
# PermissionRequest side_by_side_diff delete/insert branches
# ---------------------------------------------------------------------------
@given("a PermissionRequest with only deleted lines")
def step_create_delete_only_request(context):
from cleveragents.tui.permissions.models import FileChangeType, PermissionRequest
context._perm_request = PermissionRequest(
path="src/deleted.py",
change_type=FileChangeType.MODIFIED,
before_content="line1\nline2\nline3\n",
after_content="",
)
@given("a PermissionRequest with only inserted lines")
def step_create_insert_only_request(context):
from cleveragents.tui.permissions.models import FileChangeType, PermissionRequest
context._perm_request = PermissionRequest(
path="src/inserted.py",
change_type=FileChangeType.MODIFIED,
before_content="",
after_content="line1\nline2\nline3\n",
)
@then("the left side should have non-empty lines")
def step_left_side_non_empty(context):
left, _ = context._perm_request.side_by_side_diff()
assert any(line.strip() for line in left), f"Expected non-empty left lines: {left}"
@then("the right side should have empty lines for deleted content")
def step_right_side_empty_for_deleted(context):
_, right = context._perm_request.side_by_side_diff()
# For delete-only, right side should have empty strings
assert any(line == "" for line in right), (
f"Expected empty right lines for deleted content: {right}"
)
@then("the right side should have non-empty lines")
def step_right_side_non_empty(context):
_, right = context._perm_request.side_by_side_diff()
assert any(line.strip() for line in right), (
f"Expected non-empty right lines: {right}"
)
@then("the left side should have empty lines for inserted content")
def step_left_side_empty_for_inserted(context):
left, _ = context._perm_request.side_by_side_diff()
# For insert-only, left side should have empty strings
assert any(line == "" for line in left), (
f"Expected empty left lines for inserted content: {left}"
)
# ---------------------------------------------------------------------------
# PermissionRequestService clear_session_decision return True
# ---------------------------------------------------------------------------
@then("the clear result should be True")
def step_clear_result_true(context):
assert context._clear_result is True
+375
View File
@@ -0,0 +1,375 @@
Feature: TUI PermissionsScreen
Scenarios exercising the PermissionsScreen widget and supporting
domain models / service for tool permission requests.
# DiffDisplayMode enum
Scenario: DiffDisplayMode has three values
When I inspect the DiffDisplayMode enum
Then DiffDisplayMode should have values "unified", "side_by_side", "context"
# ── FileChangeType enum ───────────────────────────────────────
Scenario: FileChangeType has three values
When I inspect the FileChangeType enum
Then FileChangeType should have values "M", "A", "D"
# ── PermissionDecision enum ───────────────────────────────────
Scenario: PermissionDecision has four values
When I inspect the PermissionDecision enum
Then PermissionDecision should have values "allow_once", "allow_always", "reject_once", "reject_always"
# ── PermissionRequest model ───────────────────────────────────
Scenario: PermissionRequest generates a unified diff
Given a PermissionRequest for "src/auth/handler.py" with before and after content
When I render the diff in "unified" mode
Then the rendered diff should contain "@@"
And the rendered diff should contain "+"
And the rendered diff should contain "-"
Scenario: PermissionRequest generates a side-by-side diff
Given a PermissionRequest for "src/auth/handler.py" with before and after content
When I render the diff in "side_by_side" mode
Then the rendered diff should contain "|"
Scenario: PermissionRequest generates a context diff
Given a PermissionRequest for "src/auth/handler.py" with before and after content
When I render the diff in "context" mode
Then the rendered diff should contain "***"
Scenario: PermissionRequest unified diff with no changes returns empty string
Given a PermissionRequest for "src/empty.py" with identical before and after content
When I render the diff in "unified" mode
Then the rendered diff should be empty
Scenario: PermissionRequest side_by_side diff returns equal lines for identical content
Given a PermissionRequest for "src/empty.py" with identical before and after content
When I call side_by_side_diff on the request
Then both sides should have the same number of lines
Scenario: PermissionRequest context_diff with no changes returns empty string
Given a PermissionRequest for "src/empty.py" with identical before and after content
When I render the diff in "context" mode
Then the rendered diff should be empty
Scenario: PermissionRequest render_diff dispatches to unified
Given a PermissionRequest for "src/auth/handler.py" with before and after content
When I render the diff in "unified" mode
Then the rendered diff should contain "a/src/auth/handler.py"
Scenario: PermissionRequest render_diff dispatches to context
Given a PermissionRequest for "src/auth/handler.py" with before and after content
When I render the diff in "context" mode
Then the rendered diff should contain "a/src/auth/handler.py"
# ── ToolPermissionRequest model ───────────────────────────────
Scenario: ToolPermissionRequest is pending when no decision is set
Given a ToolPermissionRequest with no decision
Then the request should be pending
And the request should not be allowed
And the request should not be rejected
Scenario: ToolPermissionRequest is allowed after allow_once decision
Given a ToolPermissionRequest with no decision
When I apply decision "allow_once" to the request
Then the request should be allowed
And the request should not be pending
Scenario: ToolPermissionRequest is allowed after allow_always decision
Given a ToolPermissionRequest with no decision
When I apply decision "allow_always" to the request
Then the request should be allowed
Scenario: ToolPermissionRequest is rejected after reject_once decision
Given a ToolPermissionRequest with no decision
When I apply decision "reject_once" to the request
Then the request should be rejected
And the request should not be pending
Scenario: ToolPermissionRequest is rejected after reject_always decision
Given a ToolPermissionRequest with no decision
When I apply decision "reject_always" to the request
Then the request should be rejected
Scenario: ToolPermissionRequest apply_decision returns a new instance
Given a ToolPermissionRequest with no decision
When I apply decision "allow_once" to the request
Then the original request should still be pending
# ── PermissionRequestService ──────────────────────────────────
Scenario: PermissionRequestService enqueues a request
Given a fresh PermissionRequestService
When I enqueue a request with id "req-1" for tool "local/file-write"
Then the service should have 1 pending request
Scenario: PermissionRequestService records allow_once decision
Given a fresh PermissionRequestService
When I enqueue a request with id "req-1" for tool "local/file-write"
And I record decision "allow_once" for request "req-1"
Then the service should have 0 pending requests
And the request "req-1" should have decision "allow_once"
Scenario: PermissionRequestService records reject_once decision
Given a fresh PermissionRequestService
When I enqueue a request with id "req-1" for tool "local/file-write"
And I record decision "reject_once" for request "req-1"
Then the service should have 0 pending requests
And the request "req-1" should have decision "reject_once"
Scenario: PermissionRequestService auto-resolves with allow_always session decision
Given a fresh PermissionRequestService
When I enqueue a request with id "req-1" for tool "local/file-write"
And I record decision "allow_always" for request "req-1"
And I enqueue a request with id "req-2" for tool "local/file-write"
Then the service should have 0 pending requests
And the request "req-2" should have decision "allow_always"
Scenario: PermissionRequestService auto-resolves with reject_always session decision
Given a fresh PermissionRequestService
When I enqueue a request with id "req-1" for tool "local/file-write"
And I record decision "reject_always" for request "req-1"
And I enqueue a request with id "req-2" for tool "local/file-write"
Then the service should have 0 pending requests
And the request "req-2" should have decision "reject_always"
Scenario: PermissionRequestService get_session_decision returns None when not set
Given a fresh PermissionRequestService
Then the session decision for "local/file-write" should be None
Scenario: PermissionRequestService get_session_decision returns decision after allow_always
Given a fresh PermissionRequestService
When I enqueue a request with id "req-1" for tool "local/file-write"
And I record decision "allow_always" for request "req-1"
Then the session decision for "local/file-write" should be "allow_always"
Scenario: PermissionRequestService clear_session_decision removes the decision
Given a fresh PermissionRequestService
When I enqueue a request with id "req-1" for tool "local/file-write"
And I record decision "allow_always" for request "req-1"
And I clear the session decision for "local/file-write"
Then the session decision for "local/file-write" should be None
Scenario: PermissionRequestService clear_session_decision returns False when not set
Given a fresh PermissionRequestService
When I clear the session decision for "local/file-write"
Then the clear result should be False
Scenario: PermissionRequestService record_decision returns None for unknown id
Given a fresh PermissionRequestService
When I record decision "allow_once" for request "nonexistent"
Then the decision result should be None
Scenario: PermissionRequestService get returns None for unknown id
Given a fresh PermissionRequestService
Then getting request "nonexistent" should return None
Scenario: PermissionRequestService all_requests returns all requests
Given a fresh PermissionRequestService
When I enqueue a request with id "req-1" for tool "local/file-write"
And I enqueue a request with id "req-2" for tool "local/file-write"
Then the service should have 2 total requests
Scenario: PermissionRequestService clear_all removes everything
Given a fresh PermissionRequestService
When I enqueue a request with id "req-1" for tool "local/file-write"
And I record decision "allow_always" for request "req-1"
And I clear all requests
Then the service should have 0 total requests
And the session decision for "local/file-write" should be None
# ── PermissionsScreen widget ──────────────────────────────────
Scenario: PermissionsScreen loads a request and renders content
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
Then the screen text should contain "Permission Request"
And the screen text should contain "Files (3 changes)"
And the screen text should contain "local/file-write"
Scenario: PermissionsScreen shows the first file selected by default
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
Then the selected index should be 0
And the screen text should contain ""
Scenario: PermissionsScreen navigate_next moves to next file
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
And I navigate next on the screen
Then the selected index should be 1
Scenario: PermissionsScreen navigate_prev wraps around
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
And I navigate prev on the screen
Then the selected index should be 2
Scenario: PermissionsScreen navigate_next wraps around at end
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
And I navigate next on the screen
And I navigate next on the screen
And I navigate next on the screen
Then the selected index should be 0
Scenario: PermissionsScreen cycle_diff_mode cycles through modes
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
Then the diff mode should be "unified"
When I cycle the diff mode
Then the diff mode should be "side_by_side"
When I cycle the diff mode
Then the diff mode should be "context"
When I cycle the diff mode
Then the diff mode should be "unified"
Scenario: PermissionsScreen set_diff_mode sets mode directly
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
And I set the diff mode to "side_by_side"
Then the diff mode should be "side_by_side"
Scenario: PermissionsScreen allow_once records decision
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
And I press allow_once on the screen
Then the screen decision should be "allow_once"
Scenario: PermissionsScreen allow_always records decision
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
And I press allow_always on the screen
Then the screen decision should be "allow_always"
Scenario: PermissionsScreen reject_once records decision
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
And I press reject_once on the screen
Then the screen decision should be "reject_once"
Scenario: PermissionsScreen reject_always records decision
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
And I press reject_always on the screen
Then the screen decision should be "reject_always"
Scenario: PermissionsScreen clear resets state
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
And I press allow_once on the screen
And I clear the screen
Then the screen decision should be None
And the screen text should be empty
Scenario: PermissionsScreen with no request shows placeholder
Given a fresh PermissionsScreen
Then the screen text should contain "(no permission request)"
Scenario: PermissionsScreen navigate_next does nothing with no request
Given a fresh PermissionsScreen
When I navigate next on the screen
Then the selected index should be 0
Scenario: PermissionsScreen navigate_prev does nothing with no request
Given a fresh PermissionsScreen
When I navigate prev on the screen
Then the selected index should be 0
Scenario: PermissionsScreen shows diff mode in status bar
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
Then the screen text should contain "unified"
Scenario: PermissionsScreen shows keyboard bindings in status bar
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
Then the screen text should contain "Allow"
And the screen text should contain "Reject"
Scenario: PermissionsScreen shows diff content for selected file
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
Then the screen text should contain "file0.py"
Scenario: PermissionsScreen navigate_next with empty changes list does nothing
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 0 file changes
And I navigate next on the screen
Then the selected index should be 0
Scenario: PermissionsScreen navigate_prev with empty changes list does nothing
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 0 file changes
And I navigate prev on the screen
Then the selected index should be 0
# ── _next_diff_mode helper ────────────────────────────────────
Scenario: _next_diff_mode cycles unified to side_by_side
When I call _next_diff_mode with "unified"
Then the next diff mode result should be "side_by_side"
Scenario: _next_diff_mode cycles side_by_side to context
When I call _next_diff_mode with "side_by_side"
Then the next diff mode result should be "context"
Scenario: _next_diff_mode cycles context back to unified
When I call _next_diff_mode with "context"
Then the next diff mode result should be "unified"
# ── Rendering helpers ─────────────────────────────────────────
Scenario: _render_file_list marks selected file with arrow
When I render a file list with 2 files and selected index 1
Then the file list should contain "" on the second entry
Scenario: _render_diff_panel returns placeholder for None change
When I render a diff panel with no change
Then the diff panel should contain "(no file selected)"
Scenario: _render_diff_panel returns placeholder for empty diff
When I render a diff panel with an identical-content change
Then the diff panel should contain "(no changes)"
Scenario: _render_status_bar includes tool name and resource name
When I render a status bar for tool "local/file-write" and resource "local/api-service"
Then the status bar should contain "local/file-write"
And the status bar should contain "local/api-service"
# ── PermissionsScreen property accessors ─────────────────────
Scenario: PermissionsScreen current_request returns None initially
Given a fresh PermissionsScreen
Then the screen current_request should be None
Scenario: PermissionsScreen current_request returns loaded request
Given a fresh PermissionsScreen
When I load a ToolPermissionRequest with 3 file changes
Then the screen current_request should not be None
# ── PermissionRequest side_by_side_diff delete/insert branches ─
Scenario: PermissionRequest side_by_side_diff handles delete-only change
Given a PermissionRequest with only deleted lines
When I call side_by_side_diff on the request
Then the left side should have non-empty lines
And the right side should have empty lines for deleted content
Scenario: PermissionRequest side_by_side_diff handles insert-only change
Given a PermissionRequest with only inserted lines
When I call side_by_side_diff on the request
Then the right side should have non-empty lines
And the left side should have empty lines for inserted content
# ── PermissionRequestService clear_session_decision return True ─
Scenario: PermissionRequestService clear_session_decision returns True when decision exists
Given a fresh PermissionRequestService
When I enqueue a request with id "req-1" for tool "local/file-write"
And I record decision "allow_always" for request "req-1"
And I clear the session decision for "local/file-write"
Then the clear result should be True
@@ -0,0 +1,21 @@
"""TUI permissions screen package."""
from cleveragents.tui.permissions.models import (
DiffDisplayMode,
FileChangeType,
PermissionDecision,
PermissionRequest,
ToolPermissionRequest,
)
from cleveragents.tui.permissions.screen import PermissionsScreen
from cleveragents.tui.permissions.service import PermissionRequestService
__all__ = [
"DiffDisplayMode",
"FileChangeType",
"PermissionDecision",
"PermissionRequest",
"PermissionRequestService",
"PermissionsScreen",
"ToolPermissionRequest",
]
+229
View File
@@ -0,0 +1,229 @@
"""Domain models for TUI permission requests and decisions.
Defines the data structures used by the PermissionsScreen to display
tool permission requests with diff views and record user decisions.
"""
from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
__all__ = [
"DiffDisplayMode",
"FileChangeType",
"PermissionDecision",
"PermissionRequest",
"ToolPermissionRequest",
]
class DiffDisplayMode(StrEnum):
"""Diff display modes for the PermissionsScreen.
Toggled with the ``d`` key in the PermissionsScreen.
"""
UNIFIED = "unified"
"""Standard unified diff with +/- lines (default)."""
SIDE_BY_SIDE = "side_by_side"
"""Two-column view with old content left, new content right."""
CONTEXT = "context"
"""Shows only changed lines with surrounding context (3 lines default)."""
class FileChangeType(StrEnum):
"""Type of file change in a permission request."""
MODIFIED = "M"
"""File was modified."""
ADDED = "A"
"""File was added."""
DELETED = "D"
"""File was deleted."""
class PermissionDecision(StrEnum):
"""User decision for a permission request."""
ALLOW_ONCE = "allow_once"
"""Allow this operation once."""
ALLOW_ALWAYS = "allow_always"
"""Allow all operations of this type for the remainder of the session."""
REJECT_ONCE = "reject_once"
"""Reject this operation once."""
REJECT_ALWAYS = "reject_always"
"""Reject all operations of this type for the remainder of the session."""
class PermissionRequest(BaseModel):
"""A single file change within a tool permission request."""
path: str = Field(..., min_length=1, description="File path being changed.")
change_type: FileChangeType = Field(
...,
description="Type of change (M/A/D).",
)
before_content: str | None = Field(
default=None,
description="Content before the change (None for new files).",
)
after_content: str | None = Field(
default=None,
description="Content after the change (None for deleted files).",
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
def unified_diff(self, *, context_lines: int = 3) -> str:
"""Generate a unified diff string for this file change."""
import difflib
before_lines = (self.before_content or "").splitlines(keepends=True)
after_lines = (self.after_content or "").splitlines(keepends=True)
diff_lines = difflib.unified_diff(
before_lines,
after_lines,
fromfile=f"a/{self.path}",
tofile=f"b/{self.path}",
n=context_lines,
)
return "".join(diff_lines)
def side_by_side_diff(self) -> tuple[list[str], list[str]]:
"""Return (left_lines, right_lines) for side-by-side display."""
import difflib
before_lines = (self.before_content or "").splitlines()
after_lines = (self.after_content or "").splitlines()
matcher = difflib.SequenceMatcher(None, before_lines, after_lines)
left: list[str] = []
right: list[str] = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
for line in before_lines[i1:i2]:
left.append(f" {line}")
right.append(f" {line}")
elif tag == "replace":
old_chunk = before_lines[i1:i2]
new_chunk = after_lines[j1:j2]
max_len = max(len(old_chunk), len(new_chunk))
for k in range(max_len):
left_line = f"- {old_chunk[k]}" if k < len(old_chunk) else ""
right_line = f"+ {new_chunk[k]}" if k < len(new_chunk) else ""
left.append(left_line)
right.append(right_line)
elif tag == "delete":
for line in before_lines[i1:i2]:
left.append(f"- {line}")
right.append("")
elif tag == "insert":
for line in after_lines[j1:j2]:
left.append("")
right.append(f"+ {line}")
return left, right
def context_diff(self, *, context_lines: int = 3) -> str:
"""Generate a context diff showing only changed lines with context."""
import difflib
before_lines = (self.before_content or "").splitlines(keepends=True)
after_lines = (self.after_content or "").splitlines(keepends=True)
diff_lines = difflib.context_diff(
before_lines,
after_lines,
fromfile=f"a/{self.path}",
tofile=f"b/{self.path}",
n=context_lines,
)
return "".join(diff_lines)
def render_diff(
self,
mode: DiffDisplayMode,
*,
context_lines: int = 3,
) -> str:
"""Render the diff in the specified display mode."""
if mode == DiffDisplayMode.UNIFIED:
return self.unified_diff(context_lines=context_lines)
if mode == DiffDisplayMode.SIDE_BY_SIDE:
left, right = self.side_by_side_diff()
lines: list[str] = []
for l_line, r_line in zip(left, right, strict=True):
lines.append(f"{l_line:<40} | {r_line}")
return "\n".join(lines)
# CONTEXT mode
return self.context_diff(context_lines=context_lines)
class ToolPermissionRequest(BaseModel):
"""A complete tool permission request with multiple file changes."""
request_id: str = Field(
...,
min_length=1,
description="Unique identifier for this permission request.",
)
tool_name: str = Field(
...,
min_length=1,
description="Name of the tool requesting permission (e.g. 'local/file-write').",
)
resource_name: str = Field(
...,
min_length=1,
description="Name of the resource being modified (e.g. 'local/api-service').",
)
changes: list[PermissionRequest] = Field(
default_factory=list,
description="List of file changes in this request.",
)
decision: PermissionDecision | None = Field(
default=None,
description="User decision for this request (None if pending).",
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
@property
def is_pending(self) -> bool:
"""Return True if no decision has been made yet."""
return self.decision is None
@property
def is_allowed(self) -> bool:
"""Return True if the request was allowed."""
return self.decision in {
PermissionDecision.ALLOW_ONCE,
PermissionDecision.ALLOW_ALWAYS,
}
@property
def is_rejected(self) -> bool:
"""Return True if the request was rejected."""
return self.decision in {
PermissionDecision.REJECT_ONCE,
PermissionDecision.REJECT_ALWAYS,
}
def apply_decision(self, decision: PermissionDecision) -> ToolPermissionRequest:
"""Return a new request with the given decision applied."""
return self.model_copy(update={"decision": decision})
+254
View File
@@ -0,0 +1,254 @@
"""PermissionsScreen widget for displaying tool permission requests with diff views.
Shows a split-pane layout: a file list on the left and a diff view on the right.
Supports three diff display modes (unified, side-by-side, context) toggled with ``d``.
Allow/reject keyboard bindings: ``a`` allow-once, ``A`` allow-always,
``r`` reject-once, ``R`` reject-always.
"""
from __future__ import annotations
import importlib
from typing import Any
from cleveragents.tui.permissions.models import (
DiffDisplayMode,
PermissionDecision,
PermissionRequest,
ToolPermissionRequest,
)
__all__ = ["PermissionsScreen"]
# ── Optional Textual import gate ─────────────────────────────────
def _load_static_base() -> type[Any]:
try:
return importlib.import_module("textual.widgets").Static
except Exception: # pragma: no cover
class _FallbackStatic:
def __init__(self, *args: object, **kwargs: object) -> None:
self._text = ""
def update(self, text: str) -> None:
self._text = text
return _FallbackStatic
_StaticBase = _load_static_base()
# ── Diff display mode cycle ───────────────────────────────────────
_DIFF_MODE_CYCLE: list[DiffDisplayMode] = [
DiffDisplayMode.UNIFIED,
DiffDisplayMode.SIDE_BY_SIDE,
DiffDisplayMode.CONTEXT,
]
def _next_diff_mode(current: DiffDisplayMode) -> DiffDisplayMode:
"""Return the next diff display mode in the cycle."""
idx = _DIFF_MODE_CYCLE.index(current)
return _DIFF_MODE_CYCLE[(idx + 1) % len(_DIFF_MODE_CYCLE)]
# ── Rendering helpers ─────────────────────────────────────────────
def _render_file_list(
changes: list[PermissionRequest],
selected_index: int,
) -> str:
"""Render the file list panel."""
lines: list[str] = [f"Files ({len(changes)} changes):"]
for i, change in enumerate(changes):
prefix = " " if i == selected_index else " " # noqa: RUF001
lines.append(f"{prefix}{change.path} [{change.change_type}]")
return "\n".join(lines)
def _render_diff_panel(
change: PermissionRequest | None,
mode: DiffDisplayMode,
) -> str:
"""Render the diff panel for the selected file."""
if change is None:
return "(no file selected)"
header = f"{change.path}\n{'' * 50}"
diff_text = change.render_diff(mode)
if not diff_text:
diff_text = "(no changes)"
return f"{header}\n{diff_text}"
def _render_status_bar(
tool_name: str,
resource_name: str,
mode: DiffDisplayMode,
) -> str:
"""Render the status bar at the bottom of the screen."""
return (
f"{tool_name} wants to modify files in {resource_name}\n"
f"[M] Modified [A] Added [D] Deleted | Diff: {mode}\n"
"a Allow │ A Allow Always │ r Reject │ R Reject Always │ j/k Nav │ d Diff │ esc"
)
def _render_screen(
request: ToolPermissionRequest,
selected_index: int,
diff_mode: DiffDisplayMode,
) -> str:
"""Render the full PermissionsScreen content as text."""
file_list = _render_file_list(request.changes, selected_index)
selected_change = request.changes[selected_index] if request.changes else None
diff_panel = _render_diff_panel(selected_change, diff_mode)
status_bar = _render_status_bar(request.tool_name, request.resource_name, diff_mode)
return f"Permission Request\n\n{file_list}\n\n{diff_panel}\n\n{status_bar}"
# ── PermissionsScreen widget ──────────────────────────────────────
class PermissionsScreen(_StaticBase):
"""TUI widget that displays a tool permission request with a diff view.
Layout:
- Title: "Permission Request"
- Left panel: file list with change type indicators
- Right panel: diff view for the selected file
- Status bar: keyboard bindings and diff mode indicator
Keyboard bindings:
- ``a``: allow once
- ``A``: allow always (session)
- ``r``: reject once
- ``R``: reject always (session)
- ``j`` / ``k``: navigate file list (next / previous)
- ``d``: cycle diff display mode (unified → side-by-side → context)
- ``escape``: dismiss (caller responsibility)
"""
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self._request: ToolPermissionRequest | None = None
self._selected_index: int = 0
self._diff_mode: DiffDisplayMode = DiffDisplayMode.UNIFIED
self._decision: PermissionDecision | None = None
self._text: str = "(no permission request)"
self.update(self._text)
# ── Public API ────────────────────────────────────────────────
@property
def current_request(self) -> ToolPermissionRequest | None:
"""Return the currently displayed permission request."""
return self._request
@property
def selected_index(self) -> int:
"""Return the index of the currently selected file."""
return self._selected_index
@property
def diff_mode(self) -> DiffDisplayMode:
"""Return the current diff display mode."""
return self._diff_mode
@property
def decision(self) -> PermissionDecision | None:
"""Return the decision made by the user, or None if pending."""
return self._decision
def load_request(self, request: ToolPermissionRequest) -> None:
"""Load a new permission request and reset state."""
self._request = request
self._selected_index = 0
self._diff_mode = DiffDisplayMode.UNIFIED
self._decision = None
self._refresh()
def clear(self) -> None:
"""Clear the current request and reset state."""
self._request = None
self._selected_index = 0
self._diff_mode = DiffDisplayMode.UNIFIED
self._decision = None
self._text = ""
self.update("")
# ── Navigation ────────────────────────────────────────────────
def navigate_next(self) -> None:
"""Move selection to the next file (``j`` key)."""
if self._request and self._request.changes:
self._selected_index = (self._selected_index + 1) % len(
self._request.changes
)
self._refresh()
def navigate_prev(self) -> None:
"""Move selection to the previous file (``k`` key)."""
if self._request and self._request.changes:
self._selected_index = (self._selected_index - 1) % len(
self._request.changes
)
self._refresh()
def cycle_diff_mode(self) -> DiffDisplayMode:
"""Cycle to the next diff display mode (``d`` key).
Returns the new mode.
"""
self._diff_mode = _next_diff_mode(self._diff_mode)
self._refresh()
return self._diff_mode
def set_diff_mode(self, mode: DiffDisplayMode) -> None:
"""Set the diff display mode directly."""
self._diff_mode = mode
self._refresh()
# ── Decision actions ──────────────────────────────────────────
def allow_once(self) -> PermissionDecision:
"""Record an allow-once decision (``a`` key)."""
return self._record_decision(PermissionDecision.ALLOW_ONCE)
def allow_always(self) -> PermissionDecision:
"""Record an allow-always decision (``A`` key)."""
return self._record_decision(PermissionDecision.ALLOW_ALWAYS)
def reject_once(self) -> PermissionDecision:
"""Record a reject-once decision (``r`` key)."""
return self._record_decision(PermissionDecision.REJECT_ONCE)
def reject_always(self) -> PermissionDecision:
"""Record a reject-always decision (``R`` key)."""
return self._record_decision(PermissionDecision.REJECT_ALWAYS)
def _record_decision(self, decision: PermissionDecision) -> PermissionDecision:
"""Store the decision and update the display."""
self._decision = decision
if self._request is not None:
self._request = self._request.apply_decision(decision)
self._refresh()
return decision
# ── Rendering ─────────────────────────────────────────────────
def _refresh(self) -> None:
"""Re-render the screen content."""
if self._request is None:
self._text = "(no permission request)"
self.update(self._text)
return
self._text = _render_screen(
self._request,
self._selected_index,
self._diff_mode,
)
self.update(self._text)
+107
View File
@@ -0,0 +1,107 @@
"""Permission request service for managing tool permission requests and decisions.
Provides an in-memory store for pending permission requests and persists
session-scoped decisions (allow-always / reject-always) so that repeated
requests from the same tool are handled automatically.
"""
from __future__ import annotations
from collections import OrderedDict
from cleveragents.tui.permissions.models import (
PermissionDecision,
ToolPermissionRequest,
)
__all__ = ["PermissionRequestService"]
class PermissionRequestService:
"""Manages tool permission requests and session-scoped decisions.
* Queues incoming ``ToolPermissionRequest`` objects.
* Records ``allow_always`` / ``reject_always`` decisions so that
subsequent requests from the same tool are auto-resolved.
* Provides iteration over pending requests.
"""
def __init__(self) -> None:
# Ordered dict preserves insertion order for queue semantics.
self._requests: OrderedDict[str, ToolPermissionRequest] = OrderedDict()
# Maps tool_name -> PermissionDecision for session-scoped decisions.
self._session_decisions: dict[str, PermissionDecision] = {}
# ── Queue management ──────────────────────────────────────────
def enqueue(self, request: ToolPermissionRequest) -> None:
"""Add a new permission request to the queue.
If a session-scoped decision already exists for the tool, the
request is auto-resolved immediately and stored with that decision.
"""
session_decision = self._session_decisions.get(request.tool_name)
if session_decision is not None:
request = request.apply_decision(session_decision)
self._requests[request.request_id] = request
def get(self, request_id: str) -> ToolPermissionRequest | None:
"""Return the request with the given ID, or None."""
return self._requests.get(request_id)
def pending_requests(self) -> list[ToolPermissionRequest]:
"""Return all requests that have not yet been decided."""
return [r for r in self._requests.values() if r.is_pending]
def all_requests(self) -> list[ToolPermissionRequest]:
"""Return all requests (pending and decided)."""
return list(self._requests.values())
# ── Decision recording ────────────────────────────────────────
def record_decision(
self,
request_id: str,
decision: PermissionDecision,
) -> ToolPermissionRequest | None:
"""Apply *decision* to the request identified by *request_id*.
If the decision is ``allow_always`` or ``reject_always``, the
tool name is recorded in the session-scoped decision map so that
future requests from the same tool are auto-resolved.
Returns the updated request, or ``None`` if not found.
"""
request = self._requests.get(request_id)
if request is None:
return None
updated = request.apply_decision(decision)
self._requests[request_id] = updated
if decision in {
PermissionDecision.ALLOW_ALWAYS,
PermissionDecision.REJECT_ALWAYS,
}:
self._session_decisions[request.tool_name] = decision
return updated
def get_session_decision(self, tool_name: str) -> PermissionDecision | None:
"""Return the session-scoped decision for *tool_name*, or None."""
return self._session_decisions.get(tool_name)
def clear_session_decision(self, tool_name: str) -> bool:
"""Remove the session-scoped decision for *tool_name*.
Returns True if a decision was removed, False if none existed.
"""
if tool_name in self._session_decisions:
del self._session_decisions[tool_name]
return True
return False
def clear_all(self) -> None:
"""Clear all requests and session decisions."""
self._requests.clear()
self._session_decisions.clear()