feat(tui): implement PermissionsScreen with diff view #1307
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user