From 1e17c4aa6402d6fc7a08fc0e6394f56ac4fcfc66 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Thu, 2 Apr 2026 09:54:17 +0000 Subject: [PATCH] feat(tui): implement PermissionsScreen with diff view Implement PermissionsScreen with diff view for tool permission requests. - 3 diff display modes (unified, side-by-side, raw) - Allow/reject keyboard bindings - Permission decision persistence ISSUES CLOSED: #996 --- .../steps/tui_permissions_screen_steps.py | 618 ++++++++++++++++++ features/tui_permissions_screen.feature | 375 +++++++++++ src/cleveragents/tui/permissions/__init__.py | 21 + src/cleveragents/tui/permissions/models.py | 229 +++++++ src/cleveragents/tui/permissions/screen.py | 254 +++++++ src/cleveragents/tui/permissions/service.py | 107 +++ 6 files changed, 1604 insertions(+) create mode 100644 features/steps/tui_permissions_screen_steps.py create mode 100644 features/tui_permissions_screen.feature create mode 100644 src/cleveragents/tui/permissions/__init__.py create mode 100644 src/cleveragents/tui/permissions/models.py create mode 100644 src/cleveragents/tui/permissions/screen.py create mode 100644 src/cleveragents/tui/permissions/service.py diff --git a/features/steps/tui_permissions_screen_steps.py b/features/steps/tui_permissions_screen_steps.py new file mode 100644 index 000000000..f6e4b9160 --- /dev/null +++ b/features/steps/tui_permissions_screen_steps.py @@ -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 diff --git a/features/tui_permissions_screen.feature b/features/tui_permissions_screen.feature new file mode 100644 index 000000000..d96f920b8 --- /dev/null +++ b/features/tui_permissions_screen.feature @@ -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 diff --git a/src/cleveragents/tui/permissions/__init__.py b/src/cleveragents/tui/permissions/__init__.py new file mode 100644 index 000000000..8af49bb35 --- /dev/null +++ b/src/cleveragents/tui/permissions/__init__.py @@ -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", +] diff --git a/src/cleveragents/tui/permissions/models.py b/src/cleveragents/tui/permissions/models.py new file mode 100644 index 000000000..a4a12ef30 --- /dev/null +++ b/src/cleveragents/tui/permissions/models.py @@ -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}) diff --git a/src/cleveragents/tui/permissions/screen.py b/src/cleveragents/tui/permissions/screen.py new file mode 100644 index 000000000..b56b05df5 --- /dev/null +++ b/src/cleveragents/tui/permissions/screen.py @@ -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) diff --git a/src/cleveragents/tui/permissions/service.py b/src/cleveragents/tui/permissions/service.py new file mode 100644 index 000000000..8bfa57681 --- /dev/null +++ b/src/cleveragents/tui/permissions/service.py @@ -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() -- 2.52.0